@lunora/platform-node 1.0.0-alpha.16 → 1.0.0-alpha.160

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.
Files changed (33) hide show
  1. package/README.md +7 -0
  2. package/dist/conformance/index.mjs +1 -1
  3. package/dist/index.d.mts +393 -148
  4. package/dist/index.d.ts +393 -148
  5. package/dist/index.mjs +1 -1
  6. package/dist/packem_shared/NODE_SCHEDULER_DISPATCH_PATH-CNBAtztq.mjs +1 -0
  7. package/dist/packem_shared/bindShardSockets-9GvSocBW.mjs +1 -0
  8. package/dist/packem_shared/createNodeGlobalStore-DhHyIT8I.mjs +1 -0
  9. package/dist/packem_shared/createNodeHttpServer-D4YVdhJn.mjs +4 -0
  10. package/dist/packem_shared/createNodePlatform-C1z36ZqT.mjs +1 -0
  11. package/dist/packem_shared/createNodeProfileHandler-MX5xKPg0.mjs +1 -0
  12. package/dist/packem_shared/createNodeQueueHost-CttDb5sY.mjs +17 -0
  13. package/dist/packem_shared/createNodeR2Bucket-6I2HobcB.mjs +1 -0
  14. package/dist/packem_shared/createNodeSchedulerHost-BIJ4H38-.mjs +15 -0
  15. package/dist/packem_shared/createNodeShardHost-B6mt1S-h.mjs +1 -0
  16. package/dist/packem_shared/createNodeShardNamespace-BgQjDv7V.mjs +1 -0
  17. package/dist/packem_shared/createNodeShardRegistry-D1gAjQBl.mjs +1 -0
  18. package/dist/packem_shared/createNodeSocketHost-Bpn7V-ru.mjs +2 -0
  19. package/dist/packem_shared/createNodeWorkflowHost-B76TKiw_.mjs +1 -0
  20. package/dist/packem_shared/hmac-url-BrwGfbCr.mjs +1 -0
  21. package/dist/packem_shared/node-socket-pair-0baNnoFw.mjs +1 -0
  22. package/dist/packem_shared/prepared-statement-3XBQZXtC.mjs +1 -0
  23. package/package.json +14 -8
  24. package/dist/packem_shared/createNodeGlobalStore-Cwi33XOk.mjs +0 -1
  25. package/dist/packem_shared/createNodePlatform-CfKfpghn.mjs +0 -1
  26. package/dist/packem_shared/createNodeQueueHost--mBLGSxF.mjs +0 -17
  27. package/dist/packem_shared/createNodeR2Bucket-CmHYlIni.mjs +0 -1
  28. package/dist/packem_shared/createNodeSchedulerHost-vZgv1CEo.mjs +0 -15
  29. package/dist/packem_shared/createNodeShardHost-ByDrsNFb.mjs +0 -1
  30. package/dist/packem_shared/createNodeShardRegistry-Zy8eZoF9.mjs +0 -1
  31. package/dist/packem_shared/createNodeShardState-CpUzvWwd.mjs +0 -1
  32. package/dist/packem_shared/createNodeSocketHost-Ce4aH2Kq.mjs +0 -2
  33. package/dist/packem_shared/createNodeWorkflowHost-CTVBYiuj.mjs +0 -1
package/dist/index.d.ts CHANGED
@@ -1,9 +1,43 @@
1
1
  import { SchemaLike } from '@lunora/shard-engine';
2
2
  import { SqlCtxDbOptions, SqlCtxExec, createSqlCtxDb } from '@lunora/sql-store';
3
3
  import Database from 'better-sqlite3';
4
- import { ShardKvStore, QueueBindingLike, MessageBatchLike, SchedulerHost, ShardHost, SocketHost, DirectShardDirectory, PlatformCapabilities, ShardDirectory, R2BucketLike, SocketHandle } from '@lunora/platform';
4
+ import { ShardKvStore, QueueBindingLike, MessageBatchLike, SchedulerHost, ShardHost, SocketHandle, SocketHost, DirectShardDirectory, PlatformCapabilities, ShardDirectory, R2BucketLike } from '@lunora/platform';
5
5
  import { WorkflowBindingLike } from '@lunora/workflow';
6
6
  import { WorkflowRuntime, WorkflowStore } from '@visulima/workflow';
7
+ import { ShardNamespaceLike } from '@lunora/runtime';
8
+ /** One function call the route has authenticated and parsed. */
9
+ interface NodeDispatchInvocation {
10
+ args: Record<string, unknown>;
11
+ functionPath: string;
12
+ /** The scheduler record or delivery id, used as the shard's idempotency key. */
13
+ id: string | undefined;
14
+ /** Identity the trusted caller forwarded, if any. */
15
+ identity: {
16
+ identity?: string;
17
+ userId?: string;
18
+ } | undefined;
19
+ shardKey: string;
20
+ traceparent: string | undefined;
21
+ }
22
+ interface NodeDispatchRouteOptions {
23
+ /** `LUNORA_ADMIN_TOKEN`. Used as the bearer when no HMAC signature is presented. */
24
+ adminToken?: string;
25
+ /** The shard a body without `shardKey` is sent to. */
26
+ defaultShardKey: string;
27
+ /** Runs the call in-process and returns the shard's response, unchanged. */
28
+ invoke: (call: NodeDispatchInvocation) => Promise<Response>;
29
+ /** `LUNORA_SCHEDULER_SECRET`. When set, a signed request is verified against it. */
30
+ schedulerSecret?: string;
31
+ }
32
+ /**
33
+ * Build the dispatch route. The returned function answers any request it is
34
+ * handed; mounting it at {@link NODE_SCHEDULER_DISPATCH_PATH} is the caller's job.
35
+ * With neither secret configured every request is refused, so an unconfigured
36
+ * route fails closed.
37
+ */
38
+ declare const createNodeDispatchRoute: (options: NodeDispatchRouteOptions) => ((request: Request) => Promise<Response>);
39
+ /** The path `@lunora/dispatch` and `@lunora/scheduler` POST to. */
40
+ declare const NODE_SCHEDULER_DISPATCH_PATH: "/_lunora/scheduler/dispatch";
7
41
  /**
8
42
  * The Node store options — the shared store options minus the two this binding
9
43
  * owns. `dialect` is fixed (SQLite) and `exec` is the store's own connection;
@@ -14,13 +48,14 @@ type NodeGlobalContextDatabaseOptions = Omit<SqlCtxDbOptions, "dialect" | "exec"
14
48
  /**
15
49
  * Wrap a `better-sqlite3` connection as the async exec the store core consumes.
16
50
  *
17
- * `batch` is deliberately **not** implemented. The contract lets an exec that
18
- * omits it fall back to a sequential `run()` loop, and that fallback is already
19
- * optimal here: `batch` exists to collapse network round trips (D1 does it
20
- * atomically in one request; the Hyperdrive adapters dispatch concurrently over
21
- * a pool), and an embedded database has no round trip to collapse. Declaring it
22
- * would buy nothing and would opt this exec into the "MAY reorder or
23
- * parallelize" licence for no reason.
51
+ * `batch` runs the statements in order inside one `better-sqlite3`
52
+ * transaction, which is synchronous, so nothing else runs between them. There is
53
+ * no round trip to save; atomicity is the point. The store core's FTS5 search
54
+ * companion writes each document as an ordered list of statements, and without
55
+ * this two async writers interleaved at every `await` between them: one could
56
+ * move a document's mapping while the other's entry was mid-write, leaving an
57
+ * entry no later write or purge could reach. The `sqlite` dialect's contract
58
+ * requires an ordered, atomic `batch` for exactly that reason.
24
59
  */
25
60
  declare const createNodeSqlExec: (database: Database.Database) => SqlCtxExec;
26
61
  /** Options for {@link createNodeGlobalStore}. */
@@ -63,6 +98,32 @@ interface NodeGlobalStore {
63
98
  * which is the whole point of the dialect seam.
64
99
  */
65
100
  declare const createNodeGlobalStore: (options?: NodeGlobalStoreOptions) => NodeGlobalStore;
101
+ /** Serve `options.fetch` over `node:http`. Resolves once the server is listening. */
102
+ declare const createNodeHttpServer: (options: NodeHttpServerOptions) => Promise<NodeHttpServer>;
103
+ /** Options for {@link createNodeHttpServer}. */
104
+ interface NodeHttpServerOptions {
105
+ /** The app worker's `fetch`, already bound to its env and context. */
106
+ fetch: (request: Request) => Promise<Response> | Response;
107
+ /** Host to bind. Defaults to the loopback interface. */
108
+ host?: string;
109
+ /**
110
+ * The public base URL the app is served at, e.g. `https://app.example`. Every request URL uses its origin, so origin-sensitive
111
+ * behaviour (CSRF, WebSocket origin checks, cookie rules) sees the public scheme behind a TLS proxy. The client's `Host` header is
112
+ * never used. Defaults to the bound `http://host:port`, which is only allowed for a loopback `host`: binding a non-loopback address
113
+ * (e.g. `0.0.0.0` or a LAN IP) without `origin` makes {@link createNodeHttpServer} reject, because browsers would send that address
114
+ * as `Origin` and fail the CSRF check. Set `origin` to the URL users open.
115
+ */
116
+ origin?: string;
117
+ /** Port to bind. `0` (the default) picks a free ephemeral port. */
118
+ port?: number;
119
+ }
120
+ /** A listening server. */
121
+ interface NodeHttpServer {
122
+ /** Stop accepting connections and end every open one, streams included. */
123
+ close: () => Promise<void>;
124
+ /** The `http://host:port` origin the server answers on. */
125
+ origin: string;
126
+ }
66
127
  /** Build a `ShardKvStore` over a `better-sqlite3` table in the shard's database. */
67
128
  declare const createNodeShardKvStore: (database: Database.Database) => ShardKvStore;
68
129
  /** Options for {@link createNodeQueueHost}. */
@@ -197,9 +258,12 @@ interface NodeShardHostOptions {
197
258
  * `_lunora_alarm` when the host is constructed over an existing database,
198
259
  * including an alarm whose time elapsed while nothing was running.
199
260
  *
200
- * A handler that throws is isolated to its own delivery; it never reaches
261
+ * A handler that throws is isolated to its own delivery — it never reaches
201
262
  * the caller that set the alarm, because by then that call has long
202
- * returned.
263
+ * returned — and the wakeup is then re-delivered with backoff up to
264
+ * {@link ALARM_RETRY_LIMIT} times, the way workerd retries a throwing
265
+ * `alarm()`. Delivery is at-least-once, so the handler must tolerate
266
+ * running twice for one scheduled timestamp.
203
267
  */
204
268
  onAlarm?: () => Promise<void> | void;
205
269
  /**
@@ -218,25 +282,59 @@ interface NodeShardHostOptions {
218
282
  /**
219
283
  * Build a `ShardHost` over a real `better-sqlite3` database.
220
284
  *
221
- * `runSerialized` chains onto a single `tail` promise rather than the
222
- * reference host's explicit job-array-plus-drain-loop: every queued closure
223
- * runs once `tail` settles, and `tail` is reset to a version that always
224
- * resolves (never rejects) so one job's failure cannot wedge the queue for
225
- * every job after it — the same "no two closures interleave" guarantee,
226
- * fewer moving parts to get wrong.
285
+ * `runSerialized` and `transaction` share **one** boundary lock, because they
286
+ * write **one** `better-sqlite3` connection. `transaction` issues raw
287
+ * `BEGIN`/`COMMIT`/`ROLLBACK` — legal here (unlike inside a Cloudflare Durable
288
+ * Object, where the runtime forbids it and callers must use
289
+ * `storage.transaction`) because better-sqlite3 is a plain embedded database
290
+ * with no platform-level transaction primitive layered over it.
291
+ *
292
+ * ## Why one lane and not two
293
+ *
294
+ * This host shipped with two private tail chains, one per entry point, so each
295
+ * serialized against itself and neither against the other. `runSerialized`
296
+ * issues no `BEGIN` of its own, so one of its closures overlapping a bare
297
+ * `transaction()` wrote straight into that transaction's span. `assertOwnTurn`
298
+ * below catches the write — it is the only reason this host never silently lost
299
+ * one the way an unguarded connection would — but catching it is not the same
300
+ * as preventing it, and the refusal lands in the wrong place:
227
301
  *
228
- * `transaction` issues raw `BEGIN`/`COMMIT`/`ROLLBACK` — legal here (unlike
229
- * inside a Cloudflare Durable Object, where the runtime forbids it and
230
- * callers must use `storage.transaction`) because better-sqlite3 is a plain
231
- * embedded database with no platform-level transaction primitive layered over
232
- * it. It runs on its own private `transactionTail` chain, the same shape as
233
- * `runSerialized`'s but never shared with it: two bare, overlapping
234
- * `transaction()` calls on this host serialize against each other rather than
235
- * corrupting each other's commits (raw `BEGIN` on a connection already inside
236
- * a transaction throws, or worse, interleaves). Routing `transaction` through
237
- * `runSerialized` itself would deadlock, since the engine already composes
238
- * `runSerialized(() => transaction(work))` — the inner enqueue would then wait
239
- * on the outer closure awaiting it.
302
+ * 1. A `runSerialized` closure writes a row. Nothing is open, so it commits.
303
+ * 2. It awaits (a scheduler hop, an `onShardInit` read, any real I/O).
304
+ * 3. A bare `transaction()` runs `BEGIN` while it is parked. `transaction` is
305
+ * a public contract surface, and `./node-shard-state` re-exports it as the
306
+ * `storage.transaction` member `ShardDO` documents as "the platform
307
+ * primitive", so a subclass reaches one without going through the engine.
308
+ * 4. The closure resumes and its next write is refused with
309
+ * `SHARD_UNAVAILABLE`, so `runSerialized` **rejects** — with step 1 already
310
+ * durable.
311
+ *
312
+ * The boundary the contract promises is atomic-or-nothing to its caller tore in
313
+ * half: a rejection the caller will retry, on top of a write the retry now
314
+ * re-applies. That is `runSerialized`'s "no two closures interleave" guarantee
315
+ * broken by a `transaction` it was never serialized against.
316
+ *
317
+ * ## Why the lock is re-entrant, and why `AsyncLocalStorage` is what makes it so
318
+ *
319
+ * The engine composes the two, and stacks them: `ShardRunner.runInTransaction`
320
+ * is `runSerialized(() => transaction(work))` (`@lunora/shard-engine`), and
321
+ * `ShardDO.fetch` widens a mutation dispatch carrying `x-lunora-mutation-id`
322
+ * into a *further* `runSerialized` span around it (`@lunora/do`). Under two
323
+ * plain FIFO chains that outer span is already fatal on this host — the inner
324
+ * `runSerialized` waits on a `tail` that only settles when the outer closure it
325
+ * is running inside resolves, and because nothing ever resets that `tail`, the
326
+ * shard's write lane is wedged for the life of the process rather than for the
327
+ * life of the request.
328
+ *
329
+ * So the lock is skipped for a boundary opened from inside a boundary, and
330
+ * `AsyncLocalStorage` is the only thing that can tell that case from the one
331
+ * that must queue: its store follows a single call's own await chain without
332
+ * leaking into a sibling chain. A held boolean cannot — it reads `true` for an
333
+ * unrelated *second top-level* `transaction()` too, which would then nest a raw
334
+ * `BEGIN` on a connection already inside one. This is the same distinction
335
+ * workerd's `blockConcurrencyWhile` draws natively with "does not queue events
336
+ * initiated as part of the callback itself", which is what
337
+ * `@lunora/platform-cloudflare` leans on for the identical composition.
240
338
  *
241
339
  * The returned `dispose()` is this host's lifecycle owner: it clears the
242
340
  * pending alarm `setTimeout` (so it can never fire against a connection this
@@ -252,8 +350,55 @@ declare const createNodeShardHost: (options?: NodeShardHostOptions) => {
252
350
  drain: () => Promise<void>;
253
351
  host: ShardHost;
254
352
  };
353
+ /** The end a shard accepts. Its `send` and `close` are what the socket host calls through its handle. */
354
+ interface NodeServerEnd {
355
+ close: (code?: number, reason?: string) => void;
356
+ send: (data: string | ArrayBuffer) => void;
357
+ }
358
+ /**
359
+ * The socket-host half of a Node platform instance, plus the test-only hooks
360
+ * `@lunora/platform/conformance`'s `ConformanceHost` needs to drive a recycle
361
+ * from inside a test.
362
+ */
363
+ interface NodeSocketHost {
364
+ /**
365
+ * Wait for every close handler still running, including ones they start. `undefined` when none are,
366
+ * so a caller can dispose synchronously.
367
+ */
368
+ drain: () => Promise<void> | undefined;
369
+ /** Deliver a connection's close to `handler`, which calls `webSocketClose`. One handler per host. */
370
+ onClose: (handler: (socket: NodeServerEnd, code: number, reason: string, wasClean: boolean) => void | Promise<void>) => void;
371
+ /**
372
+ * Deliver a connection's inbound frames to `handler`. The handler is the
373
+ * shard that accepted the connection, so it calls `webSocketMessage`. One
374
+ * handler per host: a later call replaces the earlier one.
375
+ */
376
+ onMessage: (handler: (socket: NodeServerEnd, data: string | ArrayBuffer) => void) => void;
377
+ /** Read back the frames sent to a socket, oldest first, text frames only. */
378
+ readFrames: (handle: SocketHandle) => string[];
379
+ /**
380
+ * Re-create a runtime socket from durable state.
381
+ *
382
+ * `attachment` is a fallback only: this host restores what it persisted,
383
+ * because that is what a real wake looks like. The argument covers an id
384
+ * this host never durably tracked (a synthetic id a test constructs).
385
+ */
386
+ restoreSocket: (id: string, attachment: unknown) => SocketHandle;
387
+ /** Drop the runtime socket map while keeping durable attachments/tags. */
388
+ simulateRecycle: () => void;
389
+ /** The `SocketHost` contract implementation. */
390
+ socket: SocketHost;
391
+ }
392
+ /** Build the socket registry, persisting attachments and tags to `database`. */
393
+ declare const createNodeSocketHost: (database: Database.Database) => NodeSocketHost;
255
394
  /** The contracts scoped to one shard key. */
256
395
  interface NodeShard {
396
+ /**
397
+ * Inbound socket events for this shard's registry. Bind the shard that owns
398
+ * the sockets here, so a frame or close reaches its `webSocketMessage` and
399
+ * `webSocketClose`. See `bindShardSockets`.
400
+ */
401
+ inbound: Pick<NodeSocketHost, "onClose" | "onMessage">;
257
402
  /** Durable key-value storage on this shard's database. */
258
403
  kv: ShardKvStore;
259
404
  /** Single-writer execution, local SQL, transactions, durable alarms. */
@@ -310,22 +455,65 @@ interface NodeShardRegistry {
310
455
  }
311
456
  /** Build the in-process shard registry. */
312
457
  declare const createNodeShardRegistry: (options?: NodeShardRegistryOptions) => NodeShardRegistry;
458
+ /** Options for {@link createNodeWorkflowHost}. */
459
+ interface NodeWorkflowHostOptions<Workflows extends Record<string, {
460
+ isLunoraWorkflow: true;
461
+ }>> {
462
+ /** Base env merged under the derived workflow bindings — surfaced to workflow bodies as `ctx.env` and used to resolve spawned children. */
463
+ env?: Record<string, unknown>;
464
+ /** How long (ms) the engine holds a cross-process lease while an activation runs, for stores that implement `acquire`. Defaults to 30000. */
465
+ leaseTtlMs?: number;
466
+ /** Where runs are persisted. Required — see the header for why there is no default. `createNodeWorkflowStore(database)` is the durable one. */
467
+ store: WorkflowStore;
468
+ /** The declared workflows keyed by their `lunora/workflows.ts` export name (e.g. `{ orderPipeline: orderPipeline }`). Values must be `defineWorkflow` results. */
469
+ workflows: Workflows;
470
+ }
471
+ /** A fully-wired Node workflow host. */
472
+ interface NodeWorkflowHost<Workflows extends Record<string, {
473
+ isLunoraWorkflow: true;
474
+ }>> {
475
+ /** Per-export-name `WorkflowBindingLike` — the map `ctx.workflows` consumes. */
476
+ readonly bindings: { [K in keyof Workflows]: WorkflowBindingLike; };
477
+ /**
478
+ * The caller's `env` plus one binding per workflow under its class name
479
+ * (`OrderPipelineWorkflow`), the key `ctx.exports` would carry on Cloudflare —
480
+ * merge this into a worker env so `ctx.spawn`/`ctx.parallel` resolve
481
+ * children through the same runtime.
482
+ */
483
+ readonly env: Record<string, unknown>;
484
+ /** The underlying visulima runtime — `sweep`/`signal` for a dev loop or tests. */
485
+ readonly runtime: WorkflowRuntime;
486
+ }
487
+ /**
488
+ * Create a Node workflow host: compile every declared Lunora workflow onto the
489
+ * visulima engine, derive the workflow env, and expose the per-workflow
490
+ * `WorkflowBindingLike` handles.
491
+ */
492
+ declare const createNodeWorkflowHost: <Workflows extends Record<string, {
493
+ isLunoraWorkflow: true;
494
+ }>>(options: NodeWorkflowHostOptions<Workflows>) => NodeWorkflowHost<Workflows>;
313
495
  /** Every contract this package provides, composed for one Node process. */
314
496
  interface NodePlatform<Queues extends Record<string, {
315
497
  isLunoraQueue: true;
498
+ }> = Record<string, never>, Workflows extends Record<string, {
499
+ isLunoraWorkflow: true;
316
500
  }> = Record<string, never>> {
317
501
  /** `using platform = createNodePlatform(...)` support — delegates to `close()`. */
318
502
  [Symbol.dispose]: () => void;
319
503
  /** What this target supports — see `NODE_CAPABILITIES` in `@lunora/platform`. */
320
504
  capabilities: PlatformCapabilities;
321
505
  /**
322
- * Tear this platform instance down: clears the shard host's pending alarm
323
- * timer and closes its `better-sqlite3` database (and, with it, `kv`'s
324
- * table — both live on the same connection), then clears every armed
325
- * scheduler job timer. Nothing in this package closes these resources on
326
- * its own — a `NodePlatform` a caller stops using without calling `close()`
327
- * leaks the open file handle (plus its WAL/SHM sidecar files) and keeps
328
- * the process alive on outstanding timers. Safe to call more than once.
506
+ * Tear this platform instance down: every resource this root built, in
507
+ * reverse construction order — the global store's own connection, every
508
+ * armed scheduler job timer, the registry's live shards, and last the shard
509
+ * host's pending alarm timer and its `better-sqlite3` database (and, with
510
+ * it, `kv`'s table — both live on the same connection). Nothing in this
511
+ * package closes these resources on its own — a `NodePlatform` a caller
512
+ * stops using without calling `close()` leaks the open file handles (plus
513
+ * their WAL/SHM sidecar files) and keeps the process alive on outstanding
514
+ * timers. Safe to call more than once. A `createNodePlatform` that throws
515
+ * unwinds the same list itself, so a failed construction leaks nothing and
516
+ * leaves nothing to call this on.
329
517
  *
330
518
  * **`close()` is a terminal state, not merely a cleanup step.** After it
331
519
  * runs: `scheduler.schedule()` throws instead of arming a fresh timer
@@ -335,13 +523,25 @@ interface NodePlatform<Queues extends Record<string, {
335
523
  * benign); `shard.alarms.set()`/`delete()` throw before mutating any
336
524
  * in-memory state (checked against the connection's own open/closed state,
337
525
  * the single source of truth); `shard.alarms.get()` keeps answering
338
- * whatever it last held. A no-op instead of a throw would be
339
- * indistinguishable from a working call — exactly the silent-vanishing
526
+ * whatever it last held; `sockets.accept()`/`setTag()`/`removeTag()` throw
527
+ * the same way, checked against the same connection state, before touching
528
+ * any runtime socket map or durable row. A no-op instead of a throw would
529
+ * be indistinguishable from a working call — exactly the silent-vanishing
340
530
  * this lifecycle exists to end.
341
531
  */
342
532
  close: () => void;
343
533
  /** In-process shard directory (see `createNodeShardRegistry` for what it can and cannot do). */
344
534
  directory: ShardDirectory;
535
+ /**
536
+ * The scheduler dispatch route, served in-process: `Request` in, shard
537
+ * response out, no listener. It answers the path `@lunora/dispatch` and
538
+ * `@lunora/scheduler` POST to. Pass it to `dispatchQueueBatch` as
539
+ * `queueFetchImpl` on `createWorker` so a queue handler's `message.run` /
540
+ * `ctx.run` reaches the shard; the env you pass there must carry `LUNORA_ORIGIN_URL` and the same
541
+ * `LUNORA_ADMIN_TOKEN` as `dispatch.adminToken`. Every request is refused
542
+ * unless `dispatch` supplies a secret.
543
+ */
544
+ dispatch: (request: Request) => Promise<Response>;
345
545
  /**
346
546
  * Wait for every promise handed to `shard.waitUntil` to settle.
347
547
  *
@@ -351,8 +551,28 @@ interface NodePlatform<Queues extends Record<string, {
351
551
  * shutdown is `await platform.drain()` then `platform.close()`.
352
552
  */
353
553
  drain: () => Promise<void>;
554
+ /**
555
+ * The `.global()` backend, or `undefined` when the caller named no database
556
+ * file for it. There is no default path, because a global store silently
557
+ * rooted at `:memory:` loses every row when the process exits.
558
+ *
559
+ * **A building block, not a wiring.** Unlike its three siblings, nothing
560
+ * downstream of this composition root reads it: a `.global()` read or write
561
+ * reaches its backend through exactly one seam, `createShardCtxDb({ globalDb
562
+ * })`, and the only thing that passes `globalDb` is the generated `shard.ts`,
563
+ * from its `d1` / `hyperdriveGlobal` config thunks. `createNodePlatform`
564
+ * constructs no shard DO, so it cannot make that hop itself. A caller that
565
+ * wants `.global()` on this host makes it: after `migrate(schema)` has
566
+ * provisioned the tables, give the generated `createShardDO` a `d1` thunk
567
+ * returning `platform.globalTables.writer({ schema, … })` — `writer` builds
568
+ * the `createSqlCtxDb` facade that seam expects, and `node-platform.test.ts`
569
+ * round-trips a row through exactly that object.
570
+ */
571
+ globalTables?: NodeGlobalStore;
354
572
  /** Durable key-value storage backed by the same `better-sqlite3` database as `shard`. */
355
573
  kv: ShardKvStore;
574
+ /** The local-filesystem bucket, or `undefined` when the caller named no bucket directory. */
575
+ objectStorage?: R2BucketLike;
356
576
  /**
357
577
  * The declared queues, or `undefined` when the caller declared none.
358
578
  *
@@ -367,6 +587,28 @@ interface NodePlatform<Queues extends Record<string, {
367
587
  shard: ShardHost;
368
588
  /** Socket registry with mutable tags and SQLite-persisted attachments. */
369
589
  sockets: SocketHost;
590
+ /**
591
+ * The declared workflows, or `undefined` when the caller declared none.
592
+ * Runs are persisted to the same `better-sqlite3` database as the shard, so
593
+ * they survive a restart without a second store to configure.
594
+ */
595
+ workflows?: NodeWorkflowHost<Workflows>;
596
+ }
597
+ /**
598
+ * The secrets the dispatch route authenticates with. With neither, the route
599
+ * refuses everything.
600
+ *
601
+ * Queue and topic delivery authenticates with `adminToken` alone:
602
+ * `@lunora/dispatch` always sends `Bearer <LUNORA_ADMIN_TOKEN>` and never signs,
603
+ * so the value here must match the token the caller puts on the env it passes
604
+ * `dispatchQueueBatch`. `schedulerSecret` only verifies a signed body, which the
605
+ * scheduler's own delivery sends; queue delivery never does.
606
+ */
607
+ interface NodePlatformDispatchOptions {
608
+ /** `LUNORA_ADMIN_TOKEN`. The bearer queue and topic delivery sends, so it must match the caller's env. */
609
+ adminToken?: string;
610
+ /** `LUNORA_SCHEDULER_SECRET`. When set, a signed body is verified against it. Queue delivery does not sign. */
611
+ schedulerSecret?: string;
370
612
  }
371
613
  /**
372
614
  * Options for {@link createNodePlatform} — the shard host's (`path`,
@@ -376,23 +618,58 @@ interface NodePlatform<Queues extends Record<string, {
376
618
  * or job with nowhere to land is bookkeeping. `directory` makes the shards the
377
619
  * directory resolves for fan-out file-backed too, and `onAlarm` gives their
378
620
  * durable alarms somewhere to land.
621
+ *
622
+ * `queues`, `workflows`, `objectStorageDirectory` and `globalTablesPath` are the
623
+ * four declarations — each is absent from the returned platform when omitted.
379
624
  */
380
625
  type NodePlatformOptions<Queues extends Record<string, {
381
626
  isLunoraQueue: true;
627
+ }> = Record<string, never>, Workflows extends Record<string, {
628
+ isLunoraWorkflow: true;
382
629
  }> = Record<string, never>> = {
630
+ /** Secrets for the dispatch route. Omit to leave the route refusing every request. */
631
+ dispatch?: NodePlatformDispatchOptions;
632
+ /**
633
+ * Database file the `.global()` tables live in — its own file, never a
634
+ * shard's, because a table every shard reads must not be inside any one of
635
+ * them. Omit when the app declares no `.global()` table; there is no
636
+ * default, for the same reason the bucket has none.
637
+ */
638
+ globalTablesPath?: string;
383
639
  /**
384
- * Deliver one assembled queue batch — wire this to `dispatchQueueBatch`.
385
- * Required alongside `queues`; without it the messages would be stored
386
- * and never consumed.
640
+ * Directory the object-storage bucket keeps its objects in, one file per
641
+ * key. Omit when the app declares no buckets — there is no default,
642
+ * because a bucket silently rooted at the process's working directory is
643
+ * worse than an absent one.
644
+ */
645
+ objectStorageDirectory?: string;
646
+ /**
647
+ * Deliver one assembled queue batch — wire this to the app worker's `queue`
648
+ * export from `createWorker`, not to bare `dispatchQueueBatch`. That export
649
+ * is the only path that hands the consumer `ctx.live`, and it takes
650
+ * `queueFetchImpl` (this platform's `dispatch`, wrapped as a `fetch`) so
651
+ * `ctx.run` reaches the shard here rather than `LUNORA_ORIGIN_URL`. Required
652
+ * alongside `queues`; without it the messages would be stored and never consumed.
387
653
  */
388
654
  onQueueBatch?: NodeQueueHostOptions<Queues>["onBatch"];
389
655
  /** The app's `defineQueue` results, keyed by export name. Omit when the app declares no queues. */
390
656
  queues?: Queues;
657
+ /** The app's `defineWorkflow` results, keyed by export name. Omit when the app declares no workflows. */
658
+ workflows?: Workflows;
391
659
  } & NodeSchedulerHostOptions & NodeShardHostOptions & NodeShardRegistryOptions;
392
660
  /** Compose every contract this package provides over one `better-sqlite3` database. */
393
661
  declare const createNodePlatform: <Queues extends Record<string, {
394
662
  isLunoraQueue: true;
395
- }> = Record<string, never>>(options?: NodePlatformOptions<Queues>) => NodePlatform<Queues>;
663
+ }> = Record<string, never>, Workflows extends Record<string, {
664
+ isLunoraWorkflow: true;
665
+ }> = Record<string, never>>(options?: NodePlatformOptions<Queues, Workflows>) => NodePlatform<Queues, Workflows>;
666
+ interface NodeProfileHandlerOptions {
667
+ /** The bearer a caller must present. Required and non-empty: an empty token would make the endpoint open to anyone. */
668
+ token: string;
669
+ }
670
+ type NodeProfileHandler = (request: Request) => Promise<Response>;
671
+ /** The public handler: the real profiler, guarded by `token`. */
672
+ declare const createNodeProfileHandler: ({ token }: NodeProfileHandlerOptions) => NodeProfileHandler;
396
673
  /** Options for {@link createNodeR2Bucket}. */
397
674
  interface NodeR2BucketOptions {
398
675
  /** The bucket directory — created on first write. Objects live here, one file per key. */
@@ -400,10 +677,19 @@ interface NodeR2BucketOptions {
400
677
  }
401
678
  /**
402
679
  * Create an `R2BucketLike` over the local filesystem. Any object shape
403
- * `createStorage({ bucket })` accepts — `put`/`get`/`head`/`delete`/`list` —
680
+ * `createStorage({ bucket, bucketName })` accepts — `put`/`get`/`head`/`delete`/`list` —
404
681
  * maps directly onto a file operation.
405
682
  */
406
683
  declare const createNodeR2Bucket: (options: NodeR2BucketOptions) => R2BucketLike;
684
+ /**
685
+ * Present the node shard registry as the `ShardNamespaceLike` a worker is built
686
+ * over (`createWorker({ shardDO })`).
687
+ *
688
+ * The registry names its id derivation `idForName`, which is the
689
+ * `ShardDirectory` spelling; the worker reads Cloudflare's `idFromName`. Both
690
+ * return the key itself here, so a stub from either route reaches the same shard.
691
+ */
692
+ declare const createNodeShardNamespace: (directory: DirectShardDirectory) => ShardNamespaceLike;
407
693
  /**
408
694
  * The `DurableObjectState` subset `ShardDO` consumes.
409
695
  *
@@ -420,6 +706,11 @@ interface NodeShardState {
420
706
  id: {
421
707
  name?: string;
422
708
  };
709
+ /** Open a connection pair. Read by `@lunora/platform-cloudflare`'s socket host, which has no `WebSocketPair` here. */
710
+ openSocketPair: () => {
711
+ client: unknown;
712
+ server: unknown;
713
+ };
423
714
  storage: {
424
715
  delete: (key: string) => Promise<boolean>;
425
716
  deleteAlarm: () => Promise<void>;
@@ -436,6 +727,8 @@ interface NodeShardState {
436
727
  };
437
728
  transaction: <T>(closure: () => Promise<T>) => Promise<T>;
438
729
  };
730
+ /** Build the upgrade response for a pair's client end. A Node `Response` cannot carry status 101, so this is a marker. */
731
+ upgradeResponse: (client: unknown) => Response;
439
732
  waitUntil: (promise: Promise<unknown>) => void;
440
733
  }
441
734
  /**
@@ -450,71 +743,24 @@ interface NodeShardState {
450
743
  * report a point-in-time restore that never happened.
451
744
  */
452
745
  declare const createNodeShardState: (shard: NodeShard) => NodeShardState;
453
- /**
454
- * The socket-host half of a Node platform instance, plus the test-only hooks
455
- * `@lunora/platform/conformance`'s `ConformanceHost` needs to drive a recycle
456
- * from inside a test.
457
- */
458
- interface NodeSocketHost {
459
- /** Read back the frames sent to a socket, oldest first, text frames only. */
460
- readFrames: (handle: SocketHandle) => string[];
461
- /**
462
- * Re-create a runtime socket from durable state.
463
- *
464
- * `attachment` is a fallback only: this host restores what it persisted,
465
- * because that is what a real wake looks like. The argument covers an id
466
- * this host never durably tracked (a synthetic id a test constructs).
467
- */
468
- restoreSocket: (id: string, attachment: unknown) => SocketHandle;
469
- /** Drop the runtime socket map while keeping durable attachments/tags. */
470
- simulateRecycle: () => void;
471
- /** The `SocketHost` contract implementation. */
472
- socket: SocketHost;
473
- }
474
- /** Build the socket registry, persisting attachments and tags to `database`. */
475
- declare const createNodeSocketHost: (database: Database.Database) => NodeSocketHost;
476
- /** Options for {@link createNodeWorkflowHost}. */
477
- interface NodeWorkflowHostOptions<Workflows extends Record<string, {
478
- isLunoraWorkflow: true;
479
- }>> {
480
- /** Base env merged under the derived `WORKFLOW_*` bindings — surfaced to workflow bodies as `ctx.env` and used to resolve spawned children. */
481
- env?: Record<string, unknown>;
482
- /** How long (ms) the engine holds a cross-process lease while an activation runs, for stores that implement `acquire`. Defaults to 30000. */
483
- leaseTtlMs?: number;
484
- /** Where runs are persisted. Required — see the header for why there is no default. `createNodeWorkflowStore(database)` is the durable one. */
485
- store: WorkflowStore;
486
- /** The declared workflows keyed by their `lunora/workflows.ts` export name (e.g. `{ orderPipeline: orderPipeline }`). Values must be `defineWorkflow` results. */
487
- workflows: Workflows;
488
- }
489
- /** A fully-wired Node workflow host. */
490
- interface NodeWorkflowHost<Workflows extends Record<string, {
491
- isLunoraWorkflow: true;
492
- }>> {
493
- /** Per-export-name `WorkflowBindingLike` — the map `ctx.workflows` consumes. */
494
- readonly bindings: { [K in keyof Workflows]: WorkflowBindingLike; };
495
- /**
496
- * The caller's `env` plus one `WORKFLOW_&lt;EXPORT>` binding per workflow —
497
- * merge this into a worker env so `ctx.spawn`/`ctx.parallel` resolve
498
- * children through the same runtime.
499
- */
500
- readonly env: Record<string, unknown>;
501
- /** The underlying visulima runtime — `sweep`/`signal` for a dev loop or tests. */
502
- readonly runtime: WorkflowRuntime;
746
+ /** The two socket hooks a `ShardDO` runs on the Cloudflare runtime's behalf. */
747
+ interface ShardSocketHooks {
748
+ webSocketClose(ws: unknown, code: number, reason: string, wasClean: boolean): unknown;
749
+ webSocketMessage(ws: unknown, message: string | ArrayBuffer): unknown;
503
750
  }
504
751
  /**
505
- * Create a Node workflow host: compile every declared Lunora workflow onto the
506
- * visulima engine, derive the `WORKFLOW_*` env, and expose the per-workflow
507
- * `WorkflowBindingLike` handles.
752
+ * Deliver a shard's inbound socket events. The Cloudflare runtime calls
753
+ * `webSocketMessage` and `webSocketClose` on the Durable Object for a hibernated
754
+ * socket. On Node nothing does, so the embedder calls this once per shard
755
+ * instance, at the same point it builds the shard.
508
756
  */
509
- declare const createNodeWorkflowHost: <Workflows extends Record<string, {
510
- isLunoraWorkflow: true;
511
- }>>(options: NodeWorkflowHostOptions<Workflows>) => NodeWorkflowHost<Workflows>;
757
+ declare const bindShardSockets: (shard: ShardSocketHooks, inbound: Pick<NodeShard["inbound"], "onClose" | "onMessage">) => void;
512
758
  /**
513
759
  * Build a durable {@link WorkflowStore} over a `better-sqlite3` connection.
514
760
  * Pass the result as `createNodeWorkflowHost({ store })`.
515
761
  */
516
762
  declare const createNodeWorkflowStore: (database: Database.Database) => WorkflowStore;
517
- export {
763
+ export { NODE_SCHEDULER_DISPATCH_PATH,
518
764
  /**
519
765
  * `@lunora/platform-node` — a Node implementation of the `@lunora/platform`
520
766
  * host contracts (`ShardHost`, `SocketHost`, `ShardDirectory`, `ShardKvStore`,
@@ -534,49 +780,33 @@ export {
534
780
  * TCK, `@lunora/shard-engine/conformance`'s engine suite, and its own
535
781
  * lifecycle/global-store tests.
536
782
  *
537
- * **Still missing**, and rated accordingly in `NODE_CAPABILITIES`
538
- * (`@lunora/platform`): a dev server — so `lunora dev --target node` does not
539
- * exist yet — plus most Cloudflare product bindings (Queues, Vectorize, Workers
540
- * AI, Browser Rendering, Containers, Analytics Engine, Pipelines, Secrets Store,
541
- * Hyperdrive). R2 buckets are emulated over the local filesystem
542
- * (`./node-r2-bucket`) and Workflows over the `@visulima/workflow` engine
543
- * (`./node-workflow-host`). Cross-shard fan-out is emulated via
544
- * `@lunora/runtime`'s query coordinator over the in-process shard registry, and
545
- * a `@lunora/config` deploy driver makes `--target node` resolve. See
546
- * `plans/234-node-host-findings.md`.
547
- */
548
- type NodeGlobalContextDatabaseOptions,
549
- /**
550
- * `@lunora/platform-node` — a Node implementation of the `@lunora/platform`
551
- * host contracts (`ShardHost`, `SocketHost`, `ShardDirectory`, `ShardKvStore`,
552
- * `SchedulerHost`) over `better-sqlite3` and an in-process socket/directory/
553
- * scheduler registry.
554
- *
555
- * Promoted from `@lunora/platform`'s `node:sqlite` reference host
556
- * (`src/conformance/reference-host.ts`) under plan 234, then hardened until the
557
- * durability half of each contract actually holds: alarms and scheduler jobs
558
- * are persisted **and re-armed on construction**, socket attachments and tags
559
- * live in SQLite rather than a `Map`, and `.global()` tables run the real
560
- * `@lunora/sql-store` core (`./node-global-store`). Each of those is pinned by
561
- * a restart test — a second host over the same database file — not only by the
562
- * TCK's simulated recycle.
783
+ * **Emulated here**, and rated accordingly in `NODE_CAPABILITIES`
784
+ * (`@lunora/platform`): queues over a durable table (`./node-queue-host`), R2
785
+ * buckets over the local filesystem (`./node-r2-bucket`), workflows over the
786
+ * `@visulima/workflow` engine (`./node-workflow-host`), and cross-shard fan-out
787
+ * via `@lunora/runtime`'s query coordinator over the in-process shard registry.
788
+ * `createNodePlatform` binds all three declarations (`queues`, `workflows`,
789
+ * `objectStorageDirectory`) — a capability rated `emulated` with nothing bound
790
+ * is the one combination that fails at runtime with no diagnostic before it.
563
791
  *
564
- * Three suites run against this package: `@lunora/platform/conformance`'s host
565
- * TCK, `@lunora/shard-engine/conformance`'s engine suite, and its own
566
- * lifecycle/global-store tests.
792
+ * **Still missing:** a dev server. There is no `lunora dev --target node`, and
793
+ * nothing here owns a timer, so queue delivery is driven by an explicit
794
+ * `poll()`. What exists is the listener a dev server would sit on:
795
+ * `createNodeHttpServer` serves an app worker's `fetch` over `node:http`,
796
+ * streaming the response body, and `createNodeShardNamespace` presents the
797
+ * registry as the `shardDO` namespace that worker routes to. Also absent are
798
+ * the Cloudflare product bindings with no local
799
+ * equivalent — Vectorize, Workers AI, Browser Rendering, Containers, Analytics
800
+ * Engine, Pipelines, Secrets Store, Hyperdrive.
567
801
  *
568
- * **Still missing**, and rated accordingly in `NODE_CAPABILITIES`
569
- * (`@lunora/platform`): a dev server — so `lunora dev --target node` does not
570
- * exist yet — plus most Cloudflare product bindings (Queues, Vectorize, Workers
571
- * AI, Browser Rendering, Containers, Analytics Engine, Pipelines, Secrets Store,
572
- * Hyperdrive). R2 buckets are emulated over the local filesystem
573
- * (`./node-r2-bucket`) and Workflows over the `@visulima/workflow` engine
574
- * (`./node-workflow-host`). Cross-shard fan-out is emulated via
575
- * `@lunora/runtime`'s query coordinator over the in-process shard registry, and
576
- * a `@lunora/config` deploy driver makes `--target node` resolve. See
802
+ * `@lunora/config` ships a `node` **deploy** driver, so `--target node` resolves
803
+ * for `provision` — which reports, once, which declared features this target
804
+ * cannot serve, and writes nothing: there is no hosted control plane to deploy
805
+ * to and no `wrangler`-equivalent to shell out to. A deploy driver is not a dev
806
+ * server, which is why both statements above hold at once. See
577
807
  * `plans/234-node-host-findings.md`.
578
808
  */
579
- type NodeGlobalStore,
809
+ type NodeDispatchInvocation,
580
810
  /**
581
811
  * `@lunora/platform-node` — a Node implementation of the `@lunora/platform`
582
812
  * host contracts (`ShardHost`, `SocketHost`, `ShardDirectory`, `ShardKvStore`,
@@ -596,15 +826,30 @@ type NodeGlobalStore,
596
826
  * TCK, `@lunora/shard-engine/conformance`'s engine suite, and its own
597
827
  * lifecycle/global-store tests.
598
828
  *
599
- * **Still missing**, and rated accordingly in `NODE_CAPABILITIES`
600
- * (`@lunora/platform`): a dev server — so `lunora dev --target node` does not
601
- * exist yet — plus most Cloudflare product bindings (Queues, Vectorize, Workers
602
- * AI, Browser Rendering, Containers, Analytics Engine, Pipelines, Secrets Store,
603
- * Hyperdrive). R2 buckets are emulated over the local filesystem
604
- * (`./node-r2-bucket`) and Workflows over the `@visulima/workflow` engine
605
- * (`./node-workflow-host`). Cross-shard fan-out is emulated via
606
- * `@lunora/runtime`'s query coordinator over the in-process shard registry, and
607
- * a `@lunora/config` deploy driver makes `--target node` resolve. See
829
+ * **Emulated here**, and rated accordingly in `NODE_CAPABILITIES`
830
+ * (`@lunora/platform`): queues over a durable table (`./node-queue-host`), R2
831
+ * buckets over the local filesystem (`./node-r2-bucket`), workflows over the
832
+ * `@visulima/workflow` engine (`./node-workflow-host`), and cross-shard fan-out
833
+ * via `@lunora/runtime`'s query coordinator over the in-process shard registry.
834
+ * `createNodePlatform` binds all three declarations (`queues`, `workflows`,
835
+ * `objectStorageDirectory`) — a capability rated `emulated` with nothing bound
836
+ * is the one combination that fails at runtime with no diagnostic before it.
837
+ *
838
+ * **Still missing:** a dev server. There is no `lunora dev --target node`, and
839
+ * nothing here owns a timer, so queue delivery is driven by an explicit
840
+ * `poll()`. What exists is the listener a dev server would sit on:
841
+ * `createNodeHttpServer` serves an app worker's `fetch` over `node:http`,
842
+ * streaming the response body, and `createNodeShardNamespace` presents the
843
+ * registry as the `shardDO` namespace that worker routes to. Also absent are
844
+ * the Cloudflare product bindings with no local
845
+ * equivalent — Vectorize, Workers AI, Browser Rendering, Containers, Analytics
846
+ * Engine, Pipelines, Secrets Store, Hyperdrive.
847
+ *
848
+ * `@lunora/config` ships a `node` **deploy** driver, so `--target node` resolves
849
+ * for `provision` — which reports, once, which declared features this target
850
+ * cannot serve, and writes nothing: there is no hosted control plane to deploy
851
+ * to and no `wrangler`-equivalent to shell out to. A deploy driver is not a dev
852
+ * server, which is why both statements above hold at once. See
608
853
  * `plans/234-node-host-findings.md`.
609
854
  */
610
- type NodeGlobalStoreOptions, type NodePlatform, type NodePlatformOptions, type NodeQueueHost, type NodeQueueHostOptions, type NodeR2BucketOptions, type NodeSchedulerHost, type NodeSchedulerHostOptions, type NodeShard, type NodeShardHostOptions, type NodeShardRegistry, type NodeShardRegistryOptions, type NodeShardState, type NodeSocketHost, type NodeWorkflowHost, type NodeWorkflowHostOptions, createNodeGlobalStore, createNodePlatform, createNodeQueueHost, createNodeR2Bucket, createNodeSchedulerHost, createNodeShardHost, createNodeShardKvStore, createNodeShardRegistry, createNodeShardState, createNodeSocketHost, createNodeSqlExec, createNodeWorkflowHost, createNodeWorkflowStore };
855
+ type NodeDispatchRouteOptions, type NodeGlobalContextDatabaseOptions, type NodeGlobalStore, type NodeGlobalStoreOptions, type NodeHttpServer, type NodeHttpServerOptions, type NodePlatform, type NodePlatformDispatchOptions, type NodePlatformOptions, type NodeProfileHandler, type NodeProfileHandlerOptions, type NodeQueueHost, type NodeQueueHostOptions, type NodeR2BucketOptions, type NodeSchedulerHost, type NodeSchedulerHostOptions, type NodeShard, type NodeShardHostOptions, type NodeShardRegistry, type NodeShardRegistryOptions, type NodeShardState, type NodeSocketHost, type NodeWorkflowHost, type NodeWorkflowHostOptions, type ShardSocketHooks, bindShardSockets, createNodeDispatchRoute, createNodeGlobalStore, createNodeHttpServer, createNodePlatform, createNodeProfileHandler, createNodeQueueHost, createNodeR2Bucket, createNodeSchedulerHost, createNodeShardHost, createNodeShardKvStore, createNodeShardNamespace, createNodeShardRegistry, createNodeShardState, createNodeSocketHost, createNodeSqlExec, createNodeWorkflowHost, createNodeWorkflowStore };