@ashley-shrok/viewmodel-shell 0.16.0 → 1.1.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/server.d.ts CHANGED
@@ -1,15 +1,29 @@
1
1
  import type { ViewNode, ShellSideEffect } from "./index.js";
2
2
  export * from "./index.js";
3
+ /**
4
+ * Walk a ViewNode tree and assert that every dispatch-bearing action name names
5
+ * exactly one operation. Two occurrences are considered "the same operation"
6
+ * iff they share the same enclosing FormNode reference; otherwise a duplicate
7
+ * action name is a violation.
8
+ *
9
+ * Call this from your GET handler before returning the initial response if you
10
+ * want the same protection at initial-load time — the action-handler wrapper
11
+ * (`createAction`) calls it automatically on every response that carries `vm`.
12
+ *
13
+ * @throws Error when a violation is found. The message names the colliding
14
+ * action and suggests the two fixes (rename one node, or move both into the
15
+ * same enclosing form).
16
+ */
17
+ export declare function validateActionNames(vm: ViewNode): void;
3
18
  export interface ActionPayload<TState> {
4
19
  name: string;
5
- context: Record<string, unknown> | null;
6
20
  state: TState;
7
21
  /** Populated only on multipart submissions (FormData). Empty for JSON bodies. */
8
22
  files: Record<string, File>;
9
23
  }
10
24
  /** Parse a multipart/form-data action body — the wire format the TypeScript shell uses. */
11
25
  export declare function parseFormDataAction<TState>(formData: FormData): ActionPayload<TState>;
12
- /** Parse a flat JSON action body — { name, context, state }. For curl/agent callers. */
26
+ /** Parse a flat JSON action body — `{name, state}`. For curl/agent callers. */
13
27
  export declare function parseJsonAction<TState>(body: string | object): ActionPayload<TState>;
14
28
  /** What an action handler returns. All fields are optional — see ShellResponse reference in AGENTS.md. */
15
29
  export interface ShellResponseBody<TState> {
@@ -43,6 +57,63 @@ export declare const shellSideEffect: {
43
57
  * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
44
58
  download: (url: string, filename?: string) => ShellSideEffect;
45
59
  };
60
+ /**
61
+ * Thrown by an action handler to signal a malformed/invalid request. The
62
+ * createAction wrapper catches this and returns a 400 with the error
63
+ * message in the body, matching the .NET twin's BadRequest("...") path.
64
+ * Reserved for "structurally invalid request the user can't see" (missing
65
+ * required action field, etc.) — NOT for routine app validation (that stays
66
+ * state-based per gotcha #4). No `code` is set on the wire entry (D-08).
67
+ */
68
+ export declare class BadRequestError extends Error {
69
+ constructor(message: string);
70
+ }
71
+ /**
72
+ * Thrown by an action handler to signal that the dispatched action name is
73
+ * not recognised. The createAction wrapper catches this and returns a 400
74
+ * with `code: "unknown_action"` in the envelope, allowing agents to distinguish
75
+ * "I sent a name your tree doesn't expose" from "your handler crashed."
76
+ *
77
+ * Usage — add a default arm to your dispatch switch:
78
+ * default: throw new UnknownActionError(payload.name);
79
+ *
80
+ * Mirrors .NET `UnknownActionException` — both backends use the same wire code.
81
+ */
82
+ export declare class UnknownActionError extends Error {
83
+ /** The offending action name sent by the client. */
84
+ readonly actionName: string;
85
+ constructor(actionName: string);
86
+ }
87
+ /**
88
+ * The entry shape inside the `errors[]` array of an `ok: false` envelope.
89
+ * `path` and `code` are optional — absent (not null) when not applicable,
90
+ * per the WhenWritingNull / conditional-spread null-omission contract.
91
+ */
92
+ export interface ErrorEntry {
93
+ path?: string;
94
+ message: string;
95
+ code?: string;
96
+ }
97
+ /**
98
+ * Stable, framework-only error code vocabulary. Apps MUST NOT set these —
99
+ * the framework sets `code` on framework-detected failures only. Agents
100
+ * that want generic handling check `ok`; agents that want to branch by
101
+ * failure class check `code` against these constants.
102
+ *
103
+ * D-03 lock: "small, stable, framework-only set."
104
+ */
105
+ export declare const ERR_CODES: {
106
+ /** Malformed / unparseable request body. HTTP 400. */
107
+ readonly PARSE: "parse_error";
108
+ /** App threw `UnknownActionError` (action name not recognised). HTTP 400. */
109
+ readonly UNKNOWN_ACTION: "unknown_action";
110
+ /** Built view tree violates the action-name uniqueness rule. HTTP 500. */
111
+ readonly INVALID_TREE: "invalid_tree";
112
+ /** App handler threw an unrecognised exception. HTTP 500. */
113
+ readonly UNCAUGHT: "uncaught_exception";
114
+ };
115
+ /** Union type of the framework error codes from ERR_CODES. Useful for narrowing `errors[0].code`. */
116
+ export type ErrCode = typeof ERR_CODES[keyof typeof ERR_CODES];
46
117
  /**
47
118
  * Web Fetch API–native request handler factory. Auto-detects content-type
48
119
  * (application/json vs multipart/form-data), parses the body, calls your
package/dist/server.js CHANGED
@@ -8,6 +8,172 @@
8
8
  // Re-export the ViewNode hierarchy and wire types so a backend can import
9
9
  // everything it needs from one place.
10
10
  export * from "./index.js";
11
+ /**
12
+ * Walk a ViewNode tree and assert that every dispatch-bearing action name names
13
+ * exactly one operation. Two occurrences are considered "the same operation"
14
+ * iff they share the same enclosing FormNode reference; otherwise a duplicate
15
+ * action name is a violation.
16
+ *
17
+ * Call this from your GET handler before returning the initial response if you
18
+ * want the same protection at initial-load time — the action-handler wrapper
19
+ * (`createAction`) calls it automatically on every response that carries `vm`.
20
+ *
21
+ * @throws Error when a violation is found. The message names the colliding
22
+ * action and suggests the two fixes (rename one node, or move both into the
23
+ * same enclosing form).
24
+ */
25
+ export function validateActionNames(vm) {
26
+ const occurrences = [];
27
+ collectActions(vm, null, occurrences);
28
+ // Group by action name; for each group, verify all occurrences share the
29
+ // same enclosing FormNode (and that form is non-null). Anything else is a
30
+ // violation.
31
+ const byName = new Map();
32
+ for (const occ of occurrences) {
33
+ const bucket = byName.get(occ.name);
34
+ if (bucket)
35
+ bucket.push(occ);
36
+ else
37
+ byName.set(occ.name, [occ]);
38
+ }
39
+ for (const [name, group] of byName) {
40
+ if (group.length < 2)
41
+ continue;
42
+ const firstForm = group[0].enclosingForm;
43
+ // Allowed iff every occurrence is inside the SAME non-null form.
44
+ const allInSameForm = firstForm !== null && group.every((o) => o.enclosingForm === firstForm);
45
+ if (!allInSameForm) {
46
+ throw new Error(`Duplicate action name '${name}' dispatched from semantically distinct nodes. ` +
47
+ `Each action name must name exactly one operation. Either rename one of the ` +
48
+ `nodes (e.g. '${name}-X' / '${name}-Y') or move them into the same surrounding ` +
49
+ `form if they are intended to fire the same operation.`);
50
+ }
51
+ }
52
+ }
53
+ function collectActions(node, enclosingForm, out) {
54
+ switch (node.type) {
55
+ case "page": {
56
+ const page = node;
57
+ for (const child of page.children)
58
+ collectActions(child, enclosingForm, out);
59
+ return;
60
+ }
61
+ case "section": {
62
+ const section = node;
63
+ for (const child of section.children)
64
+ collectActions(child, enclosingForm, out);
65
+ return;
66
+ }
67
+ case "list": {
68
+ const list = node;
69
+ for (const child of list.children)
70
+ collectActions(child, enclosingForm, out);
71
+ return;
72
+ }
73
+ case "list-item": {
74
+ const li = node;
75
+ for (const child of li.children)
76
+ collectActions(child, enclosingForm, out);
77
+ return;
78
+ }
79
+ case "form": {
80
+ const form = node;
81
+ if (form.submitAction)
82
+ recordAction(form.submitAction, form, out);
83
+ if (form.buttons) {
84
+ for (const btn of form.buttons)
85
+ recordAction(btn.action, form, out);
86
+ }
87
+ for (const child of form.children)
88
+ collectActions(child, form, out);
89
+ return;
90
+ }
91
+ case "field": {
92
+ const field = node;
93
+ if (field.action)
94
+ recordAction(field.action, enclosingForm, out);
95
+ return;
96
+ }
97
+ case "checkbox": {
98
+ const cb = node;
99
+ if (cb.action)
100
+ recordAction(cb.action, enclosingForm, out);
101
+ return;
102
+ }
103
+ case "button": {
104
+ const btn = node;
105
+ recordAction(btn.action, enclosingForm, out);
106
+ return;
107
+ }
108
+ case "tabs": {
109
+ const tabs = node;
110
+ for (const tab of tabs.tabs)
111
+ recordAction(tab.action, enclosingForm, out);
112
+ return;
113
+ }
114
+ case "modal": {
115
+ const modal = node;
116
+ if (modal.dismissAction)
117
+ recordAction(modal.dismissAction, enclosingForm, out);
118
+ for (const child of modal.children)
119
+ collectActions(child, enclosingForm, out);
120
+ if (modal.footer) {
121
+ for (const child of modal.footer)
122
+ collectActions(child, enclosingForm, out);
123
+ }
124
+ return;
125
+ }
126
+ case "table": {
127
+ const table = node;
128
+ if (table.sortActions) {
129
+ for (const action of Object.values(table.sortActions)) {
130
+ recordAction(action, enclosingForm, out);
131
+ }
132
+ }
133
+ if (table.filterAction)
134
+ recordAction(table.filterAction, enclosingForm, out);
135
+ if (table.pagination?.prevAction) {
136
+ recordAction(table.pagination.prevAction, enclosingForm, out);
137
+ }
138
+ if (table.pagination?.nextAction) {
139
+ recordAction(table.pagination.nextAction, enclosingForm, out);
140
+ }
141
+ for (const row of table.rows) {
142
+ if (row.actions) {
143
+ // row.actions is ViewNode[] — it can include bind-only nodes (e.g. a
144
+ // per-row CheckboxNode used for selection has no .action). Filter to
145
+ // ButtonNodes the same way the .NET validator does (OfType<ButtonNode>())
146
+ // before recording — otherwise the validator throws on a non-button
147
+ // entry's missing .action property. Phase 6 surfaced this when
148
+ // TableSelection was removed and per-row selection moved into
149
+ // row.actions as bound CheckboxNodes (06-04).
150
+ for (const node of row.actions) {
151
+ // TableRow.actions is typed ButtonNode[] but Phase 6 (06-04)
152
+ // started using it for bind-only CheckboxNodes too — the .NET
153
+ // twin types it IReadOnlyList<ViewNode>. Narrow through unknown
154
+ // so both branches type-check until the TS type widens.
155
+ const n = node;
156
+ if (n.type === "button") {
157
+ recordAction(n.action, enclosingForm, out);
158
+ }
159
+ else if (n.type === "checkbox") {
160
+ if (n.action)
161
+ recordAction(n.action, enclosingForm, out);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ return;
167
+ }
168
+ // Nodes with no dispatch-bearing actions of their own:
169
+ // text, link, image, stat-bar, progress, copy-button
170
+ default:
171
+ return;
172
+ }
173
+ }
174
+ function recordAction(action, enclosingForm, out) {
175
+ out.push({ name: action.name, enclosingForm });
176
+ }
11
177
  /** Parse a multipart/form-data action body — the wire format the TypeScript shell uses. */
12
178
  export function parseFormDataAction(formData) {
13
179
  const actionRaw = formData.get("_action");
@@ -30,19 +196,20 @@ export function parseFormDataAction(formData) {
30
196
  }
31
197
  return {
32
198
  name: action.name,
33
- context: action.context ?? null,
34
199
  state,
35
200
  files,
36
201
  };
37
202
  }
38
- /** Parse a flat JSON action body — { name, context, state }. For curl/agent callers. */
203
+ /** Parse a flat JSON action body — `{name, state}`. For curl/agent callers. */
39
204
  export function parseJsonAction(body) {
40
205
  const parsed = typeof body === "string"
41
206
  ? JSON.parse(body)
42
207
  : body;
208
+ if (typeof parsed.name !== "string" || parsed.name === "") {
209
+ throw new Error("Missing required 'name' field in action payload");
210
+ }
43
211
  return {
44
212
  name: parsed.name,
45
- context: parsed.context ?? null,
46
213
  state: parsed.state,
47
214
  files: {},
48
215
  };
@@ -64,6 +231,92 @@ export const shellSideEffect = {
64
231
  download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
65
232
  };
66
233
  // ─── Action handler factory ──────────────────────────────────────────────────
234
+ /**
235
+ * Thrown by an action handler to signal a malformed/invalid request. The
236
+ * createAction wrapper catches this and returns a 400 with the error
237
+ * message in the body, matching the .NET twin's BadRequest("...") path.
238
+ * Reserved for "structurally invalid request the user can't see" (missing
239
+ * required action field, etc.) — NOT for routine app validation (that stays
240
+ * state-based per gotcha #4). No `code` is set on the wire entry (D-08).
241
+ */
242
+ export class BadRequestError extends Error {
243
+ constructor(message) {
244
+ super(message);
245
+ this.name = "BadRequestError";
246
+ }
247
+ }
248
+ /**
249
+ * Thrown by an action handler to signal that the dispatched action name is
250
+ * not recognised. The createAction wrapper catches this and returns a 400
251
+ * with `code: "unknown_action"` in the envelope, allowing agents to distinguish
252
+ * "I sent a name your tree doesn't expose" from "your handler crashed."
253
+ *
254
+ * Usage — add a default arm to your dispatch switch:
255
+ * default: throw new UnknownActionError(payload.name);
256
+ *
257
+ * Mirrors .NET `UnknownActionException` — both backends use the same wire code.
258
+ */
259
+ export class UnknownActionError extends Error {
260
+ /** The offending action name sent by the client. */
261
+ actionName;
262
+ constructor(actionName) {
263
+ super(`Unknown action: ${actionName}`);
264
+ this.name = "UnknownActionError";
265
+ this.actionName = actionName;
266
+ }
267
+ }
268
+ /**
269
+ * Stable, framework-only error code vocabulary. Apps MUST NOT set these —
270
+ * the framework sets `code` on framework-detected failures only. Agents
271
+ * that want generic handling check `ok`; agents that want to branch by
272
+ * failure class check `code` against these constants.
273
+ *
274
+ * D-03 lock: "small, stable, framework-only set."
275
+ */
276
+ export const ERR_CODES = {
277
+ /** Malformed / unparseable request body. HTTP 400. */
278
+ PARSE: "parse_error",
279
+ /** App threw `UnknownActionError` (action name not recognised). HTTP 400. */
280
+ UNKNOWN_ACTION: "unknown_action",
281
+ /** Built view tree violates the action-name uniqueness rule. HTTP 500. */
282
+ INVALID_TREE: "invalid_tree",
283
+ /** App handler threw an unrecognised exception. HTTP 500. */
284
+ UNCAUGHT: "uncaught_exception",
285
+ };
286
+ /**
287
+ * Build a JSON-stringified `{ok: false, errors: [...]}` envelope.
288
+ * Uses the conditional-spread pattern to omit `path` and `code` when
289
+ * undefined — matching the .NET WhenWritingNull null-omission contract.
290
+ */
291
+ function errorEnvelope(entries) {
292
+ const serialized = entries.map(({ message, path, code }) => ({
293
+ message,
294
+ ...(path != null ? { path } : {}),
295
+ ...(code != null ? { code } : {}),
296
+ }));
297
+ return JSON.stringify({ ok: false, errors: serialized });
298
+ }
299
+ /**
300
+ * Derive a safe wire message from an unknown thrown value.
301
+ * T1 info-disclosure mitigation: only copies `Error.prototype.message`
302
+ * for Error instances; substitutes a generic string for non-Error throws
303
+ * so unknown shapes never reach the wire.
304
+ */
305
+ function errorMessageFromUnknownThrow(err) {
306
+ if (err instanceof Error)
307
+ return err.message;
308
+ return "Internal server error";
309
+ }
310
+ /**
311
+ * Shared response factory — deduplicates the JSON + Content-Type header
312
+ * boilerplate across the four error cases and the success path.
313
+ */
314
+ function jsonResponse(body, status) {
315
+ return new Response(body, {
316
+ status,
317
+ headers: { "Content-Type": "application/json" },
318
+ });
319
+ }
67
320
  /**
68
321
  * Web Fetch API–native request handler factory. Auto-detects content-type
69
322
  * (application/json vs multipart/form-data), parses the body, calls your
@@ -92,14 +345,42 @@ export function createAction(handler) {
92
345
  }
93
346
  }
94
347
  catch (err) {
95
- return new Response(JSON.stringify({ error: err.message }), {
96
- status: 400,
97
- headers: { "Content-Type": "application/json" },
98
- });
99
- }
100
- const result = await handler(payload);
101
- return new Response(JSON.stringify(result), {
102
- headers: { "Content-Type": "application/json" },
103
- });
348
+ // Parse failure client sent malformed input. 400.
349
+ return jsonResponse(errorEnvelope([{ message: err.message, code: ERR_CODES.PARSE }]), 400);
350
+ }
351
+ let result;
352
+ try {
353
+ result = await handler(payload);
354
+ }
355
+ catch (err) {
356
+ if (err instanceof BadRequestError) {
357
+ // Structurally invalid request — no `code` per D-08.
358
+ return jsonResponse(errorEnvelope([{ message: err.message }]), 400);
359
+ }
360
+ if (err instanceof UnknownActionError) {
361
+ // App threw to signal an unknown action name. 400 per D-11.
362
+ return jsonResponse(errorEnvelope([{ message: err.message, code: ERR_CODES.UNKNOWN_ACTION }]), 400);
363
+ }
364
+ // Any other throw: server-side failure. Log server-side for observability
365
+ // (T1: stack trace stays on the server, only safe message reaches the wire).
366
+ console.error("[ViewModelShell] Uncaught exception in action handler:", err);
367
+ return jsonResponse(errorEnvelope([{ message: errorMessageFromUnknownThrow(err), code: ERR_CODES.UNCAUGHT }]), 500);
368
+ }
369
+ // Phase 06 / WIRE-05 — enforce action-name uniqueness on the built tree
370
+ // before it leaves the server. A violation here is a server-side bug, so
371
+ // we surface it as a 500 (the parse-error path above is a 400 because the
372
+ // client sent malformed input). Only run when the response carries a vm
373
+ // (redirect-only responses have nothing to walk).
374
+ if (result.vm) {
375
+ try {
376
+ validateActionNames(result.vm);
377
+ }
378
+ catch (err) {
379
+ return jsonResponse(errorEnvelope([{ message: err.message, code: ERR_CODES.INVALID_TREE }]), 500);
380
+ }
381
+ }
382
+ // Phase 07 / ERROR-01 — every successful response acquires ok:true at the
383
+ // response edge. Controllers / app handlers do NOT set ok themselves.
384
+ return jsonResponse(JSON.stringify({ ok: true, ...result }), 200);
104
385
  };
105
386
  }
package/dist/tui.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Adapter, ActionEvent, ViewNode } from "./index.js";
1
+ import type { Adapter, ActionEvent, ViewNode, StateAccess } from "./index.js";
2
2
  interface TuiOpts {
3
3
  /** "fill" (default) takes the full terminal via alternate-screen buffer;
4
4
  * "content" renders intrinsic content size in the inline scrollback. */
@@ -49,7 +49,7 @@ export declare class TuiAdapter implements Adapter {
49
49
  private setPendingButton;
50
50
  private copy;
51
51
  private resolveFieldValue;
52
- render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
52
+ render(vm: ViewNode, onAction: (action: ActionEvent) => void, _stateAccess?: StateAccess): void;
53
53
  private init;
54
54
  private flushPending;
55
55
  private navigateForLinks;