Skip to content

Commit fb0f54a

Browse files
committed
Serve linked source maps from loopback server (#660)
With cloudflare/workerd#710, `workerd` supports breakpoint debugging! Support for this in Miniflare just worked, assuming you were using a plain JavaScript worker, or you had inline source maps. `workerd` doesn't know where workers are located on disk, it just knows files' locations relative to each other. This means it's unable to resolve locations of corresponding linked `.map` files in `sourceMappingURL` comments. Miniflare _does_ have this information though. This change detects linked source maps and rewrites `sourceMappingURL` comments to `http` URLs pointing to Miniflare's loopback server. This then looks for the source map relative to the known on-disk source location. Source maps' `sourceRoot` attributes are updated to ensure correct locations are displayed in DevTools. **This enables breakpoint debugging for compiled TypeScript with linked source maps!** 🎉 Closes DEVX-872
1 parent cee744e commit fb0f54a

File tree

7 files changed

+295
-45
lines changed

7 files changed

+295
-45
lines changed

packages/miniflare/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@types/stoppable": "^1.1.1",
5757
"@types/ws": "^8.5.3",
5858
"devalue": "^4.3.0",
59+
"devtools-protocol": "^0.0.1182435",
5960
"semiver": "^1.1.0"
6061
},
6162
"engines": {

packages/miniflare/src/index.ts

+10
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
QueueConsumers,
3939
QueuesError,
4040
SharedOptions,
41+
SourceMapRegistry,
4142
WorkerOptions,
4243
getGlobalServices,
4344
maybeGetSitesManifestModule,
@@ -403,6 +404,7 @@ export class Miniflare {
403404
#runtime?: Runtime;
404405
#removeRuntimeExitHook?: () => void;
405406
#runtimeEntryURL?: URL;
407+
#sourceMapRegistry?: SourceMapRegistry;
406408

407409
// Path to temporary directory for use as scratch space/"in-memory" Durable
408410
// Object storage. Note this may not exist, it's up to the consumers to
@@ -664,6 +666,8 @@ export class Miniflare {
664666
this.#log.debug(`Error parsing response log: ${String(e)}`);
665667
}
666668
response = new Response(null, { status: 204 });
669+
} else if (url.pathname.startsWith(SourceMapRegistry.PATHNAME_PREFIX)) {
670+
response = await this.#sourceMapRegistry?.get(url);
667671
} else {
668672
// TODO: check for proxying/outbound fetch header first (with plans for fetch mocking)
669673
response = await this.#handleLoopbackPlugins(request, url);
@@ -771,6 +775,7 @@ export class Miniflare {
771775

772776
sharedOpts.core.cf = await setupCf(this.#log, sharedOpts.core.cf);
773777

778+
const sourceMapRegistry = new SourceMapRegistry(this.#log, loopbackPort);
774779
const durableObjectClassNames = getDurableObjectClassNames(allWorkerOpts);
775780
const queueConsumers = getQueueConsumers(allWorkerOpts);
776781
const allWorkerRoutes = getWorkerRoutes(allWorkerOpts);
@@ -823,6 +828,7 @@ export class Miniflare {
823828
workerIndex: i,
824829
additionalModules,
825830
tmpPath: this.#tmpPath,
831+
sourceMapRegistry,
826832
durableObjectClassNames,
827833
queueConsumers,
828834
};
@@ -845,6 +851,10 @@ export class Miniflare {
845851
}
846852
}
847853

854+
// Once we've assembled the config, and are about to restart the runtime,
855+
// update the source map registry.
856+
this.#sourceMapRegistry = sourceMapRegistry;
857+
848858
return { services: Array.from(services.values()), sockets };
849859
}
850860

packages/miniflare/src/plugins/core/index.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
HEADER_CF_BLOB,
3131
Plugin,
3232
SERVICE_LOOPBACK,
33+
SourceMapRegistry,
3334
WORKER_BINDING_SERVICE_LOOPBACK,
3435
parseRoutes,
3536
} from "../shared";
@@ -320,9 +321,14 @@ export const CORE_PLUGIN: Plugin<
320321
workerIndex,
321322
durableObjectClassNames,
322323
additionalModules,
324+
sourceMapRegistry,
323325
}) {
324326
// Define regular user worker
325-
const workerScript = getWorkerScript(options, workerIndex);
327+
const workerScript = getWorkerScript(
328+
sourceMapRegistry,
329+
options,
330+
workerIndex
331+
);
326332
// Add additional modules (e.g. "__STATIC_CONTENT_MANIFEST") if any
327333
if ("modules" in workerScript) {
328334
workerScript.modules.push(...additionalModules);
@@ -480,6 +486,7 @@ export function getGlobalServices({
480486
}
481487

482488
function getWorkerScript(
489+
sourceMapRegistry: SourceMapRegistry,
483490
options: SourceOptions,
484491
workerIndex: number
485492
): { serviceWorkerScript: string } | { modules: Worker_Module[] } {
@@ -489,7 +496,7 @@ function getWorkerScript(
489496
("modulesRoot" in options ? options.modulesRoot : undefined) ?? "";
490497
return {
491498
modules: options.modules.map((module) =>
492-
convertModuleDefinition(modulesRoot, module)
499+
convertModuleDefinition(sourceMapRegistry, modulesRoot, module)
493500
),
494501
};
495502
}
@@ -509,7 +516,7 @@ function getWorkerScript(
509516

510517
if (options.modules) {
511518
// If `modules` is `true`, automatically collect modules...
512-
const locator = new ModuleLocator(options.modulesRules);
519+
const locator = new ModuleLocator(sourceMapRegistry, options.modulesRules);
513520
// If `script` and `scriptPath` are set, resolve modules in `script`
514521
// against `scriptPath`.
515522
locator.visitEntrypoint(
@@ -520,6 +527,9 @@ function getWorkerScript(
520527
} else {
521528
// ...otherwise, `modules` will either be `false` or `undefined`, so treat
522529
// `code` as a service worker
530+
if ("scriptPath" in options && options.scriptPath !== undefined) {
531+
code = sourceMapRegistry.register(code, options.scriptPath);
532+
}
523533
return { serviceWorkerScript: code };
524534
}
525535
}

packages/miniflare/src/plugins/core/modules.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
globsToRegExps,
1616
testRegExps,
1717
} from "../../shared";
18+
import { SourceMapRegistry } from "../shared";
1819

1920
const SUGGEST_BUNDLE =
2021
"If you're trying to import an npm package, you'll need to bundle your Worker first.";
@@ -122,11 +123,13 @@ function getResolveErrorPrefix(referencingPath: string): string {
122123
}
123124

124125
export class ModuleLocator {
126+
readonly #sourceMapRegistry: SourceMapRegistry;
125127
readonly #compiledRules: CompiledModuleRule[];
126128
readonly #visitedPaths = new Set<string>();
127129
readonly modules: Worker_Module[] = [];
128130

129-
constructor(rules?: ModuleRule[]) {
131+
constructor(sourceMapRegistry: SourceMapRegistry, rules?: ModuleRule[]) {
132+
this.#sourceMapRegistry = sourceMapRegistry;
130133
this.#compiledRules = compileModuleRules(rules);
131134
}
132135

@@ -144,6 +147,7 @@ export class ModuleLocator {
144147
#visitJavaScriptModule(code: string, modulePath: string, esModule = true) {
145148
// Register module
146149
const name = path.relative("", modulePath);
150+
code = this.#sourceMapRegistry.register(code, modulePath);
147151
this.modules.push(
148152
esModule ? { name, esModule: code } : { name, commonJsModule: code }
149153
);
@@ -305,18 +309,23 @@ function contentsToArray(contents: string | Uint8Array): Uint8Array {
305309
return typeof contents === "string" ? encoder.encode(contents) : contents;
306310
}
307311
export function convertModuleDefinition(
312+
sourceMapRegistry: SourceMapRegistry,
308313
modulesRoot: string,
309314
def: ModuleDefinition
310315
): Worker_Module {
311316
// The runtime requires module identifiers to be relative paths
312317
let name = path.relative(modulesRoot, def.path);
313318
if (path.sep === "\\") name = name.replaceAll("\\", "/");
314-
const contents = def.contents ?? readFileSync(def.path);
319+
let contents = def.contents ?? readFileSync(def.path);
315320
switch (def.type) {
316321
case "ESModule":
317-
return { name, esModule: contentsToString(contents) };
322+
contents = contentsToString(contents);
323+
contents = sourceMapRegistry.register(contents, def.path);
324+
return { name, esModule: contents };
318325
case "CommonJS":
319-
return { name, commonJsModule: contentsToString(contents) };
326+
contents = contentsToString(contents);
327+
contents = sourceMapRegistry.register(contents, def.path);
328+
return { name, commonJsModule: contents };
320329
case "Text":
321330
return { name, text: contentsToString(contents) };
322331
case "Data":

packages/miniflare/src/plugins/shared/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from "zod";
22
import { Service, Worker_Binding, Worker_Module } from "../../runtime";
33
import { Awaitable, Log, OptionalZodTypeOf } from "../../shared";
44
import { GatewayConstructor } from "./gateway";
5+
import { SourceMapRegistry } from "./registry";
56
import { RouterConstructor } from "./router";
67

78
// Maps **service** names to the Durable Object class names exported by them
@@ -41,6 +42,7 @@ export interface PluginServicesOptions<
4142
workerIndex: number;
4243
additionalModules: Worker_Module[];
4344
tmpPath: string;
45+
sourceMapRegistry: SourceMapRegistry;
4446

4547
// ~~Leaky abstractions~~ "Plugin specific options" :)
4648
durableObjectClassNames: DurableObjectClassNames;
@@ -91,5 +93,6 @@ export function namespaceEntries(
9193
export * from "./constants";
9294
export * from "./gateway";
9395
export * from "./range";
96+
export * from "./registry";
9497
export * from "./router";
9598
export * from "./routing";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import crypto from "crypto";
2+
import fs from "fs/promises";
3+
import path from "path";
4+
import { fileURLToPath, pathToFileURL } from "url";
5+
import type { RawSourceMap } from "source-map";
6+
import { Response } from "../../http";
7+
import { Log } from "../../shared";
8+
9+
function maybeParseURL(url: string): URL | undefined {
10+
if (path.isAbsolute(url)) return;
11+
try {
12+
return new URL(url);
13+
} catch {}
14+
}
15+
16+
export class SourceMapRegistry {
17+
static PATHNAME_PREFIX = "/core/source-map/";
18+
19+
constructor(
20+
private readonly log: Log,
21+
private readonly loopbackPort: number
22+
) {}
23+
24+
readonly #map = new Map<string /* id */, string /* sourceMapPath */>();
25+
26+
register(script: string, scriptPath: string): string /* newScript */ {
27+
// Try to find the last source mapping URL in the file, if none could be
28+
// found, return the script as is
29+
const mappingURLIndex = script.lastIndexOf("//# sourceMappingURL=");
30+
if (mappingURLIndex === -1) return script;
31+
32+
// `pathToFileURL()` will resolve `scriptPath` relative to the current
33+
// working directory if needed
34+
const scriptURL = pathToFileURL(scriptPath);
35+
36+
const sourceSegment = script.substring(0, mappingURLIndex);
37+
const mappingURLSegment = script
38+
.substring(mappingURLIndex)
39+
.replace(/^\/\/# sourceMappingURL=(.+)/, (substring, mappingURL) => {
40+
// If the mapping URL is already a URL (e.g. `data:`), return it as is
41+
if (maybeParseURL(mappingURL) !== undefined) return substring;
42+
43+
// Otherwise, resolve it relative to the script, and register it
44+
const resolvedMappingURL = new URL(mappingURL, scriptURL);
45+
const resolvedMappingPath = fileURLToPath(resolvedMappingURL);
46+
47+
// We intentionally register source maps in a map to prevent arbitrary
48+
// file access via the loopback server.
49+
const id = crypto.randomUUID();
50+
this.#map.set(id, resolvedMappingPath);
51+
mappingURL = `http://localhost:${this.loopbackPort}${SourceMapRegistry.PATHNAME_PREFIX}${id}`;
52+
53+
this.log.verbose(
54+
`Registered source map ${JSON.stringify(
55+
resolvedMappingPath
56+
)} at ${mappingURL}`
57+
);
58+
59+
return `//# sourceMappingURL=${mappingURL}`;
60+
});
61+
62+
return sourceSegment + mappingURLSegment;
63+
}
64+
65+
async get(url: URL): Promise<Response | undefined> {
66+
// Try to get source map from registry
67+
const id = url.pathname.substring(SourceMapRegistry.PATHNAME_PREFIX.length);
68+
const sourceMapPath = this.#map.get(id);
69+
if (sourceMapPath === undefined) return;
70+
71+
// Try to load and parse source map from disk
72+
let contents: string;
73+
try {
74+
contents = await fs.readFile(sourceMapPath, "utf8");
75+
} catch (e) {
76+
this.log.warn(
77+
`Error reading source map ${JSON.stringify(sourceMapPath)}: ${e}`
78+
);
79+
return;
80+
}
81+
let map: RawSourceMap;
82+
try {
83+
map = JSON.parse(contents);
84+
} catch (e) {
85+
this.log.warn(
86+
`Error parsing source map ${JSON.stringify(sourceMapPath)}: ${e}`
87+
);
88+
return;
89+
}
90+
91+
// Modify the `sourceRoot` so source files get the correct paths. Note,
92+
// `sourceMapPath` will always be an absolute path.
93+
const sourceMapDir = path.dirname(sourceMapPath);
94+
map.sourceRoot =
95+
map.sourceRoot === undefined
96+
? sourceMapDir
97+
: path.resolve(sourceMapDir, map.sourceRoot);
98+
99+
return Response.json(map, {
100+
// This source map will be served from the loopback server to DevTools,
101+
// which will likely be on a different origin.
102+
headers: { "Access-Control-Allow-Origin": "*" },
103+
});
104+
}
105+
}

0 commit comments

Comments
 (0)