@nanobpm/urban 0.45.0 → 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.
@@ -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) => server.close(() => 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
- /* Wrap long, space-less cell values (e.g. a JSON blob or a 40-char SHA) so one
487
- cell can't force its column wide and blow the whole table past the page.
488
- overflow-wrap:anywhere reduces the cell's min-content width so the auto table
489
- layout can shrink it to fit; max-width caps a single column when there is
490
- ample room. vertical-align:top keeps a multi-line cell aligned with its
491
- single-line neighbours. */
492
- 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; max-width:32rem; vertical-align:top; }
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.45.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",
@@ -67,7 +67,7 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@nanobpm/nano-app-schema": "^0.5.0",
70
- "@nanobpm/nano-sdk": "^1.2.3",
70
+ "@nanobpm/nano-sdk": "^1.2.5",
71
71
  "@nanobpm/workflow": "^0.8.0",
72
72
  "bpmn-auto-layout": "^2.0.0-alpha.2",
73
73
  "create-urban-app": "^0.14.0",
@@ -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: () => new Promise<void>((res) => server.close(() => res())),
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
  }
@@ -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 cells wrap long, space-less values so one cell can't overflow the table", async () => {
158
- // Regression: a dataGrid cell holding a long space-less value (e.g. a JSON
159
- // blob or a 40-char SHA) has no soft-wrap opportunity, so with table-layout
160
- // auto it forces its column to the value's full width and pushes the whole
161
- // table past the page. The shared grid CSS must let such a cell break so the
162
- // column can shrink to fit its container.
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 rule = (html.match(/table\.pc-grid th,\s*table\.pc-grid td\s*\{[^}]*\}/) ?? [""])[0];
166
- assert.ok(rule, "grid th/td rule must be present");
167
- assert.match(rule, /overflow-wrap:anywhere/);
168
- assert.match(rule, /max-width:/);
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
- /* Wrap long, space-less cell values (e.g. a JSON blob or a 40-char SHA) so one
538
- cell can't force its column wide and blow the whole table past the page.
539
- overflow-wrap:anywhere reduces the cell's min-content width so the auto table
540
- layout can shrink it to fit; max-width caps a single column when there is
541
- ample room. vertical-align:top keeps a multi-line cell aligned with its
542
- single-line neighbours. */
543
- 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; max-width:32rem; vertical-align:top; }
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");
@@ -231,6 +231,7 @@ function fakeApp(stops: string[], id: string): UrbanApp {
231
231
  data: undefined,
232
232
  security: undefined,
233
233
  httpPort: undefined,
234
+ httpServer: undefined,
234
235
  };
235
236
  }
236
237