@ashley-shrok/viewmodel-shell 3.7.0 → 3.9.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/agent-skill.md +9 -0
- package/dist/browser.d.ts +17 -0
- package/dist/browser.js +72 -14
- package/dist/index.d.ts +64 -2
- package/dist/index.js +66 -1
- package/dist/server.d.ts +25 -1
- package/dist/server.js +38 -2
- package/package.json +1 -1
package/agent-skill.md
CHANGED
|
@@ -63,6 +63,7 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
|
|
|
63
63
|
| `busy` | Boolean. While `true`, drop user-initiated dispatches. Polls bypass. The next response that omits or sets `false` clears the lock. |
|
|
64
64
|
| `preventUnload` | Boolean. While `true`, treat the page as having unsaved work — warn before navigating away. |
|
|
65
65
|
| `rejected` | A SOFT (domain/validation) rejection — see below. The action was refused, but `vm`/`state` are still returned. |
|
|
66
|
+
| `serverBuild` | A string id of the client bundle the server currently deploys. Present only when the app enables versioning. If you advertise your own build via the `X-VMS-Client-Build` request header and it differs from `serverBuild`, you are running against a rolled-forward server — see *Client build / version skew*. |
|
|
66
67
|
|
|
67
68
|
**Failure:**
|
|
68
69
|
|
|
@@ -115,9 +116,17 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
|
|
|
115
116
|
| `unknown_action` | The `name` in your action envelope does not match any handler in the current tree. |
|
|
116
117
|
| `invalid_tree` | The server built a tree that violates a wire invariant (this is a server bug, not yours). |
|
|
117
118
|
| `uncaught_exception` | The action handler threw. Treat as a 500-class failure. |
|
|
119
|
+
| `stale_client` | Your request advertised an `X-VMS-Client-Build` header that no longer matches the server's current deployed build. The mutation was rejected **before your `_state` was read — nothing was applied.** The fix is to reload to the current app (re-`GET` the endpoint for a fresh `vm`/`state`), not to retry the same request. See *Client build / version skew*. |
|
|
118
120
|
|
|
119
121
|
Stop on `ok: false`. Surface the message to the user. Do not retry blindly — most of these are deterministic.
|
|
120
122
|
|
|
123
|
+
## Client build / version skew
|
|
124
|
+
|
|
125
|
+
Optional, opt-in. When the app enables versioning, every response carries a `serverBuild` string (the client bundle the server currently deploys), and you may advertise the build you are running by sending an `X-VMS-Client-Build: <your-build-id>` header on every action POST.
|
|
126
|
+
|
|
127
|
+
- **Detection.** On any successful response, if you sent a build and `serverBuild` differs from it, the server has rolled forward while you kept running an old bundle. Reload to the current app (re-`GET` the endpoint) so you are driving the current tree.
|
|
128
|
+
- **Fail-closed guard.** If you send a mismatching `X-VMS-Client-Build`, a *mutating* action is rejected with `ok: false`, HTTP 400, `code: "stale_client"` — **before** your `_state` is deserialized, so nothing is applied. Do not retry the same request against the same build; reload first. If you do NOT send the header, no request is ever rejected on this basis (the guard only fires for a client that advertised a stale build).
|
|
129
|
+
|
|
121
130
|
## Auth
|
|
122
131
|
|
|
123
132
|
The wire does not mandate an auth shape. If the app needs credentials, the app preamble above (or its own README) names them. Common patterns: a `Bearer` token in `Authorization`, a CSRF/anti-forgery token in a custom header, a session cookie. Send the same headers on every request, including polls and downloads. The `download` side-effect re-presents your auth headers when the agent fetches the file.
|
package/dist/browser.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
3
3
|
private container;
|
|
4
4
|
private fileRegistry;
|
|
5
5
|
private sa;
|
|
6
|
+
private diagWarned;
|
|
6
7
|
private detailsOpenSnapshot;
|
|
7
8
|
private sectionKeyCounter;
|
|
8
9
|
private fitsObservers;
|
|
@@ -12,6 +13,13 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
12
13
|
storage(scope: "local" | "session", key: string, value: string): void;
|
|
13
14
|
saveFile(data: Blob, filename: string, _contentType: string): void;
|
|
14
15
|
setBusy(active: boolean): void;
|
|
16
|
+
/** 3.8.0 — force a full page reload. The shell calls this as the fail-closed
|
|
17
|
+
* recovery for a `stale_client` rejection (a mutation refused because this
|
|
18
|
+
* tab is running an out-of-date bundle). `window.location.reload()` pulls the
|
|
19
|
+
* fresh, cache-revalidated shell + bundle. Fail-quiet by absence in core (the
|
|
20
|
+
* `stale_client` VmsActionError already surfaced via onError), so this is a
|
|
21
|
+
* plain implementation, not a fail-loud capability. */
|
|
22
|
+
reload(): void;
|
|
15
23
|
/** Transient confirmation toast. Lazily creates/reuses a single fixed-corner
|
|
16
24
|
* host region (.vms-toast-region) appended to <body> so toasts stack and
|
|
17
25
|
* survive the container's innerHTML wipe on each render(); appends a
|
|
@@ -70,6 +78,15 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
70
78
|
/** FieldNode — reads value from `sa.read(bind)`; writes back on input/change.
|
|
71
79
|
* When `action` is set, it fires on Enter (text-like) or change (select) —
|
|
72
80
|
* the new value is already in state by that point. */
|
|
81
|
+
/** Warn to the dev console at most once per key (deduped over this adapter's
|
|
82
|
+
* lifetime). Fires in dev AND prod — the client bundle can't tell them apart,
|
|
83
|
+
* and prod telemetry that captures console.warn should see these too. */
|
|
84
|
+
private warnOnce;
|
|
85
|
+
/** Read a bind path, tolerating a bind-less field (file inputs) — null bind
|
|
86
|
+
* reads nothing. */
|
|
87
|
+
private readBind;
|
|
88
|
+
/** Write to a bind path, no-op when the field has no bind (file inputs). */
|
|
89
|
+
private writeBind;
|
|
73
90
|
private field;
|
|
74
91
|
/** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
|
|
75
92
|
* render help + error text below it, wiring aria-describedby / aria-invalid.
|
package/dist/browser.js
CHANGED
|
@@ -46,6 +46,11 @@ export class BrowserAdapter {
|
|
|
46
46
|
container;
|
|
47
47
|
fileRegistry = new Map();
|
|
48
48
|
sa = noopStateAccess;
|
|
49
|
+
// Dev-console diagnostics dedup (3.9.0). Both [vms:no-bind] and
|
|
50
|
+
// [vms:type-mismatch] warn at most once per key over this adapter's lifetime.
|
|
51
|
+
// The client bundle can't distinguish dev/prod, so these fire in both — which
|
|
52
|
+
// is intentional: prod telemetry that captures console.warn sees them too.
|
|
53
|
+
diagWarned = new Set();
|
|
49
54
|
// 1.2.0 — open-state snapshot for SectionNode.collapsible. Captured by
|
|
50
55
|
// render() BEFORE this.container.innerHTML = "" by walking
|
|
51
56
|
// [data-section-key] details elements; consumed by render() AFTER node()
|
|
@@ -198,6 +203,15 @@ export class BrowserAdapter {
|
|
|
198
203
|
setBusy(active) {
|
|
199
204
|
this.container.classList.toggle("vms-busy", active);
|
|
200
205
|
}
|
|
206
|
+
/** 3.8.0 — force a full page reload. The shell calls this as the fail-closed
|
|
207
|
+
* recovery for a `stale_client` rejection (a mutation refused because this
|
|
208
|
+
* tab is running an out-of-date bundle). `window.location.reload()` pulls the
|
|
209
|
+
* fresh, cache-revalidated shell + bundle. Fail-quiet by absence in core (the
|
|
210
|
+
* `stale_client` VmsActionError already surfaced via onError), so this is a
|
|
211
|
+
* plain implementation, not a fail-loud capability. */
|
|
212
|
+
reload() {
|
|
213
|
+
window.location.reload();
|
|
214
|
+
}
|
|
201
215
|
/** Transient confirmation toast. Lazily creates/reuses a single fixed-corner
|
|
202
216
|
* host region (.vms-toast-region) appended to <body> so toasts stack and
|
|
203
217
|
* survive the container's innerHTML wipe on each render(); appends a
|
|
@@ -666,8 +680,35 @@ export class BrowserAdapter {
|
|
|
666
680
|
/** FieldNode — reads value from `sa.read(bind)`; writes back on input/change.
|
|
667
681
|
* When `action` is set, it fires on Enter (text-like) or change (select) —
|
|
668
682
|
* the new value is already in state by that point. */
|
|
683
|
+
/** Warn to the dev console at most once per key (deduped over this adapter's
|
|
684
|
+
* lifetime). Fires in dev AND prod — the client bundle can't tell them apart,
|
|
685
|
+
* and prod telemetry that captures console.warn should see these too. */
|
|
686
|
+
warnOnce(key, msg) {
|
|
687
|
+
if (!this.diagWarned.has(key)) {
|
|
688
|
+
this.diagWarned.add(key);
|
|
689
|
+
console.warn(msg);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/** Read a bind path, tolerating a bind-less field (file inputs) — null bind
|
|
693
|
+
* reads nothing. */
|
|
694
|
+
readBind(bind) {
|
|
695
|
+
return bind == null ? undefined : this.sa.read(bind);
|
|
696
|
+
}
|
|
697
|
+
/** Write to a bind path, no-op when the field has no bind (file inputs). */
|
|
698
|
+
writeBind(bind, value) {
|
|
699
|
+
if (bind != null)
|
|
700
|
+
this.sa.write(bind, value);
|
|
701
|
+
}
|
|
669
702
|
field(n, parent, on) {
|
|
670
|
-
const stateValue = this.
|
|
703
|
+
const stateValue = this.readBind(n.bind);
|
|
704
|
+
// [vms:no-bind] — a value-bearing input with no bind renders but silently
|
|
705
|
+
// drops user input (nothing to persist to). Exclude `file` (bind is
|
|
706
|
+
// legitimately optional — the binary rides multipart) and `hidden`
|
|
707
|
+
// (server-authoritative, no user input).
|
|
708
|
+
if (n.inputType !== "file" && n.inputType !== "hidden" && n.bind == null) {
|
|
709
|
+
this.warnOnce("no-bind:" + n.name, "[vms:no-bind] FieldNode '" + n.name + "' (inputType=" + n.inputType +
|
|
710
|
+
") has no bind — value-bearing inputs need a bind path to persist; the field renders but user input is dropped.");
|
|
711
|
+
}
|
|
671
712
|
if (n.inputType === "hidden") {
|
|
672
713
|
// Hidden fields don't write back — server is authoritative for hidden.
|
|
673
714
|
const inp = document.createElement("input");
|
|
@@ -690,7 +731,7 @@ export class BrowserAdapter {
|
|
|
690
731
|
inp.name = n.name;
|
|
691
732
|
inp.checked = Boolean(stateValue);
|
|
692
733
|
inp.addEventListener("change", () => {
|
|
693
|
-
this.
|
|
734
|
+
this.writeBind(n.bind, inp.checked);
|
|
694
735
|
});
|
|
695
736
|
wrapper.appendChild(inp);
|
|
696
737
|
if (n.label) {
|
|
@@ -743,19 +784,19 @@ export class BrowserAdapter {
|
|
|
743
784
|
// explicit empty the server can reject, never a silently-missing key.
|
|
744
785
|
if (isMulti) {
|
|
745
786
|
if (!Array.isArray(stateValue)) {
|
|
746
|
-
this.
|
|
787
|
+
this.writeBind(n.bind, Array.from(sel.selectedOptions, o => o.value));
|
|
747
788
|
}
|
|
748
789
|
}
|
|
749
790
|
else if (stateValue === undefined || String(stateValue) !== sel.value) {
|
|
750
|
-
this.
|
|
791
|
+
this.writeBind(n.bind, sel.value);
|
|
751
792
|
}
|
|
752
793
|
sel.addEventListener("change", () => {
|
|
753
794
|
if (isMulti) {
|
|
754
795
|
const arr = Array.from(sel.selectedOptions).map(o => o.value);
|
|
755
|
-
this.
|
|
796
|
+
this.writeBind(n.bind, arr);
|
|
756
797
|
}
|
|
757
798
|
else {
|
|
758
|
-
this.
|
|
799
|
+
this.writeBind(n.bind, sel.value);
|
|
759
800
|
}
|
|
760
801
|
if (n.action)
|
|
761
802
|
on({ name: n.action.name });
|
|
@@ -782,14 +823,31 @@ export class BrowserAdapter {
|
|
|
782
823
|
const file = inp.files?.[0];
|
|
783
824
|
if (file) {
|
|
784
825
|
this.fileRegistry.set(n.name, file);
|
|
826
|
+
// [vms:type-mismatch] — OBSERVABLE-SUBSET diagnostic. The client is
|
|
827
|
+
// untyped JS: it CANNOT know a state slot's *declared* server type, so
|
|
828
|
+
// it only catches the observable case where a file object overwrites a
|
|
829
|
+
// slot that already holds a scalar. It does NOT catch an empty/null slot
|
|
830
|
+
// typed string-map server-side — certain detection of that is a
|
|
831
|
+
// server-side `_state` deserialize diagnostic (a separate follow-up).
|
|
832
|
+
if (n.bind != null) {
|
|
833
|
+
const existing = this.readBind(n.bind);
|
|
834
|
+
if (existing != null && typeof existing !== "object") {
|
|
835
|
+
this.warnOnce("type-mismatch:" + n.name + ":" + n.bind, "[vms:type-mismatch] file FieldNode '" + n.name +
|
|
836
|
+
"' writes a {filename,size} object into bind '" + n.bind +
|
|
837
|
+
"', whose current state value is a " + (typeof existing) +
|
|
838
|
+
" — if that slot is typed string/string-map the _state round-trip will FAIL (cannot convert object to String). Give the file field an object-typed slot, or omit bind (the file rides multipart regardless).");
|
|
839
|
+
}
|
|
840
|
+
}
|
|
785
841
|
// Per Phase-6 decision: the picked file is visible in state as a
|
|
786
842
|
// serialization-safe placeholder; the binary travels on the
|
|
787
|
-
// multipart side channel.
|
|
788
|
-
|
|
843
|
+
// multipart side channel. Backward-compat: apps binding a file field
|
|
844
|
+
// to an object slot still get the placeholder. A bind-less file input
|
|
845
|
+
// writes nothing (writeBind no-ops) — the binary rides multipart.
|
|
846
|
+
this.writeBind(n.bind, { filename: file.name, size: file.size });
|
|
789
847
|
}
|
|
790
848
|
else {
|
|
791
849
|
this.fileRegistry.delete(n.name);
|
|
792
|
-
this.
|
|
850
|
+
this.writeBind(n.bind, null);
|
|
793
851
|
}
|
|
794
852
|
});
|
|
795
853
|
wrapper.appendChild(inp);
|
|
@@ -804,7 +862,7 @@ export class BrowserAdapter {
|
|
|
804
862
|
ta.value = stateValue == null ? "" : String(stateValue);
|
|
805
863
|
if (n.required)
|
|
806
864
|
ta.required = true;
|
|
807
|
-
ta.addEventListener("input", () => { this.
|
|
865
|
+
ta.addEventListener("input", () => { this.writeBind(n.bind, ta.value); });
|
|
808
866
|
wrapper.appendChild(ta);
|
|
809
867
|
}
|
|
810
868
|
else if (n.inputType === "code") {
|
|
@@ -827,7 +885,7 @@ export class BrowserAdapter {
|
|
|
827
885
|
ta.value = stateValue == null ? "" : String(stateValue);
|
|
828
886
|
if (n.required)
|
|
829
887
|
ta.required = true;
|
|
830
|
-
ta.addEventListener("input", () => { this.
|
|
888
|
+
ta.addEventListener("input", () => { this.writeBind(n.bind, ta.value); });
|
|
831
889
|
ta.addEventListener("keydown", (e) => {
|
|
832
890
|
if (e.key === "Tab") {
|
|
833
891
|
e.preventDefault();
|
|
@@ -835,7 +893,7 @@ export class BrowserAdapter {
|
|
|
835
893
|
const end = ta.selectionEnd ?? 0;
|
|
836
894
|
ta.value = ta.value.slice(0, start) + "\t" + ta.value.slice(end);
|
|
837
895
|
ta.selectionStart = ta.selectionEnd = start + 1;
|
|
838
|
-
this.
|
|
896
|
+
this.writeBind(n.bind, ta.value);
|
|
839
897
|
}
|
|
840
898
|
});
|
|
841
899
|
wrapper.appendChild(ta);
|
|
@@ -851,7 +909,7 @@ export class BrowserAdapter {
|
|
|
851
909
|
inp.value = stateValue == null ? "" : String(stateValue);
|
|
852
910
|
if (n.required)
|
|
853
911
|
inp.required = true;
|
|
854
|
-
inp.addEventListener("input", () => { this.
|
|
912
|
+
inp.addEventListener("input", () => { this.writeBind(n.bind, inp.value); });
|
|
855
913
|
if (n.action) {
|
|
856
914
|
const action = n.action;
|
|
857
915
|
inp.addEventListener("keydown", (e) => {
|
|
@@ -860,7 +918,7 @@ export class BrowserAdapter {
|
|
|
860
918
|
// Belt-and-suspenders: flush the latest value to state before
|
|
861
919
|
// dispatching, in case the browser hasn't fired `input` yet
|
|
862
920
|
// (e.g. an autofill that lands then submits).
|
|
863
|
-
this.
|
|
921
|
+
this.writeBind(n.bind, inp.value);
|
|
864
922
|
on({ name: action.name });
|
|
865
923
|
}
|
|
866
924
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -74,6 +74,19 @@ export interface Adapter {
|
|
|
74
74
|
tone?: string;
|
|
75
75
|
durationMs?: number;
|
|
76
76
|
}): void;
|
|
77
|
+
/** 3.8.0 — force a full reload of the running client (the browser adapter
|
|
78
|
+
* calls `window.location.reload()`). The shell invokes this ONLY as the
|
|
79
|
+
* fail-closed recovery for a `stale_client` rejection: the server refused a
|
|
80
|
+
* mutation because the tab is running an out-of-date bundle, nothing was
|
|
81
|
+
* applied, and reloading to the fresh bundle is the only honest recovery.
|
|
82
|
+
* FAIL-QUIET BY ABSENCE — modeled on setBusy/toast, NOT on
|
|
83
|
+
* navigate/storage/saveFile: the `stale_client` failure ALSO surfaces via
|
|
84
|
+
* `onError` (as a VmsActionError), so an adapter without this verb still
|
|
85
|
+
* learns of the skew and can recover its own way. A missing `reload` is
|
|
86
|
+
* therefore NOT a silent failure, so the core MUST NOT call failCapability
|
|
87
|
+
* when it is absent (non-browser targets like the TUI have no reload
|
|
88
|
+
* concept). */
|
|
89
|
+
reload?(): void;
|
|
77
90
|
}
|
|
78
91
|
export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode | EmptyStateNode | BadgeNode;
|
|
79
92
|
export interface PageNode {
|
|
@@ -254,8 +267,15 @@ export interface FieldNode {
|
|
|
254
267
|
type: "field";
|
|
255
268
|
name: string;
|
|
256
269
|
inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "code";
|
|
257
|
-
/** Path into state where this input reads its current value and writes user
|
|
258
|
-
|
|
270
|
+
/** Path into state where this input reads its current value and writes user
|
|
271
|
+
* changes (e.g. `fields.title`). REQUIRED for value-bearing inputs
|
|
272
|
+
* (text/email/password/number/date/time/datetime-local/textarea/select/
|
|
273
|
+
* select-multiple/checkbox/code) and OPTIONAL for `file` inputs — a file
|
|
274
|
+
* input's binary rides the multipart side channel (fileRegistry keyed on
|
|
275
|
+
* `name`), so omit `bind` on a file input to avoid writing a
|
|
276
|
+
* `{filename,size}` placeholder object into state (which breaks a
|
|
277
|
+
* string/string-map state slot on round-trip). */
|
|
278
|
+
bind?: string;
|
|
259
279
|
label?: string;
|
|
260
280
|
placeholder?: string;
|
|
261
281
|
required?: boolean;
|
|
@@ -620,6 +640,16 @@ export interface ShellOptions {
|
|
|
620
640
|
* The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
|
|
621
641
|
* omitting nextPollIn when no pollInterval is configured. */
|
|
622
642
|
pollInterval?: number;
|
|
643
|
+
/** 3.8.0 — the id of the client bundle this shell instance is running (the app
|
|
644
|
+
* injects it at build time, e.g. from a Vite `define`/env — VMS never derives
|
|
645
|
+
* it, staying platform-agnostic). When set, the shell (1) attaches it as the
|
|
646
|
+
* `X-VMS-Client-Build` header on every action POST so the server can
|
|
647
|
+
* fail-closed on a stale mutation, and (2) compares it against a response's
|
|
648
|
+
* `serverBuild` and fires a `VmsVersionSkewError` via `onError` when they
|
|
649
|
+
* differ (AFTER rendering — detection never swallows the render). Absent =
|
|
650
|
+
* the whole version-skew feature is off; behavior is byte-identical to a
|
|
651
|
+
* build without it. */
|
|
652
|
+
clientBuildId?: string;
|
|
623
653
|
}
|
|
624
654
|
export interface ShellSideEffect {
|
|
625
655
|
/** "set-local-storage" | "set-session-storage" | "download" | "toast" — unknown types are silently ignored. */
|
|
@@ -671,6 +701,23 @@ export declare class VmsActionError extends Error {
|
|
|
671
701
|
/** Shortcut to `errors[0]?.code`. Undefined when the first entry has no code. */
|
|
672
702
|
get code(): string | undefined;
|
|
673
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* 3.8.0 — surfaced via `onError` when a SUCCESS response's `serverBuild` differs
|
|
706
|
+
* from the configured `ShellOptions.clientBuildId` (client/server version skew:
|
|
707
|
+
* a long-lived tab is running an out-of-date bundle against a server that has
|
|
708
|
+
* rolled forward). This is fired AFTER the response renders normally — it never
|
|
709
|
+
* swallows the render — so it is a loud, catchable signal, not a failure. The
|
|
710
|
+
* app distinguishes it with `if (err instanceof VmsVersionSkewError)` (typically
|
|
711
|
+
* to prompt the user to reload). Distinct from `VmsActionError`: this rides on a
|
|
712
|
+
* fully-successful `ok:true` response.
|
|
713
|
+
*/
|
|
714
|
+
export declare class VmsVersionSkewError extends Error {
|
|
715
|
+
readonly serverBuild: string;
|
|
716
|
+
readonly clientBuild: string;
|
|
717
|
+
/** Stable discriminator for this failure class (parallels VmsActionError.code). */
|
|
718
|
+
readonly code = "version_skew";
|
|
719
|
+
constructor(serverBuild: string, clientBuild: string);
|
|
720
|
+
}
|
|
674
721
|
export interface ShellResponse {
|
|
675
722
|
vm: ViewNode;
|
|
676
723
|
state: unknown;
|
|
@@ -698,6 +745,11 @@ export interface ShellResponse {
|
|
|
698
745
|
ok?: boolean;
|
|
699
746
|
/** 1.0.0 — structured error entries. Present when `ok: false`. */
|
|
700
747
|
errors?: ErrorEntry[];
|
|
748
|
+
/** 3.8.0 — the server's current-deployed client-build id, stamped on every
|
|
749
|
+
* response when the app configures versioning. Compared against
|
|
750
|
+
* `ShellOptions.clientBuildId` to detect a never-reloaded tab running against
|
|
751
|
+
* a rolled-forward server. Absent = the feature is off. */
|
|
752
|
+
serverBuild?: string;
|
|
701
753
|
}
|
|
702
754
|
export declare class ViewModelShell {
|
|
703
755
|
private options;
|
|
@@ -747,6 +799,16 @@ export declare class ViewModelShell {
|
|
|
747
799
|
private stateAccessForAdapter;
|
|
748
800
|
private failCapability;
|
|
749
801
|
private processResponse;
|
|
802
|
+
/**
|
|
803
|
+
* 3.8.0 — Phase 1 detection. When the app configured `clientBuildId` and the
|
|
804
|
+
* response carries a differing `serverBuild`, fire a `VmsVersionSkewError`
|
|
805
|
+
* through the existing `onError` seam so the app can react (e.g. prompt a
|
|
806
|
+
* reload). Called from BOTH success paths (`load()` and `processResponse()`)
|
|
807
|
+
* AFTER the response has rendered — detection is additive and never affects
|
|
808
|
+
* the render. No-op when `clientBuildId` is unset, `serverBuild` is absent, or
|
|
809
|
+
* the two ids match.
|
|
810
|
+
*/
|
|
811
|
+
private checkVersionSkew;
|
|
750
812
|
private schedulePoll;
|
|
751
813
|
/**
|
|
752
814
|
* Authenticated download: fetch the URL with getRequestHeaders() merged
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,29 @@ export class VmsActionError extends Error {
|
|
|
36
36
|
return this.errors[0]?.code;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* 3.8.0 — surfaced via `onError` when a SUCCESS response's `serverBuild` differs
|
|
41
|
+
* from the configured `ShellOptions.clientBuildId` (client/server version skew:
|
|
42
|
+
* a long-lived tab is running an out-of-date bundle against a server that has
|
|
43
|
+
* rolled forward). This is fired AFTER the response renders normally — it never
|
|
44
|
+
* swallows the render — so it is a loud, catchable signal, not a failure. The
|
|
45
|
+
* app distinguishes it with `if (err instanceof VmsVersionSkewError)` (typically
|
|
46
|
+
* to prompt the user to reload). Distinct from `VmsActionError`: this rides on a
|
|
47
|
+
* fully-successful `ok:true` response.
|
|
48
|
+
*/
|
|
49
|
+
export class VmsVersionSkewError extends Error {
|
|
50
|
+
serverBuild;
|
|
51
|
+
clientBuild;
|
|
52
|
+
/** Stable discriminator for this failure class (parallels VmsActionError.code). */
|
|
53
|
+
code = "version_skew";
|
|
54
|
+
constructor(serverBuild, clientBuild) {
|
|
55
|
+
super(`Client build "${clientBuild}" is out of date — the server is now serving ` +
|
|
56
|
+
`build "${serverBuild}". Reload to get the current app.`);
|
|
57
|
+
this.serverBuild = serverBuild;
|
|
58
|
+
this.clientBuild = clientBuild;
|
|
59
|
+
this.name = "VmsVersionSkewError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
39
62
|
export class ViewModelShell {
|
|
40
63
|
options;
|
|
41
64
|
currentVm = null;
|
|
@@ -92,6 +115,10 @@ export class ViewModelShell {
|
|
|
92
115
|
this.syncBusy();
|
|
93
116
|
adapter.render(body.vm, (action) => this.dispatch(action), this.stateAccessForAdapter());
|
|
94
117
|
this.schedulePoll(body.nextPollIn);
|
|
118
|
+
// 3.8.0 — version-skew DETECTION (Phase 1). Render happened above FIRST;
|
|
119
|
+
// this only fires a loud, catchable signal and never affects the render.
|
|
120
|
+
// At initial load the ids normally match (fresh bundle) so it's a no-op.
|
|
121
|
+
this.checkVersionSkew(body);
|
|
95
122
|
}
|
|
96
123
|
catch (err) {
|
|
97
124
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -140,9 +167,15 @@ export class ViewModelShell {
|
|
|
140
167
|
}
|
|
141
168
|
const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
|
|
142
169
|
const adapter = this.options.adapter;
|
|
170
|
+
// 3.8.0 — Phase 2 fail-closed guard: advertise the running bundle id so the
|
|
171
|
+
// server can reject a mutation from a stale client BEFORE deserializing
|
|
172
|
+
// _state. Merged AFTER getRequestHeaders() so app headers can't clobber it.
|
|
173
|
+
const headers = { Accept: "application/json", ...extraHeaders };
|
|
174
|
+
if (this.options.clientBuildId)
|
|
175
|
+
headers["X-VMS-Client-Build"] = this.options.clientBuildId;
|
|
143
176
|
const init = {
|
|
144
177
|
method: "POST",
|
|
145
|
-
headers
|
|
178
|
+
headers,
|
|
146
179
|
body: form,
|
|
147
180
|
};
|
|
148
181
|
let res;
|
|
@@ -179,6 +212,17 @@ export class ViewModelShell {
|
|
|
179
212
|
catch (err) {
|
|
180
213
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
181
214
|
onError ? onError(error) : console.error("[ViewModelShell]", error);
|
|
215
|
+
// 3.8.0 — Phase 2 fail-closed recovery. The server rejected this mutation
|
|
216
|
+
// because the tab is running a stale bundle (nothing was applied). Order
|
|
217
|
+
// per the locked design: surface via onError FIRST (done above), THEN
|
|
218
|
+
// force a reload to the fresh bundle — the only safe recovery. reload is
|
|
219
|
+
// fail-quiet by absence (the VmsActionError already surfaced), so this is
|
|
220
|
+
// a plain optional-chain call, and we return before the below re-render
|
|
221
|
+
// (the page is reloading; re-rendering the stale tree is pointless).
|
|
222
|
+
if (error instanceof VmsActionError && error.code === "stale_client") {
|
|
223
|
+
this.options.adapter.reload?.();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
182
226
|
// 0.8.0 (#11) — re-render the current VM on dispatch error. Adapters
|
|
183
227
|
// may have applied client-side ephemeral state in onAction handlers
|
|
184
228
|
// (e.g., BrowserAdapter swaps button text for ButtonNode.pendingLabel).
|
|
@@ -337,6 +381,27 @@ export class ViewModelShell {
|
|
|
337
381
|
this.currentState = body.state;
|
|
338
382
|
}
|
|
339
383
|
this.schedulePoll(body.nextPollIn);
|
|
384
|
+
// 3.8.0 — version-skew DETECTION (Phase 1) on the dispatch / poll / push
|
|
385
|
+
// success path. Fired AFTER the render above (never swallows it). A redirect
|
|
386
|
+
// response returned early above, so this is the non-redirect path only.
|
|
387
|
+
this.checkVersionSkew(body);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* 3.8.0 — Phase 1 detection. When the app configured `clientBuildId` and the
|
|
391
|
+
* response carries a differing `serverBuild`, fire a `VmsVersionSkewError`
|
|
392
|
+
* through the existing `onError` seam so the app can react (e.g. prompt a
|
|
393
|
+
* reload). Called from BOTH success paths (`load()` and `processResponse()`)
|
|
394
|
+
* AFTER the response has rendered — detection is additive and never affects
|
|
395
|
+
* the render. No-op when `clientBuildId` is unset, `serverBuild` is absent, or
|
|
396
|
+
* the two ids match.
|
|
397
|
+
*/
|
|
398
|
+
checkVersionSkew(body) {
|
|
399
|
+
const clientBuild = this.options.clientBuildId;
|
|
400
|
+
const serverBuild = body.serverBuild;
|
|
401
|
+
if (clientBuild && serverBuild && serverBuild !== clientBuild) {
|
|
402
|
+
const err = new VmsVersionSkewError(serverBuild, clientBuild);
|
|
403
|
+
this.options.onError ? this.options.onError(err) : console.error("[ViewModelShell]", err);
|
|
404
|
+
}
|
|
340
405
|
}
|
|
341
406
|
schedulePoll(nextPollIn) {
|
|
342
407
|
const delay = nextPollIn ?? this.options.pollInterval;
|
package/dist/server.d.ts
CHANGED
|
@@ -62,6 +62,12 @@ export interface ShellResponseBody<TState> {
|
|
|
62
62
|
* ErrorEntry {path?, message, code?} shape; `path` is OPTIONAL — a violation
|
|
63
63
|
* with no path is a form/action-level rejection (vs field-bound when set). */
|
|
64
64
|
rejected?: ShellRejection;
|
|
65
|
+
/** 3.8.0 — the server's current-deployed client-build id. Normally stamped
|
|
66
|
+
* automatically by `createAction(handler, { currentBuild })`; also settable
|
|
67
|
+
* by hand on a response built outside createAction (e.g. a GET handler or a
|
|
68
|
+
* server-pushed SSE/WebSocket body) so those responses carry `serverBuild`
|
|
69
|
+
* too. Absent = the versioning feature is off for this response. */
|
|
70
|
+
serverBuild?: string;
|
|
65
71
|
}
|
|
66
72
|
/** Wrapper for a soft-validation rejection on an ok:true response. */
|
|
67
73
|
export interface ShellRejection {
|
|
@@ -207,6 +213,10 @@ export declare const ERR_CODES: {
|
|
|
207
213
|
readonly INVALID_TREE: "invalid_tree";
|
|
208
214
|
/** App handler threw an unrecognised exception. HTTP 500. */
|
|
209
215
|
readonly UNCAUGHT: "uncaught_exception";
|
|
216
|
+
/** 3.8.0 — request's `X-VMS-Client-Build` header ≠ the server's current-deployed
|
|
217
|
+
* build id (a stale, never-reloaded tab attempting a mutation). The request is
|
|
218
|
+
* rejected BEFORE `_state` is deserialized. HTTP 400. */
|
|
219
|
+
readonly STALE_CLIENT: "stale_client";
|
|
210
220
|
};
|
|
211
221
|
/** Union type of the framework error codes from ERR_CODES. Useful for narrowing `errors[0].code`. */
|
|
212
222
|
export type ErrCode = typeof ERR_CODES[keyof typeof ERR_CODES];
|
|
@@ -224,5 +234,19 @@ export type ErrCode = typeof ERR_CODES[keyof typeof ERR_CODES];
|
|
|
224
234
|
* const state = applyAction(payload);
|
|
225
235
|
* return { vm: buildVm(state), state };
|
|
226
236
|
* }));
|
|
237
|
+
*
|
|
238
|
+
* 3.8.0 — optional version-skew half. Pass `{ currentBuild }` (the id of the
|
|
239
|
+
* client bundle this server currently deploys) to opt in:
|
|
240
|
+
* - GUARD (fail-closed): if the request carries an `X-VMS-Client-Build` header
|
|
241
|
+
* whose value ≠ `currentBuild`, the mutation is rejected with a 400
|
|
242
|
+
* `stale_client` envelope BEFORE the body/`_state` is deserialized (the
|
|
243
|
+
* app's typed handler never runs on a stale client's payload).
|
|
244
|
+
* - STAMP: every successful response includes `serverBuild: currentBuild` so
|
|
245
|
+
* the client can detect a never-reloaded tab.
|
|
246
|
+
* Omit `currentBuild` (or pass nothing) and behavior is byte-identical to
|
|
247
|
+
* before — no guard, no stamp. The existing handler-only call signature is
|
|
248
|
+
* unchanged.
|
|
227
249
|
*/
|
|
228
|
-
export declare function createAction<TState>(handler: (payload: ActionPayload<TState>) => Promise<ShellResponseBody<TState>> | ShellResponseBody<TState
|
|
250
|
+
export declare function createAction<TState>(handler: (payload: ActionPayload<TState>) => Promise<ShellResponseBody<TState>> | ShellResponseBody<TState>, options?: {
|
|
251
|
+
currentBuild?: string;
|
|
252
|
+
}): (request: Request) => Promise<Response>;
|
package/dist/server.js
CHANGED
|
@@ -574,6 +574,10 @@ export const ERR_CODES = {
|
|
|
574
574
|
INVALID_TREE: "invalid_tree",
|
|
575
575
|
/** App handler threw an unrecognised exception. HTTP 500. */
|
|
576
576
|
UNCAUGHT: "uncaught_exception",
|
|
577
|
+
/** 3.8.0 — request's `X-VMS-Client-Build` header ≠ the server's current-deployed
|
|
578
|
+
* build id (a stale, never-reloaded tab attempting a mutation). The request is
|
|
579
|
+
* rejected BEFORE `_state` is deserialized. HTTP 400. */
|
|
580
|
+
STALE_CLIENT: "stale_client",
|
|
577
581
|
};
|
|
578
582
|
/**
|
|
579
583
|
* Build a JSON-stringified `{ok: false, errors: [...]}` envelope.
|
|
@@ -623,9 +627,35 @@ function jsonResponse(body, status) {
|
|
|
623
627
|
* const state = applyAction(payload);
|
|
624
628
|
* return { vm: buildVm(state), state };
|
|
625
629
|
* }));
|
|
630
|
+
*
|
|
631
|
+
* 3.8.0 — optional version-skew half. Pass `{ currentBuild }` (the id of the
|
|
632
|
+
* client bundle this server currently deploys) to opt in:
|
|
633
|
+
* - GUARD (fail-closed): if the request carries an `X-VMS-Client-Build` header
|
|
634
|
+
* whose value ≠ `currentBuild`, the mutation is rejected with a 400
|
|
635
|
+
* `stale_client` envelope BEFORE the body/`_state` is deserialized (the
|
|
636
|
+
* app's typed handler never runs on a stale client's payload).
|
|
637
|
+
* - STAMP: every successful response includes `serverBuild: currentBuild` so
|
|
638
|
+
* the client can detect a never-reloaded tab.
|
|
639
|
+
* Omit `currentBuild` (or pass nothing) and behavior is byte-identical to
|
|
640
|
+
* before — no guard, no stamp. The existing handler-only call signature is
|
|
641
|
+
* unchanged.
|
|
626
642
|
*/
|
|
627
|
-
export function createAction(handler) {
|
|
643
|
+
export function createAction(handler, options) {
|
|
644
|
+
const currentBuild = options?.currentBuild;
|
|
628
645
|
return async (request) => {
|
|
646
|
+
// 3.8.0 — fail-closed stale-client guard. Runs FIRST, before any body parse,
|
|
647
|
+
// so a stale client's `_state` is never deserialized. Only when the app
|
|
648
|
+
// configured `currentBuild` AND the header is present AND it mismatches.
|
|
649
|
+
if (currentBuild) {
|
|
650
|
+
const clientBuild = request.headers.get("x-vms-client-build");
|
|
651
|
+
if (clientBuild !== null && clientBuild !== currentBuild) {
|
|
652
|
+
return jsonResponse(errorEnvelope([{
|
|
653
|
+
message: `Stale client: request build "${clientBuild}" does not match the ` +
|
|
654
|
+
`current deployed build "${currentBuild}". Reload to continue.`,
|
|
655
|
+
code: ERR_CODES.STALE_CLIENT,
|
|
656
|
+
}]), 400);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
629
659
|
const contentType = request.headers.get("content-type") ?? "";
|
|
630
660
|
let payload;
|
|
631
661
|
try {
|
|
@@ -676,6 +706,12 @@ export function createAction(handler) {
|
|
|
676
706
|
}
|
|
677
707
|
// Phase 07 / ERROR-01 — every successful response acquires ok:true at the
|
|
678
708
|
// response edge. Controllers / app handlers do NOT set ok themselves.
|
|
679
|
-
|
|
709
|
+
// 3.8.0 — stamp serverBuild when versioning is configured. Placed after the
|
|
710
|
+
// result spread so `currentBuild` wins over any hand-set result.serverBuild.
|
|
711
|
+
return jsonResponse(JSON.stringify({
|
|
712
|
+
ok: true,
|
|
713
|
+
...result,
|
|
714
|
+
...(currentBuild ? { serverBuild: currentBuild } : {}),
|
|
715
|
+
}), 200);
|
|
680
716
|
};
|
|
681
717
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|