diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md
index 07548202e9..e66d52d724 100644
--- a/docs/javascript-artifact-reconstruction.md
+++ b/docs/javascript-artifact-reconstruction.md
@@ -223,7 +223,10 @@ Selected bounded text is then parsed as inert data to recover:
- Webpack and Rspack chunk registrations and module factories represented as
AST literals;
- static imports, dynamic imports, CommonJS `require` calls, workers, and
- service workers;
+ service workers (only `new Worker(new URL(path, import.meta.url))` resolves
+ relative to the calling module; string worker and service-worker paths use
+ the document base URL or Node's working directory, which static analysis
+ does not establish, so they keep their expression and stay unresolved);
- route, network endpoint, storage, and vendor-marker observations;
- local source-map declarations and original
source names and content digests;
diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts
index 477bef1ac4..cb602ca0f1 100644
--- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts
+++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts
@@ -27,11 +27,12 @@ describe("static callee boundary matching", () => {
["./bare.html", "call:loadFile"],
["https://optional.example.test", "call:win.loadURL"],
]);
+ // The document base URL is unknown, so the script path stays unresolved.
expect(
analysis.references
.filter(({ kind }) => kind === "service-worker")
- .map(({ specifier }) => specifier),
- ).toEqual(["./worker.js"]);
+ .map(({ specifier, expression }) => [specifier, expression]),
+ ).toEqual([[null, 'navigator.serviceWorker.register("./worker.js")']]);
expect(
analysis.endpoints
.filter(({ mechanism }) => mechanism.endsWith("loadURL"))
diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts
index 816ba3eaf2..16a123b8b5 100644
--- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts
+++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts
@@ -95,19 +95,25 @@ export const inspectCall = (
addReference(context, {
node,
kind: "worker",
- // Node string paths use the process cwd or filesystem root, neither of
- // which is established by the analyzed source's artifact location.
+ // A string path is relative to the document base URL in a browser and
+ // to the process cwd in Node; the analyzed source's artifact location
+ // establishes neither. Only `new URL(path, import.meta.url)` names a
+ // location relative to this module.
specifier:
- (workerConstructor === "node" ? undefined : first) ??
- (workerConstructor === undefined || worker === undefined
+ workerConstructor === undefined || worker === undefined
? undefined
- : workerUrlSpecifier(worker, bindings)),
+ : workerUrlSpecifier(worker, bindings),
});
+ // Service worker script URLs are also relative to the document base URL.
if (
name === "serviceWorker.register" ||
name.endsWith(".serviceWorker.register")
)
- addReference(context, { node, kind: "service-worker", specifier: first });
+ addReference(context, {
+ node,
+ kind: "service-worker",
+ specifier: undefined,
+ });
inspectEndpointCall(node, name, first, context);
inspectStorageCall(node, name, first, context);
if (first === "audio" || first === "video")
diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts
index eefa8a7f95..da514f2921 100644
--- a/tests/acceptance/applications/applicationWorkflowCli.test.ts
+++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts
@@ -541,7 +541,7 @@ it("resolves source-map URL paths once without changing their declarations", asy
await writeFile(
join(root, "app.js"),
cases.map(([url]) => `//# sourceMappingURL=${url}\n`).join("") +
- 'new Worker("./worker%20file.js");\n' +
+ 'new Worker(new URL("./worker%20file.js", import.meta.url));\n' +
'navigator.serviceWorker.register("./service%20worker.js");\n',
);
await writeFile(join(root, "worker file.js"), "export const worker = 1;\n");
@@ -598,27 +598,41 @@ it("resolves source-map URL paths once without changing their declarations", asy
}),
);
}
- for (const [kind, specifier, path] of [
- ["worker", "./worker%20file.js", "worker file.js"],
- ["service-worker", "./service%20worker.js", "service worker.js"],
- ]) {
- const worker = graph.nodes.find((node) => node.kind === kind);
- expect(worker?.observations).toContainEqual(
- expect.objectContaining({
- properties: expect.objectContaining({
- declared_specifier: specifier,
- resolved_path: path,
- }),
+ const worker = graph.nodes.find((node) => node.kind === "worker");
+ expect(worker?.observations).toContainEqual(
+ expect.objectContaining({
+ properties: expect.objectContaining({
+ declared_specifier: "./worker%20file.js",
+ resolved_path: "worker file.js",
}),
- );
- expect(graph.edges).toContainEqual(
- expect.objectContaining({
- source_node_id: worker?.node_id,
- relation: "maps_to",
- properties: { resolved_path: path },
+ }),
+ );
+ expect(graph.edges).toContainEqual(
+ expect.objectContaining({
+ source_node_id: worker?.node_id,
+ relation: "maps_to",
+ properties: { resolved_path: "worker file.js" },
+ }),
+ );
+ // A service worker script URL is relative to the unestablished document base.
+ const serviceWorker = graph.nodes.find(
+ (node) => node.kind === "service-worker",
+ );
+ expect(serviceWorker?.observations).toContainEqual(
+ expect.objectContaining({
+ properties: expect.objectContaining({
+ declared_specifier: null,
+ expression: 'navigator.serviceWorker.register("./service%20worker.js")',
+ resolved_path: null,
}),
- );
- }
+ }),
+ );
+ expect(
+ graph.edges.some(
+ ({ source_node_id, relation }) =>
+ source_node_id === serviceWorker?.node_id && relation === "maps_to",
+ ),
+ ).toBe(false);
expect((await analyzeThroughStdioMcp(root)).graph).toEqual(graph);
}, 20_000);
diff --git a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts
index 977083c760..1039f5d3a1 100644
--- a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts
+++ b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts
@@ -279,12 +279,6 @@ cliTest(
specifier: "./worker.js",
resolved: "bundled/worker.js",
},
- {
- source: "nested/literal.mjs",
- expression: 'new Worker("./literal-worker.js");',
- specifier: "./literal-worker.js",
- resolved: "nested/literal-worker.js",
- },
];
await writeFixtureFiles(root, {
...Object.fromEntries(
@@ -431,9 +425,6 @@ cliTest(
"directory-dot.mjs": "./worker.js/.",
"directory-parent.mjs": "./worker.js/child/..",
"empty-segment.mjs": "./child//../worker.js",
- "empty-worker-url.mjs": "",
- "query-only-worker-url.mjs": "?cache=1",
- "fragment-only-worker-url.mjs": "#worker",
"package.mjs": "./package-worker",
"external.mjs": "https://example.test/worker.js",
};
@@ -478,6 +469,57 @@ cliTest(
},
);
+cliTest(
+ "keeps string Worker paths unresolved because the document base is unknown",
+ async ({ cli }) => {
+ const root = await createTestTempDirectory("rea-worker-document-base-");
+ // A browser resolves these strings against the document base URL, not the
+ // calling script; Node resolves them against the working directory.
+ const expressions = {
+ "scripts/main.js": 'new Worker("./worker.js")',
+ "scripts/service.js": 'navigator.serviceWorker.register("./sw.js")',
+ };
+ await writeFixtureFiles(root, {
+ "index.html": '',
+ ...Object.fromEntries(
+ Object.entries(expressions).map(([path, code]) => [path, `${code};`]),
+ ),
+ "worker.js": "self.postMessage('document-relative worker');",
+ "scripts/worker.js": "self.postMessage('script-relative decoy');",
+ "scripts/sw.js": "self.postMessage('script-relative decoy');",
+ });
+
+ const result = await analyzeJavaScriptDirectory("CLI", cli, root);
+ for (const [source, expression] of Object.entries(expressions)) {
+ const node = result.graph.nodes.find(
+ ({ kind, observations }) =>
+ (kind === "worker" || kind === "service-worker") &&
+ observations.some(
+ ({ evidence }) =>
+ evidence.location.available &&
+ evidence.location.value.kind === "source-range" &&
+ evidence.location.value.source === source,
+ ),
+ );
+ expect(node?.observations).toContainEqual(
+ expect.objectContaining({
+ properties: expect.objectContaining({
+ declared_specifier: null,
+ expression,
+ resolved_path: null,
+ }),
+ }),
+ );
+ expect(
+ result.graph.edges.some(
+ ({ relation, source_node_id }) =>
+ relation === "maps_to" && source_node_id === node?.node_id,
+ ),
+ ).toBe(false);
+ }
+ },
+);
+
cliTest(
"bounds repeated ambient alias branches through the compiled CLI",
async ({ cli }) => {
diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts
index 8387acdaae..a575e41a8a 100644
--- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts
+++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts
@@ -148,7 +148,9 @@ it("analyzes a plain long-line loader without package metadata", async () => {
for (const node of nodes) {
expect(node.identity).toMatchObject({
strategy: "artifact-local-key",
- key: node.kind === "endpoint" ? literalEndpointKey("") : "",
+ // A Worker string is relative to the unestablished document base, so
+ // its identity is the retained expression rather than a specifier.
+ key: node.kind === "endpoint" ? literalEndpointKey("") : 'new Worker("")',
});
expect(node.observations.map(({ label }) => label)).toEqual([null]);
}
diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts
index 55807d6de7..1e1f4d970b 100644
--- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts
+++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts
@@ -55,7 +55,7 @@ const graphIdentities = async (source: string) => {
it.each([
"fetch('https://api.example.com/v1/users');",
"localStorage.setItem('token', 1);",
- "new Worker('./worker.js');",
+ "new Worker(new URL('./worker.js', import.meta.url));",
"const dep = require('./dep.js'); dep();",
"import('./dep.js');",
])(
diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts
index d9d4c8ffb5..21d5719e06 100644
--- a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts
+++ b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts
@@ -168,13 +168,13 @@ it("associates each source-map URL with its edge when aliases share one physical
});
});
-it("resolves a bare source-map file name and worker URL next to the script", async () => {
+it("resolves a bare source-map file name and module worker URL next to the script", async () => {
const root = await createTestTempDirectory("rea-source-map-relative-");
await mkdir(join(root, "dist"));
await writeFile(
join(root, "dist", "app.js"),
[
- 'new Worker("worker.js");',
+ 'new Worker(new URL("worker.js", import.meta.url));',
'navigator.serviceWorker.register("sw.js");',
"//# sourceMappingURL=app.js.map",
].join("\n"),
@@ -228,12 +228,12 @@ it("resolves a bare source-map file name and worker URL next to the script", asy
expect(workerEdge?.properties).toMatchObject({
resolved_path: "dist/worker.js",
});
- const serviceWorkerEdge = result.graph.edges.find(
- ({ relation, source_node_id }) =>
- relation === "maps_to" &&
- nodes.get(source_node_id)?.kind === "service-worker",
- );
- expect(serviceWorkerEdge?.properties).toMatchObject({
- resolved_path: "dist/sw.js",
- });
+ // A service worker URL is relative to the unestablished document base.
+ expect(
+ result.graph.edges.some(
+ ({ relation, source_node_id }) =>
+ relation === "maps_to" &&
+ nodes.get(source_node_id)?.kind === "service-worker",
+ ),
+ ).toBe(false);
});
diff --git a/tests/fixtures/javascriptRuntimeReconciliation.ts b/tests/fixtures/javascriptRuntimeReconciliation.ts
index baca10aeb6..2f0310ce46 100644
--- a/tests/fixtures/javascriptRuntimeReconciliation.ts
+++ b/tests/fixtures/javascriptRuntimeReconciliation.ts
@@ -7,7 +7,7 @@ import { createWebTextArtifact } from "../../src/domain/webContentArtifact.js";
import { analyzeJavaScriptApplication } from "../support/javascriptApplicationScope.js";
import { createTestTempDirectory } from "./temporaryDirectory.js";
-export const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`;
+export const SOURCE = `const worker = new Worker(new URL("./worker.js", import.meta.url));\nexport const observed = worker;\n`;
export const applicationFixture = async (): Promise => {
const root = await createTestTempDirectory("rea-runtime-reconciliation-");