@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.
- package/dist/runtime/adapters/node.js +23 -1
- package/dist/runtime/core/host.d.ts +10 -0
- package/dist/runtime/core/modules/pages.js +79 -10
- 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 +59 -10
- package/src/runtime/core/modules/pages.ts +79 -10
- 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,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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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.
|
|
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: () =>
|
|
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 () => {
|
|
@@ -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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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");
|