@jundle/cloudflare 0.0.0-stage → 0.0.1-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/index.js ADDED
@@ -0,0 +1,446 @@
1
+ // @jundle/cloudflare — the Cloudflare Workers runtime for the
2
+ // jundle dev server (SPEC §16.3). It IS the workerd runtime:
3
+ // a dev entry names it as its `runtime` (SPEC §6.12, §16.2).
4
+ // Miniflare + wrangler are dependencies of THIS package, not
5
+ // jundle core (so apps that only use Node never install
6
+ // workerd). Wrangler is pinned (see config.js) because its
7
+ // config utilities are `unstable_`.
8
+ // Runtime plugin shape (SPEC §16.2): { createRunner,
9
+ // defaultOptions } (a single runtime, not a map). The dev
10
+ // server calls createRunner for a dev entry whose runtime
11
+ // is this package.
12
+ // createRunner({ configDir, entryUrl, exportShape,
13
+ // options }) -> { config, wranglerFile, options,
14
+ // fetch(request), invalidate(invalidated, opts?),
15
+ // dispose(), start({ baseUrl })?, ...extras }
16
+ // options is the entry's options object, opaque to core:
17
+ // wranglerConfig (path, resolved against configDir),
18
+ // remoteBindings (names of remote bindings from the
19
+ // wrangler file), serviceWorkers ({ name: script } local
20
+ // service-binding targets via Miniflare's workers array
21
+ // (CF-3 multi-worker; the main worker stays in the array,
22
+ // since the array replaces the top-level script)).
23
+ // The runner runs a Worker in Miniflare with the full binding
24
+ // surface from the wrangler file. start(script) is the
25
+ // standalone/test path (startScript); the remaining extras
26
+ // (dispatchFetch, dispatchScheduled, getQueueProducer,
27
+ // setScript) are plugin-specific, not part of the interface.
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { Log, LogLevel, Miniflare } from "miniflare";
31
+ import { readWranglerConfig } from "./config.js";
32
+ import { wranglerToMiniflareOptions } from "./bindings.js";
33
+
34
+ // Module-runner harness (SPEC §16.11): the static worker
35
+ // script (harness.js) is read as text and passed to
36
+ // Miniflare as the main worker's script, with generated
37
+ // export wrappers substituted for the placeholder. The
38
+ // harness evaluates app modules inside workerd through the
39
+ // unsafe-eval binding — a dev-only Miniflare option, never
40
+ // a wrangler binding, never production.
41
+ const HARNESS_TEMPLATE = fs.readFileSync(new URL("./harness.js", import.meta.url), "utf8");
42
+ const EVAL_BINDING_NAME = "__jundle_runner_eval";
43
+ const BASE_BINDING_NAME = "__jundle_runner_base";
44
+ const ENTRY_BINDING_NAME = "__jundle_runner_entry";
45
+ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
46
+
47
+ // Console forwarding (SPEC §16.11): Miniflare's `log`
48
+ // option is the logger for its own errors, warnings and
49
+ // informative messages. Without it the core service
50
+ // defaults to NoOpLog, so Miniflare's own warnings and
51
+ // errors are swallowed (worker console.log/error reach
52
+ // the terminal anyway, via workerd's stdio pipe —
53
+ // log/info/debug to stdout, warn/error to stderr — and
54
+ // the `log` option does not duplicate them). Miniflare's
55
+ // own Log writes every level to stdout (its log() is
56
+ // console.log for all levels), so this subclass routes
57
+ // by level instead — ERROR/WARN to stderr, the rest to
58
+ // stdout — with the same `[mf:<level>]` prefix shape.
59
+ const LOG_LEVEL_NAME = ["", "error", "warn", "info", "debug", "verbose"];
60
+ class JundleLog extends Log {
61
+ logWithLevel(level, message) {
62
+ if (level <= 0 || level > this.level) return;
63
+ const line = `[mf:${LOG_LEVEL_NAME[level]}] ${message}`;
64
+ if (level <= LogLevel.WARN) console.error(line);
65
+ else console.log(line);
66
+ }
67
+ }
68
+
69
+ // Generate the static export wrappers for DO / Workflow /
70
+ // entrypoint class exports (SPEC §16.11). Workers require
71
+ // static exports, so the harness cannot export app classes
72
+ // dynamically: each wrapper extends the real base class,
73
+ // lazily constructs the real class from the evaluated entry
74
+ // (after a version check, so alarms never use a stale
75
+ // class), and delegates every discovered method. The names
76
+ // `__jundleReal` / `__jundleRealInstance` / `__jundleCtx` /
77
+ // `__jundleEnv` are reserved (a real class method of those
78
+ // names is dropped rather than shadowing the
79
+ // lazy-construction machinery).
80
+ function buildWrappers(classes) {
81
+ const bases = {
82
+ "durable-object": "cwNs.DurableObject",
83
+ workflow: "cwNs.WorkflowEntrypoint",
84
+ entrypoint: "cwNs.WorkerEntrypoint",
85
+ };
86
+ return (classes || [])
87
+ .map(({ name, kind, methods }) => {
88
+ if (!IDENT.test(name))
89
+ throw new Error(
90
+ `CF plugin: refusing to generate wrapper for non-identifier export '${name}'`,
91
+ );
92
+ const base = bases[kind];
93
+ if (!base) throw new Error(`CF plugin: unknown export class kind '${kind}' for '${name}'`);
94
+ const methodLines = (methods || [])
95
+ .filter(
96
+ (m) =>
97
+ IDENT.test(m) &&
98
+ m !== "constructor" &&
99
+ m !== "__jundleReal" &&
100
+ m !== "__jundleRealInstance" &&
101
+ m !== "__jundleCtx" &&
102
+ m !== "__jundleEnv",
103
+ )
104
+ .map(
105
+ (m) =>
106
+ ` async ${m}(...args) { return (await this.__jundleReal())[${JSON.stringify(m)}](...args); }`,
107
+ );
108
+ return `export class ${name} extends ${base} {
109
+ constructor(ctx, env) {
110
+ super(ctx, env);
111
+ this.__jundleCtx = ctx;
112
+ this.__jundleEnv = env;
113
+ this.__jundleRealInstance = null;
114
+ }
115
+ async __jundleReal() {
116
+ if (!this.__jundleRealInstance) {
117
+ const m = await __jundleImportEntry();
118
+ const C = m[${JSON.stringify(name)}];
119
+ if (typeof C !== "function")
120
+ throw new Error("ModuleRunner: entry export " + ${JSON.stringify(name)} + " is not a class in the evaluated entry.");
121
+ this.__jundleRealInstance = new C(this.__jundleCtx, stripEnv(this.__jundleEnv));
122
+ }
123
+ return this.__jundleRealInstance;
124
+ }
125
+ ${methodLines.join("\n")}
126
+ }`;
127
+ })
128
+ .join("\n");
129
+ }
130
+
131
+ function buildHarnessScript(exportShape) {
132
+ // Anchor on the standalone placeholder line: the header
133
+ // comment mentions the marker name, so a bare replace
134
+ // would substitute there instead of at the real line.
135
+ const replaced = HARNESS_TEMPLATE.replace(
136
+ "\n// __JUNDLE_WRAPPERS__\n",
137
+ `\n${buildWrappers(exportShape && exportShape.classes)}\n`,
138
+ );
139
+ if (replaced === HARNESS_TEMPLATE)
140
+ throw new Error("CF plugin: harness template is missing its __JUNDLE_WRAPPERS__ line");
141
+ return replaced;
142
+ }
143
+
144
+ // Find the built server entry chunk in a bundle dir.
145
+ // Primary signal: server-manifest.json's `entries` —
146
+ // the entry carrying a `runtime` is the process entry
147
+ // (SPEC §6.12). Chunk file names are content-hashed
148
+ // and change with the chunking policy, so no name
149
+ // pattern or content sniffing is the primary lookup.
150
+ // Fallback, for bundles built before `entries` existed:
151
+ // it is the entry chunk that is NOT any page's
152
+ // entryFile in page-manifest.json (pages are
153
+ // server-entry modules; the runner is the remaining
154
+ // server entry).
155
+ // Workers require a static module graph: a variable dynamic
156
+ // import (import(chunkId)) cannot be resolved, even if it is
157
+ // never called. The browser shim (src/runtime/shim.js) has
158
+ // exactly that — its loadChunk dynamic-imports the chunk URL
159
+ // string (SPEC §6.11 manifest-free browser loading). SSR in
160
+ // the Worker never calls it (SSR loads chunks through the
161
+ // server shim's literal-import manifest), so the Worker's
162
+ // copy of the bundle patches it to a throw. The browser still
163
+ // gets the ORIGINAL bundle (served by the dev server as a dev
164
+ // module, or in prod from the client dir at /assets/*), so
165
+ // hydration is unaffected. Only files
166
+ // containing a variable import are patched; a file with no
167
+ // such import is copied verbatim.
168
+ export async function createRunner({
169
+ configDir,
170
+ entryUrl,
171
+ exportShape,
172
+ options: entryOptions = {},
173
+ }) {
174
+ const wranglerConfig = entryOptions.wranglerConfig
175
+ ? path.resolve(configDir, entryOptions.wranglerConfig)
176
+ : undefined;
177
+ const serviceWorkers = entryOptions.serviceWorkers;
178
+ const {
179
+ file: wranglerFile,
180
+ config,
181
+ devVars,
182
+ } = await readWranglerConfig({ configDir, wranglerConfig });
183
+ const options = wranglerToMiniflareOptions({
184
+ config,
185
+ devVars,
186
+ configDir,
187
+ wranglerFile,
188
+ remoteBindings: entryOptions.remoteBindings,
189
+ });
190
+ let mf = null;
191
+ // Runner state (SPEC §16.11): the entry dev URL + export
192
+ // shape from creation, and the dev-server base URL once
193
+ // start() runs after the server binds.
194
+ let runnerState = { entryUrl, exportShape, baseUrl: null };
195
+
196
+ // Build the Miniflare constructor args for a main worker.
197
+ // Single-worker (no serviceWorkers): the current top-level
198
+ // form (script or scriptPath). Multi-worker: Miniflare's
199
+ // `workers` array REPLACES the top-level script (CF-1
200
+ // quirk), so the main worker must be IN the array, carrying
201
+ // its bindings, with the Shared persist/compat options at
202
+ // top level. Unknown keys are stripped by Miniflare's Zod
203
+ // schemas, so passing the full mapped options on the main
204
+ // worker entry is safe (Shared-only keys like persist paths
205
+ // are stripped there and land via the top-level shared).
206
+ // Logging (see JundleLog above): pass a Log so
207
+ // Miniflare's own warnings/errors reach the terminal, and
208
+ // disable per-request logging — the dev server prints its
209
+ // own boot lines and no request logs, so Miniflare's
210
+ // "[mf:info] GET / 200 OK" lines would be noise. The
211
+ // `log` option is a shared (core) option, so it lands at
212
+ // top level in both forms.
213
+ const logging = { log: new JundleLog(LogLevel.INFO), logRequests: false };
214
+ function buildArgs(mainScript) {
215
+ if (!serviceWorkers || !Object.keys(serviceWorkers).length) {
216
+ return { ...options, ...logging, ...mainScript };
217
+ }
218
+ const shared = { ...logging };
219
+ for (const k of [
220
+ "kvPersist",
221
+ "r2Persist",
222
+ "d1Persist",
223
+ "durableObjectsPersist",
224
+ "workflowsPersist",
225
+ "compatibilityDate",
226
+ "compatibilityFlags",
227
+ ]) {
228
+ if (options[k] !== undefined) shared[k] = options[k];
229
+ }
230
+ const mainWorker = { name: options.name, ...options, ...mainScript };
231
+ const workers = [mainWorker];
232
+ for (const [name, script] of Object.entries(serviceWorkers)) {
233
+ // Service scripts are ESM (export default); without
234
+ // modules:true Miniflare parses them as CommonJS and
235
+ // `export` is a syntax error (same .js->CJS default as
236
+ // the CF-2 bundle chunks).
237
+ workers.push({ name, script, modules: true });
238
+ }
239
+ // The unsafe-eval binding is a per-worker option in the
240
+ // array form (and top-level in the single-worker form);
241
+ // include it in the shared options too, so it lands
242
+ // whichever schema Miniflare validates against. Unknown
243
+ // keys are stripped, so the duplicate is harmless.
244
+ if (mainScript.unsafeEvalBinding) shared.unsafeEvalBinding = mainScript.unsafeEvalBinding;
245
+ return { ...shared, workers };
246
+ }
247
+
248
+ async function applyMigrations(target) {
249
+ // D1 migrations (mapped in bindings.js as d1Migrations,
250
+ // NOT a Miniflare option — wrangler dev applies them
251
+ // itself). After Miniflare starts, execute each migration
252
+ // file's SQL against its D1 binding via getD1Database.
253
+ // `target` lets a reload run migrations against the new
254
+ // instance before it is swapped in; defaults to the live one.
255
+ // Applied files are tracked per binding in a
256
+ // __jundle_migrations table (same idea as wrangler's own
257
+ // tracking): a reconstruction or bundle reload builds a
258
+ // NEW Miniflare against the SAME persisted D1, and
259
+ // re-running CREATE TABLE fails with "table already
260
+ // exists" (and re-running data migrations would
261
+ // duplicate rows). New files still apply; a file whose
262
+ // exec fails is not recorded, so it retries next time.
263
+ const inst = target || mf;
264
+ for (const m of options.d1Migrations || []) {
265
+ const db = await inst.getD1Database(m.binding);
266
+ await db.exec("CREATE TABLE IF NOT EXISTS __jundle_migrations (filename TEXT PRIMARY KEY)");
267
+ const applied = new Set(
268
+ (await db.prepare("SELECT filename FROM __jundle_migrations").all()).results.map(
269
+ (r) => r.filename,
270
+ ),
271
+ );
272
+ for (const f of fs
273
+ .readdirSync(m.dir)
274
+ .filter((x) => x.endsWith(".sql"))
275
+ .sort()) {
276
+ if (applied.has(f)) continue;
277
+ await db.exec(fs.readFileSync(path.join(m.dir, f), "utf8"));
278
+ await db.prepare("INSERT INTO __jundle_migrations (filename) VALUES (?)").bind(f).run();
279
+ }
280
+ }
281
+ }
282
+
283
+ async function startRunnerInternal({ entryUrl, baseUrl, exportShape }) {
284
+ // The harness statically imports node: builtins, so it
285
+ // requires nodejs_compat from the wrangler file (the
286
+ // single source of truth — jundle never injects
287
+ // compatibility flags). Without it, Miniflare fails
288
+ // the first dispatch with a cryptic string-script
289
+ // module error; fail fast with the actual fix instead.
290
+ if (!(options.compatibilityFlags || []).includes("nodejs_compat")) {
291
+ throw new Error(
292
+ `CF plugin: the module runner requires "nodejs_compat" in compatibility_flags in ${wranglerFile} (the harness imports node: builtins); add it to the wrangler file.`,
293
+ );
294
+ }
295
+ // One static harness worker per session: the harness
296
+ // script (with wrappers for the export shape) + the
297
+ // unsafe-eval binding + the wrangler-derived bindings
298
+ // (merged with the harness's internal bindings, which
299
+ // the harness strips before app code sees env). App code
300
+ // is never a Miniflare option. Construct-before-dispose
301
+ // applies here too (see invalidate).
302
+ const script = buildHarnessScript(exportShape);
303
+ const harnessBindings = {
304
+ ...options.bindings,
305
+ [BASE_BINDING_NAME]: baseUrl,
306
+ [ENTRY_BINDING_NAME]: entryUrl,
307
+ };
308
+ const builtArgs = buildArgs({
309
+ script,
310
+ modules: true,
311
+ unsafeEvalBinding: EVAL_BINDING_NAME,
312
+ bindings: harnessBindings,
313
+ });
314
+ const next = new Miniflare(builtArgs);
315
+ try {
316
+ await applyMigrations(next);
317
+ } catch (e) {
318
+ // Mirror the bundle path: a failed construction must
319
+ // not leak its workerd child (an undisposed instance
320
+ // keeps the dev-server process alive at exit).
321
+ await next.dispose().catch(() => {});
322
+ throw e;
323
+ }
324
+ return next;
325
+ }
326
+
327
+ return {
328
+ config,
329
+ wranglerFile,
330
+ options,
331
+ // Uniform runner interface (SPEC §16.2): fetch serves
332
+ // the app, invalidate reconstructs on an export-shape
333
+ // change (otherwise a no-op — the harness pulls the
334
+ // recorded batch via the version route on next
335
+ // dispatch), dispose tears down Miniflare, and start
336
+ // (optional) starts the harness once the dev server
337
+ // has bound (it needs the base URL).
338
+ async fetch(request) {
339
+ if (!mf) throw new Error("CF plugin: runner not started (call start first)");
340
+ // Miniflare's dispatchFetch does a cross-realm
341
+ // instanceof Request check that Node's Request fails,
342
+ // falling through to new URL("[object Request]") —
343
+ // so dispatch uses plain values (URL string, plain
344
+ // headers object, string body), NOT the incoming
345
+ // Request object. Both parts must be forwarded:
346
+ // dropping init silently downgrades every request to
347
+ // a bare GET, so POST /action arrived as GET /action
348
+ // and the app 404'd on route no-match (GETs worked,
349
+ // which hid the bug).
350
+ const init = {
351
+ method: request.method,
352
+ headers: Object.fromEntries(request.headers.entries()),
353
+ };
354
+ if (request.method !== "GET" && request.method !== "HEAD") {
355
+ init.body = await request.text();
356
+ }
357
+ return mf.dispatchFetch(request.url, init);
358
+ },
359
+ async invalidate(_invalidated, opts) {
360
+ if (!opts?.exportShape) return;
361
+ if (!mf || !runnerState) throw new Error("CF plugin: cannot reconstruct before start");
362
+ // Export-shape change (SPEC §16.11): the export set is
363
+ // construction-time, so a new DO / Workflow / entrypoint
364
+ // class export (or a removed one) requires rebuilding
365
+ // Miniflare with new wrappers. Same bindings and
366
+ // persistence root (wrangler file unchanged), and the
367
+ // new instance is built before the old one is disposed.
368
+ const next = await startRunnerInternal({
369
+ entryUrl: runnerState.entryUrl,
370
+ baseUrl: runnerState.baseUrl,
371
+ exportShape: opts.exportShape,
372
+ });
373
+ const oldMf = mf;
374
+ mf = next;
375
+ runnerState = { ...runnerState, exportShape: opts.exportShape };
376
+ await oldMf.dispose();
377
+ // Observable like the "built in" line: a
378
+ // reconstruction costs a fresh Miniflare, so the dev
379
+ // server says so instead of silently stalling; tests
380
+ // capture this line as the positive reconstruction
381
+ // signal.
382
+ console.log("jundle: runner reconstructed (export shape changed)");
383
+ },
384
+ async start({ baseUrl }) {
385
+ if (mf) await mf.dispose();
386
+ mf = await startRunnerInternal({
387
+ entryUrl: runnerState.entryUrl,
388
+ baseUrl,
389
+ exportShape: runnerState.exportShape,
390
+ });
391
+ runnerState = { ...runnerState, baseUrl };
392
+ },
393
+ // Standalone/test path: start Miniflare with a given
394
+ // script (no harness). Not part of the interface.
395
+ async startScript(script) {
396
+ if (mf) await mf.dispose();
397
+ mf = new Miniflare(buildArgs({ script }));
398
+ await applyMigrations();
399
+ return mf;
400
+ },
401
+ // dispatchFetch takes (url, init) — plain values, not a
402
+ // Request object (Miniflare's dispatchFetch does a
403
+ // cross-realm instanceof check that Node's Request
404
+ // fails). Both args must be forwarded: dropping init
405
+ // silently downgrades every request to a bare GET, so POST
406
+ // /action arrived as GET /action and the app 404'd on route
407
+ // no-match (GETs worked, which hid the bug).
408
+ async dispatchFetch(url, init) {
409
+ if (!mf) throw new Error("CF plugin: runner not started (call start first)");
410
+ return mf.dispatchFetch(url, init);
411
+ },
412
+ async setScript(script) {
413
+ if (!mf) throw new Error("CF plugin: runner not started (call start first)");
414
+ await mf.setOptions({ script });
415
+ },
416
+ // Event handlers (SPEC §16.3, CF-3): fetch via
417
+ // dispatchFetch (above), plus scheduled + queue.
418
+ // Scheduled: Miniflare's getWorker().scheduled({ cron })
419
+ // (the documented dispatch path; there is no
420
+ // dispatchScheduled on Miniflare itself in v4).
421
+ async dispatchScheduled(cron) {
422
+ if (!mf) throw new Error("CF plugin: runner not started (call start first)");
423
+ const worker = await mf.getWorker();
424
+ return worker.scheduled({ cron: cron || "" });
425
+ },
426
+ // Queue: producers send (Miniflare delivers to the
427
+ // consumer Worker's queue() handler — the real path, not
428
+ // a synthetic dispatch). This returns the producer for a
429
+ // binding so callers can send; the consumer runs
430
+ // automatically on delivery.
431
+ async getQueueProducer(binding) {
432
+ if (!mf) throw new Error("CF plugin: runner not started (call start first)");
433
+ return mf.getQueueProducer(binding);
434
+ },
435
+ async dispose() {
436
+ if (mf) {
437
+ await mf.dispose();
438
+ mf = null;
439
+ }
440
+ },
441
+ };
442
+ }
443
+
444
+ export const defaultOptions = { wranglerConfig: null };
445
+
446
+ export default { createRunner, defaultOptions };
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.