@nanobpm/urban 0.45.1 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/adapters/node.js +23 -1
- package/dist/runtime/core/host.d.ts +10 -0
- package/dist/runtime/core/modules/pages.js +11 -8
- package/dist/runtime/core/runtime.d.ts +9 -0
- package/dist/runtime/core/runtime.js +4 -0
- package/package.json +1 -1
- package/src/runtime/adapters/node.ts +24 -1
- package/src/runtime/core/host.ts +10 -0
- package/src/runtime/core/modules/pages.test.ts +17 -10
- package/src/runtime/core/modules/pages.ts +11 -8
- package/src/runtime/core/runtime.test.ts +140 -0
- package/src/runtime/core/runtime.ts +13 -0
- package/src/runtime/devserver.test.ts +1 -0
|
@@ -229,11 +229,33 @@ async function startNodeServer(port, handler) {
|
|
|
229
229
|
nres.end(String(err));
|
|
230
230
|
}
|
|
231
231
|
});
|
|
232
|
+
// Track every raw TCP socket so `stop()` can force teardown. `server.close()` only
|
|
233
|
+
// stops accepting new connections and waits for existing ones to end; a lingering
|
|
234
|
+
// long-lived socket would keep it pending forever and hang `app.stop()`. Now that we
|
|
235
|
+
// expose `native` for same-port WebSocket upgrades, that is a real risk — and
|
|
236
|
+
// `server.closeAllConnections()` does NOT cover sockets that have already been
|
|
237
|
+
// upgraded (they are detached from the server's HTTP connection tracking after the
|
|
238
|
+
// `upgrade` event). The underlying TCP socket from the `connection` event is the same
|
|
239
|
+
// object that a later upgrade reuses, so destroying those is what actually unblocks
|
|
240
|
+
// shutdown regardless of what an upgrade handler left open. Attach the listener
|
|
241
|
+
// BEFORE `listen()` so no connection can slip through untracked in the window between
|
|
242
|
+
// the server accepting sockets and the tracker being wired up.
|
|
243
|
+
const sockets = new Set();
|
|
244
|
+
server.on("connection", (socket) => {
|
|
245
|
+
sockets.add(socket);
|
|
246
|
+
socket.once("close", () => sockets.delete(socket));
|
|
247
|
+
});
|
|
232
248
|
await new Promise((res) => server.listen(port, res));
|
|
233
249
|
const addr = server.address();
|
|
234
250
|
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
235
251
|
return {
|
|
236
252
|
port: actualPort,
|
|
237
|
-
stop: () => new Promise((res) =>
|
|
253
|
+
stop: () => new Promise((res) => {
|
|
254
|
+
server.close(() => res());
|
|
255
|
+
for (const socket of sockets)
|
|
256
|
+
socket.destroy();
|
|
257
|
+
sockets.clear();
|
|
258
|
+
}),
|
|
259
|
+
native: server,
|
|
238
260
|
};
|
|
239
261
|
}
|
|
@@ -20,6 +20,16 @@ export type HttpHandler = (req: HttpRequest) => Promise<HttpResponse> | HttpResp
|
|
|
20
20
|
export interface HttpServer {
|
|
21
21
|
readonly port: number;
|
|
22
22
|
stop(): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* The host's native server object, when it exposes one — the Node adapter's `node:http` `Server`.
|
|
25
|
+
* Lets an app attach a WebSocket upgrade (e.g. `ws`'s `WebSocketServer({ server })`) to the *same*
|
|
26
|
+
* port the app already serves. `undefined` on hosts that don't surface an upgradeable server object
|
|
27
|
+
* (the Deno adapter, whose WS story is `upgradeWebSocket` on the Deno global in the request
|
|
28
|
+
* handler). Typed
|
|
29
|
+
* `unknown` because this is the host-adapter boundary — the consumer narrows it with a runtime
|
|
30
|
+
* check (e.g. `instanceof Server`) before use, rather than a type assertion.
|
|
31
|
+
*/
|
|
32
|
+
readonly native?: unknown;
|
|
23
33
|
}
|
|
24
34
|
/** A handle to an active filesystem watch; call close() to stop watching. */
|
|
25
35
|
export interface WatchHandle {
|
|
@@ -482,14 +482,17 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
482
482
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
483
483
|
.pc-msg.err { color:var(--nano-danger); }
|
|
484
484
|
.pc-msg.ok { color:var(--nano-ok); }
|
|
485
|
-
table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
|
|
486
|
-
/*
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
485
|
+
table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-layout:fixed; }
|
|
486
|
+
/* table-layout:fixed divides the available width across columns and wraps text
|
|
487
|
+
within each, so a grid with several long free-text columns (e.g. the epic
|
|
488
|
+
detail's coordination-notes / trial-merge-results) can't sum its columns'
|
|
489
|
+
content widths past the page — the failure mode of table-layout:auto, where a
|
|
490
|
+
per-cell max-width let two wide columns each claim ~32rem and overrun the page.
|
|
491
|
+
overflow-wrap:anywhere additionally breaks space-less tokens (a 40-char SHA, a
|
|
492
|
+
JSON blob) so they wrap inside the column instead of forcing a scrollbar.
|
|
493
|
+
vertical-align:top keeps a multi-line cell aligned with its single-line
|
|
494
|
+
neighbours. */
|
|
495
|
+
table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; vertical-align:top; }
|
|
493
496
|
table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
494
497
|
.pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
|
|
495
498
|
.pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
|
|
@@ -65,5 +65,14 @@ export interface UrbanApp {
|
|
|
65
65
|
readonly data: DataLayer | undefined;
|
|
66
66
|
readonly security: SecurityPolicy | undefined;
|
|
67
67
|
readonly httpPort: number | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* The runtime's native HTTP server object once `start()` has bound it (the Node adapter's
|
|
70
|
+
* `node:http` `Server`), for attaching a WebSocket upgrade on the app's *own* port. Snapshot it
|
|
71
|
+
* into a local `const` and narrow that with a runtime `instanceof` check before use (no type
|
|
72
|
+
* assertion needed) — reading the property again after the check would not narrow — e.g.
|
|
73
|
+
* `const { Server } = await import("node:http"); const server = app.httpServer; if (server instanceof Server) new WebSocketServer({ server, path: "/agentic" });`.
|
|
74
|
+
* `undefined` before `start()`, after `stop()`, and on hosts that don't surface one (Deno).
|
|
75
|
+
*/
|
|
76
|
+
readonly httpServer: object | undefined;
|
|
68
77
|
}
|
|
69
78
|
export declare function createUrbanApp(opts: CreateUrbanAppOptions): Promise<UrbanApp>;
|
|
@@ -114,6 +114,10 @@ export async function createUrbanApp(opts) {
|
|
|
114
114
|
get httpPort() {
|
|
115
115
|
return httpPort;
|
|
116
116
|
},
|
|
117
|
+
get httpServer() {
|
|
118
|
+
const native = server?.native;
|
|
119
|
+
return typeof native === "object" && native !== null ? native : undefined;
|
|
120
|
+
},
|
|
117
121
|
async start() {
|
|
118
122
|
if (started)
|
|
119
123
|
throw new Error("app already started");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -5,6 +5,7 @@ import { readFile, readdir, stat } from "node:fs/promises";
|
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { watch as fsWatch, type FSWatcher } from "node:fs";
|
|
7
7
|
import { createServer } from "node:http";
|
|
8
|
+
import type { Socket } from "node:net";
|
|
8
9
|
import { register } from "node:module";
|
|
9
10
|
import { isAbsolute, resolve } from "node:path";
|
|
10
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
@@ -246,11 +247,33 @@ async function startNodeServer(port: number, handler: HttpHandler): Promise<Http
|
|
|
246
247
|
nres.end(String(err));
|
|
247
248
|
}
|
|
248
249
|
});
|
|
250
|
+
// Track every raw TCP socket so `stop()` can force teardown. `server.close()` only
|
|
251
|
+
// stops accepting new connections and waits for existing ones to end; a lingering
|
|
252
|
+
// long-lived socket would keep it pending forever and hang `app.stop()`. Now that we
|
|
253
|
+
// expose `native` for same-port WebSocket upgrades, that is a real risk — and
|
|
254
|
+
// `server.closeAllConnections()` does NOT cover sockets that have already been
|
|
255
|
+
// upgraded (they are detached from the server's HTTP connection tracking after the
|
|
256
|
+
// `upgrade` event). The underlying TCP socket from the `connection` event is the same
|
|
257
|
+
// object that a later upgrade reuses, so destroying those is what actually unblocks
|
|
258
|
+
// shutdown regardless of what an upgrade handler left open. Attach the listener
|
|
259
|
+
// BEFORE `listen()` so no connection can slip through untracked in the window between
|
|
260
|
+
// the server accepting sockets and the tracker being wired up.
|
|
261
|
+
const sockets = new Set<Socket>();
|
|
262
|
+
server.on("connection", (socket) => {
|
|
263
|
+
sockets.add(socket);
|
|
264
|
+
socket.once("close", () => sockets.delete(socket));
|
|
265
|
+
});
|
|
249
266
|
await new Promise<void>((res) => server.listen(port, res));
|
|
250
267
|
const addr = server.address();
|
|
251
268
|
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
252
269
|
return {
|
|
253
270
|
port: actualPort,
|
|
254
|
-
stop: () =>
|
|
271
|
+
stop: () =>
|
|
272
|
+
new Promise<void>((res) => {
|
|
273
|
+
server.close(() => res());
|
|
274
|
+
for (const socket of sockets) socket.destroy();
|
|
275
|
+
sockets.clear();
|
|
276
|
+
}),
|
|
277
|
+
native: server,
|
|
255
278
|
};
|
|
256
279
|
}
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -28,6 +28,16 @@ export type HttpHandler = (req: HttpRequest) => Promise<HttpResponse> | HttpResp
|
|
|
28
28
|
export interface HttpServer {
|
|
29
29
|
readonly port: number;
|
|
30
30
|
stop(): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* The host's native server object, when it exposes one — the Node adapter's `node:http` `Server`.
|
|
33
|
+
* Lets an app attach a WebSocket upgrade (e.g. `ws`'s `WebSocketServer({ server })`) to the *same*
|
|
34
|
+
* port the app already serves. `undefined` on hosts that don't surface an upgradeable server object
|
|
35
|
+
* (the Deno adapter, whose WS story is `upgradeWebSocket` on the Deno global in the request
|
|
36
|
+
* handler). Typed
|
|
37
|
+
* `unknown` because this is the host-adapter boundary — the consumer narrows it with a runtime
|
|
38
|
+
* check (e.g. `instanceof Server`) before use, rather than a type assertion.
|
|
39
|
+
*/
|
|
40
|
+
readonly native?: unknown;
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
/** A handle to an active filesystem watch; call close() to stop watching. */
|
|
@@ -154,18 +154,25 @@ test("the warn badge stays legible in light appearance", async () => {
|
|
|
154
154
|
);
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
test("grid
|
|
158
|
-
// Regression:
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
157
|
+
test("grid uses fixed layout + wrapping so several wide columns can't overflow the page", async () => {
|
|
158
|
+
// Regression: with table-layout:auto a per-cell max-width let two long
|
|
159
|
+
// free-text columns (e.g. the epic detail's coordination-notes files+note, or
|
|
160
|
+
// trial-merge-results heads+conflicts+failing+summary) each claim ~32rem, and
|
|
161
|
+
// their summed content widths overran the page's width regardless of the
|
|
162
|
+
// viewport. table-layout:fixed divides the width across columns and wraps text
|
|
163
|
+
// within each; overflow-wrap:anywhere still breaks space-less tokens (a 40-char
|
|
164
|
+
// SHA, a JSON blob) inside the column instead of forcing a horizontal scrollbar.
|
|
163
165
|
const res = await dispatch("GET", "/");
|
|
164
166
|
const html = res.body ?? "";
|
|
165
|
-
const
|
|
166
|
-
assert.ok(
|
|
167
|
-
assert.match(
|
|
168
|
-
|
|
167
|
+
const tableRule = (html.match(/table\.pc-grid\s*\{[^}]*\}/) ?? [""])[0];
|
|
168
|
+
assert.ok(tableRule, "table.pc-grid rule must be present");
|
|
169
|
+
assert.match(tableRule, /table-layout:\s*fixed/);
|
|
170
|
+
const cellRule = (html.match(/table\.pc-grid th,\s*table\.pc-grid td\s*\{[^}]*\}/) ?? [""])[0];
|
|
171
|
+
assert.ok(cellRule, "grid th/td rule must be present");
|
|
172
|
+
assert.match(cellRule, /overflow-wrap:anywhere/);
|
|
173
|
+
// The per-cell max-width cap was the root cause of the overflow: guard against
|
|
174
|
+
// any future change silently reintroducing it (which would pass the checks above).
|
|
175
|
+
assert.doesNotMatch(cellRule, /max-width:/);
|
|
169
176
|
});
|
|
170
177
|
|
|
171
178
|
test("GET /app/runtime.js serves the renderer module", async () => {
|
|
@@ -533,14 +533,17 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
|
|
|
533
533
|
.pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
|
|
534
534
|
.pc-msg.err { color:var(--nano-danger); }
|
|
535
535
|
.pc-msg.ok { color:var(--nano-ok); }
|
|
536
|
-
table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
|
|
537
|
-
/*
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
536
|
+
table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-layout:fixed; }
|
|
537
|
+
/* table-layout:fixed divides the available width across columns and wraps text
|
|
538
|
+
within each, so a grid with several long free-text columns (e.g. the epic
|
|
539
|
+
detail's coordination-notes / trial-merge-results) can't sum its columns'
|
|
540
|
+
content widths past the page — the failure mode of table-layout:auto, where a
|
|
541
|
+
per-cell max-width let two wide columns each claim ~32rem and overrun the page.
|
|
542
|
+
overflow-wrap:anywhere additionally breaks space-less tokens (a 40-char SHA, a
|
|
543
|
+
JSON blob) so they wrap inside the column instead of forcing a scrollbar.
|
|
544
|
+
vertical-align:top keeps a multi-line cell aligned with its single-line
|
|
545
|
+
neighbours. */
|
|
546
|
+
table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; vertical-align:top; }
|
|
544
547
|
table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
545
548
|
.pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
|
|
546
549
|
.pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import * as http from "node:http";
|
|
3
4
|
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { join } from "node:path";
|
|
@@ -226,6 +227,145 @@ test("app.sdk is undefined when the engine exposes no SDK client", async () => {
|
|
|
226
227
|
}
|
|
227
228
|
});
|
|
228
229
|
|
|
230
|
+
test("exposes the native http server for a same-port WebSocket upgrade", async () => {
|
|
231
|
+
const dir = await makeFixture();
|
|
232
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
233
|
+
const engine = new FakeEngine();
|
|
234
|
+
const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
|
|
235
|
+
|
|
236
|
+
// undefined before start
|
|
237
|
+
const httpServerBeforeStart = app.httpServer;
|
|
238
|
+
assert.equal(httpServerBeforeStart, undefined);
|
|
239
|
+
|
|
240
|
+
await app.start();
|
|
241
|
+
try {
|
|
242
|
+
// the live node:http Server, on the app's own port — narrow the getter's `object | undefined`
|
|
243
|
+
// to a concrete server with runtime checks (no type assertion needed).
|
|
244
|
+
const server = app.httpServer;
|
|
245
|
+
if (server === undefined) throw new Error("httpServer should be defined after start");
|
|
246
|
+
assert.ok(server instanceof http.Server, "httpServer is a node:http Server after start");
|
|
247
|
+
|
|
248
|
+
// an attached 'upgrade' handler completes a WebSocket handshake on the app's port —
|
|
249
|
+
// exactly what @nanobpm/agentic's WebSocketChannelTransport({ server }) needs.
|
|
250
|
+
server.on("upgrade", (_req, socket) => {
|
|
251
|
+
socket.write(
|
|
252
|
+
"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n",
|
|
253
|
+
);
|
|
254
|
+
socket.destroy();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const upgraded = await new Promise<boolean>((resolve, reject) => {
|
|
258
|
+
const req = http.request({
|
|
259
|
+
port: app.httpPort!,
|
|
260
|
+
path: "/agentic",
|
|
261
|
+
headers: { Connection: "Upgrade", Upgrade: "websocket" },
|
|
262
|
+
});
|
|
263
|
+
// fail fast rather than hang the runner if the handshake never happens
|
|
264
|
+
const timer = setTimeout(() => {
|
|
265
|
+
req.destroy();
|
|
266
|
+
reject(new Error("upgrade handshake timed out"));
|
|
267
|
+
}, 5000);
|
|
268
|
+
req.on("upgrade", (_res, socket) => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
socket.destroy();
|
|
271
|
+
resolve(true);
|
|
272
|
+
});
|
|
273
|
+
// a plain HTTP response means the server did NOT upgrade — surface it clearly
|
|
274
|
+
req.on("response", (res) => {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
res.resume();
|
|
277
|
+
req.destroy();
|
|
278
|
+
reject(new Error(`expected upgrade, got HTTP ${res.statusCode}`));
|
|
279
|
+
});
|
|
280
|
+
req.on("error", (err) => {
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
reject(err);
|
|
283
|
+
});
|
|
284
|
+
req.end();
|
|
285
|
+
});
|
|
286
|
+
assert.equal(upgraded, true, "upgrade handshake completed on the app port");
|
|
287
|
+
} finally {
|
|
288
|
+
await app.stop();
|
|
289
|
+
await rm(dir, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// undefined again after stop
|
|
293
|
+
assert.equal(app.httpServer, undefined);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("stop() completes promptly even with a lingering upgraded socket", async () => {
|
|
297
|
+
const dir = await makeFixture();
|
|
298
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
299
|
+
const engine = new FakeEngine();
|
|
300
|
+
const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
|
|
301
|
+
|
|
302
|
+
await app.start();
|
|
303
|
+
const openSockets: import("node:stream").Duplex[] = [];
|
|
304
|
+
try {
|
|
305
|
+
const server = app.httpServer;
|
|
306
|
+
if (server === undefined) throw new Error("httpServer should be defined after start");
|
|
307
|
+
assert.ok(server instanceof http.Server, "httpServer is a node:http Server after start");
|
|
308
|
+
|
|
309
|
+
// Complete the handshake but deliberately KEEP the upgraded socket open on both
|
|
310
|
+
// ends — this is exactly the long-lived WebSocket case that would make a plain
|
|
311
|
+
// `server.close()` hang forever.
|
|
312
|
+
server.on("upgrade", (_req, socket) => {
|
|
313
|
+
openSockets.push(socket);
|
|
314
|
+
socket.write(
|
|
315
|
+
"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n",
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await new Promise<void>((resolve, reject) => {
|
|
320
|
+
const req = http.request({
|
|
321
|
+
port: app.httpPort!,
|
|
322
|
+
path: "/agentic",
|
|
323
|
+
headers: { Connection: "Upgrade", Upgrade: "websocket" },
|
|
324
|
+
});
|
|
325
|
+
const timer = setTimeout(() => {
|
|
326
|
+
req.destroy();
|
|
327
|
+
reject(new Error("upgrade handshake timed out"));
|
|
328
|
+
}, 5000);
|
|
329
|
+
req.on("upgrade", (_res, socket) => {
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
openSockets.push(socket);
|
|
332
|
+
resolve();
|
|
333
|
+
});
|
|
334
|
+
req.on("response", (res) => {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
res.resume();
|
|
337
|
+
req.destroy();
|
|
338
|
+
reject(new Error(`expected upgrade, got HTTP ${res.statusCode}`));
|
|
339
|
+
});
|
|
340
|
+
req.on("error", (err) => {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
reject(err);
|
|
343
|
+
});
|
|
344
|
+
req.end();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// stop() must resolve without waiting on the still-open upgraded socket.
|
|
348
|
+
await new Promise<void>((resolve, reject) => {
|
|
349
|
+
const timer = setTimeout(() => reject(new Error("app.stop() hung on an open upgraded socket")), 5000);
|
|
350
|
+
app.stop().then(
|
|
351
|
+
() => {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
resolve();
|
|
354
|
+
},
|
|
355
|
+
(err) => {
|
|
356
|
+
clearTimeout(timer);
|
|
357
|
+
reject(err);
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
assert.equal(app.httpServer, undefined, "httpServer is undefined after stop");
|
|
363
|
+
} finally {
|
|
364
|
+
for (const s of openSockets) s.destroy();
|
|
365
|
+
await rm(dir, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
229
369
|
test("stop() resets state so the app can be cleanly restarted", async () => {
|
|
230
370
|
const dir = await makeFixture();
|
|
231
371
|
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
@@ -108,6 +108,15 @@ export interface UrbanApp {
|
|
|
108
108
|
readonly data: DataLayer | undefined;
|
|
109
109
|
readonly security: SecurityPolicy | undefined;
|
|
110
110
|
readonly httpPort: number | undefined;
|
|
111
|
+
/**
|
|
112
|
+
* The runtime's native HTTP server object once `start()` has bound it (the Node adapter's
|
|
113
|
+
* `node:http` `Server`), for attaching a WebSocket upgrade on the app's *own* port. Snapshot it
|
|
114
|
+
* into a local `const` and narrow that with a runtime `instanceof` check before use (no type
|
|
115
|
+
* assertion needed) — reading the property again after the check would not narrow — e.g.
|
|
116
|
+
* `const { Server } = await import("node:http"); const server = app.httpServer; if (server instanceof Server) new WebSocketServer({ server, path: "/agentic" });`.
|
|
117
|
+
* `undefined` before `start()`, after `stop()`, and on hosts that don't surface one (Deno).
|
|
118
|
+
*/
|
|
119
|
+
readonly httpServer: object | undefined;
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<UrbanApp> {
|
|
@@ -193,6 +202,10 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
|
|
|
193
202
|
get httpPort() {
|
|
194
203
|
return httpPort;
|
|
195
204
|
},
|
|
205
|
+
get httpServer(): object | undefined {
|
|
206
|
+
const native = server?.native;
|
|
207
|
+
return typeof native === "object" && native !== null ? native : undefined;
|
|
208
|
+
},
|
|
196
209
|
|
|
197
210
|
async start() {
|
|
198
211
|
if (started) throw new Error("app already started");
|