@ashley-shrok/viewmodel-shell 3.6.2 → 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 +51 -0
- package/dist/index.d.ts +57 -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
|
@@ -36,6 +36,12 @@ const noopStateAccess = {
|
|
|
36
36
|
read: () => undefined,
|
|
37
37
|
write: () => { },
|
|
38
38
|
};
|
|
39
|
+
// SectionNode.followTail — a `[data-follow-tail]` element counts as "at the
|
|
40
|
+
// bottom" (and should stay pinned to the newest content on re-render) when it
|
|
41
|
+
// is within this many pixels of the bottom. A small tolerance so sub-pixel
|
|
42
|
+
// rounding and being ~a line off the bottom still counts as "following"; scroll
|
|
43
|
+
// up past it and the adapter respects the user reading history instead.
|
|
44
|
+
const FOLLOW_TAIL_STICK_THRESHOLD_PX = 40;
|
|
39
45
|
export class BrowserAdapter {
|
|
40
46
|
container;
|
|
41
47
|
fileRegistry = new Map();
|
|
@@ -74,9 +80,26 @@ export class BrowserAdapter {
|
|
|
74
80
|
const winScrollY = window.scrollY;
|
|
75
81
|
const scrollMap = new Map();
|
|
76
82
|
this.container.querySelectorAll("[id]").forEach(el => {
|
|
83
|
+
// follow-tail elements own their own restore (see below) — the generic
|
|
84
|
+
// preserve-the-prior-scrollTop contract is exactly what they must NOT do.
|
|
85
|
+
if (el.hasAttribute("data-follow-tail"))
|
|
86
|
+
return;
|
|
77
87
|
if (el.scrollTop !== 0 || el.scrollLeft !== 0)
|
|
78
88
|
scrollMap.set(el.id, { top: el.scrollTop, left: el.scrollLeft });
|
|
79
89
|
});
|
|
90
|
+
// SectionNode.followTail — snapshot, in document order, whether each
|
|
91
|
+
// append-only feed was scrolled near its bottom (and its prior scrollTop
|
|
92
|
+
// for the scrolled-up case). Ordinal-matched to the post-render walk below,
|
|
93
|
+
// the same stable-order approach as the collapsible-section snapshot; a
|
|
94
|
+
// brand-new feed has no entry at its ordinal and is pinned to the bottom.
|
|
95
|
+
const followTail = [];
|
|
96
|
+
this.container.querySelectorAll("[data-follow-tail]").forEach(el => {
|
|
97
|
+
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
98
|
+
followTail.push({
|
|
99
|
+
nearBottom: distanceFromBottom <= FOLLOW_TAIL_STICK_THRESHOLD_PX,
|
|
100
|
+
top: el.scrollTop,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
80
103
|
// 1.2.0 — snapshot collapsible-section open state by stable key. Same
|
|
81
104
|
// pattern as focusId/scrollMap above: capture before innerHTML wipe, walk
|
|
82
105
|
// the rebuilt tree after node() returns, restore matching keys. Reset
|
|
@@ -116,6 +139,19 @@ export class BrowserAdapter {
|
|
|
116
139
|
el.scrollLeft = left;
|
|
117
140
|
}
|
|
118
141
|
});
|
|
142
|
+
// SectionNode.followTail restore — runs AFTER the generic scrollMap restore
|
|
143
|
+
// so it wins on any element carrying both an id and data-follow-tail. Walk
|
|
144
|
+
// the rebuilt feeds in document order and match them to the pre-render
|
|
145
|
+
// snapshot by ordinal: a feed that WAS near the bottom (or is brand new, no
|
|
146
|
+
// snapshot at its ordinal) is pinned to the NEW bottom so freshly appended
|
|
147
|
+
// content is visible; a feed the user had scrolled up in keeps its place.
|
|
148
|
+
this.container.querySelectorAll("[data-follow-tail]").forEach((el, i) => {
|
|
149
|
+
const snap = followTail[i];
|
|
150
|
+
if (!snap || snap.nearBottom)
|
|
151
|
+
el.scrollTop = el.scrollHeight;
|
|
152
|
+
else
|
|
153
|
+
el.scrollTop = snap.top;
|
|
154
|
+
});
|
|
119
155
|
// Only restore window scroll when the page was actually scrolled — restoring
|
|
120
156
|
// to (0,0) is a no-op, and skipping it avoids jsdom's noisy "Not implemented:
|
|
121
157
|
// window.scrollTo" virtual-console log in unit tests (jsdom never scrolls, so
|
|
@@ -162,6 +198,15 @@ export class BrowserAdapter {
|
|
|
162
198
|
setBusy(active) {
|
|
163
199
|
this.container.classList.toggle("vms-busy", active);
|
|
164
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
|
+
}
|
|
165
210
|
/** Transient confirmation toast. Lazily creates/reuses a single fixed-corner
|
|
166
211
|
* host region (.vms-toast-region) appended to <body> so toasts stack and
|
|
167
212
|
* survive the container's innerHTML wipe on each render(); appends a
|
|
@@ -474,6 +519,12 @@ export class BrowserAdapter {
|
|
|
474
519
|
}
|
|
475
520
|
const el = document.createElement("section");
|
|
476
521
|
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}${n.action ? " vms-section--clickable" : ""}`;
|
|
522
|
+
// SectionNode.followTail — mark this as an append-only feed so render()'s
|
|
523
|
+
// snapshot/restore keeps its newest content in view (see render() + the
|
|
524
|
+
// FOLLOW_TAIL_STICK_THRESHOLD_PX constant). No CSS/class — the scroll comes
|
|
525
|
+
// from the element already being an overflow region (pair with fill).
|
|
526
|
+
if (n.followTail === true)
|
|
527
|
+
el.dataset.followTail = "";
|
|
477
528
|
if (n.heading) {
|
|
478
529
|
const h = document.createElement("h2");
|
|
479
530
|
h.className = "vms-section__heading";
|
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 {
|
|
@@ -110,6 +123,8 @@ export interface SectionNode {
|
|
|
110
123
|
layout?: "stack" | "split" | "cards" | "sidebar" | "row" | "switcher";
|
|
111
124
|
/** When true (and inside a `fill` page) this section takes the remaining column height and scrolls internally (`flex:1 1 auto; min-height:0; overflow-y:auto`) — the body region of a full-height app shell (e.g. a chat transcript above a pinned composer). Orthogonal to `layout` — a fill section still arranges its own children via `layout`. Outside a `fill` page it's a harmless no-op (the modifier class is inert without a `100dvh` parent). Absent/false = byte-identical to today. */
|
|
112
125
|
fill?: boolean;
|
|
126
|
+
/** When true, this section is a "follow the tail" scroll region — an append-only feed that keeps its NEWEST content in view across re-renders, unless the user has scrolled up to read history. The general primitive for a growing transcript: a chat log above a pinned composer, a live `tail -f` log view, an activity/audit stream, streamed job output. It exists because the default scroll-preservation contract (0.7.1/#7 — restore the prior `scrollTop`) is INVERTED for a growing feed: the old bottom becomes mid-scroll once taller content is appended, so the newest content silently ends up off-screen. Pure client-side render behavior (the server stays stateless — scroll position never rides the wire): the BrowserAdapter records, before each re-render, whether this element was within a small threshold of the bottom; after the re-render it pins a near-bottom element to the NEW bottom (`scrollTop = scrollHeight`) and leaves a scrolled-up element exactly where the user parked it. The decision is a pure function of the feed's scroll position at render time — `render()` doesn't know (or care) what triggered it, so a background poll, an SSE push, and the user's own form submit all follow the same rule; a genuinely scrolled-up feed is never hijacked. (When a chat visibly jumps to the bottom on the user's OWN send, that's the send interaction scrolling to the bottom before the re-render — the standard "your message pulls you down" UX — which the feed then correctly reads as at-bottom, not a special case here.) A brand-new follow-tail section starts pinned to the bottom (opens at the latest message). Emits `data-follow-tail` (no CSS — the scroll comes from the element already being an overflow region), so it's meant to pair with `fill` (which provides the internal `overflow-y:auto`) or any app that makes the section scroll; on a non-scrolling element it's an inert no-op. Orthogonal to `fill` and `layout`. The TUI ignores it (terminals follow naturally). Absent/false = byte-identical to today's preserve-my-place restore. */
|
|
127
|
+
followTail?: boolean;
|
|
113
128
|
/** Main-axis arrangement for `layout:"row"` (the cluster primitive) — maps to `justify-content`. Omitted = no class → the row default (`flex-start`, left-pack) holds = byte-identical to today. Closed union copied verbatim from Jetpack Compose `Arrangement` ∩ Flutter `MainAxisAlignment` (ALIGN-01). Emits .vms-arrange--{value}. */
|
|
114
129
|
arrange?: "start" | "center" | "end" | "space-between" | "space-around" | "space-evenly";
|
|
115
130
|
/** Cross-axis alignment for `layout:"row"` (the cluster primitive) — maps to `align-items`. Omitted = no class → the row default (`center`) holds = byte-identical to today. Closed union copied verbatim from Flutter `CrossAxisAlignment` (ALIGN-02). Emits .vms-align--{value}. */
|
|
@@ -618,6 +633,16 @@ export interface ShellOptions {
|
|
|
618
633
|
* The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
|
|
619
634
|
* omitting nextPollIn when no pollInterval is configured. */
|
|
620
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;
|
|
621
646
|
}
|
|
622
647
|
export interface ShellSideEffect {
|
|
623
648
|
/** "set-local-storage" | "set-session-storage" | "download" | "toast" — unknown types are silently ignored. */
|
|
@@ -669,6 +694,23 @@ export declare class VmsActionError extends Error {
|
|
|
669
694
|
/** Shortcut to `errors[0]?.code`. Undefined when the first entry has no code. */
|
|
670
695
|
get code(): string | undefined;
|
|
671
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
|
+
}
|
|
672
714
|
export interface ShellResponse {
|
|
673
715
|
vm: ViewNode;
|
|
674
716
|
state: unknown;
|
|
@@ -696,6 +738,11 @@ export interface ShellResponse {
|
|
|
696
738
|
ok?: boolean;
|
|
697
739
|
/** 1.0.0 — structured error entries. Present when `ok: false`. */
|
|
698
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;
|
|
699
746
|
}
|
|
700
747
|
export declare class ViewModelShell {
|
|
701
748
|
private options;
|
|
@@ -745,6 +792,16 @@ export declare class ViewModelShell {
|
|
|
745
792
|
private stateAccessForAdapter;
|
|
746
793
|
private failCapability;
|
|
747
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;
|
|
748
805
|
private schedulePoll;
|
|
749
806
|
/**
|
|
750
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",
|