@jundle/cloudflare 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/harness.js ADDED
@@ -0,0 +1,707 @@
1
+ // Jundle workerd module-runner harness (SPEC §16.11).
2
+ //
3
+ // Static worker script, constructed ONCE per dev session by the
4
+ // CF plugin's runner (Miniflare: this script + the unsafe-eval
5
+ // binding + the wrangler-derived bindings). It contains NO app
6
+ // code: app modules are fetched as text from the dev server
7
+ // over HTTP and evaluated through the unsafe-eval binding as
8
+ // CJS-converted code with a require hook — the workerd
9
+ // counterpart of the Node runner's CJS evaluation
10
+ // (packages/jundle/src/dev/runner.js), sharing its semantics:
11
+ // same registry keys, same importer eviction (via the dev
12
+ // server's version route), same loud failures for targets the
13
+ // closure walk missed.
14
+ //
15
+ // This file is a TEMPLATE: the plugin reads it as text and
16
+ // replaces the `// __JUNDLE_WRAPPERS__` line with generated
17
+ // export wrappers (one per DO / Workflow / entrypoint class
18
+ // export, discovered by static analysis on the dev server).
19
+ // It must stay free of Node-only imports: everything here runs
20
+ // in the worker (static node: imports + cloudflare:workers
21
+ // only). It is dev-only machinery: the unsafe-eval binding is
22
+ // a Miniflare option on this worker, never a wrangler binding,
23
+ // never production, never in a prod bundle.
24
+ import * as streamNs from "node:stream";
25
+ import * as bufferNs from "node:buffer";
26
+ import * as cryptoNs from "node:crypto";
27
+ import * as eventsNs from "node:events";
28
+ import * as pathNs from "node:path";
29
+ import * as urlNs from "node:url";
30
+ import * as utilNs from "node:util";
31
+ import * as querystringNs from "node:querystring";
32
+ import * as stringDecoderNs from "node:string_decoder";
33
+ import * as asyncHooksNs from "node:async_hooks";
34
+ import * as timersNs from "node:timers";
35
+ import * as diagnosticsChannelNs from "node:diagnostics_channel";
36
+ import * as cwNs from "cloudflare:workers";
37
+
38
+ // Internal binding names. They are stripped from user `env`
39
+ // before app code ever sees it (SPEC §16.11: user code receives
40
+ // exactly its own bindings, identical in shape to production).
41
+ const EVAL_NAME = "__jundle_runner_eval";
42
+ const BASE_NAME = "__jundle_runner_base";
43
+ const ENTRY_NAME = "__jundle_runner_entry";
44
+ const INTERNAL = new Set([EVAL_NAME, BASE_NAME, ENTRY_NAME]);
45
+
46
+ // Node builtins the require hook serves. Only builtins this
47
+ // workerd's nodejs_compat actually provides are listed here
48
+ // (verified by construction: node:os, for example, is NOT
49
+ // provided and must stay out, or the worker fails to start).
50
+ // Anything else fails loudly at require time, exactly like
51
+ // the Node runner's un-pre-loaded error.
52
+ const BUILTINS = {
53
+ "node:stream": streamNs,
54
+ "node:buffer": bufferNs,
55
+ "node:crypto": cryptoNs,
56
+ "node:events": eventsNs,
57
+ "node:path": pathNs,
58
+ "node:url": urlNs,
59
+ "node:util": utilNs,
60
+ "node:querystring": querystringNs,
61
+ "node:string_decoder": stringDecoderNs,
62
+ "node:async_hooks": asyncHooksNs,
63
+ "node:timers": timersNs,
64
+ "node:diagnostics_channel": diagnosticsChannelNs,
65
+ };
66
+
67
+ // No React Refresh globals are defined here, by design
68
+ // (SPEC §16.6): server runtimes never evaluate a
69
+ // Refresh-carrying module — client modules fetched for
70
+ // SSR arrive as refresh-free SSR variants, and server
71
+ // modules never get Refresh calls. A $RefreshReg$ call
72
+ // evaluated here would throw a ReferenceError, which
73
+ // indicates a variant-routing bug (a browser variant
74
+ // fetched for SSR), not a missing runtime, and must
75
+ // surface loudly rather than silently no-op.
76
+
77
+ // process.env.NODE_ENV (dev parity with the dev server, which
78
+ // fills NODE_ENV=development when unset). Workerd provides a
79
+ // `process` global with an (empty) env; fill only when unset.
80
+ try {
81
+ if (typeof process !== "undefined" && process && process.env && !process.env.NODE_ENV)
82
+ process.env.NODE_ENV = "development";
83
+ } catch {}
84
+
85
+ // ---- Runner state (per worker instance; lost on crash,
86
+ // rebuilt lazily on next request — see SPEC §16.11) ----
87
+ let evalBinding = null;
88
+ let baseUrl = "";
89
+ let entryUrl = "";
90
+ let lastSeenVersion = 0;
91
+ let manifestsCache = null;
92
+ let lastGoodEntry = null;
93
+ // Dev-module text cache: key `${env}\0${file}` -> {code, edges, filename, dirname}.
94
+ const textCache = new Map();
95
+ // Evaluated dev modules: key -> {exports, failed, error}.
96
+ const evaluated = new Map();
97
+ // Dep bundles: key (fileName, or `${entry}\0${rel}`) -> {code, modules, entryRel}.
98
+ const depText = new Map();
99
+ // Evaluated dep bundles: key -> {exports, failed, error}.
100
+ const depEvaluated = new Map();
101
+ // Parsed JSON values: key `${env}\0${file}` -> value.
102
+ const jsonCache = new Map();
103
+ // Prefetched asset values: key `${env}\0${file}\0${type}` ->
104
+ // the namespace-shaped object the worker returns for that
105
+ // file's asset-value URL ({ __esModule: true, default:
106
+ // <value> }), mirroring NodeRunner's assetValueCache.
107
+ // The key distinguishes type: one file as raw and as blob
108
+ // are different values. One entry per file+env+type, so
109
+ // every SSR importer in one env shares one value object.
110
+ const assetValueCache = new Map();
111
+
112
+ function devKey(url) {
113
+ const u = new URL(url, "http://dev");
114
+ return `${u.searchParams.get("env") || ""}\0${u.searchParams.get("file") || ""}`;
115
+ }
116
+
117
+ function devAssetKey(url) {
118
+ const u = new URL(url, "http://dev");
119
+ return `${u.searchParams.get("env") || ""}\0${u.searchParams.get("file") || ""}\0${u.searchParams.get("type") || ""}`;
120
+ }
121
+
122
+ function depKeyFromUrl(url) {
123
+ const u = new URL(url, "http://dev");
124
+ const rest = u.pathname.slice("/@jundle/deps/".length);
125
+ const slash = rest.indexOf("/");
126
+ if (slash === -1) return rest;
127
+ return `${rest.slice(0, slash)}\0${rest.slice(slash + 1)}`;
128
+ }
129
+
130
+ function stripEnv(env) {
131
+ const out = {};
132
+ for (const k of Object.keys(env)) {
133
+ if (!INTERNAL.has(k)) out[k] = env[k];
134
+ }
135
+ return out;
136
+ }
137
+
138
+ async function fetchJson(url) {
139
+ const res = await fetch(baseUrl + url);
140
+ if (!res.ok) {
141
+ const body = await res.text().catch(() => "");
142
+ throw new Error(`ModuleRunner: fetch ${url} failed (${res.status}): ${body.slice(0, 500)}`);
143
+ }
144
+ return res.json();
145
+ }
146
+
147
+ // Pull-based invalidation (SPEC §16.11): one loopback fetch per
148
+ // dispatch. Evicts invalidated dev-module keys (evaluated +
149
+ // text) and JSON values for edited JSON files, then advances
150
+ // the seen version. The dev server prunes acked batches.
151
+ // After eviction, pushes fresh SSR-variant instances for the
152
+ // browser-env subset (see pushSsrUpdates) before the entry
153
+ // re-imports, so the entry's decode already sees them.
154
+ async function syncVersion() {
155
+ const data = await fetchJson(`/@jundle/runner-version?since=${lastSeenVersion}`);
156
+ lastSeenVersion = data.version;
157
+ for (const key of data.invalidated || []) {
158
+ evaluated.delete(key);
159
+ textCache.delete(key);
160
+ }
161
+ for (const file of data.jsonFiles || []) {
162
+ for (const k of jsonCache.keys()) {
163
+ if (k.split("\0")[1] === file) jsonCache.delete(k);
164
+ }
165
+ }
166
+ for (const file of data.assetFiles || []) {
167
+ for (const k of assetValueCache.keys()) {
168
+ if (k.split("\0")[1] === file) assetValueCache.delete(k);
169
+ }
170
+ }
171
+ await pushSsrUpdates(data.clientInvalidated || []);
172
+ }
173
+
174
+ // Server-side updateModule push, runner path (SPEC §16.11):
175
+ // after eviction, re-evaluate each evicted browser-env key
176
+ // (server variant — ensureText fetches consumer=server)
177
+ // and push the fresh exports into the dev shim's cache
178
+ // under the stable dev URL, mirroring the browser HMR
179
+ // client's update handler. React's Flight client skips
180
+ // chunk loads on reload (its own module-level chunkCache),
181
+ // so without the push a reloaded page keeps rendering the
182
+ // first SSR instance. Failure keeps last-good silently:
183
+ // the old shim entry stays (the push is skipped, never a
184
+ // delete) and the failure is logged only, never surfaced
185
+ // through the runner-error header. A browser module
186
+ // failing worker evaluation is expected (DOM code has no
187
+ // document in a worker), and surfacing it would trip the
188
+ // hot client's first-update overlay rule and force a full
189
+ // reload on the next update instead of HMR.
190
+ async function pushSsrUpdates(clientKeys) {
191
+ for (const key of clientKeys) {
192
+ const [env, file] = key.split("\0");
193
+ const url = `/@jundle/module?env=${encodeURIComponent(env)}&file=${encodeURIComponent(file)}`;
194
+ try {
195
+ const rec = await ensureModuleByUrl(url);
196
+ globalThis.__jundleFlight?.updateModule?.(url, rec.exports);
197
+ } catch (e) {
198
+ console.error(`[jundle runner] SSR push ${url}: ${e && e.message ? e.message : String(e)}`);
199
+ }
200
+ }
201
+ }
202
+
203
+ // Closure walk (async, mirrors NodeRunner.ensureClosure): fetch
204
+ // texts for the module and everything it reaches, so the sync
205
+ // evaluation below never has to load anything. Dev edges,
206
+ // client-ref edges, and server-action edges are all fetched
207
+ // (client modules are transformed server-side by the fetch,
208
+ // which is what populates the dev manifests, but never
209
+ // evaluated — server code references them, it does not import
210
+ // them). Dep edges fetch dep units; JSON edges fetch parsed
211
+ // values; asset-value edges fetch values. CSS / host /
212
+ // external edges fetch nothing.
213
+ async function ensureText(url, visiting = new Set()) {
214
+ const key = devKey(url);
215
+ if (textCache.has(key) || visiting.has(key)) return;
216
+ visiting.add(key);
217
+ const u = new URL(url, "http://dev");
218
+ // Server consumer (SPEC §16.6): everything the harness
219
+ // fetches runs in the worker, so every module fetch
220
+ // asks for the SSR variant (refresh-free for client
221
+ // modules). Keys ignore the extra param (devKey reads
222
+ // env+file only), so registry/textCache identities and
223
+ // version-check eviction are unchanged.
224
+ u.searchParams.set("consumer", "server");
225
+ // Dev URL params are always sorted (SPEC §16.12), so the
226
+ // added consumer param must not land last out of order.
227
+ u.searchParams.sort();
228
+ const data = await fetchJson(`/@jundle/runner-module?${u.searchParams.toString()}`);
229
+ textCache.set(key, data);
230
+ for (const edge of data.edges || []) {
231
+ if (edge.kind === "dev" || edge.kind === "client-ref" || edge.kind === "server-action") {
232
+ await ensureText(edge.url, visiting);
233
+ } else if (edge.kind === "dep") {
234
+ await ensureDepText(edge.url, new Set());
235
+ } else if (edge.kind === "json") {
236
+ const jk = devKey(edge.url);
237
+ if (!jsonCache.has(jk)) {
238
+ const ju = new URL(edge.url, "http://dev");
239
+ jsonCache.set(jk, await fetchJson(`/@jundle/runner-json?${ju.searchParams.toString()}`));
240
+ }
241
+ } else if (edge.kind === "asset-value") {
242
+ // Asset-value edges (SPEC §16.12): the harness
243
+ // prefetches the value like a JSON value, then
244
+ // reconstructs the namespace the worker returns
245
+ // for the asset-value URL. Blob values arrive as
246
+ // base64 + MIME and are rebuilt here (Blob exists
247
+ // in workerd); raw and json values arrive as-is.
248
+ const ak = devAssetKey(edge.url);
249
+ if (!assetValueCache.has(ak)) {
250
+ const au = new URL(edge.url, "http://dev");
251
+ const data = await fetchJson(`/@jundle/runner-asset-value?${au.searchParams.toString()}`);
252
+ let value;
253
+ if (data.kind === "blob") {
254
+ value = new Blob([Uint8Array.from(atob(data.base64), (c) => c.charCodeAt(0))], {
255
+ type: data.mime,
256
+ });
257
+ } else {
258
+ value = data.value;
259
+ }
260
+ assetValueCache.set(ak, { __esModule: true, default: value });
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ // Dep-unit walk. Dep CJS text carries require literals, so
267
+ // edges are derived from them (same literal-call shape the
268
+ // CJS transform emits). A require that resolves to nothing
269
+ // is skipped here — it fails loudly at eval time if actually
270
+ // required, which is the correct (non-wedging) behavior for
271
+ // both genuine misses and string-literal false positives.
272
+ function extractRequires(code) {
273
+ const out = [];
274
+ const re = /require\("([^"]+)"\)/g;
275
+ let m;
276
+ while ((m = re.exec(code)) !== null) out.push(m[1]);
277
+ return out;
278
+ }
279
+
280
+ async function ensureDepText(depUrl, visiting = new Set()) {
281
+ const key = depKeyFromUrl(depUrl);
282
+ if (depText.has(key) || visiting.has(key)) return;
283
+ visiting.add(key);
284
+ const u = new URL(depUrl, "http://dev");
285
+ const data = await fetchJson(
286
+ `/@jundle/runner-dep${u.pathname.slice("/@jundle/deps".length)}${u.search}`,
287
+ );
288
+ depText.set(key, data);
289
+ for (const spec of extractRequires(data.code)) {
290
+ if (spec.startsWith("/@jundle/deps/")) {
291
+ await ensureDepText(spec, visiting);
292
+ continue;
293
+ }
294
+ if (spec.startsWith("./") || spec.startsWith("../")) {
295
+ const slash = key.indexOf("\0");
296
+ if (slash !== -1) {
297
+ const bundleEntry = key.slice(0, slash);
298
+ const rel = key.slice(slash + 1);
299
+ const normalized = pathNs.posix.normalize(
300
+ pathNs.posix.join(pathNs.posix.dirname(rel), spec),
301
+ );
302
+ if (data.modules && data.modules.includes(normalized)) {
303
+ await ensureDepText(`/@jundle/deps/${bundleEntry}/${normalized}`, visiting);
304
+ } else {
305
+ const base = normalized.split("/").pop();
306
+ await ensureDepText(`/@jundle/deps/${base}`, visiting).catch(() => {});
307
+ }
308
+ } else {
309
+ await ensureDepText(`/@jundle/deps/${spec.slice(2).split("?")[0]}`, visiting).catch(
310
+ () => {},
311
+ );
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ // Sync evaluation (mirrors NodeRunner.evaluate, including the
318
+ // record-then-swap failure rule: circulars see partial
319
+ // exports during evaluation; on failure the record becomes
320
+ // FAILED (not half-populated), so the next request retries
321
+ // instead of serving a partial module).
322
+ function evaluateDev(key) {
323
+ const existing = evaluated.get(key);
324
+ if (existing && !existing.failed) return existing;
325
+ const text = textCache.get(key);
326
+ if (!text)
327
+ throw new Error(
328
+ `ModuleRunner: module '${key}' was not pre-fetched (the closure walk missed it); use a static, literal specifier so the walk pre-fetches it; dynamic specifiers are not pre-fetched.`,
329
+ );
330
+ const record = { exports: {}, failed: false };
331
+ evaluated.set(key, record);
332
+ const module = { exports: record.exports };
333
+ try {
334
+ if (!evalBinding)
335
+ throw new Error(
336
+ "ModuleRunner: unsafe-eval binding missing (Miniflare did not provide __jundle_runner_eval).",
337
+ );
338
+ const fn = evalBinding.eval(
339
+ "(function(require, module, exports, __filename, __dirname) {\n" +
340
+ text.code +
341
+ "\n})\n//# sourceURL=" +
342
+ (key.split("\0")[1] || key),
343
+ );
344
+ fn((spec) => requireHook(spec, key), module, module.exports, text.filename, text.dirname);
345
+ } catch (e) {
346
+ evaluated.set(key, { exports: {}, failed: true, error: e });
347
+ throw e;
348
+ }
349
+ record.exports = module.exports;
350
+ return record;
351
+ }
352
+
353
+ function evaluateDep(depUrl) {
354
+ const key = depKeyFromUrl(depUrl);
355
+ const existing = depEvaluated.get(key);
356
+ if (existing && !existing.failed) return existing;
357
+ const text = depText.get(key);
358
+ if (!text)
359
+ throw new Error(
360
+ `ModuleRunner: dep bundle '${key}' was not pre-fetched (the closure walk missed it).`,
361
+ );
362
+ // Bundle-dir entry redirect (NodeRunner parity: an entry
363
+ // with an entryRel evaluates through the bundle path).
364
+ if (!key.includes("\0") && text.entryRel)
365
+ return evaluateDep(`/@jundle/deps/${key}/${text.entryRel}`);
366
+ const record = { exports: {}, failed: false };
367
+ depEvaluated.set(key, record);
368
+ const module = { exports: record.exports };
369
+ const slash = key.indexOf("\0");
370
+ const rel = slash === -1 ? key : key.slice(slash + 1);
371
+ try {
372
+ if (!evalBinding)
373
+ throw new Error(
374
+ "ModuleRunner: unsafe-eval binding missing (Miniflare did not provide __jundle_runner_eval).",
375
+ );
376
+ const fn = evalBinding.eval(
377
+ "(function(require, module, exports, __filename, __dirname) {\n" +
378
+ text.code +
379
+ "\n})\n//# sourceURL=" +
380
+ rel,
381
+ );
382
+ fn((spec) => depRequireHook(spec, key, text), module, module.exports, rel, "");
383
+ } catch (e) {
384
+ depEvaluated.set(key, { exports: {}, failed: true, error: e });
385
+ throw e;
386
+ }
387
+ record.exports = module.exports;
388
+ return record;
389
+ }
390
+
391
+ function depRequireHook(spec, depKey, depTextRec) {
392
+ if (spec.startsWith("/@jundle/deps/")) return evaluateDep(spec).exports;
393
+ // Workerd provides node: builtins (via the harness's
394
+ // static imports) and cloudflare:workers to any code it
395
+ // runs, including dep bundles — so the dep hook serves
396
+ // them, exactly as the workerd bundle's static imports
397
+ // receive them. (NodeRunner's dep hook fails these; that
398
+ // is a Node-dev gap, not a runner gap.)
399
+ if (spec.startsWith("node:")) {
400
+ if (BUILTINS[spec]) return BUILTINS[spec];
401
+ throw new Error(
402
+ `ModuleRunner: dep bundle '${depKey}' requires unsupported Node builtin '${spec}'.`,
403
+ );
404
+ }
405
+ if (spec === "cloudflare:workers") return cwNs;
406
+ if (spec.startsWith("./") || spec.startsWith("../")) {
407
+ const slash = depKey.indexOf("\0");
408
+ if (slash !== -1) {
409
+ const bundleEntry = depKey.slice(0, slash);
410
+ const rel = depKey.slice(slash + 1);
411
+ const normalized = pathNs.posix.normalize(pathNs.posix.join(pathNs.posix.dirname(rel), spec));
412
+ if (depTextRec.modules && depTextRec.modules.includes(normalized))
413
+ return evaluateDep(`/@jundle/deps/${bundleEntry}/${normalized}`).exports;
414
+ const base = normalized.split("/").pop();
415
+ if (depText.has(base)) return evaluateDep(`/@jundle/deps/${base}`).exports;
416
+ throw new Error(
417
+ `ModuleRunner: dep bundle '${depKey}' requires unexpected specifier '${spec}' (dep bundles only import sibling dep files).`,
418
+ );
419
+ }
420
+ return evaluateDep(`/@jundle/deps/${spec.slice(2).split("?")[0]}`).exports;
421
+ }
422
+ throw new Error(
423
+ `ModuleRunner: dep bundle '${depKey}' requires unexpected specifier '${spec}' (dep bundles only import sibling dep files).`,
424
+ );
425
+ }
426
+
427
+ // Sync require hook for evaluated app modules (mirrors
428
+ // NodeRunner.requireHook, minus the native-external path:
429
+ // the worker has no native import, so bare non-node specs
430
+ // fail loudly instead of loading natively).
431
+ function requireHook(spec, importerKey) {
432
+ if (spec.startsWith("/@jundle/module?")) {
433
+ return evaluateDev(devKey(spec)).exports;
434
+ }
435
+ if (spec.startsWith("/@jundle/deps/")) {
436
+ return evaluateDep(spec).exports;
437
+ }
438
+ if (spec.startsWith("/@jundle/json?")) {
439
+ const key = devKey(spec);
440
+ if (!jsonCache.has(key))
441
+ throw new Error(`ModuleRunner: JSON module '${spec}' was not pre-fetched.`);
442
+ return { __esModule: true, default: jsonCache.get(key) };
443
+ }
444
+ if (spec.startsWith("/@jundle/asset-value?")) {
445
+ const key = devAssetKey(spec);
446
+ if (!assetValueCache.has(key))
447
+ throw new Error(`ModuleRunner: asset value '${spec}' was not pre-fetched.`);
448
+ return assetValueCache.get(key);
449
+ }
450
+ if (spec.startsWith("/@jundle/css?")) return {};
451
+ if (spec === "jundle:dev") return hostContext;
452
+ if (spec === "cloudflare:workers") return cwNs;
453
+ if (spec.startsWith("node:")) {
454
+ if (BUILTINS[spec]) return BUILTINS[spec];
455
+ throw new Error(
456
+ `ModuleRunner: Node builtin '${spec}' is not available in workerd dev (unsupported by this workerd's nodejs_compat).`,
457
+ );
458
+ }
459
+ // JSON requires via the importer's server-computed edges
460
+ // (literal require() calls resolving to JSON): the value
461
+ // directly, not the namespace (NodeRunner require-json
462
+ // parity).
463
+ const importerText = importerKey ? textCache.get(importerKey) : null;
464
+ if (importerText) {
465
+ for (const edge of importerText.edges || []) {
466
+ if (edge.kind === "json" && edge.spec === spec) {
467
+ const key = devKey(edge.url);
468
+ if (!jsonCache.has(key))
469
+ throw new Error(`ModuleRunner: JSON module '${spec}' was not pre-fetched.`);
470
+ return jsonCache.get(key);
471
+ }
472
+ }
473
+ }
474
+ // Relative JSON requires (NodeRunner parity for the common
475
+ // case): the server pre-fetches the value via the
476
+ // importer's requireJsonFiles edges, and the hook resolves
477
+ // the relative spec against the importer file. Both the
478
+ // exact name and the +.json form are tried (NodeRunner
479
+ // resolves "./data" to data.json the same way). Bare
480
+ // JSON requires are NOT resolved here (no resolver in
481
+ // the worker) — they fall through to the loud error
482
+ // below; use an import for those.
483
+ if (
484
+ importerKey &&
485
+ typeof spec === "string" &&
486
+ (spec.startsWith("./") || spec.startsWith("../")) &&
487
+ !spec.startsWith("/@jundle/")
488
+ ) {
489
+ const parts = importerKey.split("\0");
490
+ const importerFile = parts[1] || "";
491
+ const importerEnv = parts[0] || "";
492
+ const normalized = pathNs.posix.normalize(
493
+ pathNs.posix.join(pathNs.posix.dirname(importerFile), spec),
494
+ );
495
+ for (const candidate of [normalized, `${normalized}.json`]) {
496
+ const key = `${importerEnv}\0${candidate}`;
497
+ if (jsonCache.has(key)) return jsonCache.get(key);
498
+ }
499
+ }
500
+ throw new Error(
501
+ `ModuleRunner: '${spec}' was not pre-loaded (the closure walk missed it); use a static, literal specifier for this import so the walk pre-loads it; dynamic specifiers are not pre-loaded.`,
502
+ );
503
+ }
504
+
505
+ // Host context (the harness side of jundle:dev, mirroring
506
+ // NodeRunner's hostContext: manifests are fetched per
507
+ // dispatch (after import, so they are fresh); importModule
508
+ // resolves through the dev server so absolute manifest file
509
+ // paths map to canonical dev URLs; loadChunk parses the
510
+ // dev-URL chunk id directly.
511
+ const hostContext = {
512
+ getManifests: () => {
513
+ return (
514
+ manifestsCache || { clientManifest: {}, serverManifest: {}, clientEntry: [], clientCss: [] }
515
+ );
516
+ },
517
+ importModule: async (file, env) => {
518
+ const resolved = await fetchJson(
519
+ `/@jundle/runner-resolve?env=${encodeURIComponent(env)}&file=${encodeURIComponent(file)}`,
520
+ );
521
+ return (await ensureModuleByUrl(resolved.url)).exports;
522
+ },
523
+ loadChunk: async (chunkId) => {
524
+ const u = new URL(chunkId, "http://dev");
525
+ if (u.pathname !== "/@jundle/module")
526
+ throw new Error(`ModuleRunner: refusing to load chunk (not a dev module URL): ${chunkId}`);
527
+ const url = `/@jundle/module?env=${encodeURIComponent(u.searchParams.get("env") || "react-client")}&file=${encodeURIComponent(u.searchParams.get("file") || "")}`;
528
+ return (await ensureModuleByUrl(url)).exports;
529
+ },
530
+ };
531
+
532
+ async function ensureModuleByUrl(url) {
533
+ await ensureText(url, new Set());
534
+ return evaluateDev(devKey(url));
535
+ }
536
+
537
+ // Entry import for wrapper classes (DO / Workflow /
538
+ // entrypoint methods run during dispatch, but an alarm can
539
+ // fire outside any dispatch): always version-check first,
540
+ // so a lazily-created real instance never uses a stale
541
+ // class after an edit.
542
+ async function __jundleImportEntry() {
543
+ await ensureEvalShims();
544
+ await syncVersion();
545
+ const rec = await ensureModuleByUrl(entryUrl);
546
+ return rec.exports;
547
+ }
548
+
549
+ // Eval-realm global shims (SPEC §16.11): this workerd
550
+ // provides no WeakRef / FinalizationRegistry, but React's
551
+ // Flight client constructs a WeakRef per response (and
552
+ // other deps may use FinalizationRegistry). Install
553
+ // dev-only shims in the EVAL realm (via the binding —
554
+ // the eval realm may or may not share the worker's
555
+ // global, so assigning from the harness is not enough)
556
+ // before any module evaluates, and only when missing
557
+ // (a future workerd with real WeakRef keeps its own).
558
+ // WeakRef holds strongly: safe here, because React pairs
559
+ // it with a strong `response` field it nulls when done,
560
+ // and the shim reference itself is a collectable cycle.
561
+ // The FinalizationRegistry shim never fires callbacks:
562
+ // finalization is non-deterministic by spec, so dev code
563
+ // must not depend on it firing.
564
+ let evalShimsInstalled = false;
565
+ async function ensureEvalShims() {
566
+ if (evalShimsInstalled || !evalBinding) return;
567
+ evalShimsInstalled = true;
568
+ const missing = evalBinding.eval(
569
+ "typeof WeakRef === 'undefined' || typeof FinalizationRegistry === 'undefined'",
570
+ );
571
+ if (!missing) return;
572
+ evalBinding.eval(
573
+ "if (typeof WeakRef === 'undefined') { globalThis.WeakRef = class WeakRef { constructor(t) { this._t = t; } deref() { return this._t; } }; }" +
574
+ "if (typeof FinalizationRegistry === 'undefined') { globalThis.FinalizationRegistry = class FinalizationRegistry { constructor() {} register() {} unregister() { return false; } }; }",
575
+ );
576
+ }
577
+
578
+ // Per-dispatch preamble, shared by fetch / scheduled /
579
+ // queue: bind the internal bindings, pull invalidation,
580
+ // (re-)import the entry, refresh manifests on success.
581
+ // Entry failures keep the last-good entry for dispatch
582
+ // (fetch only — scheduled/queue throw, they have no page
583
+ // to serve stale); manifests stay at their previous
584
+ // version on entry failure, so a stale entry never renders
585
+ // against fresh manifests.
586
+ async function prepareDispatch(env) {
587
+ evalBinding = env[EVAL_NAME];
588
+ baseUrl = env[BASE_NAME] || "";
589
+ entryUrl = env[ENTRY_NAME] || "";
590
+ if (!evalBinding)
591
+ throw new Error(
592
+ "ModuleRunner: unsafe-eval binding missing (Miniflare did not provide __jundle_runner_eval).",
593
+ );
594
+ // Dev-only global eval polyfill (SPEC §16.11): workerd bans
595
+ // codegen from strings at request time, so app code calling
596
+ // eval (notably React's dev Flight client, which warns and
597
+ // loses its stack-reconstruction features without it) would
598
+ // throw. The harness replaces the global eval with a
599
+ // binding-backed one before any app module evaluates: bare
600
+ // `eval` (including React's indirect `(0, eval)`) resolves
601
+ // to this, and the binding evaluates the string as a script
602
+ // in the worker's global scope — the same semantics as
603
+ // Node's indirect eval. Non-string arguments return as-is,
604
+ // per spec. Dev-only by construction: the binding never
605
+ // exists in production, so prod workerd still throws — and
606
+ // user code calling eval therefore works in runner dev but
607
+ // throws in production, a deliberate dev/prod gap stated
608
+ // in SPEC, not hidden.
609
+ // eslint-disable-next-line no-eval
610
+ globalThis.eval = (code) => (typeof code === "string" ? evalBinding.eval(code) : code);
611
+ if (!baseUrl)
612
+ throw new Error(`ModuleRunner: dev-server base URL missing (binding ${BASE_NAME} not set).`);
613
+ if (!entryUrl)
614
+ throw new Error(`ModuleRunner: entry URL missing (binding ${ENTRY_NAME} not set).`);
615
+ await ensureEvalShims();
616
+ await syncVersion();
617
+ let entryError = null;
618
+ let entry = null;
619
+ try {
620
+ const rec = await ensureModuleByUrl(entryUrl);
621
+ entry = rec.exports;
622
+ lastGoodEntry = entry;
623
+ } catch (e) {
624
+ entryError = e;
625
+ entry = lastGoodEntry;
626
+ }
627
+ if (entry && !entryError) {
628
+ manifestsCache = await fetchJson("/@jundle/runner-manifests");
629
+ }
630
+ return { entry, entryError };
631
+ }
632
+
633
+ function errorHeaderValue(e) {
634
+ return String((e && e.message) || e)
635
+ .replace(/[\r\n]+/g, " ")
636
+ .slice(0, 500);
637
+ }
638
+
639
+ function entryAppOf(entry) {
640
+ return entry ? entry.default || entry.app : null;
641
+ }
642
+
643
+ export default {
644
+ async fetch(request, env, ctx) {
645
+ try {
646
+ const { entry, entryError } = await prepareDispatch(env);
647
+ if (!entry)
648
+ // No last-good entry: surface the original import
649
+ // failure, not a generic message, so the overlay
650
+ // and the 500 body name the real cause.
651
+ throw (
652
+ entryError ||
653
+ new Error(
654
+ "ModuleRunner: no entry available (first import failed and no last-good entry exists).",
655
+ )
656
+ );
657
+ const app = entryAppOf(entry);
658
+ if (!app || typeof app.fetch !== "function")
659
+ throw new Error("ModuleRunner: entry's default export must be an app with a fetch method.");
660
+ const res = await app.fetch(request, stripEnv(env), ctx);
661
+ // Only entry errors set the runner-error header (the
662
+ // dev server broadcasts it to the overlay and strips
663
+ // it before returning). Push failures are silent
664
+ // keep-last-good (see pushSsrUpdates) and set no
665
+ // header: the entry itself is fine and the render
666
+ // keeps the last-good instance.
667
+ const headerError = entryError;
668
+ if (headerError) {
669
+ const headers = new Headers(res.headers);
670
+ headers.set("x-jundle-runner-error", errorHeaderValue(headerError));
671
+ return new Response(res.body, { status: res.status, headers });
672
+ }
673
+ return res;
674
+ } catch (e) {
675
+ return new Response(
676
+ "ModuleRunner: " +
677
+ (e && e.message ? e.message : String(e)) +
678
+ (e && e.stack ? "\n" + e.stack : ""),
679
+ {
680
+ status: 500,
681
+ headers: { "x-jundle-runner-error": errorHeaderValue(e), "content-type": "text/plain" },
682
+ },
683
+ );
684
+ }
685
+ },
686
+ async scheduled(controller, env, ctx) {
687
+ const { entry, entryError } = await prepareDispatch(env);
688
+ if (entryError) throw entryError;
689
+ if (!entry) throw new Error("ModuleRunner: no entry available.");
690
+ const app = entryAppOf(entry);
691
+ const handler = app && app.scheduled;
692
+ if (typeof handler !== "function")
693
+ throw new Error("ModuleRunner: entry has no scheduled handler.");
694
+ return handler(controller, stripEnv(env), ctx);
695
+ },
696
+ async queue(batch, env, ctx) {
697
+ const { entry, entryError } = await prepareDispatch(env);
698
+ if (entryError) throw entryError;
699
+ if (!entry) throw new Error("ModuleRunner: no entry available.");
700
+ const app = entryAppOf(entry);
701
+ const handler = app && app.queue;
702
+ if (typeof handler !== "function") throw new Error("ModuleRunner: entry has no queue handler.");
703
+ return handler(batch, stripEnv(env), ctx);
704
+ },
705
+ };
706
+
707
+ // __JUNDLE_WRAPPERS__