@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 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.sa.read(n.bind);
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.sa.write(n.bind, inp.checked);
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.sa.write(n.bind, Array.from(sel.selectedOptions, o => o.value));
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.sa.write(n.bind, sel.value);
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.sa.write(n.bind, arr);
796
+ this.writeBind(n.bind, arr);
756
797
  }
757
798
  else {
758
- this.sa.write(n.bind, sel.value);
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
- this.sa.write(n.bind, { filename: file.name, size: file.size });
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.sa.write(n.bind, null);
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.sa.write(n.bind, ta.value); });
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.sa.write(n.bind, ta.value); });
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.sa.write(n.bind, ta.value);
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.sa.write(n.bind, inp.value); });
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.sa.write(n.bind, inp.value);
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 changes (e.g. `fields.title`). */
258
- bind: string;
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: { Accept: "application/json", ...extraHeaders },
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>): (request: Request) => Promise<Response>;
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
- return jsonResponse(JSON.stringify({ ok: true, ...result }), 200);
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.7.0",
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",