@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 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. Defaults to window.location.href = 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 res = await fetch(actionEndpoint, {
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
- localStorage.setItem(effect.key, effect.value ?? "");
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
- sessionStorage.setItem(effect.key, effect.value ?? "");
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
- window.location.href = body.redirect;
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.12",
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
  ".": {