@ashley-shrok/viewmodel-shell 0.3.12 → 0.3.14

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;
@@ -21,4 +30,5 @@ export declare class BrowserAdapter implements Adapter {
21
30
  private progress;
22
31
  private modal;
23
32
  private table;
33
+ private copyButton;
24
34
  }
package/dist/browser.js CHANGED
@@ -1,3 +1,19 @@
1
+ function legacyCopy(text) {
2
+ try {
3
+ const ta = document.createElement("textarea");
4
+ ta.value = text;
5
+ ta.style.position = "fixed";
6
+ ta.style.opacity = "0";
7
+ document.body.appendChild(ta);
8
+ ta.select();
9
+ const ok = document.execCommand("copy");
10
+ document.body.removeChild(ta);
11
+ return ok;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
1
17
  export class BrowserAdapter {
2
18
  container;
3
19
  fileRegistry = new Map();
@@ -45,6 +61,86 @@ export class BrowserAdapter {
45
61
  }
46
62
  });
47
63
  }
64
+ navigate(url) {
65
+ window.location.href = url;
66
+ }
67
+ storage(scope, key, value) {
68
+ const store = scope === "session" ? sessionStorage : localStorage;
69
+ store.setItem(key, value);
70
+ }
71
+ async transport(input, init, hooks) {
72
+ const onUploadProgress = hooks?.onUploadProgress;
73
+ if (!onUploadProgress) {
74
+ // No progress requested → identical to the core fetch path (D-02 fallback parity).
75
+ return fetch(input, init);
76
+ }
77
+ return new Promise((resolve, reject) => {
78
+ const xhr = new XMLHttpRequest();
79
+ // IN-01: this seam only exists to carry a body+files action request, so
80
+ // a method-less init is non-sensical. dispatch() (the sole caller) always
81
+ // passes "POST"; default to "POST" (not "GET") so a future caller bug
82
+ // never silently produces a body-bearing GET.
83
+ xhr.open(init.method ?? "POST", input);
84
+ // WR-02: every header dispatch() builds in `init.headers` (Accept +
85
+ // getRequestHeaders()) is applied here, so the XHR path's request
86
+ // headers are byte-identical to the fetch path's. Scope note: this
87
+ // seam is same-origin only. The fetch fallback sends cookies on
88
+ // same-origin requests via its default `credentials: "same-origin"`;
89
+ // XHR sends same-origin cookies without `withCredentials`, so the
90
+ // common (same-origin `actionEndpoint`) case matches fetch exactly.
91
+ // Cross-origin action endpoints are out of scope for this transport.
92
+ for (const [k, v] of Object.entries(init.headers ?? {})) {
93
+ xhr.setRequestHeader(k, v);
94
+ }
95
+ let knownTotal = 0; // last computable total (0 = never computable)
96
+ let lastLoaded = 0; // last reported bytes sent
97
+ xhr.upload.onprogress = (e) => {
98
+ lastLoaded = e.loaded;
99
+ if (e.lengthComputable) {
100
+ knownTotal = e.total;
101
+ onUploadProgress(e.loaded, e.total); // D-05 in-flight, computable
102
+ }
103
+ else {
104
+ onUploadProgress(e.loaded, 0); // D-05 indeterminate sentinel (0)
105
+ }
106
+ };
107
+ xhr.onload = () => {
108
+ // D-05 terminal emission: mirror whichever value was being reported.
109
+ // Known total → (total, total); indeterminate → (finalLoaded, finalLoaded).
110
+ // NEVER (0,0) once any progress event has fired; a body that produces
111
+ // no progress event (e.g. a zero-byte upload, or a transport that
112
+ // completes before the browser emits any upload progress) legitimately
113
+ // terminates at (0,0), which the documented `total > 0` consumer guard
114
+ // (MIGRATION.md 5b) handles.
115
+ if (knownTotal > 0)
116
+ onUploadProgress(knownTotal, knownTotal);
117
+ else
118
+ onUploadProgress(lastLoaded, lastLoaded);
119
+ // D-08: status 0 means a network-level failure (CORS rejection / blocked
120
+ // request) where onload fired but onerror did not. The Fetch Response
121
+ // constructor throws RangeError for status 0, and that throw would land
122
+ // OUTSIDE the Promise executor (never settling it → dispatch() hangs).
123
+ // Reject instead so dispatch()'s try/catch routes it to onError —
124
+ // byte-identical to fetch, which rejects on CORS/network failure.
125
+ if (xhr.status === 0) {
126
+ reject(new Error(`Transport request to ${input} failed (status 0)`));
127
+ return;
128
+ }
129
+ // D-08: resolve a real Response so dispatch()'s res.ok / await res.json()
130
+ // / processResponse() is byte-identical to the fetch path.
131
+ resolve(new Response(xhr.responseText, {
132
+ status: xhr.status,
133
+ statusText: xhr.statusText,
134
+ }));
135
+ };
136
+ // D-07: error / timeout / abort → reject so dispatch()'s existing
137
+ // try/catch routes it to onError exactly like a failed fetch.
138
+ xhr.onerror = () => reject(new Error(`Transport request to ${input} failed`));
139
+ xhr.ontimeout = () => reject(new Error(`Transport request to ${input} timed out`));
140
+ xhr.onabort = () => reject(new Error(`Transport request to ${input} aborted`));
141
+ xhr.send(init.body ?? null);
142
+ });
143
+ }
48
144
  node(n, parent, on) {
49
145
  switch (n.type) {
50
146
  case "page": return this.page(n, parent, on);
@@ -62,6 +158,7 @@ export class BrowserAdapter {
62
158
  case "progress": return this.progress(n, parent);
63
159
  case "modal": return this.modal(n, parent, on);
64
160
  case "table": return this.table(n, parent, on);
161
+ case "copy-button": return this.copyButton(n, parent);
65
162
  }
66
163
  }
67
164
  kids(nodes, parent, on) {
@@ -537,4 +634,35 @@ export class BrowserAdapter {
537
634
  wrapper.appendChild(table);
538
635
  parent.appendChild(wrapper);
539
636
  }
637
+ copyButton(n, parent) {
638
+ const btn = document.createElement("button");
639
+ btn.type = "button";
640
+ btn.className = "vms-button";
641
+ btn.textContent = n.label ?? "Copy";
642
+ btn.addEventListener("click", () => {
643
+ const write = navigator.clipboard?.writeText(n.text);
644
+ if (write) {
645
+ write.then(() => {
646
+ btn.textContent = n.copiedLabel ?? "Copied!";
647
+ setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
648
+ }).catch(() => {
649
+ // primary failed — try legacy execCommand fallback
650
+ if (legacyCopy(n.text)) {
651
+ btn.textContent = n.copiedLabel ?? "Copied!";
652
+ setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
653
+ }
654
+ // both paths failed: silent, no confirmation
655
+ });
656
+ }
657
+ else {
658
+ // navigator.clipboard absent — try legacy
659
+ if (legacyCopy(n.text)) {
660
+ btn.textContent = n.copiedLabel ?? "Copied!";
661
+ setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
662
+ }
663
+ // else: silent
664
+ }
665
+ });
666
+ parent.appendChild(btn);
667
+ }
540
668
  }
package/dist/index.d.ts CHANGED
@@ -5,8 +5,26 @@ export interface ActionEvent {
5
5
  }
6
6
  export interface Adapter {
7
7
  render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
8
- }
9
- export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode;
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>;
26
+ }
27
+ export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
10
28
  export interface PageNode {
11
29
  type: "page";
12
30
  title?: string;
@@ -142,6 +160,15 @@ export interface TableNode {
142
160
  /** Base action. Adapter merges { column, value, filters } into context on Enter. */
143
161
  filterAction?: ActionEvent;
144
162
  }
163
+ export interface CopyButtonNode {
164
+ type: "copy-button";
165
+ /** The string to write to the clipboard on click. */
166
+ text: string;
167
+ /** Label shown on the button before copying. Adapter default: "Copy". */
168
+ label?: string;
169
+ /** Ephemeral label shown after a successful copy, reverts after ~1.5 s. Adapter default: "Copied!". */
170
+ copiedLabel?: string;
171
+ }
145
172
  export interface ShellOptions {
146
173
  endpoint: string;
147
174
  actionEndpoint: string;
@@ -150,8 +177,12 @@ export interface ShellOptions {
150
177
  onLoading?: (loading: boolean) => void;
151
178
  /** Called before each dispatch — merge the returned headers into every POST request. */
152
179
  getRequestHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
153
- /** Called when the server responds with a redirect URL. Defaults to window.location.href = url. */
180
+ /** 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
181
  onRedirect?: (url: string) => void;
182
+ /** Called during a files-bearing dispatch when the plugged-in adapter implements transport().
183
+ * sent = bytes uploaded so far; total = total bytes, or 0 when the total is indeterminate
184
+ * (guard total > 0 before computing sent / total). Never fires on the fetch fallback path. */
185
+ onUploadProgress?: (sent: number, total: number) => void;
155
186
  /** When set, the shell dispatches a "poll" action at this interval (ms) after every load/dispatch.
156
187
  * The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
157
188
  * omitting nextPollIn when no pollInterval is configured. */
@@ -187,6 +218,7 @@ export declare class ViewModelShell {
187
218
  stopPolling(): void;
188
219
  getCurrentVm(): ViewNode | null;
189
220
  getCurrentState(): unknown;
221
+ private failCapability;
190
222
  private processResponse;
191
223
  private schedulePoll;
192
224
  }
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.14",
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
  ".": {