@ashley-shrok/viewmodel-shell 0.3.12 → 0.3.13
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/dist/browser.d.ts +9 -0
- package/dist/browser.js +80 -0
- package/dist/index.d.ts +24 -1
- package/dist/index.js +33 -5
- package/package.json +6 -2
package/dist/browser.d.ts
CHANGED
|
@@ -4,6 +4,15 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
4
4
|
private fileRegistry;
|
|
5
5
|
constructor(container: HTMLElement);
|
|
6
6
|
render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
|
|
7
|
+
navigate(url: string): void;
|
|
8
|
+
storage(scope: "local" | "session", key: string, value: string): void;
|
|
9
|
+
transport(input: string, init: {
|
|
10
|
+
method?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
body?: FormData | string;
|
|
13
|
+
}, hooks?: {
|
|
14
|
+
onUploadProgress?: (sent: number, total: number) => void;
|
|
15
|
+
}): Promise<Response>;
|
|
7
16
|
private node;
|
|
8
17
|
private kids;
|
|
9
18
|
private page;
|
package/dist/browser.js
CHANGED
|
@@ -45,6 +45,86 @@ export class BrowserAdapter {
|
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
+
navigate(url) {
|
|
49
|
+
window.location.href = url;
|
|
50
|
+
}
|
|
51
|
+
storage(scope, key, value) {
|
|
52
|
+
const store = scope === "session" ? sessionStorage : localStorage;
|
|
53
|
+
store.setItem(key, value);
|
|
54
|
+
}
|
|
55
|
+
async transport(input, init, hooks) {
|
|
56
|
+
const onUploadProgress = hooks?.onUploadProgress;
|
|
57
|
+
if (!onUploadProgress) {
|
|
58
|
+
// No progress requested → identical to the core fetch path (D-02 fallback parity).
|
|
59
|
+
return fetch(input, init);
|
|
60
|
+
}
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const xhr = new XMLHttpRequest();
|
|
63
|
+
// IN-01: this seam only exists to carry a body+files action request, so
|
|
64
|
+
// a method-less init is non-sensical. dispatch() (the sole caller) always
|
|
65
|
+
// passes "POST"; default to "POST" (not "GET") so a future caller bug
|
|
66
|
+
// never silently produces a body-bearing GET.
|
|
67
|
+
xhr.open(init.method ?? "POST", input);
|
|
68
|
+
// WR-02: every header dispatch() builds in `init.headers` (Accept +
|
|
69
|
+
// getRequestHeaders()) is applied here, so the XHR path's request
|
|
70
|
+
// headers are byte-identical to the fetch path's. Scope note: this
|
|
71
|
+
// seam is same-origin only. The fetch fallback sends cookies on
|
|
72
|
+
// same-origin requests via its default `credentials: "same-origin"`;
|
|
73
|
+
// XHR sends same-origin cookies without `withCredentials`, so the
|
|
74
|
+
// common (same-origin `actionEndpoint`) case matches fetch exactly.
|
|
75
|
+
// Cross-origin action endpoints are out of scope for this transport.
|
|
76
|
+
for (const [k, v] of Object.entries(init.headers ?? {})) {
|
|
77
|
+
xhr.setRequestHeader(k, v);
|
|
78
|
+
}
|
|
79
|
+
let knownTotal = 0; // last computable total (0 = never computable)
|
|
80
|
+
let lastLoaded = 0; // last reported bytes sent
|
|
81
|
+
xhr.upload.onprogress = (e) => {
|
|
82
|
+
lastLoaded = e.loaded;
|
|
83
|
+
if (e.lengthComputable) {
|
|
84
|
+
knownTotal = e.total;
|
|
85
|
+
onUploadProgress(e.loaded, e.total); // D-05 in-flight, computable
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
onUploadProgress(e.loaded, 0); // D-05 indeterminate sentinel (0)
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
xhr.onload = () => {
|
|
92
|
+
// D-05 terminal emission: mirror whichever value was being reported.
|
|
93
|
+
// Known total → (total, total); indeterminate → (finalLoaded, finalLoaded).
|
|
94
|
+
// NEVER (0,0) once any progress event has fired; a body that produces
|
|
95
|
+
// no progress event (e.g. a zero-byte upload, or a transport that
|
|
96
|
+
// completes before the browser emits any upload progress) legitimately
|
|
97
|
+
// terminates at (0,0), which the documented `total > 0` consumer guard
|
|
98
|
+
// (MIGRATION.md 5b) handles.
|
|
99
|
+
if (knownTotal > 0)
|
|
100
|
+
onUploadProgress(knownTotal, knownTotal);
|
|
101
|
+
else
|
|
102
|
+
onUploadProgress(lastLoaded, lastLoaded);
|
|
103
|
+
// D-08: status 0 means a network-level failure (CORS rejection / blocked
|
|
104
|
+
// request) where onload fired but onerror did not. The Fetch Response
|
|
105
|
+
// constructor throws RangeError for status 0, and that throw would land
|
|
106
|
+
// OUTSIDE the Promise executor (never settling it → dispatch() hangs).
|
|
107
|
+
// Reject instead so dispatch()'s try/catch routes it to onError —
|
|
108
|
+
// byte-identical to fetch, which rejects on CORS/network failure.
|
|
109
|
+
if (xhr.status === 0) {
|
|
110
|
+
reject(new Error(`Transport request to ${input} failed (status 0)`));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// D-08: resolve a real Response so dispatch()'s res.ok / await res.json()
|
|
114
|
+
// / processResponse() is byte-identical to the fetch path.
|
|
115
|
+
resolve(new Response(xhr.responseText, {
|
|
116
|
+
status: xhr.status,
|
|
117
|
+
statusText: xhr.statusText,
|
|
118
|
+
}));
|
|
119
|
+
};
|
|
120
|
+
// D-07: error / timeout / abort → reject so dispatch()'s existing
|
|
121
|
+
// try/catch routes it to onError exactly like a failed fetch.
|
|
122
|
+
xhr.onerror = () => reject(new Error(`Transport request to ${input} failed`));
|
|
123
|
+
xhr.ontimeout = () => reject(new Error(`Transport request to ${input} timed out`));
|
|
124
|
+
xhr.onabort = () => reject(new Error(`Transport request to ${input} aborted`));
|
|
125
|
+
xhr.send(init.body ?? null);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
48
128
|
node(n, parent, on) {
|
|
49
129
|
switch (n.type) {
|
|
50
130
|
case "page": return this.page(n, parent, on);
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,24 @@ export interface ActionEvent {
|
|
|
5
5
|
}
|
|
6
6
|
export interface Adapter {
|
|
7
7
|
render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
|
|
8
|
+
/** Hand the platform off to a URL (the browser adapter sets the page location).
|
|
9
|
+
* No safe no-op exists — if a redirect arrives and neither ShellOptions.onRedirect
|
|
10
|
+
* nor this method is available, the shell fails loudly. */
|
|
11
|
+
navigate?(url: string): void;
|
|
12
|
+
/** Write a client side-effect to platform storage. Write-only — the wire
|
|
13
|
+
* contract has no storage read. scope is "local" | "session". */
|
|
14
|
+
storage?(scope: "local" | "session", key: string, value: string): void;
|
|
15
|
+
/** OPTIONAL transport override. Phase 1 leaves the core's `fetch` as the
|
|
16
|
+
* universal default and does NOT route load()/dispatch() through this.
|
|
17
|
+
* Defined now so Phase 2 (upload progress) can plug an XHR binding in via
|
|
18
|
+
* the `hooks.onUploadProgress` callback with no further wire/API change. */
|
|
19
|
+
transport?(input: string, init: {
|
|
20
|
+
method?: string;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
body?: FormData | string;
|
|
23
|
+
}, hooks?: {
|
|
24
|
+
onUploadProgress?: (sent: number, total: number) => void;
|
|
25
|
+
}): Promise<Response>;
|
|
8
26
|
}
|
|
9
27
|
export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode;
|
|
10
28
|
export interface PageNode {
|
|
@@ -150,8 +168,12 @@ export interface ShellOptions {
|
|
|
150
168
|
onLoading?: (loading: boolean) => void;
|
|
151
169
|
/** Called before each dispatch — merge the returned headers into every POST request. */
|
|
152
170
|
getRequestHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
153
|
-
/** Called when the server responds with a redirect URL.
|
|
171
|
+
/** Called when the server responds with a redirect URL. When unset, falls back to adapter.navigate(url); if the adapter has no navigate, the shell fails loudly. */
|
|
154
172
|
onRedirect?: (url: string) => void;
|
|
173
|
+
/** Called during a files-bearing dispatch when the plugged-in adapter implements transport().
|
|
174
|
+
* sent = bytes uploaded so far; total = total bytes, or 0 when the total is indeterminate
|
|
175
|
+
* (guard total > 0 before computing sent / total). Never fires on the fetch fallback path. */
|
|
176
|
+
onUploadProgress?: (sent: number, total: number) => void;
|
|
155
177
|
/** When set, the shell dispatches a "poll" action at this interval (ms) after every load/dispatch.
|
|
156
178
|
* The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
|
|
157
179
|
* omitting nextPollIn when no pollInterval is configured. */
|
|
@@ -187,6 +209,7 @@ export declare class ViewModelShell {
|
|
|
187
209
|
stopPolling(): void;
|
|
188
210
|
getCurrentVm(): ViewNode | null;
|
|
189
211
|
getCurrentState(): unknown;
|
|
212
|
+
private failCapability;
|
|
190
213
|
private processResponse;
|
|
191
214
|
private schedulePoll;
|
|
192
215
|
}
|
package/dist/index.js
CHANGED
|
@@ -55,11 +55,21 @@ export class ViewModelShell {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
|
|
58
|
-
const
|
|
58
|
+
const adapter = this.options.adapter;
|
|
59
|
+
const init = {
|
|
59
60
|
method: "POST",
|
|
60
61
|
headers: { Accept: "application/json", ...extraHeaders },
|
|
61
62
|
body: form,
|
|
62
|
-
}
|
|
63
|
+
};
|
|
64
|
+
let res;
|
|
65
|
+
if (action.files && this.options.onUploadProgress && adapter.transport) {
|
|
66
|
+
res = await adapter.transport(actionEndpoint, init, {
|
|
67
|
+
onUploadProgress: this.options.onUploadProgress,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
res = await fetch(actionEndpoint, init);
|
|
72
|
+
}
|
|
63
73
|
if (!res.ok)
|
|
64
74
|
throw new Error(`Action '${action.name}' failed: ${res.status}`);
|
|
65
75
|
this.processResponse((await res.json()));
|
|
@@ -88,21 +98,39 @@ export class ViewModelShell {
|
|
|
88
98
|
}
|
|
89
99
|
getCurrentVm() { return this.currentVm; }
|
|
90
100
|
getCurrentState() { return this.currentState; }
|
|
101
|
+
failCapability(capability, detail) {
|
|
102
|
+
const err = new Error(`[ViewModelShell] Adapter is missing the "${capability}" capability but the ` +
|
|
103
|
+
`server response requires it (${detail}). This is a hard failure, not a no-op: ` +
|
|
104
|
+
`a silently-dropped ${capability} (e.g. an auth token never persisted, or a ` +
|
|
105
|
+
`redirect that never happens) is a correctness/security bug. Implement ` +
|
|
106
|
+
`${capability}() on your Adapter, or (for redirect) pass ShellOptions.onRedirect.`);
|
|
107
|
+
this.options.onError ? this.options.onError(err) : console.error("[ViewModelShell]", err);
|
|
108
|
+
}
|
|
91
109
|
processResponse(body) {
|
|
110
|
+
const adapter = this.options.adapter;
|
|
92
111
|
for (const effect of body.sideEffects ?? []) {
|
|
93
112
|
if (effect.type === "set-local-storage" && effect.key != null) {
|
|
94
|
-
|
|
113
|
+
if (adapter.storage)
|
|
114
|
+
adapter.storage("local", effect.key, effect.value ?? "");
|
|
115
|
+
else
|
|
116
|
+
this.failCapability("storage", `side-effect "${effect.type}" key="${effect.key}"`);
|
|
95
117
|
}
|
|
96
118
|
else if (effect.type === "set-session-storage" && effect.key != null) {
|
|
97
|
-
|
|
119
|
+
if (adapter.storage)
|
|
120
|
+
adapter.storage("session", effect.key, effect.value ?? "");
|
|
121
|
+
else
|
|
122
|
+
this.failCapability("storage", `side-effect "${effect.type}" key="${effect.key}"`);
|
|
98
123
|
}
|
|
99
124
|
}
|
|
100
125
|
if (body.redirect) {
|
|
101
126
|
if (this.options.onRedirect) {
|
|
102
127
|
this.options.onRedirect(body.redirect);
|
|
103
128
|
}
|
|
129
|
+
else if (adapter.navigate) {
|
|
130
|
+
adapter.navigate(body.redirect);
|
|
131
|
+
}
|
|
104
132
|
else {
|
|
105
|
-
|
|
133
|
+
this.failCapability("navigate", `redirect to "${body.redirect}"`);
|
|
106
134
|
}
|
|
107
135
|
return;
|
|
108
136
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
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 — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,10 +26,14 @@
|
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsc -p tsconfig.json",
|
|
29
|
+
"check:core-globals": "node scripts/check-core-platform-globals.mjs",
|
|
30
|
+
"test": "vitest run",
|
|
29
31
|
"prepublishOnly": "npm run build"
|
|
30
32
|
},
|
|
31
33
|
"devDependencies": {
|
|
32
|
-
"typescript": "^5.4.0"
|
|
34
|
+
"typescript": "^5.4.0",
|
|
35
|
+
"jsdom": "^25.0.1",
|
|
36
|
+
"vitest": "^2.1.4"
|
|
33
37
|
},
|
|
34
38
|
"exports": {
|
|
35
39
|
".": {
|