@nanobpm/urban 0.45.1 → 0.47.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,21 @@ 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
+ .pc-req { color:var(--nano-danger); font-weight:600; }
486
+ .pc-field-err { font-size:.75rem; color:var(--nano-danger); margin:.1rem 0 0; }
487
+ .pc-field-err:empty { display:none; }
488
+ .pc-field input.pc-invalid, .pc-field-check input.pc-invalid { border-color:var(--nano-danger); outline-color:var(--nano-danger); }
489
+ table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-layout:fixed; }
490
+ /* table-layout:fixed divides the available width across columns and wraps text
491
+ within each, so a grid with several long free-text columns (e.g. the epic
492
+ detail's coordination-notes / trial-merge-results) can't sum its columns'
493
+ content widths past the page — the failure mode of table-layout:auto, where a
494
+ per-cell max-width let two wide columns each claim ~32rem and overrun the page.
495
+ overflow-wrap:anywhere additionally breaks space-less tokens (a 40-char SHA, a
496
+ JSON blob) so they wrap inside the column instead of forcing a scrollbar.
497
+ vertical-align:top keeps a multi-line cell aligned with its single-line
498
+ neighbours. */
499
+ 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
500
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
494
501
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
495
502
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
@@ -1028,9 +1035,38 @@ function renderActionForm(node) {
1028
1035
  // pollute Object.prototype or shadow inherited props (prototype-pollution class).
1029
1036
  const inputs = new Map();
1030
1037
  const fieldTypes = new Map();
1038
+ // Client-side required-field validation state: the set of required keys, each
1039
+ // field's custom message, and its inline error node (populated on a blocked
1040
+ // submit, cleared as soon as the user edits the field).
1041
+ const required = new Set();
1042
+ const reqMsg = new Map();
1043
+ const errs = new Map();
1044
+ // A required field's label carries a danger-toned "*" so the requirement is
1045
+ // visible BEFORE submit; spread into el(...) — an empty array adds no child.
1046
+ const reqMark = (f) => (f.required === true ? [el("span", { class: "pc-req", title: "Required" }, " *")] : []);
1047
+ // Register a field's inline error node and, when required, mark the input
1048
+ // (aria-required) and wire a clear-on-edit handler so a hint disappears the
1049
+ // moment the user starts fixing it. Editing a required field also clears the
1050
+ // blocked-submit summary ("Please fill in the required fields.") so it never
1051
+ // lingers as a stale/misleading banner while the user is correcting input.
1052
+ const wireField = (f, input, ferr, evt) => {
1053
+ errs.set(f.key, ferr);
1054
+ if (f.required !== true) return;
1055
+ required.add(f.key);
1056
+ if (typeof f.requiredMessage === "string" && f.requiredMessage !== "") reqMsg.set(f.key, f.requiredMessage);
1057
+ input.setAttribute("aria-required", "true");
1058
+ input.addEventListener(evt, () => {
1059
+ input.classList.remove("pc-invalid");
1060
+ input.removeAttribute("aria-invalid");
1061
+ ferr.textContent = "";
1062
+ msg.textContent = "";
1063
+ msg.className = "pc-msg";
1064
+ });
1065
+ };
1031
1066
  for (const f of p.fields || []) {
1032
1067
  const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
1033
1068
  fieldTypes.set(f.key, kind);
1069
+ const ferr = el("p", { class: "pc-field-err" });
1034
1070
  if (kind === "checkbox") {
1035
1071
  // A boolean field renders a real checkbox; its default checked state comes
1036
1072
  // from default/checked on the field (unset -> unchecked). We record that on
@@ -1041,9 +1077,10 @@ function renderActionForm(node) {
1041
1077
  input.defaultChecked = checked;
1042
1078
  input.checked = checked;
1043
1079
  inputs.set(f.key, input);
1080
+ wireField(f, input, ferr, "change");
1044
1081
  // Input-first, wrapped in the label so the whole row is a click target.
1045
1082
  card.append(
1046
- el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
1083
+ el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key), ...reqMark(f)), ferr),
1047
1084
  );
1048
1085
  continue;
1049
1086
  }
@@ -1056,11 +1093,43 @@ function renderActionForm(node) {
1056
1093
  }
1057
1094
  const input = el("input", attrs);
1058
1095
  inputs.set(f.key, input);
1059
- card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
1096
+ wireField(f, input, ferr, "input");
1097
+ card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key, ...reqMark(f)), input, ferr));
1060
1098
  }
1061
1099
  const msg = el("p", { class: "pc-msg" });
1062
1100
  const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
1063
1101
  btn.addEventListener("click", async () => {
1102
+ // Required-field gate: block the submit and surface an inline hint on each
1103
+ // empty required field (a text/number left blank, a required checkbox left
1104
+ // unchecked), focusing the first offender — so a missing value never
1105
+ // round-trips to the server only to bounce back as a generic error.
1106
+ let firstInvalid = null;
1107
+ for (const [k, input] of inputs) {
1108
+ const ferr = errs.get(k);
1109
+ if (ferr) ferr.textContent = "";
1110
+ input.classList.remove("pc-invalid");
1111
+ input.removeAttribute("aria-invalid");
1112
+ if (!required.has(k)) continue;
1113
+ const type = fieldTypes.get(k);
1114
+ const val = String(input.value).trim();
1115
+ const missing = type === "checkbox"
1116
+ ? input.checked !== true
1117
+ : type === "number"
1118
+ ? (val === "" || !Number.isFinite(Number(val)))
1119
+ : val === "";
1120
+ if (missing) {
1121
+ if (ferr) ferr.textContent = reqMsg.get(k) || "Required";
1122
+ input.classList.add("pc-invalid");
1123
+ input.setAttribute("aria-invalid", "true");
1124
+ if (!firstInvalid) firstInvalid = input;
1125
+ }
1126
+ }
1127
+ if (firstInvalid) {
1128
+ msg.className = "pc-msg err";
1129
+ msg.textContent = "Please fill in the required fields.";
1130
+ firstInvalid.focus();
1131
+ return;
1132
+ }
1064
1133
  const variables = Object.create(null);
1065
1134
  for (const [k, input] of inputs) {
1066
1135
  if (fieldTypes.get(k) === "checkbox") {
@@ -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.1",
3
+ "version": "0.47.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: () => 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 () => {
@@ -495,6 +502,48 @@ test("the renderer honours boolean (checkbox) actionForm fields", async () => {
495
502
  assert.match(js, /input\.checked = input\.defaultChecked;/);
496
503
  });
497
504
 
505
+ test("the renderer enforces required actionForm fields client-side, blocking submit with an inline hint", async () => {
506
+ const res = await dispatch("GET", "/app/runtime.js");
507
+ const js = res.body ?? "";
508
+ // A field declared `required: true` is tracked in a Set and given an inline
509
+ // error node; the label carries a danger-toned "*" marker before submit.
510
+ assert.match(js, /required\.add\(f\.key\)/);
511
+ assert.match(js, /reqMark = \(f\) =>/);
512
+ assert.match(js, /class: "pc-req"/);
513
+ // The input is marked aria-required, and a clear-on-edit handler wipes the hint
514
+ // the moment the user starts fixing the field (input for text/number, change for
515
+ // a checkbox).
516
+ assert.match(js, /input\.setAttribute\("aria-required", "true"\)/);
517
+ assert.match(js, /wireField\(f, input, ferr, "input"\)/);
518
+ assert.match(js, /wireField\(f, input, ferr, "change"\)/);
519
+ // Submit is GATED: an empty required text/number or an unchecked required
520
+ // checkbox is "missing"; the first offender is focused and the submit RETURNS
521
+ // early (never round-trips to the server).
522
+ assert.match(js, /input\.checked !== true/);
523
+ assert.match(js, /!Number\.isFinite\(Number\(val\)\)/);
524
+ assert.match(js, /if \(firstInvalid\)/);
525
+ assert.match(js, /Please fill in the required fields\./);
526
+ assert.match(js, /firstInvalid\.focus\(\)/);
527
+ assert.match(js, /input\.classList\.add\("pc-invalid"\)/);
528
+ // Editing a required field after a blocked submit clears the summary banner too,
529
+ // so "Please fill in the required fields." never lingers stale while correcting.
530
+ assert.match(js, /msg\.textContent = "";\s*\n\s*msg\.className = "pc-msg";/);
531
+ // A per-field custom message (`requiredMessage`) overrides the default "Required".
532
+ assert.match(js, /reqMsg\.get\(k\) \|\| "Required"/);
533
+ assert.match(js, /f\.requiredMessage/);
534
+ });
535
+
536
+ test("the shell styles required-field markers and inline field errors", async () => {
537
+ const res = await dispatch("GET", "/");
538
+ const html = res.body ?? "";
539
+ // The "*" required marker and the inline per-field error both resolve through
540
+ // the shared danger token, and an empty error node collapses (no reserved gap).
541
+ assert.match(html, /\.pc-req \{ color:var\(--nano-danger\)/);
542
+ assert.match(html, /\.pc-field-err \{ font-size:.75rem; color:var\(--nano-danger\)/);
543
+ assert.match(html, /\.pc-field-err:empty \{ display:none; \}/);
544
+ assert.match(html, /input\.pc-invalid/);
545
+ });
546
+
498
547
  test("renderer makes collapsible nodes persist their state across sessions", async () => {
499
548
  // A node with `collapsible` gets a clickable header that hides/shows the card
500
549
  // body, and the collapsed state is written to localStorage keyed by the home
@@ -533,14 +533,21 @@ 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
+ .pc-req { color:var(--nano-danger); font-weight:600; }
537
+ .pc-field-err { font-size:.75rem; color:var(--nano-danger); margin:.1rem 0 0; }
538
+ .pc-field-err:empty { display:none; }
539
+ .pc-field input.pc-invalid, .pc-field-check input.pc-invalid { border-color:var(--nano-danger); outline-color:var(--nano-danger); }
540
+ table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; table-layout:fixed; }
541
+ /* table-layout:fixed divides the available width across columns and wraps text
542
+ within each, so a grid with several long free-text columns (e.g. the epic
543
+ detail's coordination-notes / trial-merge-results) can't sum its columns'
544
+ content widths past the page — the failure mode of table-layout:auto, where a
545
+ per-cell max-width let two wide columns each claim ~32rem and overrun the page.
546
+ overflow-wrap:anywhere additionally breaks space-less tokens (a 40-char SHA, a
547
+ JSON blob) so they wrap inside the column instead of forcing a scrollbar.
548
+ vertical-align:top keeps a multi-line cell aligned with its single-line
549
+ neighbours. */
550
+ 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
551
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
545
552
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
546
553
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
@@ -1080,9 +1087,38 @@ function renderActionForm(node) {
1080
1087
  // pollute Object.prototype or shadow inherited props (prototype-pollution class).
1081
1088
  const inputs = new Map();
1082
1089
  const fieldTypes = new Map();
1090
+ // Client-side required-field validation state: the set of required keys, each
1091
+ // field's custom message, and its inline error node (populated on a blocked
1092
+ // submit, cleared as soon as the user edits the field).
1093
+ const required = new Set();
1094
+ const reqMsg = new Map();
1095
+ const errs = new Map();
1096
+ // A required field's label carries a danger-toned "*" so the requirement is
1097
+ // visible BEFORE submit; spread into el(...) — an empty array adds no child.
1098
+ const reqMark = (f) => (f.required === true ? [el("span", { class: "pc-req", title: "Required" }, " *")] : []);
1099
+ // Register a field's inline error node and, when required, mark the input
1100
+ // (aria-required) and wire a clear-on-edit handler so a hint disappears the
1101
+ // moment the user starts fixing it. Editing a required field also clears the
1102
+ // blocked-submit summary ("Please fill in the required fields.") so it never
1103
+ // lingers as a stale/misleading banner while the user is correcting input.
1104
+ const wireField = (f, input, ferr, evt) => {
1105
+ errs.set(f.key, ferr);
1106
+ if (f.required !== true) return;
1107
+ required.add(f.key);
1108
+ if (typeof f.requiredMessage === "string" && f.requiredMessage !== "") reqMsg.set(f.key, f.requiredMessage);
1109
+ input.setAttribute("aria-required", "true");
1110
+ input.addEventListener(evt, () => {
1111
+ input.classList.remove("pc-invalid");
1112
+ input.removeAttribute("aria-invalid");
1113
+ ferr.textContent = "";
1114
+ msg.textContent = "";
1115
+ msg.className = "pc-msg";
1116
+ });
1117
+ };
1083
1118
  for (const f of p.fields || []) {
1084
1119
  const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
1085
1120
  fieldTypes.set(f.key, kind);
1121
+ const ferr = el("p", { class: "pc-field-err" });
1086
1122
  if (kind === "checkbox") {
1087
1123
  // A boolean field renders a real checkbox; its default checked state comes
1088
1124
  // from default/checked on the field (unset -> unchecked). We record that on
@@ -1093,9 +1129,10 @@ function renderActionForm(node) {
1093
1129
  input.defaultChecked = checked;
1094
1130
  input.checked = checked;
1095
1131
  inputs.set(f.key, input);
1132
+ wireField(f, input, ferr, "change");
1096
1133
  // Input-first, wrapped in the label so the whole row is a click target.
1097
1134
  card.append(
1098
- el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
1135
+ el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key), ...reqMark(f)), ferr),
1099
1136
  );
1100
1137
  continue;
1101
1138
  }
@@ -1108,11 +1145,43 @@ function renderActionForm(node) {
1108
1145
  }
1109
1146
  const input = el("input", attrs);
1110
1147
  inputs.set(f.key, input);
1111
- card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key), input));
1148
+ wireField(f, input, ferr, "input");
1149
+ card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key, ...reqMark(f)), input, ferr));
1112
1150
  }
1113
1151
  const msg = el("p", { class: "pc-msg" });
1114
1152
  const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
1115
1153
  btn.addEventListener("click", async () => {
1154
+ // Required-field gate: block the submit and surface an inline hint on each
1155
+ // empty required field (a text/number left blank, a required checkbox left
1156
+ // unchecked), focusing the first offender — so a missing value never
1157
+ // round-trips to the server only to bounce back as a generic error.
1158
+ let firstInvalid = null;
1159
+ for (const [k, input] of inputs) {
1160
+ const ferr = errs.get(k);
1161
+ if (ferr) ferr.textContent = "";
1162
+ input.classList.remove("pc-invalid");
1163
+ input.removeAttribute("aria-invalid");
1164
+ if (!required.has(k)) continue;
1165
+ const type = fieldTypes.get(k);
1166
+ const val = String(input.value).trim();
1167
+ const missing = type === "checkbox"
1168
+ ? input.checked !== true
1169
+ : type === "number"
1170
+ ? (val === "" || !Number.isFinite(Number(val)))
1171
+ : val === "";
1172
+ if (missing) {
1173
+ if (ferr) ferr.textContent = reqMsg.get(k) || "Required";
1174
+ input.classList.add("pc-invalid");
1175
+ input.setAttribute("aria-invalid", "true");
1176
+ if (!firstInvalid) firstInvalid = input;
1177
+ }
1178
+ }
1179
+ if (firstInvalid) {
1180
+ msg.className = "pc-msg err";
1181
+ msg.textContent = "Please fill in the required fields.";
1182
+ firstInvalid.focus();
1183
+ return;
1184
+ }
1116
1185
  const variables = Object.create(null);
1117
1186
  for (const [k, input] of inputs) {
1118
1187
  if (fieldTypes.get(k) === "checkbox") {
@@ -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