@nylorun/runtime 0.10.0-beta → 0.11.0-beta

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 (190) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +53 -51
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/sandbox/virtual.js +1 -1
  6. package/dist/adapters/streams/s2.d.ts +18 -0
  7. package/dist/adapters/streams/s2.js +361 -0
  8. package/dist/core/executors.d.ts +4 -1
  9. package/dist/core/executors.js +20 -1
  10. package/dist/core/flow-host.d.ts +93 -132
  11. package/dist/core/flow-host.js +282 -547
  12. package/dist/core/sandbox-routes.d.ts +8 -3
  13. package/dist/core/sandbox-routes.js +10 -10
  14. package/dist/execution/memory.d.ts +54 -0
  15. package/dist/execution/memory.js +217 -0
  16. package/dist/execution/types.d.ts +125 -0
  17. package/dist/execution/types.js +52 -0
  18. package/dist/host/config-for.d.ts +2 -1
  19. package/dist/host/config-for.js +3 -21
  20. package/dist/host/config.d.ts +0 -8
  21. package/dist/host/create-host.d.ts +36 -1
  22. package/dist/host/create-host.js +48 -24
  23. package/dist/host/execution.d.ts +61 -0
  24. package/dist/host/execution.js +69 -0
  25. package/dist/host/http.d.ts +4 -0
  26. package/dist/host/http.js +6 -0
  27. package/dist/host/index.d.ts +3 -1
  28. package/dist/host/index.js +2 -0
  29. package/dist/host/main.js +106 -47
  30. package/dist/host/stack-config.d.ts +72 -0
  31. package/dist/host/stack-config.js +193 -0
  32. package/dist/index.d.ts +1 -1
  33. package/dist/infra/database.d.ts +16 -0
  34. package/dist/infra/database.js +31 -0
  35. package/dist/infra/execution.d.ts +39 -0
  36. package/dist/infra/execution.js +68 -0
  37. package/dist/infra/index.d.ts +42 -0
  38. package/dist/infra/index.js +56 -0
  39. package/dist/infra/readiness.d.ts +33 -0
  40. package/dist/infra/readiness.js +53 -0
  41. package/dist/infra/streams.d.ts +17 -0
  42. package/dist/infra/streams.js +33 -0
  43. package/dist/infra/workers.d.ts +39 -0
  44. package/dist/infra/workers.js +31 -0
  45. package/dist/model/pi-model.d.ts +2 -2
  46. package/dist/model/pi-model.js +5 -5
  47. package/dist/sandbox/manager.d.ts +26 -8
  48. package/dist/sandbox/manager.js +95 -47
  49. package/dist/sandbox/select.d.ts +1 -1
  50. package/dist/sandbox/select.js +4 -5
  51. package/dist/sandbox/types.d.ts +2 -2
  52. package/dist/store/canonical.d.ts +5 -0
  53. package/dist/store/canonical.js +17 -0
  54. package/dist/store/cursor.d.ts +9 -0
  55. package/dist/store/cursor.js +18 -0
  56. package/dist/store/memory.d.ts +57 -0
  57. package/dist/store/memory.js +643 -0
  58. package/dist/store/ownership.d.ts +19 -0
  59. package/dist/store/ownership.js +33 -0
  60. package/dist/store/postgres/connect.d.ts +25 -0
  61. package/dist/store/postgres/connect.js +25 -0
  62. package/dist/store/postgres/locking.d.ts +16 -0
  63. package/dist/store/postgres/locking.js +22 -0
  64. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  65. package/dist/store/postgres/migrations/001_initial.js +196 -0
  66. package/dist/store/postgres/migrations/index.d.ts +49 -0
  67. package/dist/store/postgres/migrations/index.js +75 -0
  68. package/dist/store/postgres/names.d.ts +11 -0
  69. package/dist/store/postgres/names.js +42 -0
  70. package/dist/store/postgres/store.d.ts +11 -0
  71. package/dist/store/postgres/store.js +790 -0
  72. package/dist/store/postgres/tenants.d.ts +90 -0
  73. package/dist/store/postgres/tenants.js +207 -0
  74. package/dist/store/types.d.ts +440 -0
  75. package/dist/store/types.js +10 -0
  76. package/dist/streams/basin.d.ts +23 -0
  77. package/dist/streams/basin.js +51 -0
  78. package/dist/streams/memory.d.ts +25 -0
  79. package/dist/streams/memory.js +124 -0
  80. package/dist/streams/relay.d.ts +37 -0
  81. package/dist/streams/relay.js +254 -0
  82. package/dist/streams/types.d.ts +115 -0
  83. package/dist/streams/types.js +86 -0
  84. package/dist/tenant/actions.d.ts +153 -0
  85. package/dist/tenant/actions.js +191 -0
  86. package/dist/tenant/advance.d.ts +15 -0
  87. package/dist/tenant/advance.js +439 -0
  88. package/dist/tenant/auth.d.ts +11 -0
  89. package/dist/tenant/auth.js +34 -0
  90. package/dist/tenant/commands.d.ts +3 -0
  91. package/dist/tenant/commands.js +384 -0
  92. package/dist/tenant/context.d.ts +140 -0
  93. package/dist/tenant/context.js +40 -0
  94. package/dist/tenant/effects.d.ts +20 -0
  95. package/dist/tenant/effects.js +539 -0
  96. package/dist/tenant/envelope.d.ts +0 -3
  97. package/dist/tenant/envelope.js +2 -34
  98. package/dist/tenant/ephemeral.d.ts +15 -4
  99. package/dist/tenant/ephemeral.js +106 -38
  100. package/dist/tenant/http.d.ts +32 -0
  101. package/dist/tenant/http.js +51 -0
  102. package/dist/tenant/live.d.ts +92 -0
  103. package/dist/tenant/live.js +290 -0
  104. package/dist/tenant/model-setting.d.ts +16 -0
  105. package/dist/tenant/model-setting.js +12 -0
  106. package/dist/tenant/module.d.ts +29 -8
  107. package/dist/tenant/module.js +162 -158
  108. package/dist/tenant/paths.d.ts +12 -8
  109. package/dist/tenant/paths.js +38 -12
  110. package/dist/tenant/principals.d.ts +9 -13
  111. package/dist/tenant/principals.js +31 -38
  112. package/dist/tenant/quarantine-error.d.ts +1 -1
  113. package/dist/tenant/quarantine-error.js +2 -8
  114. package/dist/tenant/quarantine.d.ts +0 -8
  115. package/dist/tenant/quarantine.js +8 -30
  116. package/dist/tenant/reset.d.ts +7 -6
  117. package/dist/tenant/reset.js +7 -51
  118. package/dist/tenant/routes-tenant.d.ts +12 -0
  119. package/dist/tenant/routes-tenant.js +126 -0
  120. package/dist/tenant/routes.d.ts +9 -0
  121. package/dist/tenant/routes.js +142 -0
  122. package/dist/tenant/runtime.d.ts +59 -58
  123. package/dist/tenant/runtime.js +243 -2201
  124. package/dist/tenant/scheduler.d.ts +25 -0
  125. package/dist/tenant/scheduler.js +84 -0
  126. package/dist/tenant/session.d.ts +60 -0
  127. package/dist/tenant/session.js +94 -0
  128. package/dist/tenant/sessions.d.ts +34 -0
  129. package/dist/tenant/sessions.js +149 -0
  130. package/dist/tenant/status.d.ts +13 -4
  131. package/dist/tenant/status.js +76 -41
  132. package/dist/tenant/store-memory.d.ts +9 -12
  133. package/dist/tenant/store-memory.js +79 -68
  134. package/dist/tenant/store-pg.d.ts +15 -0
  135. package/dist/tenant/store-pg.js +140 -0
  136. package/dist/tenant/streams.d.ts +126 -0
  137. package/dist/tenant/streams.js +419 -0
  138. package/dist/tenant/sweep.d.ts +15 -0
  139. package/dist/tenant/sweep.js +122 -0
  140. package/dist/tenant/types.d.ts +95 -15
  141. package/dist/tenant/types.js +23 -1
  142. package/dist/tenant/worker.d.ts +84 -0
  143. package/dist/tenant/worker.js +189 -0
  144. package/dist/vault/service.d.ts +37 -31
  145. package/dist/vault/service.js +439 -359
  146. package/dist/version.d.ts +1 -1
  147. package/dist/version.js +1 -1
  148. package/package.json +13 -13
  149. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  150. package/dist/adapters/sandbox/microsandbox.js +0 -238
  151. package/dist/core/store.d.ts +0 -32
  152. package/dist/core/store.js +0 -143
  153. package/dist/launcher/commands.d.ts +0 -24
  154. package/dist/launcher/commands.js +0 -260
  155. package/dist/launcher/ensure-config.d.ts +0 -16
  156. package/dist/launcher/ensure-config.js +0 -79
  157. package/dist/launcher/errors.d.ts +0 -12
  158. package/dist/launcher/errors.js +0 -22
  159. package/dist/launcher/host-config.d.ts +0 -17
  160. package/dist/launcher/host-config.js +0 -64
  161. package/dist/launcher/host-state.d.ts +0 -17
  162. package/dist/launcher/host-state.js +0 -61
  163. package/dist/launcher/lifecycle.d.ts +0 -66
  164. package/dist/launcher/lifecycle.js +0 -407
  165. package/dist/launcher/locks.d.ts +0 -26
  166. package/dist/launcher/locks.js +0 -117
  167. package/dist/launcher/logs.d.ts +0 -18
  168. package/dist/launcher/logs.js +0 -117
  169. package/dist/launcher/main.d.ts +0 -2
  170. package/dist/launcher/main.js +0 -66
  171. package/dist/launcher/output.d.ts +0 -11
  172. package/dist/launcher/output.js +0 -68
  173. package/dist/launcher/paths.d.ts +0 -20
  174. package/dist/launcher/paths.js +0 -36
  175. package/dist/launcher/probe.d.ts +0 -23
  176. package/dist/launcher/probe.js +0 -72
  177. package/dist/launcher/protocol.d.ts +0 -62
  178. package/dist/launcher/protocol.js +0 -1
  179. package/dist/launcher/schema-guard.d.ts +0 -6
  180. package/dist/launcher/schema-guard.js +0 -36
  181. package/dist/launcher/spawn.d.ts +0 -22
  182. package/dist/launcher/spawn.js +0 -85
  183. package/dist/launcher/status.d.ts +0 -6
  184. package/dist/launcher/status.js +0 -65
  185. package/dist/tenant/migration.d.ts +0 -16
  186. package/dist/tenant/migration.js +0 -116
  187. package/dist/tenant/schema.d.ts +0 -23
  188. package/dist/tenant/schema.js +0 -151
  189. package/dist/tenant/store-fs.d.ts +0 -16
  190. package/dist/tenant/store-fs.js +0 -205
@@ -1,10 +1,9 @@
1
1
  import { createServer, } from "node:http";
2
2
  import { createHash, timingSafeEqual } from "node:crypto";
3
- import { unlinkSync, existsSync } from "node:fs";
4
3
  import { checkCompatibility, HOST_PROTOCOL, isTenantId, PROTOCOL_HEADER, TENANT_HEADER, } from "@nylorun/core/compatibility";
5
4
  import { AdminStatusSchema, CreateTenantRequestSchema, } from "@nylorun/core/contracts";
6
- import { hostPaths } from "../tenant/paths.js";
7
5
  import { TenantBusyError, TenantConflictError, } from "../tenant/quarantine.js";
6
+ import { TenantNotFoundError, } from "../tenant/types.js";
8
7
  import { EXIT_NON_LOOPBACK, EXIT_PORT_IN_USE, HostListenError, isAllowedRequestHost, isJsonContentType, isLoopbackHost, readBearer, readJsonBody, redactRoutePath, requestHasBody, sendJson, sendOpaqueNotFound, sendProtocolRejected, sendRejected, } from "./http.js";
9
8
  import { RUNTIME_VERSION } from "../version.js";
10
9
  function hashUtf8(value) {
@@ -41,15 +40,17 @@ function protocolAccepted(raw) {
41
40
  export function createHost(options) {
42
41
  const { hostRoot, module, config, credentials, logger, coreVersion, } = options;
43
42
  const pid = options.pid ?? process.pid;
44
- const paths = hostPaths(hostRoot);
43
+ const containerListen = options.listen;
44
+ const bindHost = containerListen?.host ?? config.host;
45
+ const bindPort = containerListen?.port ?? config.port;
45
46
  let server;
46
47
  let url = "";
47
- let listenPort = config.port;
48
+ let listenPort = bindPort;
48
49
  let closing = false;
49
50
  let closePromise;
50
51
  const adminStatusBody = async () => {
51
52
  const tenants = await module.list();
52
- const aggregate = module.summarize();
53
+ const aggregate = await module.summarize();
53
54
  return AdminStatusSchema.parse({
54
55
  service: "nylorun-runtime",
55
56
  version: RUNTIME_VERSION,
@@ -62,7 +63,7 @@ export function createHost(options) {
62
63
  aggregate,
63
64
  host: {
64
65
  hostId: config.hostId,
65
- url,
66
+ url: options.publicUrl ?? url,
66
67
  pid,
67
68
  },
68
69
  });
@@ -92,6 +93,9 @@ export function createHost(options) {
92
93
  principalId: body.principalId,
93
94
  credentialHash: body.credentialHash,
94
95
  idempotencyKey: body.idempotencyKey,
96
+ ...(body.studioCredentialHash
97
+ ? { studioCredentialHash: body.studioCredentialHash }
98
+ : {}),
95
99
  });
96
100
  return sendJson(response, result.created ? 201 : 200, result.envelope);
97
101
  }
@@ -132,6 +136,8 @@ export function createHost(options) {
132
136
  return;
133
137
  }
134
138
  catch (error) {
139
+ if (error instanceof TenantNotFoundError)
140
+ return sendOpaqueNotFound(response);
135
141
  const code = error.code;
136
142
  if (error instanceof TenantBusyError ||
137
143
  error.name === "TenantBusyError" ||
@@ -173,6 +179,9 @@ export function createHost(options) {
173
179
  port: listenPort,
174
180
  host: config.host,
175
181
  allowNonLoopback: config.allowNonLoopback,
182
+ ...(containerListen
183
+ ? { allowedHosts: containerListen.allowedHosts }
184
+ : {}),
176
185
  })) {
177
186
  sendRejected(response, 421, "host_rejected", "Host header is not an allowed loopback or configured address");
178
187
  statusCode = 421;
@@ -209,11 +218,12 @@ export function createHost(options) {
209
218
  if (pathname === "/ready") {
210
219
  const listener = Boolean(server?.listening);
211
220
  const discovery = module.started;
212
- const ready = listener && discovery && !closing;
221
+ const infra = await options.readiness?.();
222
+ const ready = listener && discovery && !closing && (infra?.ok ?? true);
213
223
  sendJson(response, ready ? 200 : 503, {
214
224
  status: ready ? "ready" : "not_ready",
215
225
  service: "nylorun-runtime",
216
- checks: { listener, discovery },
226
+ checks: { listener, discovery, ...infra?.checks },
217
227
  });
218
228
  statusCode = ready ? 200 : 503;
219
229
  return;
@@ -262,7 +272,7 @@ export function createHost(options) {
262
272
  statusCode = 426;
263
273
  return;
264
274
  }
265
- const resolution = module.resolve(tenantId);
275
+ const resolution = await module.resolve(tenantId);
266
276
  if (resolution.kind !== "open") {
267
277
  if (resolution.kind === "quarantined") {
268
278
  logger.warn("tenant_quarantined", {
@@ -328,7 +338,11 @@ export function createHost(options) {
328
338
  async function listen() {
329
339
  if (server)
330
340
  throw new Error("Already listening");
331
- if (!config.allowNonLoopback && !isLoopbackHost(config.host)) {
341
+ // Container mode binds a non-loopback address behind an explicit Host
342
+ // allowlist; otherwise only loopback unless host.json allows more.
343
+ if (!containerListen &&
344
+ !config.allowNonLoopback &&
345
+ !isLoopbackHost(config.host)) {
332
346
  throw new HostListenError(`Refusing to bind non-loopback host ${config.host} without allowNonLoopback`, EXIT_NON_LOOPBACK);
333
347
  }
334
348
  server = createServer((req, res) => {
@@ -337,17 +351,17 @@ export function createHost(options) {
337
351
  await new Promise((resolve, reject) => {
338
352
  server.once("error", (error) => {
339
353
  if (error.code === "EADDRINUSE") {
340
- reject(new HostListenError(`Port ${config.port} on ${config.host} is already in use`, EXIT_PORT_IN_USE, error));
354
+ reject(new HostListenError(`Port ${bindPort} on ${bindHost} is already in use`, EXIT_PORT_IN_USE, error));
341
355
  return;
342
356
  }
343
357
  reject(error);
344
358
  });
345
- server.listen(config.port, config.host, resolve);
359
+ server.listen(bindPort, bindHost, resolve);
346
360
  });
347
361
  const address = server.address();
348
362
  listenPort =
349
- typeof address === "object" && address ? address.port : config.port;
350
- url = `http://${config.host}:${listenPort}`;
363
+ typeof address === "object" && address ? address.port : bindPort;
364
+ url = `http://${bindHost}:${listenPort}`;
351
365
  await module.start();
352
366
  logger.info("host_listening", {
353
367
  hostId: config.hostId,
@@ -355,6 +369,21 @@ export function createHost(options) {
355
369
  pid,
356
370
  });
357
371
  }
372
+ const step = async (name, run) => {
373
+ try {
374
+ await run?.();
375
+ }
376
+ catch (error) {
377
+ logger.error("host_shutdown_step_failed", {
378
+ step: name,
379
+ error: error instanceof Error ? error.message : String(error),
380
+ });
381
+ }
382
+ };
383
+ let settleClosed;
384
+ const closed = new Promise((resolve) => {
385
+ settleClosed = resolve;
386
+ });
358
387
  async function close() {
359
388
  if (closePromise)
360
389
  return closePromise;
@@ -368,21 +397,16 @@ export function createHost(options) {
368
397
  });
369
398
  server = undefined;
370
399
  }
371
- await module.close();
372
- if (existsSync(paths.state)) {
373
- try {
374
- unlinkSync(paths.state);
375
- }
376
- catch {
377
- /* best-effort */
378
- }
379
- }
380
- })();
400
+ await step("beforeTenants", options.shutdown?.beforeTenants);
401
+ await step("tenants", () => module.close());
402
+ await step("afterTenants", options.shutdown?.afterTenants);
403
+ })().finally(settleClosed);
381
404
  return closePromise;
382
405
  }
383
406
  return {
384
407
  listen,
385
408
  close,
409
+ closed,
386
410
  get url() {
387
411
  return url;
388
412
  },
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The Host's Durable Session Execution (architecture §6, §12.3, §14.8): one per process,
3
+ * shared by every Tenant the Host opens.
4
+ *
5
+ * - `tenantExecution` is what the Host passes to each Tenant it opens
6
+ * (`TenantOpenHooks.execution`): the execution, the `TenantWorkers` registry its handlers
7
+ * dispatch through (with `resolve` opening a Tenant on demand when an invocation arrives for
8
+ * one that is not open), and the Tenant status hook for stuck invocations.
9
+ * - `start` serves the Worker endpoint for `--role worker|all` (`infra/workers.ts`) and does
10
+ * nothing for `api`, whose wakes, timers and sweep arming go through Restate's ingress.
11
+ * - `armAll` re-arms every Tenant's sweep at startup. Arming is idempotent, so it is safe on
12
+ * every start, and it is what recovers from lost Restate state: the sweep re-wakes `runnable`
13
+ * sessions without a live owner (§14.8).
14
+ * - `disarm` stops a deleted Tenant's sweep.
15
+ *
16
+ * **Order at startup.** Call `start` before opening any Tenant. Opening a Tenant arms its
17
+ * sweep through Restate's ingress, which answers 404 until a Worker has registered the
18
+ * services, so the open would fail. An api-role process relies on a Worker having registered
19
+ * them first.
20
+ *
21
+ * **One Worker URL.** `start` registers the Worker endpoint (`NYLORUN_WORKER_URL`) with
22
+ * Restate once. Restate sends new invocations to the latest registered deployment, so
23
+ * several Workers that each register their own URL do not share load: the last one to start
24
+ * takes every new invocation. The V1 stack runs a single `runtime` container with
25
+ * `--role all`. A deployment with several Worker processes (Helm, Cloud) must advertise one
26
+ * load-balanced `NYLORUN_WORKER_URL` shared by all of them.
27
+ */
28
+ import type { DurableExecution } from "../execution/types.js";
29
+ import type { Logger } from "../tenant/types.js";
30
+ import { type TenantExecution, type TenantWorker } from "../tenant/worker.js";
31
+ import type { RuntimeRole } from "./stack-config.js";
32
+ export interface CreateHostExecutionOptions {
33
+ execution: DurableExecution;
34
+ role: RuntimeRole;
35
+ /** Finds or opens a Tenant an invocation arrived for that is not open on this process. */
36
+ resolve: (tenantId: string) => Promise<TenantWorker | undefined>;
37
+ logger?: Logger;
38
+ /**
39
+ * How long one advance may run before its signal aborts and it settles
40
+ * (`tenant/worker.ts`). Default 50 minutes, below Restate's timeouts.
41
+ */
42
+ advanceDeadlineMs?: number;
43
+ /** How long an aborted advance has to return before it is abandoned. Default 30 s. */
44
+ advanceGraceMs?: number;
45
+ }
46
+ export interface HostExecution {
47
+ /** Pass to every Tenant the Host opens (`TenantOpenHooks.execution`). */
48
+ readonly tenantExecution: TenantExecution;
49
+ /** Serves the Worker endpoint for the worker and all roles. Idempotent. */
50
+ start(): Promise<void>;
51
+ /** Stops serving: aborts running advances and waits for them. Idempotent. */
52
+ stop(): Promise<void>;
53
+ /**
54
+ * Arms every listed Tenant's sweep (idempotent). Tries them all; rejects with an
55
+ * `AggregateError` of the failures after logging each.
56
+ */
57
+ armAll(tenantIds: readonly string[]): Promise<void>;
58
+ /** Stops re-arming a deleted Tenant's sweep. */
59
+ disarm(tenantId: string): Promise<void>;
60
+ }
61
+ export declare function createHostExecution(options: CreateHostExecutionOptions): HostExecution;
@@ -0,0 +1,69 @@
1
+ import { startWorker, stopWorker } from "../infra/workers.js";
2
+ import { TenantWorkers, } from "../tenant/worker.js";
3
+ /** How many sweep arms `armAll` sends at once. */
4
+ const ARM_CONCURRENCY = 16;
5
+ export function createHostExecution(options) {
6
+ const { execution, role, logger } = options;
7
+ const workers = new TenantWorkers({
8
+ resolve: options.resolve,
9
+ ...(options.advanceDeadlineMs !== undefined
10
+ ? { advanceDeadlineMs: options.advanceDeadlineMs }
11
+ : {}),
12
+ ...(options.advanceGraceMs !== undefined
13
+ ? { advanceGraceMs: options.advanceGraceMs }
14
+ : {}),
15
+ ...(logger ? { logger } : {}),
16
+ });
17
+ const tenantExecution = {
18
+ execution,
19
+ workers,
20
+ ...(execution.stuckInvocations
21
+ ? {
22
+ stuckInvocations: (tenantId) => execution.stuckInvocations(tenantId),
23
+ }
24
+ : {}),
25
+ };
26
+ let started;
27
+ return {
28
+ tenantExecution,
29
+ async start() {
30
+ started ??= startWorker({ role, execution, handlers: workers.handlers });
31
+ try {
32
+ await started;
33
+ }
34
+ catch (error) {
35
+ started = undefined;
36
+ throw error;
37
+ }
38
+ },
39
+ async stop() {
40
+ const handle = await started?.catch(() => undefined);
41
+ if (handle)
42
+ await stopWorker(handle);
43
+ },
44
+ async armAll(tenantIds) {
45
+ const queue = [...new Set(tenantIds)];
46
+ const failures = [];
47
+ const next = async () => {
48
+ for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
49
+ try {
50
+ await execution.armSweep(id);
51
+ }
52
+ catch (error) {
53
+ failures.push(error);
54
+ logger?.warn("tenant sweep arm failed", {
55
+ tenantId: id,
56
+ message: error instanceof Error ? error.message : String(error),
57
+ });
58
+ }
59
+ }
60
+ };
61
+ await Promise.all(Array.from({ length: Math.min(ARM_CONCURRENCY, queue.length) }, next));
62
+ if (failures.length > 0)
63
+ throw new AggregateError(failures, `Arming ${failures.length} Tenant sweep(s) failed`);
64
+ },
65
+ async disarm(tenantId) {
66
+ await execution.disarmSweep(tenantId);
67
+ },
68
+ };
69
+ }
@@ -19,11 +19,15 @@ export declare function sendProtocolRejected(response: ServerResponse, protocol?
19
19
  /**
20
20
  * D§11 Host allowlist: loopback forms with the listening port, plus the
21
21
  * configured host when `allowNonLoopback` is set.
22
+ *
23
+ * An explicit `allowedHosts` list (container mode) replaces that rule: only
24
+ * those exact `name:port` values are accepted.
22
25
  */
23
26
  export declare function isAllowedRequestHost(hostHeader: string | undefined, options: {
24
27
  port: number;
25
28
  host: string;
26
29
  allowNonLoopback?: boolean;
30
+ allowedHosts?: readonly string[];
27
31
  }): boolean;
28
32
  export declare function isJsonContentType(value: string | undefined): boolean;
29
33
  /** True when the request carries a body (Content-Length > 0 or chunked). */
package/dist/host/http.js CHANGED
@@ -48,11 +48,17 @@ export function sendProtocolRejected(response, protocol = HOST_PROTOCOL) {
48
48
  /**
49
49
  * D§11 Host allowlist: loopback forms with the listening port, plus the
50
50
  * configured host when `allowNonLoopback` is set.
51
+ *
52
+ * An explicit `allowedHosts` list (container mode) replaces that rule: only
53
+ * those exact `name:port` values are accepted.
51
54
  */
52
55
  export function isAllowedRequestHost(hostHeader, options) {
53
56
  if (hostHeader === undefined)
54
57
  return false;
55
58
  const normalized = hostHeader.trim().toLowerCase();
59
+ if (options.allowedHosts !== undefined) {
60
+ return options.allowedHosts.some((entry) => entry.trim().toLowerCase() === normalized);
61
+ }
56
62
  const port = String(options.port);
57
63
  const allowed = new Set([
58
64
  `127.0.0.1:${port}`,
@@ -1,6 +1,8 @@
1
- export type { HostConfigFile, HostCredentialsFile, HostStateFile, } from "./config.js";
1
+ export type { HostConfigFile, HostCredentialsFile, } from "./config.js";
2
2
  export { baselineEnvironment, hostProcessEnvironment, tenantChildEnvironment, } from "./environment.js";
3
3
  export { createHost, adminKeyMatches, type CreateHostOptions, type HostServer } from "./create-host.js";
4
4
  export { createHostLogger } from "./logger.js";
5
5
  export { configForFactory } from "./config-for.js";
6
+ export { createHostExecution, type CreateHostExecutionOptions, type HostExecution, } from "./execution.js";
7
+ export { parseStackConfig, StackConfigError, type ContainerListen, type RuntimeRole, type StackConfig, type StackEndpoints, } from "./stack-config.js";
6
8
  export { EXIT_PORT_IN_USE, EXIT_NON_LOOPBACK, HostListenError, OPAQUE_NOT_FOUND, } from "./http.js";
@@ -2,4 +2,6 @@ export { baselineEnvironment, hostProcessEnvironment, tenantChildEnvironment, }
2
2
  export { createHost, adminKeyMatches } from "./create-host.js";
3
3
  export { createHostLogger } from "./logger.js";
4
4
  export { configForFactory } from "./config-for.js";
5
+ export { createHostExecution, } from "./execution.js";
6
+ export { parseStackConfig, StackConfigError, } from "./stack-config.js";
5
7
  export { EXIT_PORT_IN_USE, EXIT_NON_LOOPBACK, HostListenError, OPAQUE_NOT_FOUND, } from "./http.js";
package/dist/host/main.js CHANGED
@@ -1,27 +1,34 @@
1
1
  /**
2
2
  * Runtime Host process entry (`@nylorun/runtime/server`).
3
3
  *
4
- * Reads only `NYLORUN_HOME` from the environment (falls back to `~/.nylorun`).
5
- * Absolute Host root is resolved once and passed down.
4
+ * Reads `NYLORUN_HOME` (falls back to `~/.nylorun`), and hands an environment
5
+ * snapshot and argv to `parseStackConfig` (container listen mode, `--role`,
6
+ * stack endpoints). Absolute Host root is resolved once and passed down.
7
+ *
8
+ * Composition: `createInfra` builds the Postgres pool, Durable Session
9
+ * Execution and Durable Streams from the endpoints; `createHostExecution`
10
+ * shares one execution across the Tenants; the Tenant store is Postgres
11
+ * (`NYLORUN_DATABASE_URL`, required); `/ready` reports the infrastructure
12
+ * checks. See the startup order in `main()`. Tests compose a Host without this
13
+ * entry, with `createHost` and an injected Tenant module.
6
14
  */
7
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
15
+ import { readFileSync, mkdirSync, existsSync } from "node:fs";
8
16
  import { createRequire } from "node:module";
9
17
  import { homedir } from "node:os";
10
18
  import { resolve } from "node:path";
11
- import { fileURLToPath } from "node:url";
12
- import { hostPaths } from "../tenant/paths.js";
19
+ import { hostPaths, trashSqliteTenants } from "../tenant/paths.js";
13
20
  import { createTenantModule } from "../tenant/module.js";
14
- import { createFsTenantStore } from "../tenant/store-fs.js";
21
+ import { createPostgresTenantStore } from "../tenant/store-pg.js";
15
22
  import { openTenantRuntime } from "../tenant/runtime.js";
16
- import { bootstrapPrincipal } from "../tenant/principals.js";
17
- import { migrateTenantDatabase } from "../tenant/schema.js";
23
+ import { createTenantStreams, deleteTenantStreams } from "../tenant/streams.js";
18
24
  import { configForFactory } from "./config-for.js";
19
25
  import { createHost } from "./create-host.js";
20
- import { baselineEnvironment, hostProcessEnvironment, } from "./environment.js";
26
+ import { createHostExecution } from "./execution.js";
27
+ import { baselineEnvironment } from "./environment.js";
21
28
  import { HostListenError, EXIT_PORT_IN_USE, EXIT_NON_LOOPBACK, } from "./http.js";
22
29
  import { createHostLogger } from "./logger.js";
23
- import { RUNTIME_VERSION } from "../version.js";
24
- const entry = fileURLToPath(import.meta.url);
30
+ import { describeEndpoints, parseStackConfig } from "./stack-config.js";
31
+ import { createExecution, createInfra } from "../infra/index.js";
25
32
  const nodeRequire = createRequire(import.meta.url);
26
33
  function coreVersion() {
27
34
  try {
@@ -40,51 +47,89 @@ function resolveHostRoot() {
40
47
  return resolve(root);
41
48
  }
42
49
  export async function main() {
50
+ const stack = parseStackConfig(process.env, process.argv.slice(2));
43
51
  const hostRoot = resolveHostRoot();
44
52
  const paths = hostPaths(hostRoot);
45
53
  mkdirSync(paths.home, { recursive: true });
46
54
  mkdirSync(paths.tmp, { recursive: true });
47
55
  mkdirSync(paths.tenants, { recursive: true });
48
56
  if (!existsSync(paths.config)) {
49
- throw new Error(`Missing host.json at ${paths.config}; run \`nylorun runtime up\` first`);
57
+ throw new Error(`Missing host.json at ${paths.config}; run \`nylorun start\` first`);
50
58
  }
51
59
  if (!existsSync(paths.credentials)) {
52
- throw new Error(`Missing host-credentials.json at ${paths.credentials}; run \`nylorun runtime up\` first`);
60
+ throw new Error(`Missing host-credentials.json at ${paths.credentials}; run \`nylorun start\` first`);
61
+ }
62
+ // Tenants are Postgres schemas. Only a Host outside a container (a local
63
+ // development Host) may run without S2, on in-process streams.
64
+ if (!stack.endpoints.databaseUrl) {
65
+ throw new Error("NYLORUN_DATABASE_URL is required: the Postgres URL of the Session Store (`nylorun start` sets it)");
66
+ }
67
+ if (stack.listen && !stack.endpoints.s2Endpoint) {
68
+ throw new Error("NYLORUN_S2_ENDPOINT is required in container mode: the S2 endpoint of Durable Streams (`nylorun start` sets it)");
53
69
  }
54
70
  const config = loadJson(paths.config);
55
71
  const credentials = loadJson(paths.credentials);
56
72
  const logger = createHostLogger();
73
+ logger.info("host_stack_config", {
74
+ role: stack.role,
75
+ mode: stack.listen ? "container" : "local",
76
+ endpoints: describeEndpoints(stack.endpoints),
77
+ });
78
+ // Tenants from the SQLite Runtime are not migrated: move them out of the way.
79
+ trashSqliteTenants(hostRoot, logger);
80
+ const infra = createInfra(stack, { logger });
81
+ const database = infra.database;
82
+ if (!database)
83
+ throw new Error("NYLORUN_DATABASE_URL did not yield a Postgres pool");
57
84
  const baseline = baselineEnvironment(process.env);
58
- void hostProcessEnvironment(baseline, config, paths);
59
- // Credential-free release/dev fixture (create-agent / CI smokes). Requires
60
- // ephemeral mode so fixture models are allowed (Tenants D10).
61
- const useFixture = process.env.NYLORUN_DEV_MODEL?.trim() === "fixture";
85
+ // A Tenant that should answer with the fixture model carries the Tenant
86
+ // setting (`tenant/model-setting.ts`); the Host has no fixture mode.
62
87
  const configFor = configForFactory({
63
88
  hostRoot,
64
89
  hostConfig: config,
65
90
  logger,
66
91
  baseline,
67
- ...(useFixture
68
- ? { mode: "ephemeral", model: { kind: "fixture" } }
69
- : {}),
70
92
  });
71
- const openRuntime = (tenantConfig) => openTenantRuntime(tenantConfig);
72
- const store = createFsTenantStore({
93
+ // One Durable Session Execution for every Tenant this process opens: Restate
94
+ // when its endpoints are set, else the in-process memory execution. An
95
+ // invocation for a Tenant that is not open here opens it on demand.
96
+ const hostExecution = createHostExecution({
97
+ execution: infra.execution ?? createExecution(stack),
98
+ role: stack.role,
99
+ resolve: (tenantId) => module.worker(tenantId),
100
+ logger,
101
+ });
102
+ // Durable Streams: S2 when configured (always in container mode). Without
103
+ // them (a local Host only) each Tenant keeps in-process streams, whose history
104
+ // does not survive a restart.
105
+ const streams = infra.streams;
106
+ const store = createPostgresTenantStore({
73
107
  hostRoot,
74
- openRuntime,
108
+ sql: database,
75
109
  configFor,
76
110
  logger,
77
- writeBootstrap: (db, bootstrap) => {
78
- migrateTenantDatabase(db);
79
- bootstrapPrincipal(db, bootstrap);
80
- },
111
+ openRuntime: (tenantConfig, opened) => openTenantRuntime(tenantConfig, {
112
+ execution: hostExecution.tenantExecution,
113
+ ...(streams ? { streams } : {}),
114
+ ...opened,
115
+ }),
81
116
  });
82
117
  const module = createTenantModule({
83
- hostRoot,
84
118
  store,
85
- openRuntime,
86
- configFor,
87
119
  logger,
120
+ // A new Tenant's basin is created with it (opening it repairs a failure);
121
+ // a deleted Tenant's sweep stops re-arming and its basin goes.
122
+ ...(streams
123
+ ? { onCreated: (tenantId) => createTenantStreams(streams, tenantId) }
124
+ : {}),
125
+ onDeleted: async (tenantId) => {
126
+ const failed = (await Promise.allSettled([
127
+ hostExecution.disarm(tenantId),
128
+ ...(streams ? [deleteTenantStreams(streams, tenantId)] : []),
129
+ ])).flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
130
+ if (failed.length > 0)
131
+ throw new AggregateError(failed, "Deleted Tenant cleanup failed");
132
+ },
88
133
  });
89
134
  const options = {
90
135
  hostRoot,
@@ -93,12 +138,32 @@ export async function main() {
93
138
  credentials,
94
139
  logger,
95
140
  coreVersion: coreVersion(),
141
+ ...(stack.listen ? { listen: stack.listen } : {}),
142
+ ...(stack.publicUrl ? { publicUrl: stack.publicUrl } : {}),
143
+ ...(infra.readiness ? { readiness: infra.readiness } : {}),
144
+ // SIGTERM and POST /v1/admin/host/shutdown both close the Host this way:
145
+ // stop the Worker, close the Tenants, then end the infrastructure clients.
146
+ shutdown: {
147
+ beforeTenants: () => hostExecution.stop(),
148
+ afterTenants: () => infra.close(),
149
+ },
96
150
  };
97
151
  const host = createHost(options);
152
+ // Startup order. The Worker starts first: opening a Tenant arms its sweep
153
+ // through Restate's ingress, which answers 404 until a Worker has registered
154
+ // the services, so no Tenant may open before `start` (the listener opens
155
+ // Tenants on demand). An api-role process serves no Worker endpoint, so it
156
+ // can open Tenants only once some Worker process has registered. Container
157
+ // mode runs one `--role all` process, which registers here. Then the
158
+ // listener starts (and marks discovery done), and every listed Tenant's
159
+ // sweep is re-armed, which recovers wakes lost with Restate's state (§14.8).
98
160
  try {
161
+ await hostExecution.start();
99
162
  await host.listen();
100
163
  }
101
164
  catch (error) {
165
+ await hostExecution.stop().catch(() => undefined);
166
+ await infra.close();
102
167
  if (error instanceof HostListenError) {
103
168
  logger.error("listen_failed", {
104
169
  message: error.message,
@@ -109,26 +174,20 @@ export async function main() {
109
174
  }
110
175
  throw error;
111
176
  }
112
- const state = {
113
- pid: process.pid,
114
- startedAt: new Date().toISOString(),
115
- version: RUNTIME_VERSION,
116
- entry,
177
+ const tenants = await module.list();
178
+ await hostExecution
179
+ .armAll(tenants.filter((t) => t.state === "open").map((t) => t.id))
180
+ .catch((error) => logger.warn("tenant_sweeps_not_armed", {
181
+ message: error instanceof Error ? error.message : String(error),
182
+ }));
183
+ logger.info("host_ready", {
117
184
  url: host.url,
118
- };
119
- writeFileSync(paths.state, `${JSON.stringify(state, null, 2)}\n`, {
120
- mode: 0o600,
185
+ hostId: config.hostId,
186
+ tenants: tenants.length,
121
187
  });
122
- logger.info("host_ready", { url: host.url, hostId: config.hostId });
123
- process.send?.({ type: "ready", url: host.url });
124
- let closing;
125
- const shutdown = () => {
126
- closing ??= host.close().then(() => {
127
- process.exit(0);
128
- });
129
- };
188
+ void host.closed.then(() => process.exit(0));
130
189
  for (const signal of ["SIGINT", "SIGTERM"]) {
131
- process.on(signal, shutdown);
190
+ process.on(signal, () => void host.close());
132
191
  }
133
192
  }
134
193
  void main().catch((error) => {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Stack configuration for the Runtime process, parsed from an environment
3
+ * snapshot and argv. Pure: `host/main.ts` passes the process environment and
4
+ * arguments; nothing here reads ambient state.
5
+ *
6
+ * Two listen modes:
7
+ * - **local** (no `NYLORUN_LISTEN_*` / `NYLORUN_ALLOWED_HOSTS`): the Host binds
8
+ * what `host.json` names, loopback only unless `allowNonLoopback`.
9
+ * - **container**: the Host binds `NYLORUN_LISTEN_HOST:NYLORUN_LISTEN_PORT`
10
+ * (default `0.0.0.0:4000`) and accepts only the `Host` headers in
11
+ * `NYLORUN_ALLOWED_HOSTS`, plus the loopback forms of the listen port for
12
+ * probes from inside the container.
13
+ *
14
+ * The Postgres, Restate and S2 endpoints are parsed and validated here;
15
+ * `infra/*` builds the clients from them.
16
+ */
17
+ export type RuntimeRole = "api" | "worker" | "all";
18
+ export declare const RUNTIME_ROLES: readonly RuntimeRole[];
19
+ export declare const DEFAULT_CONTAINER_LISTEN_HOST = "0.0.0.0";
20
+ export declare const DEFAULT_CONTAINER_LISTEN_PORT = 4000;
21
+ export interface ContainerListen {
22
+ /** Bind address, e.g. `0.0.0.0`. */
23
+ host: string;
24
+ port: number;
25
+ /**
26
+ * Exact `Host` header values accepted (lowercase `name:port`). Replaces the
27
+ * loopback-only rule.
28
+ */
29
+ allowedHosts: readonly string[];
30
+ }
31
+ export interface StackEndpoints {
32
+ databaseUrl?: string;
33
+ restateIngressUrl?: string;
34
+ restateAdminUrl?: string;
35
+ workerUrl?: string;
36
+ s2Endpoint?: string;
37
+ s2Token?: string;
38
+ workspaceStoreUrl?: string;
39
+ /**
40
+ * Restate request-identity public keys (`publickeyv1_...`) the Worker
41
+ * endpoint accepts, from `NYLORUN_RESTATE_IDENTITY_KEY` (comma-separated
42
+ * during a rotation). Unset means the endpoint accepts unsigned requests.
43
+ */
44
+ restateIdentityKeys?: string[];
45
+ }
46
+ export interface StackConfig {
47
+ role: RuntimeRole;
48
+ /** Present in container mode; absent means bind what host.json names. */
49
+ listen?: ContainerListen;
50
+ endpoints: StackEndpoints;
51
+ /**
52
+ * The URL clients use to reach this Host (`NYLORUN_PUBLIC_URL`), reported by
53
+ * `/v1/admin/status`. In container mode the bind address (`0.0.0.0:4000`)
54
+ * means nothing outside the container.
55
+ */
56
+ publicUrl?: string;
57
+ }
58
+ export declare class StackConfigError extends Error {
59
+ constructor(message: string);
60
+ }
61
+ type EnvSnapshot = Readonly<Record<string, string | undefined>>;
62
+ export declare function parseRole(argv: readonly string[]): RuntimeRole;
63
+ /**
64
+ * Normalize one `Host` header allowlist entry to lowercase `name:port`.
65
+ * IPv6 literals must be bracketed (`[::1]:4000`).
66
+ */
67
+ export declare function normalizeAllowedHost(name: string, entry: string): string;
68
+ /** Parse the stack configuration; throws `StackConfigError` naming the variable. */
69
+ export declare function parseStackConfig(env: EnvSnapshot, argv: readonly string[]): StackConfig;
70
+ /** Endpoint summary for logs: which endpoints are set, never their values. */
71
+ export declare function describeEndpoints(endpoints: StackEndpoints): string[];
72
+ export {};