diff --git a/.gitignore b/.gitignore index 5ac63ca..5baa6fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /reference-implementation/node_modules/ +/reference-implementation/coverage/ /out/ /spec.html /deploy_key diff --git a/.travis.yml b/.travis.yml index 53b6b1e..c16338f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ node_js: before_install: - cd reference-implementation script: - - npm run lint - npm test - cd .. && bash ./deploy.sh diff --git a/reference-implementation/__tests__/composition.js b/reference-implementation/__tests__/composition.js new file mode 100644 index 0000000..3e1a324 --- /dev/null +++ b/reference-implementation/__tests__/composition.js @@ -0,0 +1,543 @@ +'use strict'; +const { URL } = require('url'); +const { parseFromString } = require('../lib/parser.js'); +const { concatMaps } = require('../lib/composer.js'); +const { BUILT_IN_MODULE_SCHEME } = require('../lib/utils.js'); +const { testWarningHandler } = require('./helpers/parsing.js'); + +const mapBaseURL = new URL('https://example.com/app/index.html'); + +// a convenience function for folding composition over a list of maps +function composeMaps(mapLikes, baseURL = mapBaseURL) { + if (!Array.isArray(mapLikes) || mapLikes.length < 1) { + throw new Error('composeMaps must be given a non-empty array of mapLikes'); + } + let map = parseFromString(mapLikes.shift(), baseURL); + for (const mapLike of mapLikes) { + const newMap = parseFromString(mapLike, baseURL); + map = concatMaps(map, newMap); + } + return map; +} + +describe('Composition', () => { + it('should compose with the empty map on the left', () => { + const map = parseFromString(`{ + "imports": { "https://a/": ["https://b/"] }, + "scopes": { + "https://c/": { "https://d/": ["https://e/"] } + } + }`, mapBaseURL); + + const resultMap = concatMaps(parseFromString('{}', mapBaseURL), map); + + expect(resultMap).toStrictEqual(map); + }); + + it('should compose with the empty map on the right', () => { + const map = parseFromString(`{ + "imports": { "https://a/": ["https://b/"] }, + "scopes": { + "https://c/": { "https://d/": ["https://e/"] } + } + }`, mapBaseURL); + + const resultMap = concatMaps(map, parseFromString('{}', mapBaseURL)); + + expect(resultMap).toStrictEqual(map); + }); + + it('should compose maps that do not interact in any way', () => { + expect(composeMaps([ + `{ + "imports": { "https://a/": "https://b/" } + }`, + `{ + "imports": { "https://c/": "https://d/" } + }`, + `{ + "imports": { "https://e/": "https://f/" } + }` + ])).toStrictEqual({ + imports: { + 'https://a/': ['https://b/'], + 'https://c/': ['https://d/'], + 'https://e/': ['https://f/'] + }, + scopes: {} + }); + }); + + it('should compose maps that interact via cascading', () => { + expect(composeMaps([ + `{ + "imports": { "https://c/": "https://d/" } + }`, + `{ + "imports": { "https://b/": "https://c/" } + }`, + `{ + "imports": { "https://a/": "https://b/" } + }` + ])).toStrictEqual({ + imports: { + 'https://a/': ['https://d/'], + 'https://b/': ['https://d/'], + 'https://c/': ['https://d/'] + }, + scopes: {} + }); + }); + + it('should compose maps with fallbacks that interact via cascading', () => { + expect(composeMaps([ + `{ + "imports": { "https://e/": ["https://g/", "https://h/"] } + }`, + `{ + "imports": { + "https://c/": ["https://f/"], + "https://b/": ["https://d/", "https://e/"] + } + }`, + `{ + "imports": { "https://a/": ["https://b/", "https://c/"] } + }` + ])).toStrictEqual({ + imports: { + 'https://a/': ['https://d/', 'https://g/', 'https://h/', 'https://f/'], + 'https://b/': ['https://d/', 'https://g/', 'https://h/'], + 'https://c/': ['https://f/'], + 'https://e/': ['https://g/', 'https://h/'] + }, + scopes: {} + }); + }); + + it('should compose maps that are using the virtualization patterns we expect to see in the wild', () => { + expect(composeMaps([ + `{ + "imports": { + "${BUILT_IN_MODULE_SCHEME}:blank": "https://built-in-enhancement-1/" + }, + "scopes": { + "https://built-in-enhancement-1/": { + "${BUILT_IN_MODULE_SCHEME}:blank": "${BUILT_IN_MODULE_SCHEME}:blank" + } + } + }`, + `{ + "imports": { + "${BUILT_IN_MODULE_SCHEME}:blank": "https://built-in-enhancement-2/" + }, + "scopes": { + "https://built-in-enhancement-2/": { + "${BUILT_IN_MODULE_SCHEME}:blank": "${BUILT_IN_MODULE_SCHEME}:blank" + } + } + }`, + `{ + "imports": { + "${BUILT_IN_MODULE_SCHEME}:blank": "https://built-in-enhancement-3/" + }, + "scopes": { + "https://built-in-enhancement-3/": { + "${BUILT_IN_MODULE_SCHEME}:blank": "${BUILT_IN_MODULE_SCHEME}:blank" + } + } + }` + ])).toStrictEqual({ + imports: { [`${BUILT_IN_MODULE_SCHEME}:blank`]: ['https://built-in-enhancement-3/'] }, + scopes: { + 'https://built-in-enhancement-1/': { [`${BUILT_IN_MODULE_SCHEME}:blank`]: [`${BUILT_IN_MODULE_SCHEME}:blank`] }, + 'https://built-in-enhancement-2/': { [`${BUILT_IN_MODULE_SCHEME}:blank`]: ['https://built-in-enhancement-1/'] }, + 'https://built-in-enhancement-3/': { [`${BUILT_IN_MODULE_SCHEME}:blank`]: ['https://built-in-enhancement-2/'] } + } + }); + }); + + it('should compose equivalent scopes by merging', () => { + expect(composeMaps([ + `{ + "imports": {}, + "scopes": { + "/x/": { + "/a": "/b", + "/c": "/d" + } + } + }`, + `{ + "imports": {}, + "scopes": { + "/x/": { + "/c": "/z", + "/e": "/f" + } + } + }` + ])).toStrictEqual({ + imports: {}, + scopes: { + 'https://example.com/x/': { + 'https://example.com/a': ['https://example.com/b'], + 'https://example.com/c': ['https://example.com/z'], + 'https://example.com/e': ['https://example.com/f'] + } + } + }); + }); + + it('should use the merge-within-scopes strategy', () => { + expect(composeMaps([ + `{ + "imports": { + "a": "/a-1.mjs", + "b": "/b-1.mjs", + "${BUILT_IN_MODULE_SCHEME}:blank": ["${BUILT_IN_MODULE_SCHEME}:blank", "/blank-1.mjs"] + }, + "scopes": { + "/scope1/": { + "a": "/a-2.mjs" + } + } + }`, + `{ + "imports": { + "b": null, + "${BUILT_IN_MODULE_SCHEME}:blank": "/blank-2.mjs" + }, + "scopes": { + "/scope1/": { + "b": "/b-2.mjs" + } + } + }` + ])).toStrictEqual({ + imports: { + a: ['https://example.com/a-1.mjs'], + b: [], + [`${BUILT_IN_MODULE_SCHEME}:blank`]: ['https://example.com/blank-2.mjs'] + }, + scopes: { + 'https://example.com/scope1/': { + a: ['https://example.com/a-2.mjs'], + b: ['https://example.com/b-2.mjs'] + } + } + }); + }); + + it('should strip bare specifiers on the RHS and warn (empty first map)', () => { + const assertWarnings = testWarningHandler([ + 'Non-URL specifier "b" is not allowed to be the target of an import mapping following composition.', + 'Non-URL specifier "d" is not allowed to be the target of an import mapping following composition.' + ]); + expect(composeMaps([ + `{}`, + `{ + "imports": { + "a": "b", + "c": ["d", "/"] + } + }` + ])).toStrictEqual({ + imports: { + a: [], + c: ['https://example.com/'] + }, + scopes: {} + }); + assertWarnings(); + }); + + it('should strip bare specifiers on the RHS and warn (non-empty first map)', () => { + const assertWarnings = testWarningHandler([ + 'Non-URL specifier "d" is not allowed to be ' + + 'the target of an import mapping following composition.' + ]); + expect(composeMaps([ + `{ + "imports": { + "a": "/a.mjs" + } + }`, + `{ + "imports": { + "b": "a", + "c": "d" + } + }` + ])).toStrictEqual({ + imports: { + a: ['https://example.com/a.mjs'], + b: ['https://example.com/a.mjs'], + c: [] + }, + scopes: {} + }); + assertWarnings(); + }); + + it('should not be confused by different representations of URLs', () => { + expect(composeMaps([ + `{ + "imports": { + "/a": "/b", + "/c": "/d", + "/e": "/f", + "/g": "/h", + "/i": "/j" + } + }`, + `{ + "imports": { + "/v": "/%61", + "/w": "/useless/../c", + "/x": "../../../../../e", + "/y": "./useless%2F..%2F..%2F/g", + "/z": "https://example.com/i" + } + }` + ])).toStrictEqual({ + imports: { + 'https://example.com/a': ['https://example.com/b'], + 'https://example.com/c': ['https://example.com/d'], + 'https://example.com/e': ['https://example.com/f'], + 'https://example.com/g': ['https://example.com/h'], + 'https://example.com/i': ['https://example.com/j'], + + 'https://example.com/v': ['https://example.com/%61'], + 'https://example.com/w': ['https://example.com/d'], + 'https://example.com/x': ['https://example.com/f'], + 'https://example.com/y': ['https://example.com/app/useless%2F..%2F..%2F/g'], + 'https://example.com/z': ['https://example.com/j'] + }, + scopes: {} + }); + }); + + it('should compose "nested" scopes', () => { + expect(composeMaps([ + `{ + "imports": { "https://a/": "https://b/" }, + "scopes": { + "https://example.com/x/y/": { "https://c/": "https://d/" }, + "https://example.com/x/y/z": { "https://e/": "https://f/" } + } + }`, + `{ + "imports": { "https://m/": "https://n/" }, + "scopes": { + "https://example.com/x/y/z": { + "https://g/": "https://a/", + "https://h/": "https://c/", + "https://i/": "https://e/" + } + } + }` + ])).toStrictEqual({ + imports: { + 'https://a/': ['https://b/'], + 'https://m/': ['https://n/'] + }, + scopes: { + 'https://example.com/x/y/': { 'https://c/': ['https://d/'] }, + 'https://example.com/x/y/z': { + 'https://e/': ['https://f/'], + 'https://g/': ['https://b/'], + 'https://h/': ['https://d/'], + 'https://i/': ['https://f/'] + } + } + }); + }); + + it('should not clobber earlier more-specific scopes with later less-specific scopes', () => { + expect(composeMaps([ + `{ + "imports": {}, + "scopes": { + "https://example.com/x/y/": { "https://a/": "https://b/" }, + "https://example.com/x/y/z": { "https://c/": "https://d/" } + } + }`, + `{ + "imports": { + "https://a/": "https://e/" + }, + "scopes": { + "https://example.com/x/": { + "https://c/": "https://f/" + } + } + }` + ])).toStrictEqual({ + imports: { + 'https://a/': ['https://e/'] + }, + scopes: { + 'https://example.com/x/': { 'https://c/': ['https://f/'] }, + 'https://example.com/x/y/': { 'https://a/': ['https://b/'] }, + 'https://example.com/x/y/z': { 'https://c/': ['https://d/'] } + } + }); + }); + + it('composition does not result in a map cascading to itself even for package-prefix-relative resolution', () => { + const assertWarnings = testWarningHandler([ + 'Non-URL specifier "utils/foo.js" is not allowed to be ' + + 'the target of an import mapping following composition.' + ]); + expect(composeMaps([ + `{ + "imports": { + "moment/": "/node_modules/moment/src/" + } + }`, + `{ + "imports": { + "utils/": "moment/", + "foo": "utils/foo.js" + } + }` + ])).toStrictEqual({ + imports: { + 'moment/': ['https://example.com/node_modules/moment/src/'], + 'utils/': ['https://example.com/node_modules/moment/src/'], + foo: [] + }, + scopes: {} + }); + assertWarnings(); + }); + + it('should perform package-prefix-relative composition', () => { + expect(composeMaps([ + `{ + "imports": { + "moment/": "/node_modules/moment/src/" + }, + "scopes": {} + }`, + `{ + "imports": { + "utils/": "moment/lib/utils/", + "is-date": "moment/lib/utils/is-date.js" + }, + "scopes": {} + }`, + `{ + "imports": { + "is-number": "utils/is-number.js" + } + }` + ])).toStrictEqual({ + imports: { + 'moment/': ['https://example.com/node_modules/moment/src/'], + 'utils/': ['https://example.com/node_modules/moment/src/lib/utils/'], + 'is-date': ['https://example.com/node_modules/moment/src/lib/utils/is-date.js'], + 'is-number': ['https://example.com/node_modules/moment/src/lib/utils/is-number.js'] + }, + scopes: {} + }); + }); + + it('should URL-normalize things which have composed into URLs', () => { + expect(composeMaps([ + `{ + "imports": { + "a/": "https://example.com/x/" + }, + "scopes": {} + }`, + `{ + "imports": { + "dot-test": "a/測試" + } + }` + ])).toStrictEqual({ + imports: { + 'a/': ['https://example.com/x/'], + 'dot-test': ['https://example.com/x/%E6%B8%AC%E8%A9%A6'] + }, + scopes: {} + }); + }); + + it('should compose according to the most specific applicable scope', () => { + expect(composeMaps([ + `{ + "imports": { + "a": "https://b/" + }, + "scopes": { + "x/": { "a": "https://c/" }, + "x/y/": { "a": "https://d/" }, + "x/y/z/": { "a": "https://e/" } + } + }`, + `{ + "imports": {}, + "scopes": { + "x/": { + "a-x": "a" + }, + "x/y/": { + "a-y": "a" + }, + "x/y/z/": { + "a-z": "a" + }, + "x/y/w/": { + "a-w": "a" + } + } + }` + ])).toStrictEqual({ + imports: { + a: ['https://b/'] + }, + scopes: { + 'https://example.com/app/x/': { + a: ['https://c/'], + 'a-x': ['https://c/'] + }, + 'https://example.com/app/x/y/': { + a: ['https://d/'], + 'a-y': ['https://d/'] + }, + 'https://example.com/app/x/y/z/': { + a: ['https://e/'], + 'a-z': ['https://e/'] + }, + 'https://example.com/app/x/y/w/': { + 'a-w': ['https://d/'] + } + } + }); + }); + + it('should produce maps with scopes in sorted order', () => { + expect(Object.keys(composeMaps([ + `{ + "imports": {}, + "scopes": { + "https://example.com/x/": { "https://c/": "https://f/" } + } + }`, + `{ + "imports": {}, + "scopes": { + "https://example.com/x/y/": { "https://a/": "https://b/" }, + "https://example.com/x/y/z": { "https://c/": "https://d/" } + } + }` + ]).scopes)).toStrictEqual([ + 'https://example.com/x/y/z', + 'https://example.com/x/y/', + 'https://example.com/x/' + ]); + }); +}); + diff --git a/reference-implementation/__tests__/helpers/parsing.js b/reference-implementation/__tests__/helpers/parsing.js index 5800b77..619dcf2 100644 --- a/reference-implementation/__tests__/helpers/parsing.js +++ b/reference-implementation/__tests__/helpers/parsing.js @@ -2,14 +2,14 @@ const { parseFromString } = require('../../lib/parser.js'); exports.expectSpecifierMap = (input, baseURL, output, warnings = []) => { - const checkWarnings1 = testWarningHandler(warnings); + const checkWarnings1 = exports.testWarningHandler(warnings); expect(parseFromString(`{ "imports": ${input} }`, baseURL)) .toEqual({ imports: output, scopes: {} }); checkWarnings1(); - const checkWarnings2 = testWarningHandler(warnings); + const checkWarnings2 = exports.testWarningHandler(warnings); expect(parseFromString(`{ "scopes": { "https://scope.example/": ${input} } }`, baseURL)) .toEqual({ imports: {}, scopes: { 'https://scope.example/': output } }); @@ -18,7 +18,7 @@ exports.expectSpecifierMap = (input, baseURL, output, warnings = []) => { }; exports.expectScopes = (inputArray, baseURL, outputArray, warnings = []) => { - const checkWarnings = testWarningHandler(warnings); + const checkWarnings = exports.testWarningHandler(warnings); const inputScopesAsStrings = inputArray.map(scopePrefix => `${JSON.stringify(scopePrefix)}: {}`); const inputString = `{ "scopes": { ${inputScopesAsStrings.join(', ')} } }`; @@ -34,19 +34,19 @@ exports.expectScopes = (inputArray, baseURL, outputArray, warnings = []) => { }; exports.expectBad = (input, baseURL, warnings = []) => { - const checkWarnings = testWarningHandler(warnings); + const checkWarnings = exports.testWarningHandler(warnings); expect(() => parseFromString(input, baseURL)).toThrow(TypeError); checkWarnings(); }; -exports.expectWarnings = (input, baseURL, output, warnings = []) => { - const checkWarnings = testWarningHandler(warnings); +exports.expectWarnings = (input, baseURL, output, warnings) => { + const checkWarnings = exports.testWarningHandler(warnings); expect(parseFromString(input, baseURL)).toEqual(output); checkWarnings(); }; -function testWarningHandler(expectedWarnings) { +exports.testWarningHandler = expectedWarnings => { const warnings = []; const { warn } = console; console.warn = warning => { @@ -56,4 +56,4 @@ function testWarningHandler(expectedWarnings) { console.warn = warn; expect(warnings).toEqual(expectedWarnings); }; -} +}; diff --git a/reference-implementation/__tests__/parsing-addresses.js b/reference-implementation/__tests__/parsing-addresses.js index 0f5fc73..0dda394 100644 --- a/reference-implementation/__tests__/parsing-addresses.js +++ b/reference-implementation/__tests__/parsing-addresses.js @@ -2,6 +2,8 @@ const { expectSpecifierMap } = require('./helpers/parsing.js'); const { BUILT_IN_MODULE_SCHEME } = require('../lib/utils.js'); +const baseURL = new URL('https://base.example/path1/path2/path3'); + describe('Relative URL-like addresses', () => { it('should accept strings prefixed with ./, ../, or /', () => { expectSpecifierMap( @@ -10,11 +12,11 @@ describe('Relative URL-like addresses', () => { "dotDotSlash": "../foo", "slash": "/foo" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - dotSlash: [expect.toMatchURL('https://base.example/path1/path2/foo')], - dotDotSlash: [expect.toMatchURL('https://base.example/path1/foo')], - slash: [expect.toMatchURL('https://base.example/foo')] + dotSlash: ['https://base.example/path1/path2/foo'], + dotDotSlash: ['https://base.example/path1/foo'], + slash: ['https://base.example/foo'] } ); }); @@ -26,16 +28,16 @@ describe('Relative URL-like addresses', () => { "dotDotSlash": "../foo", "slash": "/foo" }`, - 'data:text/html,test', + new URL('data:text/html,test'), { dotSlash: [], dotDotSlash: [], slash: [] }, [ - `Invalid address "./foo" for the specifier key "dotSlash".`, - `Invalid address "../foo" for the specifier key "dotDotSlash".`, - `Invalid address "/foo" for the specifier key "slash".` + `Path-based module specifier "./foo" cannot be parsed against the base URL "data:text/html,test".`, + `Path-based module specifier "../foo" cannot be parsed against the base URL "data:text/html,test".`, + `Path-based module specifier "/foo" cannot be parsed against the base URL "data:text/html,test".` ] ); }); @@ -47,16 +49,16 @@ describe('Relative URL-like addresses', () => { "dotDotSlash": "../", "slash": "/" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - dotSlash: [expect.toMatchURL('https://base.example/path1/path2/')], - dotDotSlash: [expect.toMatchURL('https://base.example/path1/')], - slash: [expect.toMatchURL('https://base.example/')] + dotSlash: ['https://base.example/path1/path2/'], + dotDotSlash: ['https://base.example/path1/'], + slash: ['https://base.example/'] } ); }); - it('should ignore percent-encoded variants of ./, ../, or /', () => { + it('should treat percent-encoded variants of ./, ../, or / as non-URLs', () => { expectSpecifierMap( `{ "dotSlash1": "%2E/", @@ -67,25 +69,17 @@ describe('Relative URL-like addresses', () => { "dotSlash3": "%2E%2F", "dotDotSlash3": "%2E%2E%2F" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - dotSlash1: [], - dotDotSlash1: [], - dotSlash2: [], - dotDotSlash2: [], - slash2: [], - dotSlash3: [], - dotDotSlash3: [] + dotSlash1: ['%2E/'], + dotDotSlash1: ['%2E%2E/'], + dotSlash2: ['.%2F'], + dotDotSlash2: ['..%2F'], + slash2: ['%2F'], + dotSlash3: ['%2E%2F'], + dotDotSlash3: ['%2E%2E%2F'] }, - [ - `Invalid address "%2E/" for the specifier key "dotSlash1".`, - `Invalid address "%2E%2E/" for the specifier key "dotDotSlash1".`, - `Invalid address ".%2F" for the specifier key "dotSlash2".`, - `Invalid address "..%2F" for the specifier key "dotDotSlash2".`, - `Invalid address "%2F" for the specifier key "slash2".`, - `Invalid address "%2E%2F" for the specifier key "dotSlash3".`, - `Invalid address "%2E%2E%2F" for the specifier key "dotDotSlash3".` - ] + [] ); }); }); @@ -96,23 +90,23 @@ describe('Built-in module addresses', () => { `{ "foo": "${BUILT_IN_MODULE_SCHEME}:foo" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - foo: [expect.toMatchURL(`${BUILT_IN_MODULE_SCHEME}:foo`)] + foo: [`${BUILT_IN_MODULE_SCHEME}:foo`] } ); }); - it('should ignore percent-encoded variants of the built-in module scheme', () => { + it('should treat percent-encoded variants of the built-in module scheme as non-URLs', () => { expectSpecifierMap( `{ "foo": "${encodeURIComponent(BUILT_IN_MODULE_SCHEME + ':')}foo" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - foo: [] + foo: [`${encodeURIComponent(BUILT_IN_MODULE_SCHEME + ':')}foo`] }, - [`Invalid address "${encodeURIComponent(BUILT_IN_MODULE_SCHEME + ':')}foo" for the specifier key "foo".`] + [] ); }); @@ -123,11 +117,11 @@ describe('Built-in module addresses', () => { "slashMiddle": "${BUILT_IN_MODULE_SCHEME}:foo/bar", "backslash": "${BUILT_IN_MODULE_SCHEME}:foo\\\\baz" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - slashEnd: [expect.toMatchURL(`${BUILT_IN_MODULE_SCHEME}:foo/`)], - slashMiddle: [expect.toMatchURL(`${BUILT_IN_MODULE_SCHEME}:foo/bar`)], - backslash: [expect.toMatchURL(`${BUILT_IN_MODULE_SCHEME}:foo\\baz`)] + slashEnd: [`${BUILT_IN_MODULE_SCHEME}:foo/`], + slashMiddle: [`${BUILT_IN_MODULE_SCHEME}:foo/bar`], + backslash: [`${BUILT_IN_MODULE_SCHEME}:foo\\baz`] } ); }); @@ -150,27 +144,22 @@ describe('Absolute URL addresses', () => { "javascript": "javascript:bad", "wss": "wss:bad" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - about: [expect.toMatchURL('about:good')], - blob: [expect.toMatchURL('blob:good')], - data: [expect.toMatchURL('data:good')], - file: [expect.toMatchURL('file:///good')], - filesystem: [expect.toMatchURL('filesystem:good')], - http: [expect.toMatchURL('http://good/')], - https: [expect.toMatchURL('https://good/')], - ftp: [expect.toMatchURL('ftp://good/')], - import: [], - mailto: [], - javascript: [], - wss: [] + about: ['about:good'], + blob: ['blob:good'], + data: ['data:good'], + file: ['file:///good'], + filesystem: ['filesystem:good'], + http: ['http://good/'], + https: ['https://good/'], + ftp: ['ftp://good/'], + import: ['import:bad'], + mailto: ['mailto:bad'], + javascript: ['javascript:bad'], + wss: ['wss:bad'] }, - [ - `Invalid address "import:bad" for the specifier key "import".`, - `Invalid address "mailto:bad" for the specifier key "mailto".`, - `Invalid address "javascript:bad" for the specifier key "javascript".`, - `Invalid address "wss:bad" for the specifier key "wss".` - ] + [] ); }); @@ -190,58 +179,57 @@ describe('Absolute URL addresses', () => { "javascript": ["javascript:bad"], "wss": ["wss:bad"] }`, - 'https://base.example/path1/path2/path3', + baseURL, { - about: [expect.toMatchURL('about:good')], - blob: [expect.toMatchURL('blob:good')], - data: [expect.toMatchURL('data:good')], - file: [expect.toMatchURL('file:///good')], - filesystem: [expect.toMatchURL('filesystem:good')], - http: [expect.toMatchURL('http://good/')], - https: [expect.toMatchURL('https://good/')], - ftp: [expect.toMatchURL('ftp://good/')], - import: [], - mailto: [], - javascript: [], - wss: [] + about: ['about:good'], + blob: ['blob:good'], + data: ['data:good'], + file: ['file:///good'], + filesystem: ['filesystem:good'], + http: ['http://good/'], + https: ['https://good/'], + ftp: ['ftp://good/'], + import: ['import:bad'], + mailto: ['mailto:bad'], + javascript: ['javascript:bad'], + wss: ['wss:bad'] }, - [ - `Invalid address "import:bad" for the specifier key "import".`, - `Invalid address "mailto:bad" for the specifier key "mailto".`, - `Invalid address "javascript:bad" for the specifier key "javascript".`, - `Invalid address "wss:bad" for the specifier key "wss".` - ] + [] ); }); - it('should parse absolute URLs, ignoring unparseable ones', () => { + it('should parse/normalize absolute URLs, and treat unparseable ones as non-URLs', () => { expectSpecifierMap( `{ "unparseable1": "https://ex ample.org/", "unparseable2": "https://example.com:demo", "unparseable3": "http://[www.example.com]/", + "unparseable4": "-https://測試.com/測試", + "unparseable5": "-HTTPS://example.com", "invalidButParseable1": "https:example.org", "invalidButParseable2": "https://///example.com///", "prettyNormal": "https://example.net", "percentDecoding": "https://ex%41mple.com/", - "noPercentDecoding": "https://example.com/%41" + "noPercentDecoding": "https://example.com/%41", + "nonAscii": "https://測試.com/測試", + "uppercase": "HTTPS://example.com" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - unparseable1: [], - unparseable2: [], - unparseable3: [], - invalidButParseable1: [expect.toMatchURL('https://example.org/')], - invalidButParseable2: [expect.toMatchURL('https://example.com///')], - prettyNormal: [expect.toMatchURL('https://example.net/')], - percentDecoding: [expect.toMatchURL('https://example.com/')], - noPercentDecoding: [expect.toMatchURL('https://example.com/%41')] + unparseable1: ['https://ex ample.org/'], + unparseable2: ['https://example.com:demo'], + unparseable3: ['http://[www.example.com]/'], + unparseable4: ['-https://測試.com/測試'], + unparseable5: ['-HTTPS://example.com'], + invalidButParseable1: ['https://example.org/'], + invalidButParseable2: ['https://example.com///'], + prettyNormal: ['https://example.net/'], + percentDecoding: ['https://example.com/'], + noPercentDecoding: ['https://example.com/%41'], + nonAscii: ['https://xn--g6w251d.com/%E6%B8%AC%E8%A9%A6'], + uppercase: ['https://example.com/'] }, - [ - `Invalid address "https://ex ample.org/" for the specifier key "unparseable1".`, - `Invalid address "https://example.com:demo" for the specifier key "unparseable2".`, - `Invalid address "http://[www.example.com]/" for the specifier key "unparseable3".` - ] + [] ); }); @@ -257,22 +245,18 @@ describe('Absolute URL addresses', () => { "percentDecoding": ["https://ex%41mple.com/"], "noPercentDecoding": ["https://example.com/%41"] }`, - 'https://base.example/path1/path2/path3', + baseURL, { - unparseable1: [], - unparseable2: [], - unparseable3: [], - invalidButParseable1: [expect.toMatchURL('https://example.org/')], - invalidButParseable2: [expect.toMatchURL('https://example.com///')], - prettyNormal: [expect.toMatchURL('https://example.net/')], - percentDecoding: [expect.toMatchURL('https://example.com/')], - noPercentDecoding: [expect.toMatchURL('https://example.com/%41')] + unparseable1: ['https://ex ample.org/'], + unparseable2: ['https://example.com:demo'], + unparseable3: ['http://[www.example.com]/'], + invalidButParseable1: ['https://example.org/'], + invalidButParseable2: ['https://example.com///'], + prettyNormal: ['https://example.net/'], + percentDecoding: ['https://example.com/'], + noPercentDecoding: ['https://example.com/%41'] }, - [ - `Invalid address "https://ex ample.org/" for the specifier key "unparseable1".`, - `Invalid address "https://example.com:demo" for the specifier key "unparseable2".`, - `Invalid address "http://[www.example.com]/" for the specifier key "unparseable3".` - ] + [] ); }); }); @@ -284,7 +268,7 @@ describe('Failing addresses: mismatched trailing slashes', () => { "trailer/": "/notrailer", "${BUILT_IN_MODULE_SCHEME}:trailer/": "/bim-notrailer" }`, - 'https://base.example/path1/path2/path3', + baseURL, { 'trailer/': [], [`${BUILT_IN_MODULE_SCHEME}:trailer/`]: [] @@ -302,7 +286,7 @@ describe('Failing addresses: mismatched trailing slashes', () => { "trailer/": ["/notrailer"], "${BUILT_IN_MODULE_SCHEME}:trailer/": ["/bim-notrailer"] }`, - 'https://base.example/path1/path2/path3', + baseURL, { 'trailer/': [], [`${BUILT_IN_MODULE_SCHEME}:trailer/`]: [] @@ -320,10 +304,10 @@ describe('Failing addresses: mismatched trailing slashes', () => { "trailer/": ["/atrailer/", "/notrailer"], "${BUILT_IN_MODULE_SCHEME}:trailer/": ["/bim-atrailer/", "/bim-notrailer"] }`, - 'https://base.example/path1/path2/path3', + baseURL, { - 'trailer/': [expect.toMatchURL('https://base.example/atrailer/')], - [`${BUILT_IN_MODULE_SCHEME}:trailer/`]: [expect.toMatchURL('https://base.example/bim-atrailer/')] + 'trailer/': ['https://base.example/atrailer/'], + [`${BUILT_IN_MODULE_SCHEME}:trailer/`]: ['https://base.example/bim-atrailer/'] }, [ `Invalid address "https://base.example/notrailer" for package specifier key "trailer/". Package addresses must end with "/".`, @@ -334,17 +318,17 @@ describe('Failing addresses: mismatched trailing slashes', () => { }); describe('Other invalid addresses', () => { - it('should ignore unprefixed strings that are not absolute URLs', () => { - for (const bad of ['bar', '\\bar', '~bar', '#bar', '?bar']) { + it('should treat unprefixed strings that are not absolute URLs as non-URLs', () => { + for (const nonURL of ['bar', '\\bar', '~bar', '#bar', '?bar']) { expectSpecifierMap( `{ - "foo": ${JSON.stringify(bad)} + "foo": ${JSON.stringify(nonURL)} }`, - 'https://base.example/path1/path2/path3', + baseURL, { - foo: [] + foo: [nonURL] }, - [`Invalid address "${bad}" for the specifier key "foo".`] + [] ); } }); diff --git a/reference-implementation/__tests__/parsing-schema.js b/reference-implementation/__tests__/parsing-schema.js index 6950345..021c8a0 100644 --- a/reference-implementation/__tests__/parsing-schema.js +++ b/reference-implementation/__tests__/parsing-schema.js @@ -2,28 +2,29 @@ const { parseFromString } = require('../lib/parser.js'); const { expectBad, expectWarnings, expectSpecifierMap } = require('./helpers/parsing.js'); +const baseURL = new URL('https://base.example/'); const nonObjectStrings = ['null', 'true', '1', '"foo"', '[]']; test('Invalid JSON', () => { - expect(() => parseFromString('{ imports: {} }', 'https://base.example/')).toThrow(SyntaxError); + expect(() => parseFromString('{ imports: {} }', baseURL)).toThrow(SyntaxError); }); describe('Mismatching the top-level schema', () => { it('should throw for top-level non-objects', () => { for (const nonObject of nonObjectStrings) { - expectBad(nonObject, 'https://base.example/'); + expectBad(nonObject, baseURL); } }); it('should throw if imports is a non-object', () => { for (const nonObject of nonObjectStrings) { - expectBad(`{ "imports": ${nonObject} }`, 'https://base.example/'); + expectBad(`{ "imports": ${nonObject} }`, baseURL); } }); it('should throw if scopes is a non-object', () => { for (const nonObject of nonObjectStrings) { - expectBad(`{ "scopes": ${nonObject} }`, 'https://base.example/'); + expectBad(`{ "scopes": ${nonObject} }`, baseURL); } }); @@ -34,7 +35,7 @@ describe('Mismatching the top-level schema', () => { "new-feature": {}, "scops": {} }`, - 'https://base.example/', + baseURL, { imports: {}, scopes: {} }, [ `Invalid top-level key "new-feature". Only "imports" and "scopes" can be present.`, @@ -55,9 +56,9 @@ describe('Mismatching the specifier map schema', () => { "foo": ${invalid}, "bar": ["https://example.com/"] }`, - 'https://base.example/', + baseURL, { - bar: [expect.toMatchURL('https://example.com/')] + bar: ['https://example.com/'] }, [ `Invalid address ${invalid} for the specifier key "foo". ` + @@ -72,9 +73,9 @@ describe('Mismatching the specifier map schema', () => { `{ "": ["https://example.com/"] }`, - 'https://base.example/', + baseURL, {}, - [`Invalid empty string specifier key.`] + [`Invalid empty string specifier.`] ); }); @@ -85,10 +86,10 @@ describe('Mismatching the specifier map schema', () => { "foo": ["https://example.com/", ${invalid}], "bar": ["https://example.com/"] }`, - 'https://base.example/', + baseURL, { - foo: [expect.toMatchURL('https://example.com/')], - bar: [expect.toMatchURL('https://example.com/')] + foo: ['https://example.com/'], + bar: ['https://example.com/'] }, [ `Invalid address ${invalid} inside the address array for the specifier key "foo". ` + @@ -100,24 +101,24 @@ describe('Mismatching the specifier map schema', () => { it('should throw if a scope\'s value is not an object', () => { for (const invalid of nonObjectStrings) { - expectBad(`{ "scopes": { "https://scope.example/": ${invalid} } }`, 'https://base.example/'); + expectBad(`{ "scopes": { "https://scope.example/": ${invalid} } }`, baseURL); } }); }); describe('Normalization', () => { it('should normalize empty import maps to have imports and scopes keys', () => { - expect(parseFromString(`{}`, 'https://base.example/')) + expect(parseFromString(`{}`, baseURL)) .toEqual({ imports: {}, scopes: {} }); }); it('should normalize an import map without imports to have imports', () => { - expect(parseFromString(`{ "scopes": {} }`, 'https://base.example/')) + expect(parseFromString(`{ "scopes": {} }`, baseURL)) .toEqual({ imports: {}, scopes: {} }); }); it('should normalize an import map without scopes to have scopes', () => { - expect(parseFromString(`{ "imports": {} }`, 'https://base.example/')) + expect(parseFromString(`{ "imports": {} }`, baseURL)) .toEqual({ imports: {}, scopes: {} }); }); @@ -128,10 +129,10 @@ describe('Normalization', () => { "bar": ["https://example.com/2"], "baz": null }`, - 'https://base.example/', + baseURL, { - foo: [expect.toMatchURL('https://example.com/1')], - bar: [expect.toMatchURL('https://example.com/2')], + foo: ['https://example.com/1'], + bar: ['https://example.com/2'], baz: [] } ); diff --git a/reference-implementation/__tests__/parsing-scope-keys.js b/reference-implementation/__tests__/parsing-scope-keys.js index cd1d9b3..2bb3076 100644 --- a/reference-implementation/__tests__/parsing-scope-keys.js +++ b/reference-implementation/__tests__/parsing-scope-keys.js @@ -1,11 +1,12 @@ 'use strict'; const { expectScopes } = require('./helpers/parsing.js'); +const baseURL = new URL('https://base.example/path1/path2/path3'); describe('Relative URL scope keys', () => { it('should work with no prefix', () => { expectScopes( ['foo'], - 'https://base.example/path1/path2/path3', + baseURL, ['https://base.example/path1/path2/foo'] ); }); @@ -13,7 +14,7 @@ describe('Relative URL scope keys', () => { it('should work with ./, ../, and / prefixes', () => { expectScopes( ['./foo', '../foo', '/foo'], - 'https://base.example/path1/path2/path3', + baseURL, [ 'https://base.example/path1/path2/foo', 'https://base.example/path1/foo', @@ -25,7 +26,7 @@ describe('Relative URL scope keys', () => { it('should work with /s, ?s, and #s', () => { expectScopes( ['foo/bar?baz#qux'], - 'https://base.example/path1/path2/path3', + baseURL, ['https://base.example/path1/path2/foo/bar?baz#qux'] ); }); @@ -33,15 +34,15 @@ describe('Relative URL scope keys', () => { it('should work with an empty string scope key', () => { expectScopes( [''], - 'https://base.example/path1/path2/path3', - ['https://base.example/path1/path2/path3'] + baseURL, + [baseURL] ); }); it('should work with / suffixes', () => { expectScopes( ['foo/', './foo/', '../foo/', '/foo/', '/foo//'], - 'https://base.example/path1/path2/path3', + baseURL, [ 'https://base.example/path1/path2/foo/', 'https://base.example/path1/path2/foo/', @@ -55,7 +56,7 @@ describe('Relative URL scope keys', () => { it('should deduplicate based on URL parsing rules', () => { expectScopes( ['foo/\\', 'foo//', 'foo\\\\'], - 'https://base.example/path1/path2/path3', + baseURL, ['https://base.example/path1/path2/foo//'] ); }); @@ -78,7 +79,7 @@ describe('Absolute URL scope keys', () => { 'javascript:bad', 'wss:ba' ], - 'https://base.example/path1/path2/path3', + baseURL, [ 'about:good', 'blob:good', @@ -110,7 +111,7 @@ describe('Absolute URL scope keys', () => { 'https://ex%41mple.com/foo/', 'https://example.com/%41' ], - 'https://base.example/path1/path2/path3', + baseURL, [ 'https://base.example/path1/path2/example.org', // tricky case! remember we have a base URL 'https://example.com///', @@ -133,7 +134,7 @@ describe('Absolute URL scope keys', () => { '../foo', '/foo' ], - 'data:text/html,test', + new URL('data:text/html,test'), [], [ 'Invalid scope "./foo" (parsed against base URL "data:text/html,test").', diff --git a/reference-implementation/__tests__/parsing-specifier-keys.js b/reference-implementation/__tests__/parsing-specifier-keys.js index 9eb423a..d9dcb73 100644 --- a/reference-implementation/__tests__/parsing-specifier-keys.js +++ b/reference-implementation/__tests__/parsing-specifier-keys.js @@ -2,6 +2,7 @@ const { expectSpecifierMap } = require('./helpers/parsing.js'); const { BUILT_IN_MODULE_SCHEME } = require('../lib/utils.js'); +const baseURL = new URL('https://base.example/path1/path2/path3'); const BLANK = `${BUILT_IN_MODULE_SCHEME}:blank`; describe('Relative URL-like specifier keys', () => { @@ -12,11 +13,11 @@ describe('Relative URL-like specifier keys', () => { "../foo": "/dotdotslash", "/foo": "/slash" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - 'https://base.example/path1/path2/foo': [expect.toMatchURL('https://base.example/dotslash')], - 'https://base.example/path1/foo': [expect.toMatchURL('https://base.example/dotdotslash')], - 'https://base.example/foo': [expect.toMatchURL('https://base.example/slash')] + 'https://base.example/path1/path2/foo': ['https://base.example/dotslash'], + 'https://base.example/path1/foo': ['https://base.example/dotdotslash'], + 'https://base.example/foo': ['https://base.example/slash'] } ); }); @@ -28,12 +29,13 @@ describe('Relative URL-like specifier keys', () => { "../foo": "https://example.com/dotdotslash", "/foo": "https://example.com/slash" }`, - 'data:text/html,test', - { - './foo': [expect.toMatchURL('https://example.com/dotslash')], - '../foo': [expect.toMatchURL('https://example.com/dotdotslash')], - '/foo': [expect.toMatchURL('https://example.com/slash')] - } + new URL('data:text/html,test'), + {}, + [ + 'Path-based module specifier "./foo" cannot be parsed against the base URL "data:text/html,test".', + 'Path-based module specifier "../foo" cannot be parsed against the base URL "data:text/html,test".', + 'Path-based module specifier "/foo" cannot be parsed against the base URL "data:text/html,test".' + ] ); }); @@ -44,11 +46,11 @@ describe('Relative URL-like specifier keys', () => { "../": "/dotdotslash/", "/": "/slash/" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - 'https://base.example/path1/path2/': [expect.toMatchURL('https://base.example/dotslash/')], - 'https://base.example/path1/': [expect.toMatchURL('https://base.example/dotdotslash/')], - 'https://base.example/': [expect.toMatchURL('https://base.example/slash/')] + 'https://base.example/path1/path2/': ['https://base.example/dotslash/'], + 'https://base.example/path1/': ['https://base.example/dotdotslash/'], + 'https://base.example/': ['https://base.example/slash/'] } ); }); @@ -64,15 +66,15 @@ describe('Relative URL-like specifier keys', () => { "%2E%2F": "/dotSlash3", "%2E%2E%2F": "/dotDotSlash3" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - '%2E/': [expect.toMatchURL('https://base.example/dotSlash1/')], - '%2E%2E/': [expect.toMatchURL('https://base.example/dotDotSlash1/')], - '.%2F': [expect.toMatchURL('https://base.example/dotSlash2')], - '..%2F': [expect.toMatchURL('https://base.example/dotDotSlash2')], - '%2F': [expect.toMatchURL('https://base.example/slash2')], - '%2E%2F': [expect.toMatchURL('https://base.example/dotSlash3')], - '%2E%2E%2F': [expect.toMatchURL('https://base.example/dotDotSlash3')] + '%2E/': ['https://base.example/dotSlash1/'], + '%2E%2E/': ['https://base.example/dotDotSlash1/'], + '.%2F': ['https://base.example/dotSlash2'], + '..%2F': ['https://base.example/dotDotSlash2'], + '%2F': ['https://base.example/slash2'], + '%2E%2F': ['https://base.example/dotSlash3'], + '%2E%2E%2F': ['https://base.example/dotDotSlash3'] } ); }); @@ -95,20 +97,20 @@ describe('Absolute URL specifier keys', () => { "javascript:bad": "/javascript", "wss:bad": "/wss" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - 'about:good': [expect.toMatchURL('https://base.example/about')], - 'blob:good': [expect.toMatchURL('https://base.example/blob')], - 'data:good': [expect.toMatchURL('https://base.example/data')], - 'file:///good': [expect.toMatchURL('https://base.example/file')], - 'filesystem:good': [expect.toMatchURL('https://base.example/filesystem')], - 'http://good/': [expect.toMatchURL('https://base.example/http/')], - 'https://good/': [expect.toMatchURL('https://base.example/https/')], - 'ftp://good/': [expect.toMatchURL('https://base.example/ftp/')], - 'import:bad': [expect.toMatchURL('https://base.example/import')], - 'mailto:bad': [expect.toMatchURL('https://base.example/mailto')], - 'javascript:bad': [expect.toMatchURL('https://base.example/javascript')], - 'wss:bad': [expect.toMatchURL('https://base.example/wss')] + 'about:good': ['https://base.example/about'], + 'blob:good': ['https://base.example/blob'], + 'data:good': ['https://base.example/data'], + 'file:///good': ['https://base.example/file'], + 'filesystem:good': ['https://base.example/filesystem'], + 'http://good/': ['https://base.example/http/'], + 'https://good/': ['https://base.example/https/'], + 'ftp://good/': ['https://base.example/ftp/'], + 'import:bad': ['https://base.example/import'], + 'mailto:bad': ['https://base.example/mailto'], + 'javascript:bad': ['https://base.example/javascript'], + 'wss:bad': ['https://base.example/wss'] } ); }); @@ -125,16 +127,16 @@ describe('Absolute URL specifier keys', () => { "https://ex%41mple.com/": "/percentDecoding/", "https://example.com/%41": "/noPercentDecoding" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - 'https://ex ample.org/': [expect.toMatchURL('https://base.example/unparseable1/')], - 'https://example.com:demo': [expect.toMatchURL('https://base.example/unparseable2')], - 'http://[www.example.com]/': [expect.toMatchURL('https://base.example/unparseable3/')], - 'https://example.org/': [expect.toMatchURL('https://base.example/invalidButParseable1/')], - 'https://example.com///': [expect.toMatchURL('https://base.example/invalidButParseable2/')], - 'https://example.net/': [expect.toMatchURL('https://base.example/prettyNormal/')], - 'https://example.com/': [expect.toMatchURL('https://base.example/percentDecoding/')], - 'https://example.com/%41': [expect.toMatchURL('https://base.example/noPercentDecoding')] + 'https://ex ample.org/': ['https://base.example/unparseable1/'], + 'https://example.com:demo': ['https://base.example/unparseable2'], + 'http://[www.example.com]/': ['https://base.example/unparseable3/'], + 'https://example.org/': ['https://base.example/invalidButParseable1/'], + 'https://example.com///': ['https://base.example/invalidButParseable2/'], + 'https://example.net/': ['https://base.example/prettyNormal/'], + 'https://example.com/': ['https://base.example/percentDecoding/'], + 'https://example.com/%41': ['https://base.example/noPercentDecoding'] } ); }); @@ -147,12 +149,12 @@ describe('Absolute URL specifier keys', () => { "${BLANK}/foo": "/blank/foo", "${BLANK}\\\\foo": "/blank/backslashfoo" }`, - 'https://base.example/path1/path2/path3', + baseURL, { - [BLANK]: [expect.toMatchURL('https://base.example/blank')], - [`${BLANK}/`]: [expect.toMatchURL('https://base.example/blank/')], - [`${BLANK}/foo`]: [expect.toMatchURL('https://base.example/blank/foo')], - [`${BLANK}\\foo`]: [expect.toMatchURL('https://base.example/blank/backslashfoo')] + [BLANK]: ['https://base.example/blank'], + [`${BLANK}/`]: ['https://base.example/blank/'], + [`${BLANK}/foo`]: ['https://base.example/blank/foo'], + [`${BLANK}\\foo`]: ['https://base.example/blank/backslashfoo'] } ); }); diff --git a/reference-implementation/__tests__/resolving-builtins.js b/reference-implementation/__tests__/resolving-builtins.js index a9383df..99dee81 100644 --- a/reference-implementation/__tests__/resolving-builtins.js +++ b/reference-implementation/__tests__/resolving-builtins.js @@ -144,6 +144,19 @@ describe('Fallbacks with built-in module addresses', () => { "none": [ "${NONE}", "./none-fallback.mjs" + ], + "twoGoodBuiltins": [ + "${BLANK}", + "${BLANK}" + ], + "oneGoodBuiltinTwoURLs": [ + "${BLANK}", + "/bad2-1.mjs", + "/bad2-2.mjs" + ], + "twoURLs": [ + "/bad3-1.mjs", + "/bad3-2.mjs" ] } }`); @@ -155,4 +168,16 @@ describe('Fallbacks with built-in module addresses', () => { it(`should fall back past "${NONE}"`, () => { expect(resolveUnderTest('none')).toMatchURL('https://example.com/app/none-fallback.mjs'); }); + + it('should fall back for [built-in, built-in]', () => { + expect(resolveUnderTest('twoGoodBuiltins')).toMatchURL(BLANK); + }); + + it('should fail for [built-in, fetch scheme, fetch scheme]', () => { + expect(resolveUnderTest('oneGoodBuiltinTwoURLs')).toMatchURL(BLANK); + }); + + it('should fail for [fetch scheme, fetch scheme]', () => { + expect(resolveUnderTest('twoURLs')).toMatchURL('https://example.com/bad3-1.mjs'); + }); }); diff --git a/reference-implementation/__tests__/resolving-not-yet-implemented.js b/reference-implementation/__tests__/resolving-not-yet-implemented.js deleted file mode 100644 index 93d782f..0000000 --- a/reference-implementation/__tests__/resolving-not-yet-implemented.js +++ /dev/null @@ -1,47 +0,0 @@ -'use strict'; -const { URL } = require('url'); -const { parseFromString } = require('../lib/parser.js'); -const { resolve } = require('../lib/resolver.js'); -const { BUILT_IN_MODULE_SCHEME } = require('../lib/utils.js'); - -const mapBaseURL = new URL('https://example.com/app/index.html'); -const scriptURL = new URL('https://example.com/js/app.mjs'); - -const BLANK = `${BUILT_IN_MODULE_SCHEME}:blank`; - -function makeResolveUnderTest(mapString) { - const map = parseFromString(mapString, mapBaseURL); - return specifier => resolve(specifier, map, scriptURL); -} - -describe('Fallbacks that are not [built-in, fetch scheme]', () => { - const resolveUnderTest = makeResolveUnderTest(`{ - "imports": { - "bad1": [ - "${BLANK}", - "${BLANK}" - ], - "bad2": [ - "${BLANK}", - "/bad2-1.mjs", - "/bad2-2.mjs" - ], - "bad3": [ - "/bad3-1.mjs", - "/bad3-2.mjs" - ] - } - }`); - - it('should fail for [built-in, built-in]', () => { - expect(() => resolveUnderTest('bad1')).toThrow(/not yet implemented/); - }); - - it('should fail for [built-in, fetch scheme, fetch scheme]', () => { - expect(() => resolveUnderTest('bad2')).toThrow(/not yet implemented/); - }); - - it('should fail for [fetch scheme, fetch scheme]', () => { - expect(() => resolveUnderTest('bad3')).toThrow(/not yet implemented/); - }); -}); diff --git a/reference-implementation/__tests__/resolving.js b/reference-implementation/__tests__/resolving.js index 29ee31c..53fa815 100644 --- a/reference-implementation/__tests__/resolving.js +++ b/reference-implementation/__tests__/resolving.js @@ -2,6 +2,11 @@ const { URL } = require('url'); const { parseFromString } = require('../lib/parser.js'); const { resolve } = require('../lib/resolver.js'); +const { BUILT_IN_MODULE_SCHEME } = require('../lib/utils.js'); +const { testWarningHandler } = require('./helpers/parsing.js'); + +const BLANK = `${BUILT_IN_MODULE_SCHEME}:blank`; +const NONE = `${BUILT_IN_MODULE_SCHEME}:none`; const mapBaseURL = new URL('https://example.com/app/index.html'); const scriptURL = new URL('https://example.com/js/app.mjs'); @@ -42,13 +47,6 @@ describe('Unmapped', () => { expect(resolveUnderTest('https://///example.com///')).toMatchURL('https://example.com///'); }); - it('should fail for absolute non-fetch-scheme URLs', () => { - expect(() => resolveUnderTest('mailto:bad')).toThrow(TypeError); - expect(() => resolveUnderTest('import:bad')).toThrow(TypeError); - expect(() => resolveUnderTest('javascript:bad')).toThrow(TypeError); - expect(() => resolveUnderTest('wss:bad')).toThrow(TypeError); - }); - it('should fail for strings not parseable as absolute URLs and not starting with ./ ../ or /', () => { expect(() => resolveUnderTest('foo')).toThrow(TypeError); expect(() => resolveUnderTest('\\foo')).toThrow(TypeError); @@ -63,6 +61,57 @@ describe('Unmapped', () => { }); }); +describe('the empty string', () => { + it('should fail for an unmapped empty string', () => { + const resolveUnderTest = makeResolveUnderTest(`{}`); + expect(() => resolveUnderTest('')).toThrow(TypeError); + }); + + it('should fail for a mapped empty string', () => { + const assertWarnings = testWarningHandler([ + 'Invalid empty string specifier.', + 'Invalid empty string specifier.', + 'Invalid empty string specifier.' + ]); + const resolveUnderTest = makeResolveUnderTest(`{ + "imports": { + "": "/", + "emptyString": "", + "emptyString/": "" + } + }`); + expect(() => resolveUnderTest('')).toThrow(TypeError); + expect(() => resolveUnderTest('emptyString')).toThrow(TypeError); + expect(() => resolveUnderTest('emptyString/a')).toThrow(TypeError); + assertWarnings(); + }); +}); + +describe('non-fetch-schemes', () => { + it('should fail for absolute non-fetch-scheme URLs', () => { + const resolveUnderTest = makeResolveUnderTest(`{}`); + expect(() => resolveUnderTest('mailto:bad')).toThrow(TypeError); + expect(() => resolveUnderTest('import:bad')).toThrow(TypeError); + expect(() => resolveUnderTest('javascript:bad')).toThrow(TypeError); + expect(() => resolveUnderTest('wss:bad')).toThrow(TypeError); + }); + + it('should allow remapping module specifiers that are non-fetch-scheme URLs', () => { + const resolveUnderTest = makeResolveUnderTest(`{ + "imports": { + "mailto:bad": "/", + "import:bad": "/", + "javascript:bad": "/", + "wss:bad": "/" + } + }`); + expect(resolveUnderTest('mailto:bad')).toMatchURL('https://example.com/'); + expect(resolveUnderTest('import:bad')).toMatchURL('https://example.com/'); + expect(resolveUnderTest('javascript:bad')).toMatchURL('https://example.com/'); + expect(resolveUnderTest('wss:bad')).toMatchURL('https://example.com/'); + }); +}); + describe('Mapped using the "imports" key only (no scopes)', () => { it('should fail when the mapping is to an empty array', () => { const resolveUnderTest = makeResolveUnderTest(`{ @@ -85,7 +134,8 @@ describe('Mapped using the "imports" key only (no scopes)', () => { "lodash-dot/": "./node_modules/lodash-es/", "lodash-dotdot": "../node_modules/lodash-es/lodash.js", "lodash-dotdot/": "../node_modules/lodash-es/", - "nowhere/": [] + "nowhere/": [], + "not-a-url/": "a/" } }`); @@ -114,6 +164,15 @@ describe('Mapped using the "imports" key only (no scopes)', () => { it('should fail for package submodules that map to nowhere', () => { expect(() => resolveUnderTest('nowhere/foo')).toThrow(TypeError); }); + + it('should not allow breaking out of a package', () => { + expect(resolveUnderTest(`moment/${BLANK}`)).toMatchURL(`https://example.com/node_modules/moment/src/${BLANK}`); + expect(resolveUnderTest(`moment/${NONE}`)).toMatchURL(`https://example.com/node_modules/moment/src/${NONE}`); + expect(resolveUnderTest('moment/https://example.org/')).toMatchURL('https://example.com/node_modules/moment/src/https://example.org/'); + expect(() => resolveUnderTest('nowhere/https://example.org/')).toThrow(TypeError); + expect(resolveUnderTest('moment/http://[www.example.com]/')).toMatchURL('https://example.com/node_modules/moment/src/http://[www.example.com]/'); + expect(() => resolveUnderTest('not-a-url/a')).toThrow(TypeError); + }); }); describe('Tricky specifiers', () => { @@ -146,11 +205,72 @@ describe('Mapped using the "imports" key only (no scopes)', () => { }); }); + describe('percent-encoding', () => { + it('should not try to resolve percent-encoded path-based URLs (in keys)', () => { + const resolveUnderTest = makeResolveUnderTest(`{ + "imports": { + "%2E/": "/dotSlash1/", + ".%2F": "/dotSlash2/", + "%2E%2F": "/dotSlash3/", + ".%2E/": "/dotDotSlash1/", + "%2E./": "/dotDotSlash2/", + "%2E%2E/": "/dotDotSlash3/", + "%2E%2E%2F": "/dotDotSlash4/", + "%2F": "/slash1/" + } + }`); + expect(resolveUnderTest('/')).toMatchURL('https://example.com/'); + expect(resolveUnderTest('./')).toMatchURL('https://example.com/js/'); + expect(resolveUnderTest('../')).toMatchURL('https://example.com/'); + expect(resolveUnderTest('%2F')).toMatchURL('https://example.com/slash1/'); + expect(resolveUnderTest('%2E/')).toMatchURL('https://example.com/dotSlash1/'); + expect(resolveUnderTest('.%2F')).toMatchURL('https://example.com/dotSlash2/'); + expect(resolveUnderTest('%2E%2F')).toMatchURL('https://example.com/dotSlash3/'); + expect(resolveUnderTest('.%2E/')).toMatchURL('https://example.com/dotDotSlash1/'); + expect(resolveUnderTest('%2E./')).toMatchURL('https://example.com/dotDotSlash2/'); + expect(resolveUnderTest('%2E%2E/')).toMatchURL('https://example.com/dotDotSlash3/'); + expect(resolveUnderTest('%2E%2E%2F')).toMatchURL('https://example.com/dotDotSlash4/'); + }); + + it('should not try to resolve percent-encoded path-based URLs (in values)', () => { + const resolveUnderTest = makeResolveUnderTest(`{ + "imports": { + "slash1": "%2F", + "dotSlash1": "%2E/", + "dotSlash2": ".%2F", + "dotSlash3": "%2E%2F", + "dotDotSlash1": ".%2E/", + "dotDotSlash2": "%2E./", + "dotDotSlash3": "%2E%2E/", + "dotDotSlash4": "%2E%2E%2F" + } + }`); + expect(() => resolveUnderTest('slash1')).toThrow(TypeError); + expect(() => resolveUnderTest('dotSlash1')).toThrow(TypeError); + expect(() => resolveUnderTest('dotSlash2')).toThrow(TypeError); + expect(() => resolveUnderTest('dotSlash3')).toThrow(TypeError); + expect(() => resolveUnderTest('dotDotSlash1')).toThrow(TypeError); + expect(() => resolveUnderTest('dotDotSlash2')).toThrow(TypeError); + expect(() => resolveUnderTest('dotDotSlash3')).toThrow(TypeError); + expect(() => resolveUnderTest('dotDotSlash4')).toThrow(TypeError); + }); + + it('should not try to resolve percent-encoded built-in modules', () => { + const resolveUnderTest = makeResolveUnderTest(`{ + "imports": { + "blank": "${BLANK.replace(/:/g, '%3A')}" + } + }`); + + expect(() => resolveUnderTest('blank')).toThrow(TypeError); + expect(resolveUnderTest(BLANK)).toMatchURL(BLANK); + expect(() => resolveUnderTest(BLANK.replace(/:/g, '%3A'))).toThrow(TypeError); + }); + }); + describe('URL-like specifiers', () => { const resolveUnderTest = makeResolveUnderTest(`{ "imports": { - "/node_modules/als-polyfill/index.mjs": "std:kv-storage", - "/lib/foo.mjs": "./more/bar.mjs", "./dotrelative/foo.mjs": "/lib/dot.mjs", "../dotdotrelative/foo.mjs": "/lib/dotdot.mjs", diff --git a/reference-implementation/jest.config.js b/reference-implementation/jest.config.js index d7c3f78..2869dca 100644 --- a/reference-implementation/jest.config.js +++ b/reference-implementation/jest.config.js @@ -3,5 +3,13 @@ module.exports = { testEnvironment: 'node', testPathIgnorePatterns: ['/__tests__/helpers/'], - setupFilesAfterEnv: ['/__tests__/helpers/matchers.js'] + setupFilesAfterEnv: ['/__tests__/helpers/matchers.js'], + coverageThreshold: { + './lib/**': { + branches: 100, + functions: 100, + lines: 100, + statements: 100 + } + } }; diff --git a/reference-implementation/lib/composer.js b/reference-implementation/lib/composer.js new file mode 100644 index 0000000..07a8d47 --- /dev/null +++ b/reference-implementation/lib/composer.js @@ -0,0 +1,43 @@ +'use strict'; +const { getFallbacks } = require('./resolver.js'); +const { parseSpecifier, sortObjectKeysByLongestFirst } = require('./utils.js'); + +exports.concatMaps = (baseImportMap, newImportMap) => { + const concatenatedImportMap = { + imports: {}, + scopes: {} + }; + + concatenatedImportMap.imports = concatSpecifierMaps(baseImportMap.imports, newImportMap.imports, baseImportMap, null); + + concatenatedImportMap.scopes = Object.assign({}, baseImportMap.scopes); + for (const [scopePrefix, newScopeSpecifierMap] of Object.entries(newImportMap.scopes)) { + const baseScopeSpecifierMap = baseImportMap.scopes[scopePrefix] || {}; + concatenatedImportMap.scopes[scopePrefix] = + concatSpecifierMaps(baseScopeSpecifierMap, newScopeSpecifierMap, baseImportMap, scopePrefix); + } + concatenatedImportMap.scopes = sortObjectKeysByLongestFirst(concatenatedImportMap.scopes); + + return concatenatedImportMap; +}; + +function concatSpecifierMaps(baseSpecifierMap, newSpecifierMap, contextImportMap, scopePrefix) { + const concatenatedSpecifierMap = Object.assign({}, baseSpecifierMap); + + for (const [specifier, addresses] of Object.entries(newSpecifierMap)) { + const newAddresses = []; + for (const address of addresses) { + const fallbacks = getFallbacks(address, contextImportMap, scopePrefix); + for (const fallback of fallbacks) { + if (parseSpecifier(fallback).type !== 'URL') { + console.warn(`Non-URL specifier ${JSON.stringify(fallback)} is not allowed to be ` + + 'the target of an import mapping following composition.'); + continue; + } + newAddresses.push(fallback); + } + } + concatenatedSpecifierMap[specifier] = newAddresses; + } + return sortObjectKeysByLongestFirst(concatenatedSpecifierMap); +} diff --git a/reference-implementation/lib/parser.js b/reference-implementation/lib/parser.js index 3a2e2ac..d9d0583 100644 --- a/reference-implementation/lib/parser.js +++ b/reference-implementation/lib/parser.js @@ -1,6 +1,6 @@ 'use strict'; const assert = require('assert'); -const { tryURLParse, hasFetchScheme, tryURLLikeSpecifierParse } = require('./utils.js'); +const { tryURLParse, hasFetchScheme, parseSpecifier, sortObjectKeysByLongestFirst } = require('./utils.js'); exports.parseFromString = (input, baseURL) => { const parsed = JSON.parse(input); @@ -45,17 +45,20 @@ function sortAndNormalizeSpecifierMap(obj, baseURL) { // Normalize all entries into arrays const normalized = {}; for (const [specifierKey, value] of Object.entries(obj)) { - const normalizedSpecifierKey = normalizeSpecifierKey(specifierKey, baseURL); - if (normalizedSpecifierKey === null) { + const parsedSpecifierKey = parseSpecifier(specifierKey, baseURL); + if (parsedSpecifierKey.type === 'invalid') { + console.warn(parsedSpecifierKey.message); continue; } + const normalizedSpecifierKey = parsedSpecifierKey.specifier; + if (typeof value === 'string') { normalized[normalizedSpecifierKey] = [value]; } else if (value === null) { normalized[normalizedSpecifierKey] = []; } else if (Array.isArray(value)) { - normalized[normalizedSpecifierKey] = obj[specifierKey]; + normalized[normalizedSpecifierKey] = value; } else { console.warn(`Invalid address ${JSON.stringify(value)} for the specifier key "${specifierKey}". ` + `Addresses must be strings, arrays, or null.`); @@ -74,30 +77,24 @@ function sortAndNormalizeSpecifierMap(obj, baseURL) { continue; } - const addressURL = tryURLLikeSpecifierParse(potentialAddress, baseURL); - if (addressURL === null) { - console.warn(`Invalid address "${potentialAddress}" for the specifier key "${specifierKey}".`); + const parsedSpecifierKey = parseSpecifier(potentialAddress, baseURL); + if (parsedSpecifierKey.type === 'invalid') { + console.warn(parsedSpecifierKey.message); continue; } - if (specifierKey.endsWith('/') && !addressURL.href.endsWith('/')) { - console.warn(`Invalid address "${addressURL.href}" for package specifier key "${specifierKey}". ` + + if (specifierKey.endsWith('/') && !parsedSpecifierKey.specifier.endsWith('/')) { + console.warn(`Invalid address "${parsedSpecifierKey.specifier}" for package specifier key "${specifierKey}". ` + `Package addresses must end with "/".`); continue; } - validNormalizedAddresses.push(addressURL); + validNormalizedAddresses.push(parsedSpecifierKey.specifier); } normalized[specifierKey] = validNormalizedAddresses; } - const sortedAndNormalized = {}; - const sortedKeys = Object.keys(normalized).sort(longerLengthThenCodeUnitOrder); - for (const key of sortedKeys) { - sortedAndNormalized[key] = normalized[key]; - } - - return sortedAndNormalized; + return sortObjectKeysByLongestFirst(normalized); } function sortAndNormalizeScopes(obj, baseURL) { @@ -122,44 +119,9 @@ function sortAndNormalizeScopes(obj, baseURL) { normalized[normalizedScopePrefix] = sortAndNormalizeSpecifierMap(potentialSpecifierMap, baseURL); } - const sortedAndNormalized = {}; - const sortedKeys = Object.keys(normalized).sort(longerLengthThenCodeUnitOrder); - for (const key of sortedKeys) { - sortedAndNormalized[key] = normalized[key]; - } - - return sortedAndNormalized; -} - -function normalizeSpecifierKey(specifierKey, baseURL) { - // Ignore attempts to use the empty string as a specifier key - if (specifierKey === '') { - console.warn(`Invalid empty string specifier key.`); - return null; - } - - const url = tryURLLikeSpecifierParse(specifierKey, baseURL); - if (url !== null) { - return url.href; - } - - return specifierKey; + return sortObjectKeysByLongestFirst(normalized); } function isJSONObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } - -function longerLengthThenCodeUnitOrder(a, b) { - return compare(b.length, a.length) || compare(a, b); -} - -function compare(a, b) { - if (a > b) { - return 1; - } - if (b > a) { - return -1; - } - return 0; -} diff --git a/reference-implementation/lib/resolver.js b/reference-implementation/lib/resolver.js index 19bb030..a2af65e 100644 --- a/reference-implementation/lib/resolver.js +++ b/reference-implementation/lib/resolver.js @@ -1,100 +1,89 @@ 'use strict'; const { URL } = require('url'); const assert = require('assert'); -const { tryURLLikeSpecifierParse, BUILT_IN_MODULE_SCHEME, BUILT_IN_MODULE_PROTOCOL } = require('./utils.js'); +const { + hasFetchScheme, + parseSpecifier, + BUILT_IN_MODULE_PROTOCOL +} = require('./utils.js'); // TODO: clean up by allowing caller (and thus tests) to choose the list of built-ins? const supportedBuiltInModules = new Set([ - `${BUILT_IN_MODULE_SCHEME}:blank`, - `${BUILT_IN_MODULE_SCHEME}:blank/for-testing` // NOTE: not in the spec. + `${BUILT_IN_MODULE_PROTOCOL}blank`, + `${BUILT_IN_MODULE_PROTOCOL}blank/for-testing` // NOTE: not in the spec. ]); -exports.resolve = (specifier, parsedImportMap, scriptURL) => { - const asURL = tryURLLikeSpecifierParse(specifier, scriptURL); - const normalizedSpecifier = asURL ? asURL.href : specifier; - const scriptURLString = scriptURL.href; - - for (const [scopePrefix, scopeImports] of Object.entries(parsedImportMap.scopes)) { - if (scopePrefix === scriptURLString || - (scopePrefix.endsWith('/') && scriptURLString.startsWith(scopePrefix))) { - const scopeImportsMatch = resolveImportsMatch(normalizedSpecifier, scopeImports); - if (scopeImportsMatch !== null) { - return scopeImportsMatch; - } +exports.resolve = (specifier, parsedImportMap, baseURL) => { + const parsedSpecifier = parseSpecifier(specifier, baseURL); + if (parsedSpecifier.type === 'invalid') { + throw new TypeError(parsedSpecifier.message); + } + const fallbacks = exports.getFallbacks(parsedSpecifier.specifier, parsedImportMap, baseURL.href); + for (const address of fallbacks) { + const parsedFallback = parseSpecifier(address, baseURL); + if (parsedFallback.type !== 'URL') { + throw new TypeError(`The specifier ${JSON.stringify(specifier)} was resolved to ` + + `non-URL ${JSON.stringify(address)}.`); + } + const url = new URL(parsedFallback.specifier); + if (isValidModuleScriptURL(url)) { + return url; } } + throw new TypeError(`The specifier ${JSON.stringify(specifier)} could not be resolved.`); +}; - const topLevelImportsMatch = resolveImportsMatch(normalizedSpecifier, parsedImportMap.imports); - if (topLevelImportsMatch !== null) { - return topLevelImportsMatch; +// TODO: "settings object" would allow us to check the module map like the spec does, also solving the "allow the +// caller to choose" TODO above. +function isValidModuleScriptURL(url) { + if (url.protocol === BUILT_IN_MODULE_PROTOCOL) { + return supportedBuiltInModules.has(url.href); } - // The specifier was able to be turned into a URL, but wasn't remapped into anything. - if (asURL) { - if (asURL.protocol === BUILT_IN_MODULE_PROTOCOL && !supportedBuiltInModules.has(asURL.href)) { - throw new TypeError(`The "${asURL.href}" built-in module is not implemented.`); - } - return asURL; + /* istanbul ignore if */ + if (!hasFetchScheme(url)) { + // The spec includes this check because it could happen due to the - - +A specifier parse result is a [=struct=] with two [=struct/items=]: - is equivalent to +* a type, one of "`invalid`", "`URL`", or "`non-URL`", and +* a specifier, a [=string=] or null (defaults to null). - - <script type="importmap"> - { - "imports": { - "a": "/a-1.mjs", - "b": null, - "std:kv-storage": "kvs-2.mjs" - }, - "scopes": { - "/scope1/": { - "b": "/b-2.mjs" - } - } - } - </script> - +
+ To parse an import specifier, given a [=string=] |specifier| and a [=URL=] |baseURL|: - Notice how the definition for "`/scope1/`" was completely overridden, so there is no longer a redirection for the "`a`" module specifier within that scope. + 1. If |specifier| is the empty string, then return a [=specifier parse result=] whose [=specifier parse result/type=] is "`invalid`". + 1. If |specifier| [=/starts with=] "`/`", "`./`", or "`../`", then: + 1. Let |url| be the result of [=URL parser|parsing=] |specifier| with |baseURL| as the base URL. + 1. If |url| is failure, then return a [=specifier parse result=] whose [=specifier parse result/type=] is "`invalid`". +

One way this could happen is if |specifier| is "`../foo`" and |baseURL| is a `data:` URL.

+ 1. Return a [=specifier parse result=] whose [=specifier parse result/type=] is `"URL"` and [=specifier parse result/specifier=] is |url|, [=URL serializer|serialized=]. + 1. Let |url| be the result of [=URL parser|parsing=] |specifier| (with no base URL). + 1. If |url| is failure, then return a [=specifier parse result=] whose [=specifier parse result/type=] is `"non-URL"` and [=specifier parse result/specifier=] is |specifier|. + 1. If |url|'s [=url/scheme=] is either a [=fetch scheme=] or "`std`", then return a [=specifier parse result=] whose [=specifier parse result/type=] is `"URL"`, whose [=specifier parse result/specifier=] is |url|, [=URL serializer|serialized=]. + 1. Return a [=specifier parse result=] whose [=specifier parse result/type=] is `"non-URL"` and [=specifier parse result/specifier=] is |specifier|.

Acquiring import maps

@@ -304,7 +262,7 @@ To register an import map given an {{HTMLScriptElement}} |element|: 1. Report the exception given |import map parse result|'s [=import map parse result/error to rethrow=].

There are no relevant [=script=], because [=import map parse result=] isn't a [=script=]. This needs to wait for whatwg/html#958 before it is fixable.

1. Return. -1. [=update an import map|Update=] |element|'s [=node document=]'s [=Document/import map=] with |import map parse result|'s [=import map parse result/import map=]. +1. Set |element|'s [=node document=]'s [=Document/import map=] to the result of [=import map/concatenating=] |element|'s [=node document=]'s [=Document/import map=] with the |import map parse result|'s [=import map parse result/import map=]. 1. If |element| is from an external file, then [=fire an event=] named `load` at |element|.

@@ -315,6 +273,13 @@ To register an import map given an {{HTMLScriptElement}} |element|:

Parsing import maps

+
+ To create an import map parse result, given a [=string=] |input|, a [=URL=] |baseURL|, and an [=environment settings object=] |settings object|: + + 1. Let |import map| be the result of [=parse an import map string=] given |input| and |baseURL|. If this throws an exception, let |error to rethrow| be the exception. Otherwise, let |error to rethrow| be null. + 1. Return an [=import map parse result=] with [=import map parse result/settings object=] is |settings object|, [=import map parse result/import map=] is |import map|, and [=import map parse result/error to rethrow=] is |error to rethrow|. +
+
To parse an import map string, given a [=string=] |input| and a [=URL=] |baseURL|: @@ -333,14 +298,6 @@ To register an import map given an {{HTMLScriptElement}} |element|: 1. Return the [=/import map=] whose [=import map/imports=] are |sortedAndNormalizedImports| and whose [=import map/scopes=] scopes are |sortedAndNormalizedScopes|.
-
- To create an import map parse result, given a [=string=] |input|, a [=URL=] |baseURL|, and an [=environment settings object=] |settings object|: - - 1. Let |import map| be the result of [=parse an import map string=] given |input| and |baseURL|. If this throws an exception, let |error to rethrow| be the exception. Otherwise, let |error to rethrow| be null. - 1. Return an [=import map parse result=] with [=import map parse result/settings object=] is |settings object|, [=import map parse result/import map=] is |import map|, and [=import map parse result/error to rethrow=] is |error to rethrow|. -
- -
The [=/import map=] is a highly normalized structure. For example, given a base URL of ``, the input @@ -361,11 +318,11 @@ To register an import map given an {{HTMLScriptElement}} |element|: «[ "https://example.com/app/helper" → « - <https://example.com/base/node_modules/helper/index.mjs> + "https://example.com/base/node_modules/helper/index.mjs" », "std:kv-storage" → « - <std:kv-storage>, - <https://example.com/base/node_modules/kv-storage-polyfill/index.mjs> + "std:kv-storage", + "https://example.com/base/node_modules/kv-storage-polyfill/index.mjs" » ]» @@ -378,8 +335,11 @@ To register an import map given an {{HTMLScriptElement}} |element|: 1. Let |normalized| be an empty [=map=]. 1. First, normalize all [=map/entries=] so that their [=map/values=] are [=lists=]. [=map/For each=] |specifierKey| → |value| of |originalMap|, - 1. Let |normalizedSpecifierKey| be the result of [=normalizing a specifier key=] given |specifierKey| and |baseURL|. - 1. If |normalizedSpecifierKey| is null, then [=continue=]. + 1. Let |parsedSpecifierKey| be the result of [=parsing an import specifier=] given |specifierKey| and |baseURL|. + 1. If |parsedSpecifierKey|'s [=specifier parse result/type=] is "`invalid`", then: + 1. [=Report a warning to the console=] that the specifier was invalid. + 1. [=Continue=]. + 1. Let |normalizedSpecifierKey| be |parsedSpecifierKey|'s [=specifier parse result/specifier=]. 1. If |value| is a [=string=], then set |normalized|[|normalizedSpecifierKey|] to «|value|». 1. Otherwise, if |value| is null, then set |normalized|[|normalizedSpecifierKey|] to a new empty list. 1. Otherwise, if |value| is a [=list=], then set |normalized|[|normalizedSpecifierKey|] to |value|. @@ -391,14 +351,14 @@ To register an import map given an {{HTMLScriptElement}} |element|: 1. If |potentialAddress| is not a [=string=], then: 1. [=Report a warning to the console=] that the contents of address arrays must be strings. 1. [=Continue=]. - 1. Let |addressURL| be the result of [=parsing a URL-like import specifier=] given |potentialAddress| and |baseURL|. - 1. If |addressURL| is null, then: - 1. [=Report a warning to the console=] that the address was invalid. + 1. Let |parsedSpecifierKey| be the result of [=parsing an import specifier=] given |potentialAddress| and |baseURL|. + 1. If |parsedSpecifierKey|'s [=specifier parse result/type=] is "`invalid`", then: + 1. [=Report a warning to the console=] that the specifier was invalid. 1. [=Continue=]. - 1. If |specifierKey| ends with U+002F (/), and the [=URL serializer|serialization=] of |addressURL| does not end with U+002F (/), then: + 1. If |specifierKey| ends with U+002F (/), and |parsedSpecifierKey|'s [=specifier parse result/specifier=] does not end with U+002F (/), then: 1. [=Report a warning to the console=] that an invalid address was given for the specifier key |specifierKey|; since |specifierKey| ended in a slash, so must the address. 1. [=Continue=]. - 1. [=list/Append=] |addressURL| to |validNormalizedAddresses|. + 1. [=list/Append=] |parsedSpecifierKey|'s [=specifier parse result/specifier=] to |validNormalizedAddresses|. 1. Set |normalized|[|specifierKey|] to |validNormalizedAddresses|. 1. Return the result of [=map/sorting=] |normalized|, with an entry |a| being less than an entry |b| if |a|'s [=map/key=] is [=longer or code unit less than=] |b|'s [=map/key=].
@@ -422,32 +382,49 @@ To register an import map given an {{HTMLScriptElement}} |element|:
- To normalize a specifier key, given a [=string=] |specifierKey| and a [=URL=] |baseURL|: - - 1. If |specifierKey| is the empty string, then: - 1. [=Report a warning to the console=] that specifier keys cannot be the empty string. - 1. Return null. - 1. Let |url| be the result of [=parsing a URL-like import specifier=], given |specifierKey| and |baseURL|. - 1. If |url| is not null, then return the [=URL serializer|serialization=] of |url|. - 1. Return |specifierKey|. + A [=string=] |a| is longer or code unit less than |b| if |a|'s [=string/length=] is greater than |b|'s [=string/length=], or if |a| is [=code unit less than=] |b|.
-
- To parse a URL-like import specifier, given a [=string=] |specifier| and a [=URL=] |baseURL|: +

Composing import maps

- 1. If |specifier| [=/starts with=] "`/`", "`./`", or "`../`", then: - 1. Let |url| be the result of [=URL parser|parsing=] |specifier| with |baseURL| as the base URL. - 1. If |url| is failure, then return null. -

One way this could happen is if |specifier| is "`../foo`" and |baseURL| is a `data:` URL.

- 1. Return |url|. - 1. Let |url| be the result of [=URL parser|parsing=] |specifier| (with no base URL). - 1. If |url| is failure, then return null. - 1. If |url|'s [=url/scheme=] is either a [=fetch scheme=] or "`std`", then return |url|. - 1. Return null. +
+ To concatenate an import map |baseImportMap| with a second [=/import map=] |newImportMap|: + + 1. Let |concatenatedImportMap| be a new [=empty import map=]. + 1. Let |concatenatedImportMap|'s [=import map/imports=] be the [=specifier map/concatenation=] of |baseImportMap|'s [=import map/imports=] and |newImportMap|'s [=import map/imports=], given |baseImportMap| and null. + 1. Set |concatenatedImportMap|'s [=import map/scopes=] to a [=map/clone=] of |baseImportMap|'s [=import map/scopes=]. + 1. [=map/For each=] |scopePrefix| → |newScopeSpecifierMap| of |newImportMap|'s [=import map/scopes=]: + 1. Let |baseScoperSpecifierMap| be |baseImportMap|'s [=import map/scopes=][|scopePrefix|], if it [=map/exists=], or a new empty [=map=] otherwise. + 1. Set |concatenatedImportMap|'s [=import map/scopes=][|scopePrefix|] to the [=specifier map/concatenation=] of |baseScoperSpecifierMap| and |newScopeSpecifierMap|, given |baseImportMap| and |scopePrefix|. + 1. Set |concatenatedImportMap|'s [=import map/scopes=] to the result of [=map/sorting=] |concatenatedImportMap|'s [=import map/scopes=], with an entry |a| being less than an entry |b| if |a|'s [=map/key=] is [=longer or code unit less than=] |b|'s [=map/key=]. + 1. Return |concatenatedImportMap|. +
+ +
+ To concatenate a specifier map |baseSpecifierMap| with a second [=specifier map=] |newSpecifierMap|, given an [=/import map=] |contextImportMap| and a [=string=] |scopePrefix|: + + 1. Let |concatenatedSpecifierMap| be a [=map/clone=] of |baseSpecifierMap| + 1. [=map/For each=] |specifier| → |addresses| of |newSpecifierMap|: + 1. Let |newAddresses| be a new empty [=list=]. + 1. [=list/For each=] |address| of |addresses|: + 1. Let |fallbacks| be the result of [=getting the fallbacks=] for |address|, given |contextImportMap| and |scopePrefix|. + 1. [=list/For each=] |fallback| of |fallbacks|: + 1. Let |fallbackResult| be the result of [=parsing an import specifier=] given |fallback|. + 1. If |fallbackResult|'s [=specifier parse result/type=] is not "`URL`", then: + 1. [=Report a warning to the console=] stating that |fallback|, as a non-URL specifier, cannot be the ultimate target of an import mapping. + 1. [=Continue=]. + 1. [=list/Append=] |fallback| to |newAddresses|. + 1. Set |concatenatedSpecifierMap|[|specifier|] to |newAddresses|. + 1. Set |concatenatedSpecifierMap| to the result of [=map/sorting=] |concatenatedSpecifierMap|, with an entry |a| being less than an entry |b| if |a|'s [=map/key=] is [=longer or code unit less than=] |b|'s [=map/key=]. + 1. Return |concatenatedSpecifierMap|.
- A [=string=] |a| is longer or code unit less than |b| if |a|'s [=string/length=] is greater than |b|'s [=string/length=], or if |a| is [=code unit less than=] |b|. + To clone... TODO: delete when whatwg/infra#265 gets merged. +
+ +
+ [=import map/Concatenating=] an import map TODO explain the mental model, with a good example or three...

Resolving module specifiers

@@ -463,80 +440,59 @@ To register an import map given an {{HTMLScriptElement}} |element|: 1. Set |settingsObject| to |referringScript|'s [=script/settings object=]. 1. Set |baseURL| to |referringScript|'s [=script/base URL=]. 1. Let |importMap| be |settingsObject|'s [=environment settings object/import map=]. - 1. Let |moduleMap| be |settingsObject|'s [=environment settings object/module map=]. - 1. Let |baseURLString| be |baseURL|, [=URL serializer|serialized=]. - 1. Let |asURL| be the result of [=parsing a URL-like import specifier=] given |specifier| and |baseURL|. - 1. Let |normalizedSpecifier| be the [=URL serializer|serialization=] of |asURL|, if |asURL| is non-null; otherwise, |specifier|. - 1. [=map/For each=] |scopePrefix| → |scopeImports| of |importMap|'s [=import map/scopes=], - 1. If |scopePrefix| is |baseURLString|, or if |scopePrefix| ends with U+002F (/) and |baseURLString| [=/starts with=] |scopePrefix|, then: - 1. Let |scopeImportsMatch| be the result of [=resolving an imports match=] given |normalizedSpecifier|, |scopeImports|, and |moduleMap|. - 1. If |scopeImportsMatch| is not null, then: - 1. [=Validate the module script URL=] given |scopeImportsMatch|, |settingsObject|, and |baseURL|. - 1. Return |scopeImportsMatch|. - 1. Let |topLevelImportsMatch| be the reuslt of [=resolving an imports match=] given |normalizedSpecifier|, |importMap|'s [=import map/imports=], and |moduleMap|. - 1. If |topLevelImportsMatch| is not null, then: - 1. [=Validate the module script URL=] given |topLevelImportsMatch|, |settingsObject|, and |baseURL|. - 1. Return |topLevelImportsMatch|. - 1.

At this point, the specifier was able to be turned in to a URL, but it wasn't remapped to anything by |importMap|.

- If |asURL| is not null, then: - 1. [=Validate the module script URL=] given |asURL|, |settingsObject|, and |baseURL|. - 1. Return |asURL|. - 1. Throw a {{TypeError}} indicating that |specifier| was a bare specifier, but was not remapped to anything by |importMap|. + 1. Let |parsedSpecifier| be the result of [=parsing an import specifier=] given |specifier| and |baseURL|. + 1. If |parsedSpecifier|'s [=specifier parse result/type=] is "`invalid`", then throw a {{TypeError}} indicating the reason for the invalidity. + 1. Let |fallbacks| be the result of [=getting the fallbacks=] given |parsedSpecifier|'s [=specifier parse result/specifier=], |importMap|, and |baseURL|, [=URL serializer|serialized=]. + 1. [=list/For each=] |address| of |fallbacks|: + 1. Let |parsedFallback| be the result of [=parsing an import specifier=] given |address| and |baseURL|. + 1. If |parsedFallback|'s [=specifier parse result/type=] is not "`URL`", then throw a {{TypeError}} indicating that the resolution ultimately did not resolve in a URL. + 1. Let |url| be the result of [=URL parser|parsing=] |parsedFallback|'s [=specifier parse result/specifier=]. + 1. If |url| [=is a valid module script URL=], given |settingsObject| and |baseURL|, then return |url|. + 1. Throw a new {{TypeError}} indicating that the specifier could not be resolved.

It seems possible that the return type could end up being a [=list=] of [=URLs=], not just a single URL, to support HTTPS → HTTPS fallback. But, we haven't gotten that far yet; for now let's assume it stays a single URL.

- To resolve an imports match, given a [=string=] |normalizedSpecifier|, a [=specifier map=] |specifierMap|, and a [=module map=] |moduleMap|: - - 1. For each |specifierKey| → |addresses| of |specifierMap|, - 1. If |specifierKey| is |normalizedSpecifier|, then: - 1. If |addresses|'s [=list/size=] is 0, then throw a {{TypeError}} indicating that |normalizedSpecifier| was mapped to no addresses. - 1. If |addresses|'s [=list/size=] is 1, then: - 1. Let |singleAddress| be |addresses|[0]. - 1. If |singleAddress|'s [=url/scheme=] is "`std`", and |moduleMap|[|singleAddress|] does not [=map/exist=], then throw a {{TypeError}} indicating that the requested built-in module is not implemented. - 1. Return |singleAddress|. - 1. If |addresses|'s [=list/size=] is 2, and |addresses|[0]'s [=url/scheme=] is "`std`", and |addresses|[1]'s [=url/scheme=] is not "`std`", then: - 1. Return |addresses|[0], if |moduleMap|[|addresses|[0]] [=map/exists=]; otherwise, return |addresses|[1]. - 1. Otherwise, we have no specification for more complicated fallbacks yet; throw a {{TypeError}} indicating this is not yet supported. - 1. If |specifierKey| ends with U+002F (/) and |normalizedSpecifier| [=/starts with=] |specifierKey|, then: - 1. If |addresses|'s [=list/size=] is 0, then throw a {{TypeError}} indicating that |normalizedSpecifier| was mapped to no addresses. - 1. If |addresses|'s [=list/size=] is 1, then: - 1. Let |afterPrefix| be the portion of |normalizedSpecifier| after the initial |specifierKey| prefix. - 1. Assert: |afterPrefix| ends with "`/`", as enforced during [=parse an import map string|parsing=]. - 1. Let |url| be the result of [=URL parser|parsing=] the concatenation of the [=URL serializer|serialization=] of |addresses|[0] with |afterPrefix|. -

We [=URL parser|parse=] the concatenation, instead of parsing |afterPrefix| relative to |addresses|[0], due to cases such as an |afterPrefix| of "`switch`" and an |addresses|[0] of "`std:elements/`". - 1. Assert: |url| is not failure, since |addresses|[0] was a URL, and appending after the trailing "`/`" will not make it unparseable. - 1. Return |url|. - 1. If |addresses|'s [=list/size=] is 2, and |addresses|[0]'s [=url/scheme=] is "`std`", and |addresses|[1]'s [=url/scheme=] is not "`std`", then: + To get the fallbacks for a [=string=] |normalizedSpecifier|, given an [=/import map=] |importMap| and a [=string=] or null |contextURLString|: + + 1. Let |applicableSpecifierMaps| be a new empty [=list=]. + 1. If |contextURLString| is not null, then [=map/for each=] |scopePrefix| → |scopeSpecifierMap| of |importMap|'s [=import map/scopes=]: + 1. If |scopePrefix| equals |contextURLString|, or |scopePrefix| ends with U+002F (/) and |contextURLString| [=/starts with=] |scopePrefix|, then [=list/append=] |scopeSpecifierMap| to |applicableSpecifierMaps|. + 1. [=list/Append=] |importMap|'s [=import map/imports=] to |applicableSpecifierMaps|. + 1. [=list/For each=] |specifierMap| of |applicableSpecifierMaps|: + 1. [=map/For each=] |specifierKey| → |addresses| of |specifierMap|: + 1. If |specifierKey| equals |normalizedSpecifier|: + 1. Return |addresses|. + 1. If |specifierKey| ends with U+002F (/) and |normalizedSpecifier| [=/starts with=] |specifierKey|: 1. Let |afterPrefix| be the portion of |normalizedSpecifier| after the initial |specifierKey| prefix. - 1. Assert: |afterPrefix| ends with "`/`", as enforced during [=parse an import map string|parsing=]. - 1. Let |url0| be the result of [=URL parser|parsing=] the concatenation of the [=URL serializer|serialization=] of |addresses|[0] with |afterPrefix|; similarly, let |url1| be the result of [=URL parser|parsing=] the concatenation of the [=URL serializer|serialization=] of |addresses|[1] with |afterPrefix|. -

As above, we parse the concatenation to deal with built-in module cases. - 1. Assert: neither |url0| nor |url1| are failure, since |addresses|[0] and |addresses|[1] were URLs, and appending after their trailing "`/`" will not make them unparseable. - 1. Return |url0|, if |moduleMap|[|url0|] [=map/exists=]; otherwise, return |url1|. - 1. Otherwise, we have no specification for more complicated fallbacks yet; throw a {{TypeError}} indicating this is not yet supported. - 1. Return null. + 1. Let |fallbacks| be a new empty [=list=]. + 1. [=list/For each=] |address| of |addresses|: + 1. Assert: |address| ends with "`/`", as enforced during [=parse an import map string|parsing=]. + 1. Let |parseResult| be the result of [=parsing the import specifier=] given by the concatenation of |address| with |afterPrefix|. + 1. Assert: |parseResult|'s [=specifier parse result/specifier=] is not null. + 1. [=list/Append=] |parseResult|'s [=specifier parse result/specifier=] to |fallbacks|. + 1. Return |fallbacks|. + 1. Return « |normalizedSpecifier| ».

- To validate a module script URL, given a [=URL=] |url|, an [=environment settings object=] |settings object|, and a [=URL=] |base URL|: + A [=URL=] |url| is a valid module script URL, given an [=environment settings object=] |settings object| and [=URL=] |baseURL|, if the following steps return true: 1. If |url|'s [=url/scheme=] is "`std`", then: 1. Let |moduleMap| be |settings object|'s [=environment settings object/module map=]. - 1. If |moduleMap|[|url|] does not [=map/exist=], then throw a {{TypeError}} indicating that the requested built-in module is not implemented. + 1. If |moduleMap|[|url|] does not [=map/exist=], then return false.

This condition is added to ensure that |moduleMap|[|url|] does not [=map/exist=] for unimplemented built-ins. Without this condition, fetch a single module script might be called and |moduleMap|[|url|] can be set to null, which might complicates the spec around built-ins.

- 1. Return. - 1. If |url|'s [=url/scheme=] is not a [=fetch scheme=], then throw a {{TypeError}} indicating that |url| is not a fetch scheme. + 1. If |url|'s [=url/scheme=] is not a [=fetch scheme=], then return false.
This algorithm provides a convenient place for implementations to insert other useful behaviors, as long as they are not observable to web content. For example, Chromium might insert the following step at the beginning of the algorithm: - 0. If |url|'s [=url/scheme=] is "`std-internal`" and |base URL|'s [=url/scheme=] is "`std-internal`", then return. + 0. If |url|'s [=url/scheme=] is "`std-internal`" and |baseURL|'s [=url/scheme=] is "`std-internal`", then return true. This introduces a type of internal built-in module that is only accessible to other internal built-in modules. Similar steps could be used to, for example, change how extension scripts access modules. - Since [=validate a module script URL=] is called before any module script fetches, such checks are reliable and can be used as a security mechanism. + Since [=is a valid module script URL=] is called before any module script fetches, such checks are reliable and can be used as a security mechanism.
@@ -551,14 +507,14 @@ Call sites will also need to be updated to account for [=resolve a module specif
-In addition to the call sites for [=validate a module script URL=] explicitly added within this spec, insert the following at the beginning of fetch a single module script: +In addition to the call sites for [=is a valid module script URL=] explicitly added within this spec, insert the following at the beginning of fetch a single module script:
- 1. [=Validate the module script URL=] given url, |module map settings object|, and |module map settings object|'s [=environment settings object/API base URL=]. If this throws an error, then asynchronously complete this algorithm with null, and abort these steps. + 1. If url [=is not a valid module script URL=] given |module map settings object| and |module map settings object|'s [=environment settings object/API base URL=], then asynchronously complete this algorithm with null, and abort these steps.
-This will call [=validate the module script URL=] twice for each non-toplevel script fetch, first in [=resolve a module specifier=], and then in fetch a single module script. The behavior of the two calls is identical. +This will check [=is a valid module script URL=] twice for each non-toplevel script fetch, first in [=resolve a module specifier=], and then in fetch a single module script. The behavior of the two calls is identical. Alternatively, we can add the snippet at the beginning of the following HTML spec concepts (after [=wait for import maps=]), so that the validation is not done twice: @@ -568,4 +524,4 @@ Alternatively, we can add the snippet at the beginning of the following HTML spe
-

[=Validate a module script URL=] is applied to all module URLs before they start loading, even in paths where [=resolve a module specifier=] and import maps are not applied (e.g. `