@nylorun/runtime 0.13.0-beta → 0.14.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 (223) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/README.md +69 -15
  3. package/dist/adapters/execution/restate.d.ts +7 -2
  4. package/dist/adapters/execution/restate.js +38 -8
  5. package/dist/adapters/replication/pgoutput.d.ts +16 -0
  6. package/dist/adapters/replication/pgoutput.js +169 -0
  7. package/dist/adapters/streams/s2.js +37 -20
  8. package/dist/admin-openapi.json +1071 -0
  9. package/dist/api/a2a/card.d.ts +15 -0
  10. package/dist/api/a2a/card.js +24 -0
  11. package/dist/api/a2a/endpoint.d.ts +8 -0
  12. package/dist/api/a2a/endpoint.js +81 -0
  13. package/dist/api/a2a/protocol.d.ts +144 -0
  14. package/dist/api/a2a/protocol.js +188 -0
  15. package/dist/api/a2a/routes.d.ts +19 -0
  16. package/dist/api/a2a/routes.js +289 -0
  17. package/dist/api/a2a/tasks.d.ts +50 -0
  18. package/dist/api/a2a/tasks.js +186 -0
  19. package/dist/api/ag-ui/endpoint.d.ts +8 -0
  20. package/dist/api/ag-ui/endpoint.js +126 -0
  21. package/dist/api/ag-ui/history.d.ts +7 -0
  22. package/dist/api/ag-ui/history.js +60 -0
  23. package/dist/api/ag-ui/routes.d.ts +32 -0
  24. package/dist/api/ag-ui/routes.js +305 -0
  25. package/dist/api/ag-ui/session-id.d.ts +1 -0
  26. package/dist/api/ag-ui/session-id.js +12 -0
  27. package/dist/api/ag-ui/sse.d.ts +9 -0
  28. package/dist/api/ag-ui/sse.js +7 -0
  29. package/dist/api/ag-ui/translate.d.ts +38 -0
  30. package/dist/api/ag-ui/translate.js +222 -0
  31. package/dist/api/components.d.ts +7825 -0
  32. package/dist/api/components.js +83 -0
  33. package/dist/api/http/app.d.ts +18 -0
  34. package/dist/api/http/app.js +58 -0
  35. package/dist/api/http/body.d.ts +4 -0
  36. package/dist/api/http/body.js +37 -0
  37. package/dist/api/http/define.d.ts +39 -0
  38. package/dist/api/http/define.js +130 -0
  39. package/dist/api/http/respond.d.ts +9 -0
  40. package/dist/api/http/respond.js +44 -0
  41. package/dist/api/http/routes/access.d.ts +24 -0
  42. package/dist/api/http/routes/access.js +259 -0
  43. package/dist/api/http/routes/actions.d.ts +8 -0
  44. package/dist/api/http/routes/actions.js +76 -0
  45. package/dist/api/http/routes/endpoints.d.ts +7 -0
  46. package/dist/api/http/routes/endpoints.js +100 -0
  47. package/dist/api/http/routes/sessions.d.ts +3 -0
  48. package/dist/api/http/routes/sessions.js +205 -0
  49. package/dist/api/http/routes/tenant.d.ts +10 -0
  50. package/dist/api/http/routes/tenant.js +172 -0
  51. package/dist/api/http/routes/vaults.d.ts +8 -0
  52. package/dist/api/http/routes/vaults.js +167 -0
  53. package/dist/api/openapi.d.ts +19 -0
  54. package/dist/api/openapi.js +138 -0
  55. package/dist/api/route.d.ts +12 -0
  56. package/dist/api/route.js +4 -0
  57. package/dist/core/bearer.d.ts +8 -0
  58. package/dist/core/bearer.js +11 -0
  59. package/dist/core/flow-host.d.ts +7 -15
  60. package/dist/core/flow-host.js +16 -41
  61. package/dist/core/sandbox-routes.d.ts +12 -7
  62. package/dist/core/sandbox-routes.js +24 -25
  63. package/dist/execution/memory.d.ts +8 -5
  64. package/dist/execution/memory.js +33 -13
  65. package/dist/execution/types.d.ts +38 -6
  66. package/dist/execution/types.js +12 -3
  67. package/dist/host/admin-api.d.ts +22 -0
  68. package/dist/host/admin-api.js +178 -0
  69. package/dist/host/app.d.ts +34 -0
  70. package/dist/host/app.js +185 -0
  71. package/dist/host/config-for.d.ts +2 -0
  72. package/dist/host/config-for.js +1 -0
  73. package/dist/host/config.d.ts +11 -0
  74. package/dist/host/cors.d.ts +27 -0
  75. package/dist/host/cors.js +64 -0
  76. package/dist/host/create-host.d.ts +31 -3
  77. package/dist/host/create-host.js +168 -327
  78. package/dist/host/http.d.ts +14 -3
  79. package/dist/host/http.js +64 -23
  80. package/dist/host/main.js +55 -4
  81. package/dist/host/stack-config.d.ts +20 -0
  82. package/dist/host/stack-config.js +55 -0
  83. package/dist/mcp/pool.d.ts +19 -1
  84. package/dist/mcp/pool.js +60 -10
  85. package/dist/model/catalog.d.ts +2 -10
  86. package/dist/model/classify.d.ts +13 -0
  87. package/dist/model/classify.js +61 -0
  88. package/dist/model/models.d.ts +10 -1
  89. package/dist/model/models.js +14 -6
  90. package/dist/model/pi-model.d.ts +17 -2
  91. package/dist/model/pi-model.js +366 -222
  92. package/dist/openapi.json +25696 -0
  93. package/dist/sandbox/manager.d.ts +13 -1
  94. package/dist/sandbox/manager.js +8 -11
  95. package/dist/sandbox/resolve.d.ts +33 -0
  96. package/dist/sandbox/resolve.js +81 -0
  97. package/dist/sandbox/session-sandbox.d.ts +11 -0
  98. package/dist/sandbox/session-sandbox.js +51 -0
  99. package/dist/sandbox/share.d.ts +14 -2
  100. package/dist/sandbox/share.js +33 -3
  101. package/dist/sandbox/tenant-config.d.ts +37 -0
  102. package/dist/sandbox/tenant-config.js +51 -0
  103. package/dist/sandbox/tools.d.ts +7 -9
  104. package/dist/sandbox/tools.js +11 -3
  105. package/dist/sandbox/types.d.ts +8 -2
  106. package/dist/store/event.d.ts +19 -0
  107. package/dist/store/event.js +49 -0
  108. package/dist/store/memory.d.ts +20 -8
  109. package/dist/store/memory.js +265 -100
  110. package/dist/store/postgres/connect.d.ts +13 -0
  111. package/dist/store/postgres/connect.js +5 -0
  112. package/dist/store/postgres/migrations/003_subject_tokens.d.ts +8 -0
  113. package/dist/store/postgres/migrations/003_subject_tokens.js +42 -0
  114. package/dist/store/postgres/migrations/004_publishable_keys.d.ts +6 -0
  115. package/dist/store/postgres/migrations/004_publishable_keys.js +18 -0
  116. package/dist/store/postgres/migrations/005_action_endpoints.d.ts +6 -0
  117. package/dist/store/postgres/migrations/005_action_endpoints.js +32 -0
  118. package/dist/store/postgres/migrations/006_drop_executors.d.ts +11 -0
  119. package/dist/store/postgres/migrations/006_drop_executors.js +32 -0
  120. package/dist/store/postgres/migrations/007_event_time.d.ts +7 -0
  121. package/dist/store/postgres/migrations/007_event_time.js +14 -0
  122. package/dist/store/postgres/migrations/008_durable_streams.d.ts +13 -0
  123. package/dist/store/postgres/migrations/008_durable_streams.js +35 -0
  124. package/dist/store/postgres/migrations/index.d.ts +5 -1
  125. package/dist/store/postgres/migrations/index.js +22 -2
  126. package/dist/store/postgres/migrations/shared/index.d.ts +31 -0
  127. package/dist/store/postgres/migrations/shared/index.js +92 -0
  128. package/dist/store/postgres/record.d.ts +11 -0
  129. package/dist/store/postgres/record.js +64 -0
  130. package/dist/store/postgres/store.js +337 -95
  131. package/dist/store/postgres/tenants.js +14 -0
  132. package/dist/store/types.d.ts +176 -81
  133. package/dist/streams/basin.d.ts +18 -1
  134. package/dist/streams/basin.js +34 -2
  135. package/dist/streams/control.d.ts +18 -0
  136. package/dist/streams/control.js +29 -0
  137. package/dist/streams/memory.d.ts +3 -1
  138. package/dist/streams/memory.js +14 -8
  139. package/dist/streams/relay/core.d.ts +35 -0
  140. package/dist/streams/relay/core.js +366 -0
  141. package/dist/streams/relay/memory.d.ts +69 -0
  142. package/dist/streams/relay/memory.js +176 -0
  143. package/dist/streams/relay/types.d.ts +77 -0
  144. package/dist/streams/relay/types.js +7 -0
  145. package/dist/streams/types.d.ts +66 -36
  146. package/dist/streams/types.js +18 -53
  147. package/dist/tenant/access-policy.d.ts +30 -0
  148. package/dist/tenant/access-policy.js +52 -0
  149. package/dist/tenant/actions.d.ts +10 -149
  150. package/dist/tenant/actions.js +5 -167
  151. package/dist/tenant/advance.js +32 -0
  152. package/dist/tenant/auth.d.ts +19 -17
  153. package/dist/tenant/auth.js +90 -93
  154. package/dist/tenant/browser.d.ts +15 -0
  155. package/dist/tenant/browser.js +39 -0
  156. package/dist/tenant/commands.d.ts +18 -2
  157. package/dist/tenant/commands.js +107 -113
  158. package/dist/tenant/context.d.ts +75 -18
  159. package/dist/tenant/context.js +14 -10
  160. package/dist/tenant/delivery-token.d.ts +41 -0
  161. package/dist/tenant/delivery-token.js +105 -0
  162. package/dist/tenant/delivery.d.ts +62 -0
  163. package/dist/tenant/delivery.js +476 -0
  164. package/dist/tenant/effects.d.ts +5 -0
  165. package/dist/tenant/effects.js +78 -37
  166. package/dist/tenant/endpoints.d.ts +67 -0
  167. package/dist/tenant/endpoints.js +90 -0
  168. package/dist/tenant/ephemeral.d.ts +6 -0
  169. package/dist/tenant/ephemeral.js +60 -36
  170. package/dist/tenant/http.d.ts +8 -9
  171. package/dist/tenant/http.js +13 -27
  172. package/dist/tenant/jwt.d.ts +16 -0
  173. package/dist/tenant/jwt.js +50 -0
  174. package/dist/tenant/module.d.ts +3 -0
  175. package/dist/tenant/module.js +6 -12
  176. package/dist/tenant/outbound.d.ts +44 -0
  177. package/dist/tenant/outbound.js +127 -0
  178. package/dist/tenant/pool.d.ts +0 -2
  179. package/dist/tenant/pool.js +0 -18
  180. package/dist/tenant/reset.d.ts +0 -4
  181. package/dist/tenant/reset.js +0 -5
  182. package/dist/tenant/runtime.d.ts +10 -20
  183. package/dist/tenant/runtime.js +59 -42
  184. package/dist/tenant/scheduler.d.ts +7 -2
  185. package/dist/tenant/scheduler.js +7 -3
  186. package/dist/tenant/session-streams.d.ts +125 -0
  187. package/dist/tenant/session-streams.js +399 -0
  188. package/dist/tenant/sessions.d.ts +28 -4
  189. package/dist/tenant/sessions.js +118 -24
  190. package/dist/tenant/signing-keys.d.ts +55 -0
  191. package/dist/tenant/signing-keys.js +172 -0
  192. package/dist/tenant/slim.d.ts +8 -0
  193. package/dist/tenant/slim.js +21 -0
  194. package/dist/tenant/status.d.ts +0 -3
  195. package/dist/tenant/status.js +19 -12
  196. package/dist/tenant/streams.d.ts +60 -67
  197. package/dist/tenant/streams.js +140 -208
  198. package/dist/tenant/subject-limits.d.ts +16 -0
  199. package/dist/tenant/subject-limits.js +45 -0
  200. package/dist/tenant/sweep.d.ts +1 -10
  201. package/dist/tenant/sweep.js +29 -69
  202. package/dist/tenant/tokens.d.ts +14 -0
  203. package/dist/tenant/tokens.js +178 -0
  204. package/dist/tenant/transcript.d.ts +48 -5
  205. package/dist/tenant/transcript.js +58 -1
  206. package/dist/tenant/types.d.ts +29 -7
  207. package/dist/tenant/worker.d.ts +3 -1
  208. package/dist/tenant/worker.js +9 -3
  209. package/dist/vault/service.d.ts +18 -2
  210. package/dist/vault/service.js +57 -13
  211. package/dist/version.d.ts +1 -1
  212. package/dist/version.js +1 -1
  213. package/package.json +20 -8
  214. package/dist/core/executors.d.ts +0 -44
  215. package/dist/core/executors.js +0 -95
  216. package/dist/streams/relay.d.ts +0 -37
  217. package/dist/streams/relay.js +0 -254
  218. package/dist/tenant/live.d.ts +0 -92
  219. package/dist/tenant/live.js +0 -290
  220. package/dist/tenant/routes-tenant.d.ts +0 -12
  221. package/dist/tenant/routes-tenant.js +0 -137
  222. package/dist/tenant/routes.d.ts +0 -9
  223. package/dist/tenant/routes.js +0 -151
@@ -1,42 +1,13 @@
1
1
  import { createServer, } from "node:http";
2
- import { createHash, timingSafeEqual } from "node:crypto";
3
- import { checkCompatibility, HOST_PROTOCOL, isTenantId, PROTOCOL_HEADER, TENANT_HEADER, } from "@nylorun/core/compatibility";
4
- import { AdminStatusSchema, CreateTenantRequestSchema, } from "@nylorun/core/contracts";
5
- import { TenantBusyError, TenantConflictError, } from "../tenant/quarantine.js";
6
- import { TenantNotFoundError, } from "../tenant/types.js";
7
- import { EXIT_NON_LOOPBACK, EXIT_PORT_IN_USE, HostListenError, isAllowedRequestHost, isJsonContentType, isLoopbackHost, readBearer, readJsonBody, redactRoutePath, requestHasBody, sendJson, sendOpaqueNotFound, sendProtocolRejected, sendRejected, } from "./http.js";
2
+ import { getRequestListener, RequestError } from "@hono/node-server";
3
+ import { HOST_PROTOCOL } from "@nylorun/core/compatibility";
4
+ import { AdminStatusSchema } from "@nylorun/core/contracts";
5
+ import { adminDocument } from "../api/openapi.js";
6
+ import { createAdminApi } from "./admin-api.js";
7
+ import { createHostApp } from "./app.js";
8
+ import { EXIT_NON_LOOPBACK, EXIT_PORT_IN_USE, headerValue, HostListenError, isAllowedRequestHost, isLoopbackHost, pathnameIsLogged, redactRoutePath, rejectedResponse, sendRejected, } from "./http.js";
8
9
  import { RUNTIME_VERSION } from "../version.js";
9
- function hashUtf8(value) {
10
- return createHash("sha256").update(value, "utf8").digest();
11
- }
12
- /** Constant-time comparison of SHA-256 digests of the presented and stored admin keys. */
13
- export function adminKeyMatches(presented, adminKey) {
14
- if (presented === undefined)
15
- return false;
16
- const a = hashUtf8(presented);
17
- const b = hashUtf8(adminKey);
18
- return a.length === b.length && timingSafeEqual(a, b);
19
- }
20
- function headerValue(headers, name) {
21
- const raw = headers[name.toLowerCase()];
22
- if (Array.isArray(raw))
23
- return raw[0];
24
- return raw;
25
- }
26
- function parseProtocolVersion(raw) {
27
- if (raw === undefined)
28
- return undefined;
29
- const trimmed = raw.trim();
30
- if (!/^\d+$/.test(trimmed))
31
- return undefined;
32
- return Number(trimmed);
33
- }
34
- function protocolAccepted(raw) {
35
- const version = parseProtocolVersion(raw);
36
- if (version === undefined)
37
- return false;
38
- return checkCompatibility({ version, required: [] }, HOST_PROTOCOL).ok;
39
- }
10
+ export { adminKeyMatches } from "./http.js";
40
11
  export function createHost(options) {
41
12
  const { hostRoot, module, config, credentials, logger, coreVersion, } = options;
42
13
  const pid = options.pid ?? process.pid;
@@ -46,6 +17,11 @@ export function createHost(options) {
46
17
  let server;
47
18
  let url = "";
48
19
  let listenPort = bindPort;
20
+ const operator = options.operator;
21
+ let operatorServer;
22
+ let operatorPort = operator?.port ?? 0;
23
+ let adminUrl = "";
24
+ const mainRole = operator ? "public" : "combined";
49
25
  let closing = false;
50
26
  let closePromise;
51
27
  const adminStatusBody = async () => {
@@ -68,276 +44,87 @@ export function createHost(options) {
68
44
  },
69
45
  });
70
46
  };
71
- const requireAdmin = (request, response) => {
72
- const token = readBearer(headerValue(request.headers, "authorization"));
73
- if (!adminKeyMatches(token, credentials.adminKey)) {
74
- sendOpaqueNotFound(response);
75
- return false;
76
- }
77
- return true;
78
- };
79
- const handleAdmin = async (request, response, urlObj, segments) => {
80
- const method = request.method ?? "GET";
81
- // /v1/admin/...
82
- if (segments[2] === "tenants") {
83
- if (segments.length === 3 && method === "GET") {
84
- const tenants = await module.list();
85
- return sendJson(response, 200, tenants);
86
- }
87
- if (segments.length === 3 && method === "POST") {
88
- const body = CreateTenantRequestSchema.parse(await readJsonBody(request));
89
- try {
90
- const result = await module.create({
91
- tenantId: body.tenantId,
92
- name: body.name,
93
- principalId: body.principalId,
94
- credentialHash: body.credentialHash,
95
- idempotencyKey: body.idempotencyKey,
96
- ...(body.studioCredentialHash
97
- ? { studioCredentialHash: body.studioCredentialHash }
98
- : {}),
99
- ...(body.derivedPrincipals?.length
100
- ? { derivedPrincipals: body.derivedPrincipals }
101
- : {}),
102
- });
103
- return sendJson(response, result.created ? 201 : 200, result.envelope);
104
- }
105
- catch (error) {
106
- const code = error.code;
107
- if (error instanceof TenantConflictError ||
108
- error.name === "TenantConflictError" ||
109
- code === "conflict" ||
110
- code === "tenant_conflict") {
111
- return sendRejected(response, 409, "tenant_conflict", error instanceof Error ? error.message : "Tenant conflict");
112
- }
113
- throw error;
114
- }
115
- }
116
- if (segments.length === 4 && segments[3]) {
117
- const id = segments[3];
118
- if (method === "GET") {
119
- const status = await module.status(id);
120
- if (!status)
121
- return sendOpaqueNotFound(response);
122
- return sendJson(response, 200, status);
123
- }
124
- if (method === "DELETE") {
125
- const activeWork = urlObj.searchParams.get("activeWork") ?? "refuse";
126
- if (activeWork !== "refuse" &&
127
- activeWork !== "drain" &&
128
- activeWork !== "cancel") {
129
- return sendJson(response, 400, {
130
- status: "rejected",
131
- code: "invalid_request",
132
- message: "activeWork must be refuse, drain, or cancel",
133
- });
134
- }
135
- try {
136
- await module.delete(id, activeWork);
137
- response.writeHead(204);
138
- response.end();
139
- return;
140
- }
141
- catch (error) {
142
- if (error instanceof TenantNotFoundError)
143
- return sendOpaqueNotFound(response);
144
- const code = error.code;
145
- if (error instanceof TenantBusyError ||
146
- error.name === "TenantBusyError" ||
147
- code === "active_work" ||
148
- code === "conflict") {
149
- return sendRejected(response, 409, "active_work", error instanceof Error ? error.message : "Active work");
150
- }
151
- throw error;
152
- }
153
- }
154
- }
155
- }
156
- // D12: /v1/admin/status and /v1/admin/host share one handler.
157
- if ((segments[2] === "host" || segments[2] === "status") &&
158
- segments.length === 3 &&
159
- method === "GET") {
160
- return sendJson(response, 200, await adminStatusBody());
161
- }
162
- if (segments[2] === "host" &&
163
- segments.length === 4 &&
164
- segments[3] === "shutdown" &&
165
- method === "POST") {
166
- sendJson(response, 200, { status: "shutting_down" });
167
- void close();
168
- return;
169
- }
170
- return sendRejected(response, 404, "not_found", "Route not found");
171
- };
172
- const handle = async (request, response) => {
173
- const started = Date.now();
174
- let tenantId;
175
- let statusCode = 500;
176
- try {
177
- const urlObj = new URL(request.url ?? "/", "http://runtime.local");
178
- const pathname = urlObj.pathname;
179
- // D§11: Host, Origin, then Content-Type — before any other processing.
180
- const hostHeader = headerValue(request.headers, "host");
181
- if (!isAllowedRequestHost(hostHeader, {
182
- port: listenPort,
183
- host: config.host,
184
- allowNonLoopback: config.allowNonLoopback,
185
- ...(containerListen
186
- ? { allowedHosts: containerListen.allowedHosts }
187
- : {}),
188
- })) {
189
- sendRejected(response, 421, "host_rejected", "Host header is not an allowed loopback or configured address");
190
- statusCode = 421;
191
- return;
192
- }
193
- if (headerValue(request.headers, "origin") !== undefined) {
194
- sendRejected(response, 403, "origin_rejected", "Browser Origin headers are not accepted");
195
- statusCode = 403;
196
- return;
197
- }
198
- if (requestHasBody(request) &&
199
- !isJsonContentType(headerValue(request.headers, "content-type"))) {
200
- sendRejected(response, 415, "unsupported_media_type", "Request bodies must use application/json");
201
- statusCode = 415;
202
- return;
203
- }
204
- if (pathname === "/health") {
205
- sendJson(response, 200, {
206
- status: "ok",
207
- service: "nylorun-runtime",
208
- version: RUNTIME_VERSION,
209
- protocol: {
210
- min: HOST_PROTOCOL.min,
211
- max: HOST_PROTOCOL.max,
212
- features: [...HOST_PROTOCOL.features],
213
- },
214
- coreVersion,
215
- hostId: config.hostId,
216
- pid,
217
- });
218
- statusCode = 200;
219
- return;
220
- }
221
- if (pathname === "/ready") {
222
- const listener = Boolean(server?.listening);
223
- const discovery = module.started;
224
- const infra = await options.readiness?.();
225
- const ready = listener && discovery && !closing && (infra?.ok ?? true);
226
- sendJson(response, ready ? 200 : 503, {
227
- status: ready ? "ready" : "not_ready",
228
- service: "nylorun-runtime",
229
- checks: { listener, discovery, ...infra?.checks },
230
- });
231
- statusCode = ready ? 200 : 503;
232
- return;
233
- }
234
- const segments = pathname.split("/").filter(Boolean);
235
- const isAdmin = segments[0] === "v1" && segments[1] === "admin";
236
- if (isAdmin) {
237
- const protocolHeader = headerValue(request.headers, PROTOCOL_HEADER);
238
- if (!protocolAccepted(protocolHeader)) {
239
- sendProtocolRejected(response);
240
- statusCode = 426;
241
- return;
242
- }
243
- if (!requireAdmin(request, response)) {
244
- statusCode = 404;
245
- return;
246
- }
247
- await handleAdmin(request, response, urlObj, segments);
248
- statusCode = response.statusCode || 200;
249
- return;
250
- }
251
- // Tenant-scoped routes: header pattern → protocol → resolve → Tenant handle.
252
- const tenantHeader = headerValue(request.headers, TENANT_HEADER);
253
- if (tenantHeader === undefined || tenantHeader.trim() === "") {
254
- sendJson(response, 400, {
255
- status: "rejected",
256
- code: "invalid_request",
257
- message: `${TENANT_HEADER} header is required`,
258
- });
259
- statusCode = 400;
260
- return;
261
- }
262
- if (!isTenantId(tenantHeader)) {
263
- sendJson(response, 400, {
264
- status: "rejected",
265
- code: "invalid_request",
266
- message: `${TENANT_HEADER} header is malformed`,
267
- });
268
- statusCode = 400;
269
- return;
270
- }
271
- tenantId = tenantHeader;
272
- const protocolHeader = headerValue(request.headers, PROTOCOL_HEADER);
273
- if (!protocolAccepted(protocolHeader)) {
274
- sendProtocolRejected(response);
275
- statusCode = 426;
276
- return;
277
- }
278
- const resolution = await module.resolve(tenantId);
279
- if (resolution.kind !== "open") {
280
- if (resolution.kind === "quarantined") {
281
- logger.warn("tenant_quarantined", {
282
- tenantId,
283
- code: resolution.quarantine.code,
284
- repair: resolution.quarantine.repair,
285
- });
286
- }
287
- sendOpaqueNotFound(response);
288
- statusCode = 404;
289
- return;
290
- }
291
- await resolution.handle.handle(request, response, urlObj);
292
- statusCode = response.statusCode || 200;
293
- }
294
- catch (error) {
295
- const status = error.status;
296
- if (typeof status === "number" && status >= 400 && status < 600) {
297
- sendJson(response, status, {
298
- status: "rejected",
299
- code: status === 400 ? "invalid_request" : "request_rejected",
300
- message: error instanceof Error ? error.message : "Request rejected",
301
- });
302
- statusCode = status;
303
- }
304
- else if (error &&
305
- typeof error === "object" &&
306
- "name" in error &&
307
- error.name === "ZodError") {
308
- sendJson(response, 400, {
309
- status: "rejected",
310
- code: "invalid_request",
311
- message: "Invalid request body",
312
- });
313
- statusCode = 400;
314
- }
315
- else {
316
- logger.error("request_failed", {
317
- error: error instanceof Error ? error.message : String(error),
318
- });
319
- if (!response.headersSent) {
320
- sendJson(response, 500, {
321
- status: "rejected",
322
- code: "internal_error",
323
- message: "Internal error",
324
- });
325
- }
326
- statusCode = 500;
327
- }
328
- }
329
- finally {
330
- if (pathnameIsLogged(request.url)) {
47
+ const adminApi = createAdminApi({
48
+ module,
49
+ status: adminStatusBody,
50
+ shutdown: () => void close(),
51
+ document: adminDocument,
52
+ });
53
+ const app = createHostApp({
54
+ module,
55
+ logger,
56
+ hostId: config.hostId,
57
+ adminKey: credentials.adminKey,
58
+ coreVersion,
59
+ pid,
60
+ browserAccess: options.browserAccess === true,
61
+ ...(options.readiness ? { readiness: options.readiness } : {}),
62
+ listening: () => Boolean(server?.listening) && (!operator || Boolean(operatorServer?.listening)),
63
+ closing: () => closing,
64
+ admin: async (request, node) => await adminApi.fetch(request, node),
65
+ });
66
+ /** Where a listener's requests go once their `Host` header checks out. */
67
+ const pipeline = (role) => getRequestListener(async (request, node) => alreadySent(await app.fetch(request, { ...node, role })), {
68
+ // The Runtime runs inside other processes (`startEphemeralRuntime`): leave their
69
+ // `Request` and `Response` alone.
70
+ overrideGlobalObjects: false,
71
+ // A request target `@hono/node-server` cannot make a URL of, such as `*`.
72
+ errorHandler: (error) => {
73
+ if (error instanceof RequestError)
74
+ return rejectedResponse(400, "invalid_request", "Invalid request target");
75
+ logger.error("request_failed", {
76
+ error: error instanceof Error ? error.message : String(error),
77
+ });
78
+ return rejectedResponse(500, "internal_error", "Internal error");
79
+ },
80
+ });
81
+ /** The `Host` values a listener accepts. */
82
+ const hostAllowed = (role, hostHeader) => role === "operator"
83
+ ? isAllowedRequestHost(hostHeader, {
84
+ port: operatorPort,
85
+ host: operator.host,
86
+ allowNonLoopback: false,
87
+ ...(operator.allowedHosts ? { allowedHosts: operator.allowedHosts } : {}),
88
+ })
89
+ : isAllowedRequestHost(hostHeader, {
90
+ port: listenPort,
91
+ host: config.host,
92
+ allowNonLoopback: config.allowNonLoopback,
93
+ ...(containerListen ? { allowedHosts: containerListen.allowedHosts } : {}),
94
+ });
95
+ /** Responses not yet finished; shutdown ends the streams among them. */
96
+ const inFlight = new Set();
97
+ const serve = (role) => {
98
+ const next = pipeline(role);
99
+ return (req, res) => {
100
+ const started = Date.now();
101
+ inFlight.add(res);
102
+ res.once("close", () => inFlight.delete(res));
103
+ // D§11: `Host` first, before anything reads the request.
104
+ if (hostAllowed(role, headerValue(req, "host")))
105
+ return void next(req, res);
106
+ sendRejected(res, 421, "host_rejected", "Host header is not an allowed loopback or configured address");
107
+ if (pathnameIsLogged(req.url))
331
108
  logger.info("request", {
332
- status: statusCode,
333
- tenantId,
109
+ status: 421,
334
110
  durationMs: Date.now() - started,
335
- path: redactRoutePath(new URL(request.url ?? "/", "http://runtime.local").pathname),
336
- method: request.method,
111
+ path: redactRoutePath(new URL(req.url ?? "/", "http://runtime.local").pathname),
112
+ method: req.method,
337
113
  });
338
- }
339
- }
114
+ };
340
115
  };
116
+ /** Binds `listening`; once bound, a listener error is logged rather than lost. */
117
+ const bindListener = (listening, port, host) => new Promise((resolve, reject) => {
118
+ const failed = (error) => reject(error.code === "EADDRINUSE"
119
+ ? new HostListenError(`Port ${port} on ${host} is already in use`, EXIT_PORT_IN_USE, error)
120
+ : error);
121
+ listening.once("error", failed);
122
+ listening.listen(port, host, () => {
123
+ listening.off("error", failed);
124
+ listening.on("error", (error) => logger.error("listener_error", { error: error.message }));
125
+ resolve();
126
+ });
127
+ });
341
128
  async function listen() {
342
129
  if (server)
343
130
  throw new Error("Already listening");
@@ -348,27 +135,35 @@ export function createHost(options) {
348
135
  !isLoopbackHost(config.host)) {
349
136
  throw new HostListenError(`Refusing to bind non-loopback host ${config.host} without allowNonLoopback`, EXIT_NON_LOOPBACK);
350
137
  }
351
- server = createServer((req, res) => {
352
- void handle(req, res);
353
- });
354
- await new Promise((resolve, reject) => {
355
- server.once("error", (error) => {
356
- if (error.code === "EADDRINUSE") {
357
- reject(new HostListenError(`Port ${bindPort} on ${bindHost} is already in use`, EXIT_PORT_IN_USE, error));
358
- return;
359
- }
360
- reject(error);
361
- });
362
- server.listen(bindPort, bindHost, resolve);
363
- });
138
+ if (operator && !containerListen && !isLoopbackHost(operator.host))
139
+ throw new HostListenError(`Refusing to bind the operator listener on non-loopback host ${operator.host}`, EXIT_NON_LOOPBACK);
140
+ server = createServer(serve(mainRole));
141
+ await bindListener(server, bindPort, bindHost);
364
142
  const address = server.address();
365
143
  listenPort =
366
144
  typeof address === "object" && address ? address.port : bindPort;
367
145
  url = `http://${bindHost}:${listenPort}`;
146
+ adminUrl = url;
147
+ if (operator) {
148
+ operatorServer = createServer(serve("operator"));
149
+ await bindListener(operatorServer, operator.port, operator.host).catch(async (error) => {
150
+ // Leave nothing half-open: the main listener closes too.
151
+ await new Promise((resolve) => server.close(() => resolve()));
152
+ server = undefined;
153
+ throw error;
154
+ });
155
+ const operatorAddress = operatorServer.address();
156
+ operatorPort =
157
+ typeof operatorAddress === "object" && operatorAddress
158
+ ? operatorAddress.port
159
+ : operator.port;
160
+ adminUrl = `http://${operator.host}:${operatorPort}`;
161
+ }
368
162
  await module.start();
369
163
  logger.info("host_listening", {
370
164
  hostId: config.hostId,
371
165
  url,
166
+ ...(operator ? { adminUrl } : {}),
372
167
  pid,
373
168
  });
374
169
  }
@@ -393,13 +188,40 @@ export function createHost(options) {
393
188
  closePromise = (async () => {
394
189
  closing = true;
395
190
  logger.info("host_shutdown", { hostId: config.hostId });
396
- if (server) {
397
- await new Promise((resolve) => {
398
- server.close(() => resolve());
399
- server.closeIdleConnections?.();
400
- });
401
- server = undefined;
191
+ const listeners = [server, operatorServer].filter((listening) => listening !== undefined);
192
+ const stopped = Promise.all(listeners.map((listening) => new Promise((resolve) => {
193
+ listening.close(() => resolve());
194
+ listening.closeIdleConnections();
195
+ })));
196
+ // A stream never finishes by itself, and a listener closes only once every connection
197
+ // has: end the streams, close their connections once idle, let other requests finish,
198
+ // then cut what is left.
199
+ const streams = [...inFlight].filter(isStreaming);
200
+ void Promise.all(streams.map((response) => {
201
+ const done = new Promise((resolve) => response.once("close", resolve));
202
+ response.end();
203
+ return done;
204
+ })).then(() => {
205
+ for (const listening of listeners)
206
+ listening.closeIdleConnections();
207
+ });
208
+ let grace;
209
+ const graceful = await Promise.race([
210
+ stopped.then(() => true),
211
+ new Promise((resolve) => {
212
+ grace = setTimeout(() => resolve(false), SHUTDOWN_GRACE_MS);
213
+ grace.unref();
214
+ }),
215
+ ]);
216
+ clearTimeout(grace);
217
+ if (!graceful) {
218
+ logger.warn("host_shutdown_forced", { openResponses: inFlight.size });
219
+ for (const listening of listeners)
220
+ listening.closeAllConnections();
221
+ await stopped;
402
222
  }
223
+ server = undefined;
224
+ operatorServer = undefined;
403
225
  await step("beforeTenants", options.shutdown?.beforeTenants);
404
226
  await step("tenants", () => module.close());
405
227
  await step("afterTenants", options.shutdown?.afterTenants);
@@ -413,11 +235,30 @@ export function createHost(options) {
413
235
  get url() {
414
236
  return url;
415
237
  },
238
+ get adminUrl() {
239
+ return adminUrl;
240
+ },
416
241
  };
417
242
  }
418
- function pathnameIsLogged(rawUrl) {
419
- if (!rawUrl)
420
- return true;
421
- const pathname = new URL(rawUrl, "http://runtime.local").pathname;
422
- return pathname !== "/health" && pathname !== "/ready";
243
+ /**
244
+ * An answer already written to the Node response. `@hono/node-server` skips writing one that
245
+ * says so in its headers, unless it is its own `Response` class, which a process that ran its
246
+ * `serve()` has as the global one: it writes those again. This is never that class.
247
+ */
248
+ const SENT = {
249
+ status: 200,
250
+ headers: new Headers({ "x-hono-already-sent": "true" }),
251
+ body: null,
252
+ };
253
+ function alreadySent(response) {
254
+ return response.headers.has("x-hono-already-sent") ? SENT : response;
255
+ }
256
+ /** How long shutdown waits for requests in progress before closing their connections. */
257
+ const SHUTDOWN_GRACE_MS = 10_000;
258
+ /**
259
+ * A response that has started and not ended is a stream: JSON answers are written and ended
260
+ * at once. (`getHeader` cannot tell, since streams pass their headers to `writeHead`.)
261
+ */
262
+ function isStreaming(response) {
263
+ return response.headersSent && !response.writableEnded;
423
264
  }
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { jsonResponse } from "../api/http/respond.js";
2
3
  import { type ErrorCode, type ProtocolRange } from "@nylorun/core/compatibility";
3
4
  export declare const OPAQUE_NOT_FOUND: {
4
5
  status: "rejected";
@@ -13,9 +14,12 @@ export declare class HostListenError extends Error {
13
14
  constructor(message: string, exitCode: number, cause?: unknown);
14
15
  }
15
16
  export declare function sendJson(response: ServerResponse, status: number, body: unknown): void;
17
+ /** `sendJson` as a Response, byte for byte (with any extra headers): the Host's answers. */
18
+ export { jsonResponse };
16
19
  export declare function sendRejected(response: ServerResponse, status: number, code: ErrorCode, message: string, details?: unknown): void;
17
- export declare function sendOpaqueNotFound(response: ServerResponse): void;
18
- export declare function sendProtocolRejected(response: ServerResponse, protocol?: ProtocolRange): void;
20
+ export declare function rejectedResponse(status: number, code: ErrorCode, message: string, details?: unknown): Response;
21
+ export declare function opaqueNotFoundResponse(): Response;
22
+ export declare function protocolRejectedResponse(protocol?: ProtocolRange): Response;
19
23
  /**
20
24
  * D§11 Host allowlist: loopback forms with the listening port, plus the
21
25
  * configured host when `allowNonLoopback` is set.
@@ -32,8 +36,15 @@ export declare function isAllowedRequestHost(hostHeader: string | undefined, opt
32
36
  export declare function isJsonContentType(value: string | undefined): boolean;
33
37
  /** True when the request carries a body (Content-Length > 0 or chunked). */
34
38
  export declare function requestHasBody(request: IncomingMessage): boolean;
39
+ /** `/health` and `/ready` are polled; the request log leaves them out. */
40
+ export declare function pathnameIsLogged(rawUrl: string | undefined): boolean;
35
41
  /** Redact path parameters after `/v1/sessions/` for Host logs. */
36
42
  export declare function redactRoutePath(pathname: string): string;
43
+ /** Constant-time comparison of SHA-256 digests of the presented and stored admin keys. */
44
+ export declare function adminKeyMatches(presented: string | undefined, adminKey: string): boolean;
45
+ /** A request header's first value. */
46
+ export declare function headerValue(incoming: IncomingMessage, name: string): string | undefined;
37
47
  export declare function readBearer(authorization: string | undefined): string | undefined;
38
- export declare function readJsonBody(request: import("node:http").IncomingMessage, limit?: number): Promise<unknown>;
48
+ /** A JSON request body, capped at `limit` bytes (413); `undefined` when there is none. */
49
+ export declare function readJsonBody(request: Request, limit?: number): Promise<unknown>;
39
50
  export declare function isLoopbackHost(host: string): boolean;