@ashley-shrok/viewmodel-shell 0.15.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/browser.d.ts +20 -9
- package/dist/browser.js +168 -266
- package/dist/index.d.ts +142 -45
- package/dist/index.js +262 -11
- package/dist/server.d.ts +79 -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 +27 -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. */
|
|
@@ -42,6 +52,16 @@ export interface Adapter {
|
|
|
42
52
|
* targets (TUI) have no terminal equivalent. Modern browsers show a
|
|
43
53
|
* generic "Leave site?" dialog; the message is not customizable. */
|
|
44
54
|
setPreventUnload?(active: boolean): void;
|
|
55
|
+
/** 0.16.0 — visually lock the UI during periods where user dispatches will
|
|
56
|
+
* be dropped. Called by the shell on every transition with `active = true`
|
|
57
|
+
* when EITHER a user-initiated dispatch is in flight OR the server returned
|
|
58
|
+
* `ShellResponse.busy: true` on its most recent response. Polls (silent
|
|
59
|
+
* dispatches) bypass — they're the only way out of a server-busy state.
|
|
60
|
+
* `BrowserAdapter` toggles `.vms-busy` on its container; default CSS makes
|
|
61
|
+
* every interactive descendant non-clickable (cursor: wait + pointer-events:
|
|
62
|
+
* none), so a rapid checkbox click during an in-flight round-trip never
|
|
63
|
+
* visually flips the box. Fail-quiet by absence (TUI has no equivalent). */
|
|
64
|
+
setBusy?(active: boolean): void;
|
|
45
65
|
}
|
|
46
66
|
export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
|
|
47
67
|
export interface PageNode {
|
|
@@ -88,12 +108,12 @@ export interface FormNode {
|
|
|
88
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). */
|
|
89
109
|
layout?: "stack" | "inline";
|
|
90
110
|
/** Multi-action submit buttons (#15). Each is a full ButtonNode (so
|
|
91
|
-
* `variant` + `pendingLabel` apply) that, on activation,
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* ButtonNode placed in `children`
|
|
96
|
-
*
|
|
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. */
|
|
97
117
|
buttons?: ButtonNode[];
|
|
98
118
|
children: ViewNode[];
|
|
99
119
|
}
|
|
@@ -101,9 +121,10 @@ export interface FieldNode {
|
|
|
101
121
|
type: "field";
|
|
102
122
|
name: string;
|
|
103
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;
|
|
104
126
|
label?: string;
|
|
105
127
|
placeholder?: string;
|
|
106
|
-
value?: string;
|
|
107
128
|
required?: boolean;
|
|
108
129
|
options?: Array<{
|
|
109
130
|
value: string;
|
|
@@ -114,15 +135,18 @@ export interface FieldNode {
|
|
|
114
135
|
* library (CodeMirror, Monaco, etc.) — the framework only ships
|
|
115
136
|
* monospaced editable text, no coloring. */
|
|
116
137
|
language?: string;
|
|
117
|
-
/** 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. */
|
|
118
140
|
action?: ActionEvent;
|
|
119
141
|
}
|
|
120
142
|
export interface CheckboxNode {
|
|
121
143
|
type: "checkbox";
|
|
122
144
|
name: string;
|
|
123
|
-
|
|
145
|
+
/** Path into state where this input reads its current value and writes user changes (e.g. `fields.title`). */
|
|
146
|
+
bind: string;
|
|
124
147
|
label?: string;
|
|
125
|
-
/** 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. */
|
|
126
150
|
action?: ActionEvent;
|
|
127
151
|
}
|
|
128
152
|
export interface ButtonNode {
|
|
@@ -172,11 +196,15 @@ export interface StatBarNode {
|
|
|
172
196
|
export interface TabsNode {
|
|
173
197
|
type: "tabs";
|
|
174
198
|
selected: string;
|
|
175
|
-
/**
|
|
176
|
-
|
|
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. */
|
|
177
204
|
tabs: Array<{
|
|
178
205
|
value: string;
|
|
179
206
|
label: string;
|
|
207
|
+
action: ActionEvent;
|
|
180
208
|
}>;
|
|
181
209
|
}
|
|
182
210
|
export interface ProgressNode {
|
|
@@ -209,25 +237,12 @@ export interface TableColumn {
|
|
|
209
237
|
export interface TableRow {
|
|
210
238
|
id?: string;
|
|
211
239
|
cells: Record<string, string>;
|
|
212
|
-
action
|
|
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[];
|
|
213
244
|
variant?: string;
|
|
214
245
|
}
|
|
215
|
-
export interface TableSelection {
|
|
216
|
-
/** Row ids that should render PRE-SELECTED on initial render — the server's
|
|
217
|
-
* initial pre-selection. Subsequent toggles are purely client-side DOM state;
|
|
218
|
-
* the server doesn't see them until a `buttons[]` click harvests them. */
|
|
219
|
-
selectedIds: string[];
|
|
220
|
-
/** When present, the adapter renders these as a bulk-action toolbar ABOVE
|
|
221
|
-
* the table. On click, each button harvests the currently-checked rows from
|
|
222
|
-
* the DOM and dispatches its `action` with `{ selectedIds: [...] }` merged
|
|
223
|
-
* into its `context`. This is the only way to act on the selection — there
|
|
224
|
-
* is no per-toggle dispatch (0.15.0 removed the `action` mode that did,
|
|
225
|
-
* because rapid clicks were dropped by the dispatch guard and the in-flight
|
|
226
|
-
* response wiped the DOM). If a future release needs cross-page persistence
|
|
227
|
-
* or live "N selected" indicators, the way back is a redesigned wire shape
|
|
228
|
-
* (dispatch queueing + optimistic preservation), not the old `action` mode. */
|
|
229
|
-
buttons?: ButtonNode[];
|
|
230
|
-
}
|
|
231
246
|
export interface TablePagination {
|
|
232
247
|
/** 1-based current page. */
|
|
233
248
|
page: number;
|
|
@@ -236,30 +251,38 @@ export interface TablePagination {
|
|
|
236
251
|
/** Total rows across all pages — server-truth. The adapter renders the range
|
|
237
252
|
* label and enables/disables prev/next from this; it does NOT slice. */
|
|
238
253
|
totalRows: number;
|
|
239
|
-
/** Dispatched on
|
|
240
|
-
* target
|
|
241
|
-
|
|
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;
|
|
242
260
|
}
|
|
243
261
|
export interface TableNode {
|
|
244
262
|
type: "table";
|
|
245
263
|
columns: TableColumn[];
|
|
246
264
|
rows: TableRow[];
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
|
|
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. */
|
|
252
280
|
filterAction?: ActionEvent;
|
|
253
|
-
/** Per-row multi-select. When set, the adapter renders a leading checkbox
|
|
254
|
-
* column + a header select-all checkbox and tints selected rows. `TableRow.id`
|
|
255
|
-
* is REQUIRED on every row when selection is set — it's the address the
|
|
256
|
-
* toggle action reports back. */
|
|
257
|
-
selection?: TableSelection;
|
|
258
281
|
/** Server-driven pagination. When set, the adapter renders an "X–Y of N"
|
|
259
282
|
* range + prev/next controls below the table. **The server slices `rows` to
|
|
260
283
|
* the current page** — the adapter never paginates client-side (that would
|
|
261
284
|
* break for DB-backed tables, which are most of them). By convention
|
|
262
|
-
*
|
|
285
|
+
* filter dispatches reset `page` to 1 on the server side, since
|
|
263
286
|
* the row window changes underneath them. */
|
|
264
287
|
pagination?: TablePagination;
|
|
265
288
|
}
|
|
@@ -309,6 +332,36 @@ export interface ShellSideEffect {
|
|
|
309
332
|
* when present; this is the fallback before the URL basename. */
|
|
310
333
|
filename?: string;
|
|
311
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
|
+
}
|
|
312
365
|
export interface ShellResponse {
|
|
313
366
|
vm: ViewNode;
|
|
314
367
|
state: unknown;
|
|
@@ -324,6 +377,18 @@ export interface ShellResponse {
|
|
|
324
377
|
* `preventUnload: true` from each response; clear it when the work
|
|
325
378
|
* completes (typically via polling). See `Adapter.setPreventUnload`. */
|
|
326
379
|
preventUnload?: boolean;
|
|
380
|
+
/** 0.16.0 — when true, the shell locks the UI (drops user-initiated
|
|
381
|
+
* dispatches; the adapter applies `.vms-busy` for the visual cue). Polls
|
|
382
|
+
* bypass so the server can clear the state. See `Adapter.setBusy`. */
|
|
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[];
|
|
327
392
|
}
|
|
328
393
|
export declare class ViewModelShell {
|
|
329
394
|
private options;
|
|
@@ -331,7 +396,10 @@ export declare class ViewModelShell {
|
|
|
331
396
|
private currentState;
|
|
332
397
|
private dispatching;
|
|
333
398
|
private pollTimer;
|
|
399
|
+
private serverBusy;
|
|
400
|
+
private userDispatching;
|
|
334
401
|
constructor(options: ShellOptions);
|
|
402
|
+
private syncBusy;
|
|
335
403
|
load(params?: Record<string, string>): Promise<void>;
|
|
336
404
|
dispatch(action: ActionEvent, silent?: boolean): Promise<void>;
|
|
337
405
|
/** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
|
|
@@ -339,6 +407,35 @@ export declare class ViewModelShell {
|
|
|
339
407
|
stopPolling(): void;
|
|
340
408
|
getCurrentVm(): ViewNode | null;
|
|
341
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;
|
|
342
439
|
private failCapability;
|
|
343
440
|
private processResponse;
|
|
344
441
|
private schedulePoll;
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,59 @@
|
|
|
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;
|
|
5
42
|
currentState = null;
|
|
6
43
|
dispatching = false;
|
|
7
44
|
pollTimer = null;
|
|
45
|
+
// 0.16.0 — busy = serverBusy OR a user-initiated dispatch is in flight.
|
|
46
|
+
// Polls (silent=true dispatches) don't flip userDispatching so they never
|
|
47
|
+
// toggle the busy class — that's how a server-busy state stays continuously
|
|
48
|
+
// locked across many ticks without flicker.
|
|
49
|
+
serverBusy = false;
|
|
50
|
+
userDispatching = false;
|
|
8
51
|
constructor(options) {
|
|
9
52
|
this.options = options;
|
|
10
53
|
}
|
|
54
|
+
syncBusy() {
|
|
55
|
+
this.options.adapter.setBusy?.(this.serverBusy || this.userDispatching);
|
|
56
|
+
}
|
|
11
57
|
async load(params) {
|
|
12
58
|
const { endpoint, adapter, onError, onLoading } = this.options;
|
|
13
59
|
this.stopPolling();
|
|
@@ -16,16 +62,35 @@ export class ViewModelShell {
|
|
|
16
62
|
const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
|
|
17
63
|
const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
|
|
18
64
|
const res = await fetch(url, { headers: { Accept: "application/json", ...extraHeaders } });
|
|
19
|
-
|
|
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.
|
|
20
76
|
throw new Error(`${res.status} ${res.statusText}`);
|
|
21
|
-
|
|
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
|
+
}
|
|
22
84
|
this.currentVm = body.vm;
|
|
23
85
|
this.currentState = body.state;
|
|
24
86
|
// 0.14.0 — apply the unload guard from the initial-load response too. The
|
|
25
87
|
// server may legitimately want it on at first paint (e.g. the page was
|
|
26
88
|
// refreshed mid-work and the long action is still pending server-side).
|
|
27
89
|
adapter.setPreventUnload?.(body.preventUnload ?? false);
|
|
28
|
-
|
|
90
|
+
// 0.16.0 — same for the busy lockout.
|
|
91
|
+
this.serverBusy = body.busy ?? false;
|
|
92
|
+
this.syncBusy();
|
|
93
|
+
adapter.render(body.vm, (action) => this.dispatch(action), this.stateAccessForAdapter());
|
|
29
94
|
this.schedulePoll(body.nextPollIn);
|
|
30
95
|
}
|
|
31
96
|
catch (err) {
|
|
@@ -37,6 +102,10 @@ export class ViewModelShell {
|
|
|
37
102
|
}
|
|
38
103
|
}
|
|
39
104
|
async dispatch(action, silent = false) {
|
|
105
|
+
// 0.16.0 — drop user-initiated dispatches while server-busy. Polls (silent)
|
|
106
|
+
// bypass so the server can clear the busy state.
|
|
107
|
+
if (!silent && this.serverBusy)
|
|
108
|
+
return;
|
|
40
109
|
if (this.dispatching)
|
|
41
110
|
return;
|
|
42
111
|
const { actionEndpoint, onError, onLoading } = this.options;
|
|
@@ -48,10 +117,21 @@ export class ViewModelShell {
|
|
|
48
117
|
}
|
|
49
118
|
try {
|
|
50
119
|
this.dispatching = true;
|
|
51
|
-
if (!silent)
|
|
120
|
+
if (!silent) {
|
|
121
|
+
// 0.16.0 — flag a user dispatch as in-flight + apply .vms-busy. This
|
|
122
|
+
// is what kills the "rapid clicks during a round-trip silently flip the
|
|
123
|
+
// checkbox" UX bug: by the time the user's second click arrives, the
|
|
124
|
+
// container has pointer-events: none and the click never reaches the
|
|
125
|
+
// input.
|
|
126
|
+
this.userDispatching = true;
|
|
127
|
+
this.syncBusy();
|
|
52
128
|
onLoading?.(true);
|
|
129
|
+
}
|
|
53
130
|
const form = new FormData();
|
|
54
|
-
|
|
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 }));
|
|
55
135
|
form.append("_state", JSON.stringify(this.currentState));
|
|
56
136
|
if (action.files) {
|
|
57
137
|
for (const [name, file] of Object.entries(action.files)) {
|
|
@@ -74,9 +154,27 @@ export class ViewModelShell {
|
|
|
74
154
|
else {
|
|
75
155
|
res = await fetch(actionEndpoint, init);
|
|
76
156
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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);
|
|
80
178
|
}
|
|
81
179
|
catch (err) {
|
|
82
180
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -88,19 +186,32 @@ export class ViewModelShell {
|
|
|
88
186
|
// Skipped when no VM has loaded yet (pre-initial-load dispatch is
|
|
89
187
|
// already an error case handled above; currentVm stays null there).
|
|
90
188
|
if (this.currentVm !== null) {
|
|
91
|
-
this.options.adapter.render(this.currentVm, (a) => this.dispatch(a));
|
|
189
|
+
this.options.adapter.render(this.currentVm, (a) => this.dispatch(a), this.stateAccessForAdapter());
|
|
92
190
|
}
|
|
93
191
|
}
|
|
94
192
|
finally {
|
|
95
193
|
this.dispatching = false;
|
|
96
|
-
if (!silent)
|
|
194
|
+
if (!silent) {
|
|
195
|
+
this.userDispatching = false;
|
|
196
|
+
this.syncBusy();
|
|
97
197
|
onLoading?.(false);
|
|
198
|
+
}
|
|
98
199
|
}
|
|
99
200
|
}
|
|
100
201
|
/** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
|
|
101
202
|
push(response) {
|
|
102
203
|
if (this.dispatching)
|
|
103
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
|
+
}
|
|
104
215
|
this.processResponse(response);
|
|
105
216
|
}
|
|
106
217
|
stopPolling() {
|
|
@@ -111,6 +222,44 @@ export class ViewModelShell {
|
|
|
111
222
|
}
|
|
112
223
|
getCurrentVm() { return this.currentVm; }
|
|
113
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
|
+
}
|
|
114
263
|
failCapability(capability, detail) {
|
|
115
264
|
const err = new Error(`[ViewModelShell] Adapter is missing the "${capability}" capability but the ` +
|
|
116
265
|
`server response requires it (${detail}). This is a hard failure, not a no-op: ` +
|
|
@@ -147,6 +296,9 @@ export class ViewModelShell {
|
|
|
147
296
|
// wants a redirect to NOT be blocked by its own guard simply omits
|
|
148
297
|
// preventUnload (or sets it false) on that response — standard pattern.
|
|
149
298
|
adapter.setPreventUnload?.(body.preventUnload ?? false);
|
|
299
|
+
// 0.16.0 — likewise for the busy lockout.
|
|
300
|
+
this.serverBusy = body.busy ?? false;
|
|
301
|
+
this.syncBusy();
|
|
150
302
|
if (body.redirect) {
|
|
151
303
|
if (this.options.onRedirect) {
|
|
152
304
|
this.options.onRedirect(body.redirect);
|
|
@@ -161,7 +313,7 @@ export class ViewModelShell {
|
|
|
161
313
|
}
|
|
162
314
|
this.currentVm = body.vm;
|
|
163
315
|
this.currentState = body.state;
|
|
164
|
-
this.options.adapter.render(body.vm, (a) => this.dispatch(a));
|
|
316
|
+
this.options.adapter.render(body.vm, (a) => this.dispatch(a), this.stateAccessForAdapter());
|
|
165
317
|
this.schedulePoll(body.nextPollIn);
|
|
166
318
|
}
|
|
167
319
|
schedulePoll(nextPollIn) {
|
|
@@ -216,6 +368,105 @@ export class ViewModelShell {
|
|
|
216
368
|
}
|
|
217
369
|
}
|
|
218
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
|
+
}
|
|
219
470
|
// ─── Download helpers (file-private; no platform globals) ────────────────────
|
|
220
471
|
// `Blob` and `URL` are universals (browser, Node 18+, Deno, Bun) — confirmed
|
|
221
472
|
// off the check:core-globals denylist. `URL.createObjectURL` is browser-only
|