@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 +9 -0
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +9 -0
- package/dist/index.d.ts +55 -0
- 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
|
@@ -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
|
|
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.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",
|