@ashley-shrok/viewmodel-shell 0.16.0 → 1.0.1

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/index.d.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  export interface ActionEvent {
2
2
  name: string;
3
- context?: Record<string, unknown>;
4
3
  files?: Record<string, File>;
5
4
  }
5
+ export interface StateAccess {
6
+ read(path: string): unknown;
7
+ write(path: string, value: unknown): void;
8
+ }
6
9
  export interface Adapter {
7
- render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
10
+ /** Render the view tree. `stateAccess` is the shell-owned `{ read, write }`
11
+ * seam over the live state object: the adapter reads input values via
12
+ * `stateAccess.read(node.bind)` and writes user input back via
13
+ * `stateAccess.write(node.bind, value)`. The third arg is OPTIONAL so
14
+ * callers (tests, embedders) that have no live state can still mount
15
+ * the adapter for class-emission / static-tree checks — adapters supply
16
+ * a no-op fallback internally when omitted. */
17
+ render(vm: ViewNode, onAction: (action: ActionEvent) => void, stateAccess?: StateAccess): void;
8
18
  /** Hand the platform off to a URL (the browser adapter sets the page location).
9
19
  * No safe no-op exists — if a redirect arrives and neither ShellOptions.onRedirect
10
20
  * nor this method is available, the shell fails loudly. */
@@ -98,12 +108,12 @@ export interface FormNode {
98
108
  /** Layout preset for the form's controls. Omitted or "stack" = fields stacked (current, no modifier class). "inline" = field row + submit on one line (add/search bar) — emits .vms-form--inline. Closed union (D-29). */
99
109
  layout?: "stack" | "inline";
100
110
  /** Multi-action submit buttons (#15). Each is a full ButtonNode (so
101
- * `variant` + `pendingLabel` apply) that, on activation, HARVESTS this
102
- * form's current field values into its `action.context` and dispatches
103
- * the same harvest the default submit performs, but carrying a different
104
- * action. Mirrors HTML's multiple submit buttons / `formaction`. A plain
105
- * ButtonNode placed in `children` keeps its no-harvest behavior; only
106
- * buttons in THIS slot harvest. */
111
+ * `variant` + `pendingLabel` apply) that, on activation, dispatches its
112
+ * declared action by name. Field values live in state at each input's
113
+ * `bind` path and travel with the dispatch's `_state` payload. Mirrors
114
+ * HTML's multiple submit buttons / `formaction` different action per
115
+ * button, same underlying state. A plain ButtonNode placed in `children`
116
+ * has identical dispatch semantics; the buttons[] slot is a layout hint. */
107
117
  buttons?: ButtonNode[];
108
118
  children: ViewNode[];
109
119
  }
@@ -111,9 +121,10 @@ export interface FieldNode {
111
121
  type: "field";
112
122
  name: string;
113
123
  inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "code";
124
+ /** Path into state where this input reads its current value and writes user changes (e.g. `fields.title`). */
125
+ bind: string;
114
126
  label?: string;
115
127
  placeholder?: string;
116
- value?: string;
117
128
  required?: boolean;
118
129
  options?: Array<{
119
130
  value: string;
@@ -124,15 +135,18 @@ export interface FieldNode {
124
135
  * library (CodeMirror, Monaco, etc.) — the framework only ships
125
136
  * monospaced editable text, no coloring. */
126
137
  language?: string;
127
- /** Dispatched when Enter is pressed (text-like inputs only). Adapter merges { [name]: value } into context. */
138
+ /** Dispatched when Enter is pressed (text-like inputs only). Carries an
139
+ * action name only — the current value is already in state at the bind path. */
128
140
  action?: ActionEvent;
129
141
  }
130
142
  export interface CheckboxNode {
131
143
  type: "checkbox";
132
144
  name: string;
133
- checked: boolean;
145
+ /** Path into state where this input reads its current value and writes user changes (e.g. `fields.title`). */
146
+ bind: string;
134
147
  label?: string;
135
- /** Dispatched immediately on change. Adapter merges { checked: boolean } into context. */
148
+ /** Dispatched immediately on change. Carries an action name only the new
149
+ * checked value is already in state at the bind path. */
136
150
  action?: ActionEvent;
137
151
  }
138
152
  export interface ButtonNode {
@@ -182,11 +196,15 @@ export interface StatBarNode {
182
196
  export interface TabsNode {
183
197
  type: "tabs";
184
198
  selected: string;
185
- /** Base action. Adapter merges { value: tab.value } into context on click. */
186
- action: ActionEvent;
199
+ /** Path into state where this input reads its current value and writes user changes (e.g. `fields.title`). */
200
+ bind: string;
201
+ /** Each tab declares its own action — the framework requires a unique action
202
+ * name per tab (e.g. `select-tab-pending`, `select-tab-active`). The renderer
203
+ * writes `value` to the bound state path before dispatching the action. */
187
204
  tabs: Array<{
188
205
  value: string;
189
206
  label: string;
207
+ action: ActionEvent;
190
208
  }>;
191
209
  }
192
210
  export interface ProgressNode {
@@ -219,25 +237,12 @@ export interface TableColumn {
219
237
  export interface TableRow {
220
238
  id?: string;
221
239
  cells: Record<string, string>;
222
- action?: ActionEvent;
240
+ /** Per-row action buttons. Each is a full ButtonNode with its own unique
241
+ * action name (e.g. `delete-row-42`, `close-ticket-42`) — per-row identity
242
+ * is encoded in the name, not as a separate context payload. */
243
+ actions?: ButtonNode[];
223
244
  variant?: string;
224
245
  }
225
- export interface TableSelection {
226
- /** Row ids that should render PRE-SELECTED on initial render — the server's
227
- * initial pre-selection. Subsequent toggles are purely client-side DOM state;
228
- * the server doesn't see them until a `buttons[]` click harvests them. */
229
- selectedIds: string[];
230
- /** When present, the adapter renders these as a bulk-action toolbar ABOVE
231
- * the table. On click, each button harvests the currently-checked rows from
232
- * the DOM and dispatches its `action` with `{ selectedIds: [...] }` merged
233
- * into its `context`. This is the only way to act on the selection — there
234
- * is no per-toggle dispatch (0.15.0 removed the `action` mode that did,
235
- * because rapid clicks were dropped by the dispatch guard and the in-flight
236
- * response wiped the DOM). If a future release needs cross-page persistence
237
- * or live "N selected" indicators, the way back is a redesigned wire shape
238
- * (dispatch queueing + optimistic preservation), not the old `action` mode. */
239
- buttons?: ButtonNode[];
240
- }
241
246
  export interface TablePagination {
242
247
  /** 1-based current page. */
243
248
  page: number;
@@ -246,30 +251,38 @@ export interface TablePagination {
246
251
  /** Total rows across all pages — server-truth. The adapter renders the range
247
252
  * label and enables/disables prev/next from this; it does NOT slice. */
248
253
  totalRows: number;
249
- /** Dispatched on a page-control click. The adapter merges `{ page }` the
250
- * target 1-based page. */
251
- action: ActionEvent;
254
+ /** Dispatched on the prev page-control click. Carries an action name only
255
+ * the renderer writes the target page to TableNode.paginationBind before dispatch. */
256
+ prevAction?: ActionEvent;
257
+ /** Dispatched on the next page-control click. Carries an action name only —
258
+ * the renderer writes the target page to TableNode.paginationBind before dispatch. */
259
+ nextAction?: ActionEvent;
252
260
  }
253
261
  export interface TableNode {
254
262
  type: "table";
255
263
  columns: TableColumn[];
256
264
  rows: TableRow[];
257
- sortColumn?: string;
258
- sortDirection?: "asc" | "desc";
259
- /** Base action. Adapter merges { column, direction } into context on header click. */
260
- sortAction?: ActionEvent;
261
- /** Base action. Adapter merges { column, value, filters } into context on Enter. */
265
+ /** Path into state where the current sort intent (`{column, direction}`) is read/written. */
266
+ sortBind?: string;
267
+ /** Per-column filter input bind paths. The renderer reads/writes filter
268
+ * values at these paths; the values then travel with the next dispatch. */
269
+ filterBinds?: Record<string, string>;
270
+ /** Path into state where the renderer writes the target page number before
271
+ * firing `pagination.prevAction` / `pagination.nextAction`. */
272
+ paginationBind?: string;
273
+ /** Per-column sort header click actions, keyed by column key. Each carries a
274
+ * unique action name — the renderer writes the new sort intent to `sortBind`
275
+ * before dispatching. */
276
+ sortActions?: Record<string, ActionEvent>;
277
+ /** One filter-dispatch action per table. The renderer fires this when the
278
+ * user submits the filter form; per-column filter values are already in
279
+ * state at the `filterBinds` paths. */
262
280
  filterAction?: ActionEvent;
263
- /** Per-row multi-select. When set, the adapter renders a leading checkbox
264
- * column + a header select-all checkbox and tints selected rows. `TableRow.id`
265
- * is REQUIRED on every row when selection is set — it's the address the
266
- * toggle action reports back. */
267
- selection?: TableSelection;
268
281
  /** Server-driven pagination. When set, the adapter renders an "X–Y of N"
269
282
  * range + prev/next controls below the table. **The server slices `rows` to
270
283
  * the current page** — the adapter never paginates client-side (that would
271
284
  * break for DB-backed tables, which are most of them). By convention
272
- * `sortAction` / `filterAction` reset `page` to 1 on the server side, since
285
+ * filter dispatches reset `page` to 1 on the server side, since
273
286
  * the row window changes underneath them. */
274
287
  pagination?: TablePagination;
275
288
  }
@@ -319,6 +332,36 @@ export interface ShellSideEffect {
319
332
  * when present; this is the fallback before the URL basename. */
320
333
  filename?: string;
321
334
  }
335
+ /** One entry in the structured error payload returned on `ok: false`. */
336
+ export interface ErrorEntry {
337
+ /** Absent when not tied to a specific input slot (parse errors, uncaught
338
+ * exceptions, etc.). Present when bound to a specific bind-path on an input. */
339
+ path?: string;
340
+ /** Human-readable description of the error. Always present. */
341
+ message: string;
342
+ /** Framework-set discriminator for the failure class. Initial vocabulary:
343
+ * "parse_error" | "unknown_action" | "invalid_tree" | "uncaught_exception".
344
+ * Absent when not applicable (e.g. BadRequest / D-08 path). */
345
+ code?: string;
346
+ }
347
+ /**
348
+ * Structured error surfaced via `onError` when the server returns `ok: false`.
349
+ * Extends `Error` so existing `onError` consumers that don't know about the
350
+ * envelope continue receiving a normal Error with a useful `.message`.
351
+ *
352
+ * Consumers that want the structured payload: `if (err instanceof VmsActionError) { ... err.errors ... }`.
353
+ *
354
+ * `status` is the HTTP status code (0 for push-originated errors with no
355
+ * HTTP transaction). `code` is a shortcut to `errors[0].code` for ergonomic
356
+ * branching on single-error responses.
357
+ */
358
+ export declare class VmsActionError extends Error {
359
+ readonly errors: ErrorEntry[];
360
+ readonly status: number;
361
+ constructor(errors: ErrorEntry[], status: number);
362
+ /** Shortcut to `errors[0]?.code`. Undefined when the first entry has no code. */
363
+ get code(): string | undefined;
364
+ }
322
365
  export interface ShellResponse {
323
366
  vm: ViewNode;
324
367
  state: unknown;
@@ -338,6 +381,14 @@ export interface ShellResponse {
338
381
  * dispatches; the adapter applies `.vms-busy` for the visual cue). Polls
339
382
  * bypass so the server can clear the state. See `Adapter.setBusy`. */
340
383
  busy?: boolean;
384
+ /** 1.0.0 — framework-set envelope flag. Present on every framework-rendered
385
+ * response; absent on hand-constructed legacy push bodies (treated as ok:true
386
+ * for backwards compatibility). On ok:false responses, only `errors[]` is
387
+ * consumed — any vm/state present in the body is IGNORED at runtime (D-15
388
+ * hardening: the shell throws before the currentVm/currentState writes run). */
389
+ ok?: boolean;
390
+ /** 1.0.0 — structured error entries. Present when `ok: false`. */
391
+ errors?: ErrorEntry[];
341
392
  }
342
393
  export declare class ViewModelShell {
343
394
  private options;
@@ -356,6 +407,35 @@ export declare class ViewModelShell {
356
407
  stopPolling(): void;
357
408
  getCurrentVm(): ViewNode | null;
358
409
  getCurrentState(): unknown;
410
+ /**
411
+ * Read the bound state value at a dotted path (e.g. "fields.title",
412
+ * "rows.42.selected"). Used by adapters (BrowserAdapter, TuiAdapter, …) to
413
+ * render an input's current value. Returns `undefined` when any segment
414
+ * along the path is missing — adapters treat that as "no value set" and
415
+ * render the appropriate empty form (empty string, unchecked, etc.).
416
+ *
417
+ * This is half of the bind-path seam introduced in Phase 6: the shell holds
418
+ * state mutably so that input events can update it (via `stateWrite`)
419
+ * without round-tripping to the server until a real dispatch fires.
420
+ */
421
+ stateRead(path: string): unknown;
422
+ /**
423
+ * Write a value into state at a dotted path. The shell mutates the held
424
+ * state object in place at the bind path; the next dispatch sends the
425
+ * updated state to the server. Intermediate objects/arrays are created on
426
+ * demand (numeric next segment → array, else object).
427
+ *
428
+ * This is the other half of the bind-path seam — the BrowserAdapter (or any
429
+ * adapter that supports user input) calls this on every keystroke / change
430
+ * event so drafts ARE state.
431
+ */
432
+ stateWrite(path: string, value: unknown): void;
433
+ /**
434
+ * Build the read/write seam the Adapter receives as its third render arg.
435
+ * Backed by `stateRead` / `stateWrite` so the adapter never needs a direct
436
+ * reference to the shell.
437
+ */
438
+ private stateAccessForAdapter;
359
439
  private failCapability;
360
440
  private processResponse;
361
441
  private schedulePoll;
package/dist/index.js CHANGED
@@ -1,4 +1,41 @@
1
1
  // ─── Action ──────────────────────────────────────────────────────────────────
2
+ /**
3
+ * Private helper: compose a human-readable `.message` from an errors array.
4
+ * Single entry → the entry's message verbatim.
5
+ * Multiple entries → "<first> (and N more)" so console.error is still useful.
6
+ */
7
+ function summarizeErrors(errors) {
8
+ if (errors.length === 0)
9
+ return "Server returned ok:false with no error details";
10
+ if (errors.length === 1)
11
+ return errors[0].message;
12
+ return `${errors[0].message} (and ${errors.length - 1} more)`;
13
+ }
14
+ /**
15
+ * Structured error surfaced via `onError` when the server returns `ok: false`.
16
+ * Extends `Error` so existing `onError` consumers that don't know about the
17
+ * envelope continue receiving a normal Error with a useful `.message`.
18
+ *
19
+ * Consumers that want the structured payload: `if (err instanceof VmsActionError) { ... err.errors ... }`.
20
+ *
21
+ * `status` is the HTTP status code (0 for push-originated errors with no
22
+ * HTTP transaction). `code` is a shortcut to `errors[0].code` for ergonomic
23
+ * branching on single-error responses.
24
+ */
25
+ export class VmsActionError extends Error {
26
+ errors;
27
+ status;
28
+ constructor(errors, status) {
29
+ super(summarizeErrors(errors));
30
+ this.errors = errors;
31
+ this.status = status;
32
+ this.name = "VmsActionError";
33
+ }
34
+ /** Shortcut to `errors[0]?.code`. Undefined when the first entry has no code. */
35
+ get code() {
36
+ return this.errors[0]?.code;
37
+ }
38
+ }
2
39
  export class ViewModelShell {
3
40
  options;
4
41
  currentVm = null;
@@ -25,9 +62,25 @@ export class ViewModelShell {
25
62
  const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
26
63
  const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
27
64
  const res = await fetch(url, { headers: { Accept: "application/json", ...extraHeaders } });
28
- if (!res.ok)
65
+ // 1.0.0 — parse-then-branch: always parse the body even on 4xx/5xx so the
66
+ // structured envelope is available. The ok:false check throws BEFORE the
67
+ // currentVm/currentState writes below — D-15 runtime hardening (throw-before-
68
+ // write ordering means the shell never mutates state from a failure body).
69
+ let body;
70
+ try {
71
+ body = (await res.json());
72
+ }
73
+ catch (_parseErr) {
74
+ // Non-JSON body on a 4xx/5xx (proxy error page, 502, etc.).
75
+ // Fall back to a plain Error — the body was never a VMS envelope.
29
76
  throw new Error(`${res.status} ${res.statusText}`);
30
- const body = (await res.json());
77
+ }
78
+ if (body.ok === false) {
79
+ // D-15 runtime hardening: throw BEFORE currentVm/currentState are written.
80
+ // Even if the server (incorrectly) sent vm/state on an ok:false response,
81
+ // neither field is consumed — type-erosion-safe.
82
+ throw new VmsActionError(body.errors ?? [{ message: `${res.status} ${res.statusText}` }], res.status);
83
+ }
31
84
  this.currentVm = body.vm;
32
85
  this.currentState = body.state;
33
86
  // 0.14.0 — apply the unload guard from the initial-load response too. The
@@ -37,7 +90,7 @@ export class ViewModelShell {
37
90
  // 0.16.0 — same for the busy lockout.
38
91
  this.serverBusy = body.busy ?? false;
39
92
  this.syncBusy();
40
- adapter.render(body.vm, (action) => this.dispatch(action));
93
+ adapter.render(body.vm, (action) => this.dispatch(action), this.stateAccessForAdapter());
41
94
  this.schedulePoll(body.nextPollIn);
42
95
  }
43
96
  catch (err) {
@@ -75,7 +128,10 @@ export class ViewModelShell {
75
128
  onLoading?.(true);
76
129
  }
77
130
  const form = new FormData();
78
- form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
131
+ // Phase 6 wire-shape break: `_action` carries the action name only.
132
+ // The state at the input's bind path holds whatever value the previous
133
+ // `context` payload used to carry; the server reads it from there.
134
+ form.append("_action", JSON.stringify({ name: action.name }));
79
135
  form.append("_state", JSON.stringify(this.currentState));
80
136
  if (action.files) {
81
137
  for (const [name, file] of Object.entries(action.files)) {
@@ -98,9 +154,27 @@ export class ViewModelShell {
98
154
  else {
99
155
  res = await fetch(actionEndpoint, init);
100
156
  }
101
- if (!res.ok)
102
- throw new Error(`Action '${action.name}' failed: ${res.status}`);
103
- this.processResponse((await res.json()));
157
+ // 1.0.0 — parse-then-branch: always parse the body even on 4xx/5xx so the
158
+ // structured envelope is available to construct VmsActionError. The existing
159
+ // catch arm below re-renders currentVm on error (D-15 behavior) — VmsActionError
160
+ // flows through that same path since it IS an Error instance.
161
+ let body;
162
+ try {
163
+ body = (await res.json());
164
+ }
165
+ catch (_parseErr) {
166
+ // Non-JSON body on a 4xx/5xx (proxy error page, 502, etc.).
167
+ // Fall back to a plain Error — the body was never a VMS envelope.
168
+ throw new Error(`Action '${action.name}' failed: ${res.status} ${res.statusText}`);
169
+ }
170
+ if (body.ok === false) {
171
+ // D-15 runtime hardening: throw BEFORE this.processResponse(body) runs.
172
+ // The throw skips processResponse entirely, so currentVm/currentState are
173
+ // NOT updated from the failure body — even if the server (incorrectly) sent
174
+ // vm/state on an ok:false response. Type-erosion-safe.
175
+ throw new VmsActionError(body.errors ?? [{ message: `Action '${action.name}' failed: ${res.status}` }], res.status);
176
+ }
177
+ this.processResponse(body);
104
178
  }
105
179
  catch (err) {
106
180
  const error = err instanceof Error ? err : new Error(String(err));
@@ -112,7 +186,7 @@ export class ViewModelShell {
112
186
  // Skipped when no VM has loaded yet (pre-initial-load dispatch is
113
187
  // already an error case handled above; currentVm stays null there).
114
188
  if (this.currentVm !== null) {
115
- this.options.adapter.render(this.currentVm, (a) => this.dispatch(a));
189
+ this.options.adapter.render(this.currentVm, (a) => this.dispatch(a), this.stateAccessForAdapter());
116
190
  }
117
191
  }
118
192
  finally {
@@ -128,6 +202,16 @@ export class ViewModelShell {
128
202
  push(response) {
129
203
  if (this.dispatching)
130
204
  return;
205
+ // 1.0.0 — parse-then-branch for push. External push consumers (SSE, WebSocket)
206
+ // may feed ok:false responses (e.g. a server-pushed error notification). Route
207
+ // them to onError WITHOUT calling processResponse — currentVm/currentState
208
+ // are NOT updated (D-15 runtime hardening for the push path).
209
+ // status: 0 because there was no HTTP transaction in an external push.
210
+ if (response.ok === false) {
211
+ const err = new VmsActionError(response.errors ?? [{ message: "Server pushed ok:false envelope" }], 0);
212
+ this.options.onError ? this.options.onError(err) : console.error("[ViewModelShell]", err);
213
+ return;
214
+ }
131
215
  this.processResponse(response);
132
216
  }
133
217
  stopPolling() {
@@ -138,6 +222,44 @@ export class ViewModelShell {
138
222
  }
139
223
  getCurrentVm() { return this.currentVm; }
140
224
  getCurrentState() { return this.currentState; }
225
+ /**
226
+ * Read the bound state value at a dotted path (e.g. "fields.title",
227
+ * "rows.42.selected"). Used by adapters (BrowserAdapter, TuiAdapter, …) to
228
+ * render an input's current value. Returns `undefined` when any segment
229
+ * along the path is missing — adapters treat that as "no value set" and
230
+ * render the appropriate empty form (empty string, unchecked, etc.).
231
+ *
232
+ * This is half of the bind-path seam introduced in Phase 6: the shell holds
233
+ * state mutably so that input events can update it (via `stateWrite`)
234
+ * without round-tripping to the server until a real dispatch fires.
235
+ */
236
+ stateRead(path) {
237
+ return readPath(this.currentState, path);
238
+ }
239
+ /**
240
+ * Write a value into state at a dotted path. The shell mutates the held
241
+ * state object in place at the bind path; the next dispatch sends the
242
+ * updated state to the server. Intermediate objects/arrays are created on
243
+ * demand (numeric next segment → array, else object).
244
+ *
245
+ * This is the other half of the bind-path seam — the BrowserAdapter (or any
246
+ * adapter that supports user input) calls this on every keystroke / change
247
+ * event so drafts ARE state.
248
+ */
249
+ stateWrite(path, value) {
250
+ this.currentState = writePath(this.currentState, path, value);
251
+ }
252
+ /**
253
+ * Build the read/write seam the Adapter receives as its third render arg.
254
+ * Backed by `stateRead` / `stateWrite` so the adapter never needs a direct
255
+ * reference to the shell.
256
+ */
257
+ stateAccessForAdapter() {
258
+ return {
259
+ read: (path) => this.stateRead(path),
260
+ write: (path, value) => this.stateWrite(path, value),
261
+ };
262
+ }
141
263
  failCapability(capability, detail) {
142
264
  const err = new Error(`[ViewModelShell] Adapter is missing the "${capability}" capability but the ` +
143
265
  `server response requires it (${detail}). This is a hard failure, not a no-op: ` +
@@ -191,7 +313,7 @@ export class ViewModelShell {
191
313
  }
192
314
  this.currentVm = body.vm;
193
315
  this.currentState = body.state;
194
- this.options.adapter.render(body.vm, (a) => this.dispatch(a));
316
+ this.options.adapter.render(body.vm, (a) => this.dispatch(a), this.stateAccessForAdapter());
195
317
  this.schedulePoll(body.nextPollIn);
196
318
  }
197
319
  schedulePoll(nextPollIn) {
@@ -246,6 +368,105 @@ export class ViewModelShell {
246
368
  }
247
369
  }
248
370
  }
371
+ // ─── Bind-path walkers (file-private; no platform globals) ───────────────────
372
+ //
373
+ // Tiny JSON walk + write helpers consumed by stateRead/stateWrite. Numeric
374
+ // segments index into arrays when the current value is an array; otherwise
375
+ // they're object keys. writePath creates intermediate objects/arrays on
376
+ // demand when the next segment shape implies one. Bind paths arrive trimmed
377
+ // of leading/trailing dots; an empty path writes to the root.
378
+ // Reject segments that would mutate Object.prototype on write or expose it on
379
+ // read. The bind string is server-controlled today, but stateWrite() is a
380
+ // public method and demos build bind paths dynamically; the guard is defense
381
+ // in depth so consumer code can never turn a path string into prototype pollution.
382
+ function isUnsafeSegment(seg) {
383
+ return seg === "__proto__" || seg === "constructor" || seg === "prototype";
384
+ }
385
+ function readPath(obj, path) {
386
+ // Defense: a bind-less input that somehow reached the renderer would call
387
+ // here with path=undefined. The wire contract guarantees `bind` on every
388
+ // input, but the runtime should not crash if a malformed tree leaks
389
+ // through — return undefined and let the field render as empty.
390
+ if (path == null || path === "")
391
+ return path == null ? undefined : obj;
392
+ const segs = path.split(".");
393
+ let cur = obj;
394
+ for (const seg of segs) {
395
+ if (isUnsafeSegment(seg))
396
+ return undefined;
397
+ if (cur == null)
398
+ return undefined;
399
+ if (Array.isArray(cur)) {
400
+ const idx = Number(seg);
401
+ if (!Number.isInteger(idx) || idx < 0)
402
+ return undefined;
403
+ cur = cur[idx];
404
+ }
405
+ else if (typeof cur === "object") {
406
+ cur = cur[seg];
407
+ }
408
+ else {
409
+ return undefined;
410
+ }
411
+ }
412
+ return cur;
413
+ }
414
+ function writePath(obj, path, value) {
415
+ // Defense: drop writes from bind-less inputs (see readPath).
416
+ if (path == null)
417
+ return obj;
418
+ if (path === "")
419
+ return value;
420
+ const segs = path.split(".");
421
+ for (const seg of segs) {
422
+ if (isUnsafeSegment(seg))
423
+ return obj;
424
+ }
425
+ // Bootstrap a root if the current state is null/undefined; choose the shape
426
+ // implied by the first segment (numeric ⇒ array, else object).
427
+ let root = obj;
428
+ if (root == null || typeof root !== "object") {
429
+ root = isArrayIndexSegment(segs[0]) ? [] : {};
430
+ }
431
+ let cur = root;
432
+ for (let i = 0; i < segs.length - 1; i++) {
433
+ const seg = segs[i];
434
+ if (Array.isArray(cur)) {
435
+ const idx = Number(seg);
436
+ let nxt = cur[idx];
437
+ if (nxt == null || typeof nxt !== "object") {
438
+ // Intermediate slot creation: default to object. The next segment's
439
+ // shape can't be inferred safely (numeric keys appear in both arrays
440
+ // and maps keyed by id), so the round-trip-safe default is {}. The
441
+ // root bootstrap above remains the only place the array heuristic
442
+ // fires — there we genuinely have no parent shape to honor.
443
+ nxt = {};
444
+ cur[idx] = nxt;
445
+ }
446
+ cur = nxt;
447
+ }
448
+ else {
449
+ const o = cur;
450
+ let nxt = o[seg];
451
+ if (nxt == null || typeof nxt !== "object") {
452
+ nxt = {};
453
+ o[seg] = nxt;
454
+ }
455
+ cur = nxt;
456
+ }
457
+ }
458
+ const last = segs[segs.length - 1];
459
+ if (Array.isArray(cur)) {
460
+ cur[Number(last)] = value;
461
+ }
462
+ else {
463
+ cur[last] = value;
464
+ }
465
+ return root;
466
+ }
467
+ function isArrayIndexSegment(seg) {
468
+ return /^[0-9]+$/.test(seg);
469
+ }
249
470
  // ─── Download helpers (file-private; no platform globals) ────────────────────
250
471
  // `Blob` and `URL` are universals (browser, Node 18+, Deno, Bun) — confirmed
251
472
  // off the check:core-globals denylist. `URL.createObjectURL` is browser-only
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