@ashley-shrok/viewmodel-shell 3.7.0 → 3.8.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
@@ -12,6 +12,13 @@ export declare class BrowserAdapter implements Adapter {
12
12
  storage(scope: "local" | "session", key: string, value: string): void;
13
13
  saveFile(data: Blob, filename: string, _contentType: string): void;
14
14
  setBusy(active: boolean): void;
15
+ /** 3.8.0 — force a full page reload. The shell calls this as the fail-closed
16
+ * recovery for a `stale_client` rejection (a mutation refused because this
17
+ * tab is running an out-of-date bundle). `window.location.reload()` pulls the
18
+ * fresh, cache-revalidated shell + bundle. Fail-quiet by absence in core (the
19
+ * `stale_client` VmsActionError already surfaced via onError), so this is a
20
+ * plain implementation, not a fail-loud capability. */
21
+ reload(): void;
15
22
  /** Transient confirmation toast. Lazily creates/reuses a single fixed-corner
16
23
  * host region (.vms-toast-region) appended to <body> so toasts stack and
17
24
  * survive the container's innerHTML wipe on each render(); appends a
package/dist/browser.js CHANGED
@@ -198,6 +198,15 @@ export class BrowserAdapter {
198
198
  setBusy(active) {
199
199
  this.container.classList.toggle("vms-busy", active);
200
200
  }
201
+ /** 3.8.0 — force a full page reload. The shell calls this as the fail-closed
202
+ * recovery for a `stale_client` rejection (a mutation refused because this
203
+ * tab is running an out-of-date bundle). `window.location.reload()` pulls the
204
+ * fresh, cache-revalidated shell + bundle. Fail-quiet by absence in core (the
205
+ * `stale_client` VmsActionError already surfaced via onError), so this is a
206
+ * plain implementation, not a fail-loud capability. */
207
+ reload() {
208
+ window.location.reload();
209
+ }
201
210
  /** Transient confirmation toast. Lazily creates/reuses a single fixed-corner
202
211
  * host region (.vms-toast-region) appended to <body> so toasts stack and
203
212
  * survive the container's innerHTML wipe on each render(); appends a
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 {
@@ -620,6 +633,16 @@ export interface ShellOptions {
620
633
  * The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
621
634
  * omitting nextPollIn when no pollInterval is configured. */
622
635
  pollInterval?: number;
636
+ /** 3.8.0 — the id of the client bundle this shell instance is running (the app
637
+ * injects it at build time, e.g. from a Vite `define`/env — VMS never derives
638
+ * it, staying platform-agnostic). When set, the shell (1) attaches it as the
639
+ * `X-VMS-Client-Build` header on every action POST so the server can
640
+ * fail-closed on a stale mutation, and (2) compares it against a response's
641
+ * `serverBuild` and fires a `VmsVersionSkewError` via `onError` when they
642
+ * differ (AFTER rendering — detection never swallows the render). Absent =
643
+ * the whole version-skew feature is off; behavior is byte-identical to a
644
+ * build without it. */
645
+ clientBuildId?: string;
623
646
  }
624
647
  export interface ShellSideEffect {
625
648
  /** "set-local-storage" | "set-session-storage" | "download" | "toast" — unknown types are silently ignored. */
@@ -671,6 +694,23 @@ export declare class VmsActionError extends Error {
671
694
  /** Shortcut to `errors[0]?.code`. Undefined when the first entry has no code. */
672
695
  get code(): string | undefined;
673
696
  }
697
+ /**
698
+ * 3.8.0 — surfaced via `onError` when a SUCCESS response's `serverBuild` differs
699
+ * from the configured `ShellOptions.clientBuildId` (client/server version skew:
700
+ * a long-lived tab is running an out-of-date bundle against a server that has
701
+ * rolled forward). This is fired AFTER the response renders normally — it never
702
+ * swallows the render — so it is a loud, catchable signal, not a failure. The
703
+ * app distinguishes it with `if (err instanceof VmsVersionSkewError)` (typically
704
+ * to prompt the user to reload). Distinct from `VmsActionError`: this rides on a
705
+ * fully-successful `ok:true` response.
706
+ */
707
+ export declare class VmsVersionSkewError extends Error {
708
+ readonly serverBuild: string;
709
+ readonly clientBuild: string;
710
+ /** Stable discriminator for this failure class (parallels VmsActionError.code). */
711
+ readonly code = "version_skew";
712
+ constructor(serverBuild: string, clientBuild: string);
713
+ }
674
714
  export interface ShellResponse {
675
715
  vm: ViewNode;
676
716
  state: unknown;
@@ -698,6 +738,11 @@ export interface ShellResponse {
698
738
  ok?: boolean;
699
739
  /** 1.0.0 — structured error entries. Present when `ok: false`. */
700
740
  errors?: ErrorEntry[];
741
+ /** 3.8.0 — the server's current-deployed client-build id, stamped on every
742
+ * response when the app configures versioning. Compared against
743
+ * `ShellOptions.clientBuildId` to detect a never-reloaded tab running against
744
+ * a rolled-forward server. Absent = the feature is off. */
745
+ serverBuild?: string;
701
746
  }
702
747
  export declare class ViewModelShell {
703
748
  private options;
@@ -747,6 +792,16 @@ export declare class ViewModelShell {
747
792
  private stateAccessForAdapter;
748
793
  private failCapability;
749
794
  private processResponse;
795
+ /**
796
+ * 3.8.0 — Phase 1 detection. When the app configured `clientBuildId` and the
797
+ * response carries a differing `serverBuild`, fire a `VmsVersionSkewError`
798
+ * through the existing `onError` seam so the app can react (e.g. prompt a
799
+ * reload). Called from BOTH success paths (`load()` and `processResponse()`)
800
+ * AFTER the response has rendered — detection is additive and never affects
801
+ * the render. No-op when `clientBuildId` is unset, `serverBuild` is absent, or
802
+ * the two ids match.
803
+ */
804
+ private checkVersionSkew;
750
805
  private schedulePoll;
751
806
  /**
752
807
  * 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.8.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",