diff --git a/reference-implementation/__tests__/helpers/common-test-helper.js b/reference-implementation/__tests__/helpers/common-test-helper.js index 28695df..cb9a228 100644 --- a/reference-implementation/__tests__/helpers/common-test-helper.js +++ b/reference-implementation/__tests__/helpers/common-test-helper.js @@ -3,6 +3,7 @@ const assert = require('assert'); const { URL } = require('url'); const { parseFromString } = require('../../lib/parser.js'); const { resolve } = require('../../lib/resolver.js'); +const { traceDepcache } = require('../../lib/depcache.js'); function assertNoExtraProperties(object, expectedProperties, description) { for (const actualProperty in object) { @@ -22,6 +23,14 @@ function assertOwnProperty(j, name) { // expected import maps (taken from JSONs) uses strings. // This function converts `m` (expected import maps or its part) // into URL-based, for comparison. +function replaceImportMapStringWithURL(m) { + return { + depcache: m.depcache, + imports: replaceStringWithURL(m.imports), + scopes: replaceStringWithURL(m.scopes) + }; +} + function replaceStringWithURL(m) { if (typeof m === 'string') { return new URL(m); @@ -59,7 +68,7 @@ function runTests(j) { assertNoExtraProperties( j, [ - 'expectedResults', 'expectedParsedImportMap', + 'expectedResults', 'expectedParsedImportMap', 'expectedDepcache', 'baseURL', 'name', 'parsedImportMap', 'importMap', 'importMapBaseURL', 'link', 'details' @@ -85,8 +94,9 @@ function runTests(j) { } assert( 'expectedResults' in j || - 'expectedParsedImportMap' in j, - 'expectedResults or expectedParsedImportMap should exist' + 'expectedParsedImportMap' in j || + 'expectedDepcache' in j, + 'expectedResults, expectedParsedImportMap or expectedDepcache should exist' ); // Resolution tests. @@ -119,7 +129,31 @@ function runTests(j) { expect(j.parsedImportMap).toBeInstanceOf(TypeError); } else { expect(j.parsedImportMap) - .toEqual(replaceStringWithURL(j.expectedParsedImportMap)); + .toEqual(replaceImportMapStringWithURL(j.expectedParsedImportMap)); + } + }); + } + + // Depcache tests + if ('expectedDepcache' in j) { + it(j.name, () => { + assertOwnProperty(j, 'baseURL'); + describe( + 'Import map registration should be successful for resolution tests', + () => { + expect(j.parsedImportMap).not.toBeInstanceOf(Error); + } + ); + + for (const specifier in j.expectedDepcache) { + const expected = j.expectedDepcache[specifier]; + const resolved = resolve(specifier, j.parsedImportMap, new URL(j.baseURL)); + if (expected === null) { + expect(() => traceDepcache(resolved, j.parsedImportMap)).toThrow(TypeError); + } else { + const traced = traceDepcache(resolved, j.parsedImportMap); + expect(traced).toEqual(j.expectedDepcache[specifier]); + } } }); } diff --git a/reference-implementation/__tests__/helpers/matchers.js b/reference-implementation/__tests__/helpers/matchers.js index 27980b7..f40e657 100644 --- a/reference-implementation/__tests__/helpers/matchers.js +++ b/reference-implementation/__tests__/helpers/matchers.js @@ -15,7 +15,7 @@ expect.extend({ } received = received.href; - expected = (new URL(expected)).href; + expected = new URL(expected).href; const pass = received === expected; diff --git a/reference-implementation/__tests__/json/depcache.json b/reference-implementation/__tests__/json/depcache.json new file mode 100644 index 0000000..0cb992d --- /dev/null +++ b/reference-implementation/__tests__/json/depcache.json @@ -0,0 +1,144 @@ +{ + "importMapBaseURL": "https://example.com/app/index.html", + "tests": { + "Invalid depcache": { + "importMap": { + "depcache": [] + }, + "tests": { + "should report invalid depcache": { + "expectedParsedImportMap": null + } + } + }, + "Invalid depcache URL": { + "importMap": { + "depcache": { + "http://[www.example.com]/": [], + "/a.js": ["/b.js"] + } + }, + "tests": { + "should ignore invalid depcache URL": { + "baseURL": "https://example.com/", + "expectedDepcache": { + "/a.js": [ + "https://example.com/a.js", + "https://example.com/b.js" + ] + } + } + } + }, + "Invalid depcache keys and specifiers": { + "importMap": { + "depcache": { + "/a.js": ["/b.js"], + "/c.js": ["http://[www.example.com]/"] + } + }, + "tests": { + "should ignore invalid depcache URL": { + "baseURL": "https://example.com/", + "expectedParsedImportMap": { + "depcache": { + "https://example.com/a.js": ["/b.js"], + "https://example.com/c.js": ["http://[www.example.com]/"] + }, + "imports": {}, + "scopes": {} + }, + "expectedDepcache": { + "/a.js": [ + "https://example.com/a.js", + "https://example.com/b.js" + ] + } + }, + "invalid depcache key resolution": { + "baseURL": "https://example.com/", + "expectedDepcache": { + "/c.js": null + } + } + } + }, + "Invalid depcache dependencies list": { + "importMap": { + "depcache": { + "/valid.js": ["/a.js"], + "/a.js": {} + } + }, + "tests": { + "should ignore invalid depcache items": { + "baseURL": "https://example.com/", + "expectedParsedImportMap": { + "depcache": { + "https://example.com/valid.js": ["/a.js"] + }, + "imports": {}, + "scopes": {} + } + } + } + }, + "Invalid depcache list items": { + "importMap": { + "depcache": { + "/valid.js": ["/a.js"], + "/a.js": ["/b.js", null, {}] + } + }, + "tests": { + "should ignore invalid depcache items": { + "baseURL": "https://example.com/", + "expectedParsedImportMap": { + "depcache": { + "https://example.com/valid.js": ["/a.js"] + }, + "imports": {}, + "scopes": {} + } + } + } + }, + "Depcache resolution": { + "importMap": { + "imports": { + "a": "/a-1.mjs", + "b": "/scope/b-1.mjs" + }, + "scopes": { + "/a-1.mjs": { + "a": "/a-2.mjs" + }, + "/scope/": { + "a": "/scope/a-3.mjs", + "b": "./b-2.mjs" + } + }, + "depcache": { + "/a-1.mjs": ["a"], + "/a-2.mjs": ["b", "a"], + "/scope/b-1.mjs": ["./b-2.mjs"], + "/scope/b-2.mjs": ["a"] + } + }, + "tests": { + "should trace full depcache": { + "baseURL": "https://example.com/", + "expectedDepcache": { + "a": [ + "https://example.com/a-1.mjs", + "https://example.com/a-2.mjs", + "https://example.com/scope/b-1.mjs", + "https://example.com/scope/b-2.mjs", + "https://example.com/scope/a-3.mjs" + ] + } + } + } + } + } +} diff --git a/reference-implementation/__tests__/json/parsing-addresses-absolute.json b/reference-implementation/__tests__/json/parsing-addresses-absolute.json index b400439..a9af87c 100644 --- a/reference-implementation/__tests__/json/parsing-addresses-absolute.json +++ b/reference-implementation/__tests__/json/parsing-addresses-absolute.json @@ -20,6 +20,7 @@ }, "importMapBaseURL": "https://base.example/path1/path2/path3", "expectedParsedImportMap": { + "depcache": {}, "imports": { "about": "about:good", "blob": "blob:good", @@ -50,6 +51,7 @@ }, "importMapBaseURL": "https://base.example/path1/path2/path3", "expectedParsedImportMap": { + "depcache": {}, "imports": { "unparseable2": null, "unparseable3": null, diff --git a/reference-implementation/__tests__/json/parsing-addresses-invalid.json b/reference-implementation/__tests__/json/parsing-addresses-invalid.json index 4e5f182..25fcf96 100644 --- a/reference-implementation/__tests__/json/parsing-addresses-invalid.json +++ b/reference-implementation/__tests__/json/parsing-addresses-invalid.json @@ -13,6 +13,7 @@ }, "importMapBaseURL": "https://base.example/path1/path2/path3", "expectedParsedImportMap": { + "depcache": {}, "imports": { "foo1": null, "foo2": null, diff --git a/reference-implementation/__tests__/json/parsing-addresses.json b/reference-implementation/__tests__/json/parsing-addresses.json index fe92709..4ed3eba 100644 --- a/reference-implementation/__tests__/json/parsing-addresses.json +++ b/reference-implementation/__tests__/json/parsing-addresses.json @@ -3,6 +3,7 @@ "tests": { "should accept strings prefixed with ./, ../, or /": { "importMap": { + "depcache": {}, "imports": { "dotSlash": "./foo", "dotDotSlash": "../foo", @@ -11,6 +12,7 @@ }, "importMapBaseURL": "https://base.example/path1/path2/path3", "expectedParsedImportMap": { + "depcache": {}, "imports": { "dotSlash": "https://base.example/path1/path2/foo", "dotDotSlash": "https://base.example/path1/foo", @@ -21,6 +23,7 @@ }, "should not accept strings prefixed with ./, ../, or / for data: base URLs": { "importMap": { + "depcache": {}, "imports": { "dotSlash": "./foo", "dotDotSlash": "../foo", @@ -29,6 +32,7 @@ }, "importMapBaseURL": "data:text/html,test", "expectedParsedImportMap": { + "depcache": {}, "imports": { "dotSlash": null, "dotDotSlash": null, @@ -39,6 +43,7 @@ }, "should accept the literal strings ./, ../, or / with no suffix": { "importMap": { + "depcache": {}, "imports": { "dotSlash": "./", "dotDotSlash": "../", @@ -47,6 +52,7 @@ }, "importMapBaseURL": "https://base.example/path1/path2/path3", "expectedParsedImportMap": { + "depcache": {}, "imports": { "dotSlash": "https://base.example/path1/path2/", "dotDotSlash": "https://base.example/path1/", @@ -57,6 +63,7 @@ }, "should ignore percent-encoded variants of ./, ../, or /": { "importMap": { + "depcache": {}, "imports": { "dotSlash1": "%2E/", "dotDotSlash1": "%2E%2E/", @@ -69,6 +76,7 @@ }, "importMapBaseURL": "https://base.example/path1/path2/path3", "expectedParsedImportMap": { + "depcache": {}, "imports": { "dotSlash1": null, "dotDotSlash1": null, diff --git a/reference-implementation/__tests__/json/parsing-schema-normalization.json b/reference-implementation/__tests__/json/parsing-schema-normalization.json index a330bb8..9f77458 100644 --- a/reference-implementation/__tests__/json/parsing-schema-normalization.json +++ b/reference-implementation/__tests__/json/parsing-schema-normalization.json @@ -5,6 +5,7 @@ "should normalize empty import maps to have imports and scopes keys": { "importMap": {}, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": {} } @@ -14,6 +15,7 @@ "scopes": {} }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": {} } @@ -23,6 +25,7 @@ "imports": {} }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": {} } diff --git a/reference-implementation/__tests__/json/parsing-schema-specifier-map.json b/reference-implementation/__tests__/json/parsing-schema-specifier-map.json index 7d7d4be..ec168c5 100644 --- a/reference-implementation/__tests__/json/parsing-schema-specifier-map.json +++ b/reference-implementation/__tests__/json/parsing-schema-specifier-map.json @@ -17,6 +17,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "null": null, "boolean": null, @@ -36,6 +37,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": {} } diff --git a/reference-implementation/__tests__/json/parsing-schema-toplevel.json b/reference-implementation/__tests__/json/parsing-schema-toplevel.json index 278cad2..8bf15ca 100644 --- a/reference-implementation/__tests__/json/parsing-schema-toplevel.json +++ b/reference-implementation/__tests__/json/parsing-schema-toplevel.json @@ -89,6 +89,7 @@ "scops": {} }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": {} } diff --git a/reference-implementation/__tests__/json/parsing-scope-keys.json b/reference-implementation/__tests__/json/parsing-scope-keys.json index 4b2f1ee..fdd466d 100644 --- a/reference-implementation/__tests__/json/parsing-scope-keys.json +++ b/reference-implementation/__tests__/json/parsing-scope-keys.json @@ -8,6 +8,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": { "https://base.example/path1/path2/foo": {} @@ -23,6 +24,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": { "https://base.example/path1/path2/foo": {}, @@ -41,6 +43,7 @@ }, "importMapBaseURL": "data:text/html,test", "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": {} } @@ -54,6 +57,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": { "https://base.example/path1/path2/": {}, @@ -69,6 +73,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": { "https://base.example/path1/path2/foo/bar?baz#qux": {} @@ -82,6 +87,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": { "https://base.example/path1/path2/path3": {} @@ -99,6 +105,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": { "https://base.example/path1/path2/foo/": {}, @@ -123,6 +130,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": { "https://base.example/path1/path2/foo//": { @@ -149,6 +157,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": { "about:good": {}, @@ -178,6 +187,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": { "https://base.example/path1/path2/example.org": {}, diff --git a/reference-implementation/__tests__/json/parsing-specifier-keys.json b/reference-implementation/__tests__/json/parsing-specifier-keys.json index b2d9cf4..3c31f28 100644 --- a/reference-implementation/__tests__/json/parsing-specifier-keys.json +++ b/reference-implementation/__tests__/json/parsing-specifier-keys.json @@ -10,6 +10,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "https://base.example/path1/path2/foo": "https://base.example/dotslash", "https://base.example/path1/foo": "https://base.example/dotdotslash", @@ -28,6 +29,7 @@ }, "importMapBaseURL": "data:text/html,", "expectedParsedImportMap": { + "depcache": {}, "imports": { "./foo": "https://example.com/dotslash", "../foo": "https://example.com/dotdotslash", @@ -45,6 +47,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "https://base.example/path1/path2/": "https://base.example/dotslash/", "https://base.example/path1/": "https://base.example/dotdotslash/", @@ -60,6 +63,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "https://base.example/path1/path2/foo/bar?baz#qux": "https://base.example/foo" }, @@ -73,6 +77,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": {}, "scopes": {} } @@ -90,6 +95,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "%2E/": "https://base.example/dotSlash1/", "%2E%2E/": "https://base.example/dotDotSlash1/", @@ -111,6 +117,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "https://base.example/path1/path2/foo//": "https://base.example/foo3" }, @@ -135,6 +142,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "about:good": "https://base.example/about", "blob:good": "https://base.example/blob", @@ -164,6 +172,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "https://example.com:demo": "https://base.example/unparseable2", "http://[www.example.com]/": "https://base.example/unparseable3/", @@ -183,6 +192,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "https://example.com/aaa": "https://example.com/aaa", "https://example.com/a": "https://example.com/a" @@ -198,6 +208,7 @@ } }, "expectedParsedImportMap": { + "depcache": {}, "imports": { "https://example.com/aaa": "https://example.com/aaa", "https://example.com/a": "https://example.com/a" diff --git a/reference-implementation/__tests__/json/parsing-trailing-slashes.json b/reference-implementation/__tests__/json/parsing-trailing-slashes.json index 89c454f..06f1e5e 100644 --- a/reference-implementation/__tests__/json/parsing-trailing-slashes.json +++ b/reference-implementation/__tests__/json/parsing-trailing-slashes.json @@ -7,6 +7,7 @@ }, "importMapBaseURL": "https://base.example/path1/path2/path3", "expectedParsedImportMap": { + "depcache": {}, "imports": { "trailer/": null }, diff --git a/reference-implementation/__tests__/test.js b/reference-implementation/__tests__/test.js index c7f30b3..da9acc9 100644 --- a/reference-implementation/__tests__/test.js +++ b/reference-implementation/__tests__/test.js @@ -2,6 +2,7 @@ const { runTests } = require('./helpers/common-test-helper.js'); for (const jsonFile of [ 'data-base-url.json', + 'depcache.json', 'empty-import-map.json', 'overlapping-entries.json', 'packages-via-trailing-slashes.json', diff --git a/reference-implementation/lib/depcache.js b/reference-implementation/lib/depcache.js new file mode 100644 index 0000000..f8e3e40 --- /dev/null +++ b/reference-implementation/lib/depcache.js @@ -0,0 +1,21 @@ +'use strict'; + +const { resolve } = require('./resolver.js'); + +// This implementation differs from the specification since the specification +// implementation integrates closely with the module loading algorithm. +exports.traceDepcache = traceDepcache; +function traceDepcache(url, parsedImportMap, visited = new Set()) { + const urlString = url.href; + if (visited.has(urlString)) { + return visited; + } + visited.add(urlString); + + const dependencies = parsedImportMap.depcache[urlString] || []; + for (const dep of dependencies) { + const resolved = resolve(dep, parsedImportMap, url); + traceDepcache(resolved, parsedImportMap, visited); + } + return [...visited]; +} diff --git a/reference-implementation/lib/parser.js b/reference-implementation/lib/parser.js index 77b7309..6a4c908 100644 --- a/reference-implementation/lib/parser.js +++ b/reference-implementation/lib/parser.js @@ -25,17 +25,27 @@ exports.parseFromString = (input, baseURL) => { sortedAndNormalizedScopes = sortAndNormalizeScopes(parsed.scopes, baseURL); } + let normalizedDepcache = {}; + if ('depcache' in parsed) { + if (!isJSONObject(parsed.depcache)) { + throw new TypeError('Import map\'s depcache value must be an object.'); + } + normalizedDepcache = normalizeDepcache(parsed.depcache, baseURL); + } + const badTopLevelKeys = new Set(Object.keys(parsed)); badTopLevelKeys.delete('imports'); badTopLevelKeys.delete('scopes'); + badTopLevelKeys.delete('depcache'); for (const badKey of badTopLevelKeys) { - console.warn(`Invalid top-level key "${badKey}". Only "imports" and "scopes" can be present.`); + console.warn(`Invalid top-level key "${badKey}". Only "imports", "scopes" and "depcache" can be present.`); } // Always have these two keys, and exactly these two keys, in the result. return { imports: sortedAndNormalizedImports, - scopes: sortedAndNormalizedScopes + scopes: sortedAndNormalizedScopes, + depcache: normalizedDepcache }; }; @@ -108,6 +118,37 @@ function sortAndNormalizeScopes(obj, baseURL) { return sortedAndNormalized; } +function normalizeDepcache(obj, baseURL) { + const normalized = {}; + for (const [module, dependencies] of Object.entries(obj)) { + const moduleURL = tryURLParse(module, baseURL); + if (moduleURL === null) { + console.warn(`Invalid depcache entry URL "${module}" (parsed against base URL "${baseURL}").`); + continue; + } + + if (!isJSONArray(dependencies)) { + console.warn(`The value for the "${module}" depcache dependencies must be an array.`); + continue; + } + + let validDependencies = true; + for (const dependency of dependencies) { + if (typeof dependency !== 'string') { + console.warn(`Invalid depcache item type "${typeof dependency}" for "${module}, only strings are permitted.`); + validDependencies = false; + break; + } + } + if (dependencies.length && validDependencies) { + const normalizedModule = moduleURL.href; + normalized[normalizedModule] = dependencies; + } + } + + return normalized; +} + function normalizeSpecifierKey(specifierKey, baseURL) { // Ignore attempts to use the empty string as a specifier key if (specifierKey === '') { @@ -127,6 +168,10 @@ function isJSONObject(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isJSONArray(value) { + return Array.isArray(value); +} + function codeUnitCompare(a, b) { if (a > b) { return 1; diff --git a/spec.bs b/spec.bs index 504e42e..7f5d98d 100644 --- a/spec.bs +++ b/spec.bs @@ -30,7 +30,11 @@ spec: html; type: dfn; urlPrefix: https://html.spec.whatwg.org/multipage/ text: fetch an import() module script graph; url: webappapis.html#fetch-an-import()-module-script-graph text: fetch a modulepreload module script graph; url: webappapis.html#fetch-a-modulepreload-module-script-graph text: fetch an inline module script graph; url: webappapis.html#fetch-an-inline-module-script-graph + text: fetch a single module script; url: webappapis.html#fetch-a-single-module-script text: script; url: webappapis.html#concept-script + +urlPrefix: https://tc39.github.io/ecma262/#; spec: ECMA-262; type: abstract-op; + text: IsArray; url: sec-isarray