@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/browser.d.ts +25 -13
- package/dist/browser.js +209 -266
- package/dist/index.d.ts +136 -44
- package/dist/index.js +230 -9
- package/dist/server.d.ts +73 -2
- package/dist/server.js +293 -12
- package/dist/tui.d.ts +2 -2
- package/dist/tui.js +62 -97
- package/package.json +1 -1
- package/styles/default.css +19 -9
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
|
-
|
|
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,
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* ButtonNode placed in `children`
|
|
106
|
-
*
|
|
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).
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
186
|
-
|
|
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,24 @@ export interface TableColumn {
|
|
|
219
237
|
export interface TableRow {
|
|
220
238
|
id?: string;
|
|
221
239
|
cells: Record<string, string>;
|
|
240
|
+
/** Click-anywhere row dispatch primitive. When set, the renderer makes the
|
|
241
|
+
* entire row clickable AND keyboard-activatable (Enter / Space — Space
|
|
242
|
+
* preventDefaults page scroll) AND exposes accessibility (role="button",
|
|
243
|
+
* tabindex=0, aria-label derived from cell text). Per-row identity is
|
|
244
|
+
* encoded in the action name (e.g. `select-ticket-42`) — no context field,
|
|
245
|
+
* consistent with the Phase 6 wire. Coexists with `actions[]`: clicking a
|
|
246
|
+
* per-row button, checkbox, or cell linkLabel anchor does NOT also fire
|
|
247
|
+
* `row.action` (the renderer stops propagation on those targets). */
|
|
222
248
|
action?: ActionEvent;
|
|
249
|
+
/** Per-row interactive controls rendered in a trailing actions cell. Each
|
|
250
|
+
* entry is either a ButtonNode (its own unique action name encodes per-row
|
|
251
|
+
* identity, e.g. `delete-row-42`) or a CheckboxNode (its own `bind` path
|
|
252
|
+
* per row). The renderer dispatches by `entry.type` so both types render
|
|
253
|
+
* correctly — a previous version typed this as `ButtonNode[]` and called
|
|
254
|
+
* the button renderer blindly, silently dropping non-button entries. */
|
|
255
|
+
actions?: (ButtonNode | CheckboxNode)[];
|
|
223
256
|
variant?: string;
|
|
224
257
|
}
|
|
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
258
|
export interface TablePagination {
|
|
242
259
|
/** 1-based current page. */
|
|
243
260
|
page: number;
|
|
@@ -246,30 +263,38 @@ export interface TablePagination {
|
|
|
246
263
|
/** Total rows across all pages — server-truth. The adapter renders the range
|
|
247
264
|
* label and enables/disables prev/next from this; it does NOT slice. */
|
|
248
265
|
totalRows: number;
|
|
249
|
-
/** Dispatched on
|
|
250
|
-
* target
|
|
251
|
-
|
|
266
|
+
/** Dispatched on the prev page-control click. Carries an action name only —
|
|
267
|
+
* the renderer writes the target page to TableNode.paginationBind before dispatch. */
|
|
268
|
+
prevAction?: ActionEvent;
|
|
269
|
+
/** Dispatched on the next page-control click. Carries an action name only —
|
|
270
|
+
* the renderer writes the target page to TableNode.paginationBind before dispatch. */
|
|
271
|
+
nextAction?: ActionEvent;
|
|
252
272
|
}
|
|
253
273
|
export interface TableNode {
|
|
254
274
|
type: "table";
|
|
255
275
|
columns: TableColumn[];
|
|
256
276
|
rows: TableRow[];
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
|
|
261
|
-
|
|
277
|
+
/** Path into state where the current sort intent (`{column, direction}`) is read/written. */
|
|
278
|
+
sortBind?: string;
|
|
279
|
+
/** Per-column filter input bind paths. The renderer reads/writes filter
|
|
280
|
+
* values at these paths; the values then travel with the next dispatch. */
|
|
281
|
+
filterBinds?: Record<string, string>;
|
|
282
|
+
/** Path into state where the renderer writes the target page number before
|
|
283
|
+
* firing `pagination.prevAction` / `pagination.nextAction`. */
|
|
284
|
+
paginationBind?: string;
|
|
285
|
+
/** Per-column sort header click actions, keyed by column key. Each carries a
|
|
286
|
+
* unique action name — the renderer writes the new sort intent to `sortBind`
|
|
287
|
+
* before dispatching. */
|
|
288
|
+
sortActions?: Record<string, ActionEvent>;
|
|
289
|
+
/** One filter-dispatch action per table. The renderer fires this when the
|
|
290
|
+
* user submits the filter form; per-column filter values are already in
|
|
291
|
+
* state at the `filterBinds` paths. */
|
|
262
292
|
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
293
|
/** Server-driven pagination. When set, the adapter renders an "X–Y of N"
|
|
269
294
|
* range + prev/next controls below the table. **The server slices `rows` to
|
|
270
295
|
* the current page** — the adapter never paginates client-side (that would
|
|
271
296
|
* break for DB-backed tables, which are most of them). By convention
|
|
272
|
-
*
|
|
297
|
+
* filter dispatches reset `page` to 1 on the server side, since
|
|
273
298
|
* the row window changes underneath them. */
|
|
274
299
|
pagination?: TablePagination;
|
|
275
300
|
}
|
|
@@ -319,6 +344,36 @@ export interface ShellSideEffect {
|
|
|
319
344
|
* when present; this is the fallback before the URL basename. */
|
|
320
345
|
filename?: string;
|
|
321
346
|
}
|
|
347
|
+
/** One entry in the structured error payload returned on `ok: false`. */
|
|
348
|
+
export interface ErrorEntry {
|
|
349
|
+
/** Absent when not tied to a specific input slot (parse errors, uncaught
|
|
350
|
+
* exceptions, etc.). Present when bound to a specific bind-path on an input. */
|
|
351
|
+
path?: string;
|
|
352
|
+
/** Human-readable description of the error. Always present. */
|
|
353
|
+
message: string;
|
|
354
|
+
/** Framework-set discriminator for the failure class. Initial vocabulary:
|
|
355
|
+
* "parse_error" | "unknown_action" | "invalid_tree" | "uncaught_exception".
|
|
356
|
+
* Absent when not applicable (e.g. BadRequest / D-08 path). */
|
|
357
|
+
code?: string;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Structured error surfaced via `onError` when the server returns `ok: false`.
|
|
361
|
+
* Extends `Error` so existing `onError` consumers that don't know about the
|
|
362
|
+
* envelope continue receiving a normal Error with a useful `.message`.
|
|
363
|
+
*
|
|
364
|
+
* Consumers that want the structured payload: `if (err instanceof VmsActionError) { ... err.errors ... }`.
|
|
365
|
+
*
|
|
366
|
+
* `status` is the HTTP status code (0 for push-originated errors with no
|
|
367
|
+
* HTTP transaction). `code` is a shortcut to `errors[0].code` for ergonomic
|
|
368
|
+
* branching on single-error responses.
|
|
369
|
+
*/
|
|
370
|
+
export declare class VmsActionError extends Error {
|
|
371
|
+
readonly errors: ErrorEntry[];
|
|
372
|
+
readonly status: number;
|
|
373
|
+
constructor(errors: ErrorEntry[], status: number);
|
|
374
|
+
/** Shortcut to `errors[0]?.code`. Undefined when the first entry has no code. */
|
|
375
|
+
get code(): string | undefined;
|
|
376
|
+
}
|
|
322
377
|
export interface ShellResponse {
|
|
323
378
|
vm: ViewNode;
|
|
324
379
|
state: unknown;
|
|
@@ -338,6 +393,14 @@ export interface ShellResponse {
|
|
|
338
393
|
* dispatches; the adapter applies `.vms-busy` for the visual cue). Polls
|
|
339
394
|
* bypass so the server can clear the state. See `Adapter.setBusy`. */
|
|
340
395
|
busy?: boolean;
|
|
396
|
+
/** 1.0.0 — framework-set envelope flag. Present on every framework-rendered
|
|
397
|
+
* response; absent on hand-constructed legacy push bodies (treated as ok:true
|
|
398
|
+
* for backwards compatibility). On ok:false responses, only `errors[]` is
|
|
399
|
+
* consumed — any vm/state present in the body is IGNORED at runtime (D-15
|
|
400
|
+
* hardening: the shell throws before the currentVm/currentState writes run). */
|
|
401
|
+
ok?: boolean;
|
|
402
|
+
/** 1.0.0 — structured error entries. Present when `ok: false`. */
|
|
403
|
+
errors?: ErrorEntry[];
|
|
341
404
|
}
|
|
342
405
|
export declare class ViewModelShell {
|
|
343
406
|
private options;
|
|
@@ -356,6 +419,35 @@ export declare class ViewModelShell {
|
|
|
356
419
|
stopPolling(): void;
|
|
357
420
|
getCurrentVm(): ViewNode | null;
|
|
358
421
|
getCurrentState(): unknown;
|
|
422
|
+
/**
|
|
423
|
+
* Read the bound state value at a dotted path (e.g. "fields.title",
|
|
424
|
+
* "rows.42.selected"). Used by adapters (BrowserAdapter, TuiAdapter, …) to
|
|
425
|
+
* render an input's current value. Returns `undefined` when any segment
|
|
426
|
+
* along the path is missing — adapters treat that as "no value set" and
|
|
427
|
+
* render the appropriate empty form (empty string, unchecked, etc.).
|
|
428
|
+
*
|
|
429
|
+
* This is half of the bind-path seam introduced in Phase 6: the shell holds
|
|
430
|
+
* state mutably so that input events can update it (via `stateWrite`)
|
|
431
|
+
* without round-tripping to the server until a real dispatch fires.
|
|
432
|
+
*/
|
|
433
|
+
stateRead(path: string): unknown;
|
|
434
|
+
/**
|
|
435
|
+
* Write a value into state at a dotted path. The shell mutates the held
|
|
436
|
+
* state object in place at the bind path; the next dispatch sends the
|
|
437
|
+
* updated state to the server. Intermediate objects/arrays are created on
|
|
438
|
+
* demand (numeric next segment → array, else object).
|
|
439
|
+
*
|
|
440
|
+
* This is the other half of the bind-path seam — the BrowserAdapter (or any
|
|
441
|
+
* adapter that supports user input) calls this on every keystroke / change
|
|
442
|
+
* event so drafts ARE state.
|
|
443
|
+
*/
|
|
444
|
+
stateWrite(path: string, value: unknown): void;
|
|
445
|
+
/**
|
|
446
|
+
* Build the read/write seam the Adapter receives as its third render arg.
|
|
447
|
+
* Backed by `stateRead` / `stateWrite` so the adapter never needs a direct
|
|
448
|
+
* reference to the shell.
|
|
449
|
+
*/
|
|
450
|
+
private stateAccessForAdapter;
|
|
359
451
|
private failCapability;
|
|
360
452
|
private processResponse;
|
|
361
453
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|