@nylorun/runtime 0.10.0-beta → 0.12.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 (194) hide show
  1. package/CHANGELOG.md +86 -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 +13 -4
  13. package/dist/core/sandbox-routes.js +14 -12
  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 +51 -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 +645 -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/002_session_owner.d.ts +6 -0
  67. package/dist/store/postgres/migrations/002_session_owner.js +13 -0
  68. package/dist/store/postgres/migrations/index.d.ts +49 -0
  69. package/dist/store/postgres/migrations/index.js +76 -0
  70. package/dist/store/postgres/names.d.ts +11 -0
  71. package/dist/store/postgres/names.js +42 -0
  72. package/dist/store/postgres/store.d.ts +11 -0
  73. package/dist/store/postgres/store.js +797 -0
  74. package/dist/store/postgres/tenants.d.ts +90 -0
  75. package/dist/store/postgres/tenants.js +214 -0
  76. package/dist/store/types.d.ts +442 -0
  77. package/dist/store/types.js +10 -0
  78. package/dist/streams/basin.d.ts +23 -0
  79. package/dist/streams/basin.js +51 -0
  80. package/dist/streams/memory.d.ts +25 -0
  81. package/dist/streams/memory.js +124 -0
  82. package/dist/streams/relay.d.ts +37 -0
  83. package/dist/streams/relay.js +254 -0
  84. package/dist/streams/types.d.ts +115 -0
  85. package/dist/streams/types.js +86 -0
  86. package/dist/tenant/actions.d.ts +153 -0
  87. package/dist/tenant/actions.js +191 -0
  88. package/dist/tenant/advance.d.ts +15 -0
  89. package/dist/tenant/advance.js +439 -0
  90. package/dist/tenant/auth.d.ts +32 -0
  91. package/dist/tenant/auth.js +169 -0
  92. package/dist/tenant/commands.d.ts +3 -0
  93. package/dist/tenant/commands.js +393 -0
  94. package/dist/tenant/context.d.ts +147 -0
  95. package/dist/tenant/context.js +48 -0
  96. package/dist/tenant/effects.d.ts +20 -0
  97. package/dist/tenant/effects.js +564 -0
  98. package/dist/tenant/envelope.d.ts +0 -3
  99. package/dist/tenant/envelope.js +2 -34
  100. package/dist/tenant/ephemeral.d.ts +15 -4
  101. package/dist/tenant/ephemeral.js +106 -38
  102. package/dist/tenant/http.d.ts +38 -0
  103. package/dist/tenant/http.js +53 -0
  104. package/dist/tenant/live.d.ts +92 -0
  105. package/dist/tenant/live.js +290 -0
  106. package/dist/tenant/model-setting.d.ts +16 -0
  107. package/dist/tenant/model-setting.js +12 -0
  108. package/dist/tenant/module.d.ts +29 -8
  109. package/dist/tenant/module.js +165 -158
  110. package/dist/tenant/paths.d.ts +12 -8
  111. package/dist/tenant/paths.js +38 -12
  112. package/dist/tenant/principals.d.ts +12 -13
  113. package/dist/tenant/principals.js +50 -38
  114. package/dist/tenant/quarantine-error.d.ts +1 -1
  115. package/dist/tenant/quarantine-error.js +2 -8
  116. package/dist/tenant/quarantine.d.ts +0 -8
  117. package/dist/tenant/quarantine.js +8 -30
  118. package/dist/tenant/reset.d.ts +7 -6
  119. package/dist/tenant/reset.js +7 -51
  120. package/dist/tenant/routes-tenant.d.ts +12 -0
  121. package/dist/tenant/routes-tenant.js +137 -0
  122. package/dist/tenant/routes.d.ts +9 -0
  123. package/dist/tenant/routes.js +151 -0
  124. package/dist/tenant/runtime.d.ts +59 -58
  125. package/dist/tenant/runtime.js +243 -2201
  126. package/dist/tenant/scheduler.d.ts +25 -0
  127. package/dist/tenant/scheduler.js +84 -0
  128. package/dist/tenant/session.d.ts +60 -0
  129. package/dist/tenant/session.js +94 -0
  130. package/dist/tenant/sessions.d.ts +40 -0
  131. package/dist/tenant/sessions.js +163 -0
  132. package/dist/tenant/status.d.ts +13 -4
  133. package/dist/tenant/status.js +76 -41
  134. package/dist/tenant/store-memory.d.ts +9 -12
  135. package/dist/tenant/store-memory.js +79 -68
  136. package/dist/tenant/store-pg.d.ts +15 -0
  137. package/dist/tenant/store-pg.js +140 -0
  138. package/dist/tenant/streams.d.ts +126 -0
  139. package/dist/tenant/streams.js +419 -0
  140. package/dist/tenant/sweep.d.ts +15 -0
  141. package/dist/tenant/sweep.js +122 -0
  142. package/dist/tenant/transcript.d.ts +104 -0
  143. package/dist/tenant/transcript.js +58 -0
  144. package/dist/tenant/types.d.ts +101 -15
  145. package/dist/tenant/types.js +23 -1
  146. package/dist/tenant/worker.d.ts +84 -0
  147. package/dist/tenant/worker.js +189 -0
  148. package/dist/vault/service.d.ts +44 -31
  149. package/dist/vault/service.js +454 -360
  150. package/dist/version.d.ts +1 -1
  151. package/dist/version.js +1 -1
  152. package/package.json +15 -14
  153. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  154. package/dist/adapters/sandbox/microsandbox.js +0 -238
  155. package/dist/core/store.d.ts +0 -32
  156. package/dist/core/store.js +0 -143
  157. package/dist/launcher/commands.d.ts +0 -24
  158. package/dist/launcher/commands.js +0 -260
  159. package/dist/launcher/ensure-config.d.ts +0 -16
  160. package/dist/launcher/ensure-config.js +0 -79
  161. package/dist/launcher/errors.d.ts +0 -12
  162. package/dist/launcher/errors.js +0 -22
  163. package/dist/launcher/host-config.d.ts +0 -17
  164. package/dist/launcher/host-config.js +0 -64
  165. package/dist/launcher/host-state.d.ts +0 -17
  166. package/dist/launcher/host-state.js +0 -61
  167. package/dist/launcher/lifecycle.d.ts +0 -66
  168. package/dist/launcher/lifecycle.js +0 -407
  169. package/dist/launcher/locks.d.ts +0 -26
  170. package/dist/launcher/locks.js +0 -117
  171. package/dist/launcher/logs.d.ts +0 -18
  172. package/dist/launcher/logs.js +0 -117
  173. package/dist/launcher/main.d.ts +0 -2
  174. package/dist/launcher/main.js +0 -66
  175. package/dist/launcher/output.d.ts +0 -11
  176. package/dist/launcher/output.js +0 -68
  177. package/dist/launcher/paths.d.ts +0 -20
  178. package/dist/launcher/paths.js +0 -36
  179. package/dist/launcher/probe.d.ts +0 -23
  180. package/dist/launcher/probe.js +0 -72
  181. package/dist/launcher/protocol.d.ts +0 -62
  182. package/dist/launcher/protocol.js +0 -1
  183. package/dist/launcher/schema-guard.d.ts +0 -6
  184. package/dist/launcher/schema-guard.js +0 -36
  185. package/dist/launcher/spawn.d.ts +0 -22
  186. package/dist/launcher/spawn.js +0 -85
  187. package/dist/launcher/status.d.ts +0 -6
  188. package/dist/launcher/status.js +0 -65
  189. package/dist/tenant/migration.d.ts +0 -16
  190. package/dist/tenant/migration.js +0 -116
  191. package/dist/tenant/schema.d.ts +0 -23
  192. package/dist/tenant/schema.js +0 -151
  193. package/dist/tenant/store-fs.d.ts +0 -16
  194. 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,12 @@ 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
+ : {}),
99
+ ...(body.derivedPrincipals?.length
100
+ ? { derivedPrincipals: body.derivedPrincipals }
101
+ : {}),
95
102
  });
96
103
  return sendJson(response, result.created ? 201 : 200, result.envelope);
97
104
  }
@@ -132,6 +139,8 @@ export function createHost(options) {
132
139
  return;
133
140
  }
134
141
  catch (error) {
142
+ if (error instanceof TenantNotFoundError)
143
+ return sendOpaqueNotFound(response);
135
144
  const code = error.code;
136
145
  if (error instanceof TenantBusyError ||
137
146
  error.name === "TenantBusyError" ||
@@ -173,6 +182,9 @@ export function createHost(options) {
173
182
  port: listenPort,
174
183
  host: config.host,
175
184
  allowNonLoopback: config.allowNonLoopback,
185
+ ...(containerListen
186
+ ? { allowedHosts: containerListen.allowedHosts }
187
+ : {}),
176
188
  })) {
177
189
  sendRejected(response, 421, "host_rejected", "Host header is not an allowed loopback or configured address");
178
190
  statusCode = 421;
@@ -209,11 +221,12 @@ export function createHost(options) {
209
221
  if (pathname === "/ready") {
210
222
  const listener = Boolean(server?.listening);
211
223
  const discovery = module.started;
212
- const ready = listener && discovery && !closing;
224
+ const infra = await options.readiness?.();
225
+ const ready = listener && discovery && !closing && (infra?.ok ?? true);
213
226
  sendJson(response, ready ? 200 : 503, {
214
227
  status: ready ? "ready" : "not_ready",
215
228
  service: "nylorun-runtime",
216
- checks: { listener, discovery },
229
+ checks: { listener, discovery, ...infra?.checks },
217
230
  });
218
231
  statusCode = ready ? 200 : 503;
219
232
  return;
@@ -262,7 +275,7 @@ export function createHost(options) {
262
275
  statusCode = 426;
263
276
  return;
264
277
  }
265
- const resolution = module.resolve(tenantId);
278
+ const resolution = await module.resolve(tenantId);
266
279
  if (resolution.kind !== "open") {
267
280
  if (resolution.kind === "quarantined") {
268
281
  logger.warn("tenant_quarantined", {
@@ -328,7 +341,11 @@ export function createHost(options) {
328
341
  async function listen() {
329
342
  if (server)
330
343
  throw new Error("Already listening");
331
- if (!config.allowNonLoopback && !isLoopbackHost(config.host)) {
344
+ // Container mode binds a non-loopback address behind an explicit Host
345
+ // allowlist; otherwise only loopback unless host.json allows more.
346
+ if (!containerListen &&
347
+ !config.allowNonLoopback &&
348
+ !isLoopbackHost(config.host)) {
332
349
  throw new HostListenError(`Refusing to bind non-loopback host ${config.host} without allowNonLoopback`, EXIT_NON_LOOPBACK);
333
350
  }
334
351
  server = createServer((req, res) => {
@@ -337,17 +354,17 @@ export function createHost(options) {
337
354
  await new Promise((resolve, reject) => {
338
355
  server.once("error", (error) => {
339
356
  if (error.code === "EADDRINUSE") {
340
- reject(new HostListenError(`Port ${config.port} on ${config.host} is already in use`, EXIT_PORT_IN_USE, error));
357
+ reject(new HostListenError(`Port ${bindPort} on ${bindHost} is already in use`, EXIT_PORT_IN_USE, error));
341
358
  return;
342
359
  }
343
360
  reject(error);
344
361
  });
345
- server.listen(config.port, config.host, resolve);
362
+ server.listen(bindPort, bindHost, resolve);
346
363
  });
347
364
  const address = server.address();
348
365
  listenPort =
349
- typeof address === "object" && address ? address.port : config.port;
350
- url = `http://${config.host}:${listenPort}`;
366
+ typeof address === "object" && address ? address.port : bindPort;
367
+ url = `http://${bindHost}:${listenPort}`;
351
368
  await module.start();
352
369
  logger.info("host_listening", {
353
370
  hostId: config.hostId,
@@ -355,6 +372,21 @@ export function createHost(options) {
355
372
  pid,
356
373
  });
357
374
  }
375
+ const step = async (name, run) => {
376
+ try {
377
+ await run?.();
378
+ }
379
+ catch (error) {
380
+ logger.error("host_shutdown_step_failed", {
381
+ step: name,
382
+ error: error instanceof Error ? error.message : String(error),
383
+ });
384
+ }
385
+ };
386
+ let settleClosed;
387
+ const closed = new Promise((resolve) => {
388
+ settleClosed = resolve;
389
+ });
358
390
  async function close() {
359
391
  if (closePromise)
360
392
  return closePromise;
@@ -368,21 +400,16 @@ export function createHost(options) {
368
400
  });
369
401
  server = undefined;
370
402
  }
371
- await module.close();
372
- if (existsSync(paths.state)) {
373
- try {
374
- unlinkSync(paths.state);
375
- }
376
- catch {
377
- /* best-effort */
378
- }
379
- }
380
- })();
403
+ await step("beforeTenants", options.shutdown?.beforeTenants);
404
+ await step("tenants", () => module.close());
405
+ await step("afterTenants", options.shutdown?.afterTenants);
406
+ })().finally(settleClosed);
381
407
  return closePromise;
382
408
  }
383
409
  return {
384
410
  listen,
385
411
  close,
412
+ closed,
386
413
  get url() {
387
414
  return url;
388
415
  },
@@ -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 {};