@ashley-shrok/viewmodel-shell 0.4.9 → 0.5.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/README.md CHANGED
@@ -33,6 +33,22 @@ If your backend is .NET: copy `demo/Tasks/AspNetCore/ViewModels.cs` from the [Gi
33
33
 
34
34
  For other backends, implement the same JSON shape: a `GET` returning `{ vm, state }`, and a `POST` that takes `multipart/form-data` with `_action` and `_state` form fields and returns the next `{ vm, state }`. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for the full wire format.
35
35
 
36
+ ## Authenticated downloads
37
+
38
+ When a header-authenticated consumer (e.g. `Authorization: Bearer <jwt>` via `ShellOptions.getRequestHeaders()`) needs to offer a file download, return a `"download"` side-effect from your action handler — the shell fetches the URL with the same headers merged in, parses `Content-Disposition` + `Content-Type`, and triggers a browser "Save As":
39
+
40
+ ```csharp
41
+ return new ShellResponse<MyState>(BuildVm(state), state)
42
+ .WithEffect(ShellSideEffect.Download("/api/invoices/42/pdf", "invoice-42.pdf"));
43
+ ```
44
+
45
+ ```typescript
46
+ return { vm: buildVm(state), state,
47
+ sideEffects: [shellSideEffect.download("/api/invoices/42/pdf", "invoice-42.pdf")] };
48
+ ```
49
+
50
+ The download endpoint stays auth-gated and the server authorizes in the action handler — no signed-URL machinery. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md#client-side-effects) for the wire format and pattern.
51
+
36
52
  ## Terminal (TUI)
37
53
 
38
54
  The same backend renders in a terminal — same wire, no backend change. Point the CLI at any ViewModel Shell endpoint:
package/dist/browser.d.ts CHANGED
@@ -6,6 +6,10 @@ export declare class BrowserAdapter implements Adapter {
6
6
  render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
7
7
  navigate(url: string): void;
8
8
  storage(scope: "local" | "session", key: string, value: string): void;
9
+ /** Save an authenticated-download blob via the browser's native Save-As.
10
+ * contentType is informational — the Blob's own .type takes precedence in
11
+ * browsers. We accept the arg for adapter symmetry (other adapters use it). */
12
+ saveFile(data: Blob, filename: string, _contentType: string): void;
9
13
  transport(input: string, init: {
10
14
  method?: string;
11
15
  headers?: Record<string, string>;
package/dist/browser.js CHANGED
@@ -68,6 +68,26 @@ export class BrowserAdapter {
68
68
  const store = scope === "session" ? sessionStorage : localStorage;
69
69
  store.setItem(key, value);
70
70
  }
71
+ /** Save an authenticated-download blob via the browser's native Save-As.
72
+ * contentType is informational — the Blob's own .type takes precedence in
73
+ * browsers. We accept the arg for adapter symmetry (other adapters use it). */
74
+ saveFile(data, filename, _contentType) {
75
+ const url = URL.createObjectURL(data);
76
+ try {
77
+ const a = document.createElement("a");
78
+ a.href = url;
79
+ a.download = filename;
80
+ a.style.display = "none";
81
+ document.body.appendChild(a);
82
+ a.click();
83
+ a.remove();
84
+ }
85
+ finally {
86
+ // Revoke async so the browser has time to start the download. The 0ms
87
+ // setTimeout is the established pattern (Chromium/Firefox/Safari).
88
+ setTimeout(() => URL.revokeObjectURL(url), 0);
89
+ }
90
+ }
71
91
  async transport(input, init, hooks) {
72
92
  const onUploadProgress = hooks?.onUploadProgress;
73
93
  if (!onUploadProgress) {
package/dist/index.d.ts CHANGED
@@ -23,6 +23,15 @@ export interface Adapter {
23
23
  }, hooks?: {
24
24
  onUploadProgress?: (sent: number, total: number) => void;
25
25
  }): Promise<Response>;
26
+ /** Save an authenticated-download blob to platform-appropriate storage. The
27
+ * shell fetches the URL with getRequestHeaders() merged, parses
28
+ * Content-Disposition filename + Content-Type, and calls saveFile().
29
+ * No safe no-op exists — a silently-dropped authenticated download is a
30
+ * correctness/security bug (cf. navigate/storage). If a "download"
31
+ * side-effect arrives and this method is absent, the shell fails loudly.
32
+ * May return void or Promise<void>; the shell awaits the return value so
33
+ * async I/O errors surface via onError. */
34
+ saveFile?(data: Blob, filename: string, contentType: string): void | Promise<void>;
26
35
  }
27
36
  export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
28
37
  export interface PageNode {
@@ -199,10 +208,17 @@ export interface ShellOptions {
199
208
  pollInterval?: number;
200
209
  }
201
210
  export interface ShellSideEffect {
202
- /** "set-local-storage" | "set-session-storage" — unknown types are silently ignored. */
211
+ /** "set-local-storage" | "set-session-storage" | "download" — unknown types are silently ignored. */
203
212
  type: string;
213
+ /** For "set-local-storage" / "set-session-storage": the storage key. */
204
214
  key?: string;
215
+ /** For "set-local-storage" / "set-session-storage": the storage value. */
205
216
  value?: string;
217
+ /** For "download": the URL to fetch (shell merges getRequestHeaders() into the request). */
218
+ url?: string;
219
+ /** For "download": optional filename hint. Response Content-Disposition wins
220
+ * when present; this is the fallback before the URL basename. */
221
+ filename?: string;
206
222
  }
207
223
  export interface ShellResponse {
208
224
  vm: ViewNode;
@@ -231,4 +247,18 @@ export declare class ViewModelShell {
231
247
  private failCapability;
232
248
  private processResponse;
233
249
  private schedulePoll;
250
+ /**
251
+ * Authenticated download: fetch the URL with getRequestHeaders() merged
252
+ * (Bearer / anti-forgery / etc.), parse the response filename + content
253
+ * type, and hand the bytes to the adapter's platform-specific save verb.
254
+ *
255
+ * Deliberately uses core `fetch`, NOT adapter.transport. The existing
256
+ * transport override (XHR for upload-progress) constructs Response from
257
+ * xhr.responseText (a string) and would corrupt binary blobs. Download
258
+ * progress is a future, opt-in extension on its own seam.
259
+ *
260
+ * Errors (missing capability / non-OK status / adapter throw) surface
261
+ * via onError; the download() call itself never throws into the caller.
262
+ */
263
+ private download;
234
264
  }
package/dist/index.js CHANGED
@@ -101,9 +101,10 @@ export class ViewModelShell {
101
101
  failCapability(capability, detail) {
102
102
  const err = new Error(`[ViewModelShell] Adapter is missing the "${capability}" capability but the ` +
103
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.`);
104
+ `a silently-dropped ${capability} (e.g. an auth token never persisted, a ` +
105
+ `redirect that never happens, or an authenticated download silently swallowed) ` +
106
+ `is a correctness/security bug. Implement ${capability}() on your Adapter, ` +
107
+ `or (for redirect) pass ShellOptions.onRedirect.`);
107
108
  this.options.onError ? this.options.onError(err) : console.error("[ViewModelShell]", err);
108
109
  }
109
110
  processResponse(body) {
@@ -121,6 +122,12 @@ export class ViewModelShell {
121
122
  else
122
123
  this.failCapability("storage", `side-effect "${effect.type}" key="${effect.key}"`);
123
124
  }
125
+ else if (effect.type === "download" && effect.url != null) {
126
+ // Fire-and-forget. Surfaces errors via onError. Does not block the
127
+ // render/redirect branch below — downloads are a side channel, like
128
+ // storage, and a slow download MUST NOT delay the user-visible update.
129
+ void this.download(effect.url, effect.filename);
130
+ }
124
131
  }
125
132
  if (body.redirect) {
126
133
  if (this.options.onRedirect) {
@@ -150,4 +157,93 @@ export class ViewModelShell {
150
157
  this.dispatch({ name: "poll" }, true);
151
158
  }, delay);
152
159
  }
160
+ /**
161
+ * Authenticated download: fetch the URL with getRequestHeaders() merged
162
+ * (Bearer / anti-forgery / etc.), parse the response filename + content
163
+ * type, and hand the bytes to the adapter's platform-specific save verb.
164
+ *
165
+ * Deliberately uses core `fetch`, NOT adapter.transport. The existing
166
+ * transport override (XHR for upload-progress) constructs Response from
167
+ * xhr.responseText (a string) and would corrupt binary blobs. Download
168
+ * progress is a future, opt-in extension on its own seam.
169
+ *
170
+ * Errors (missing capability / non-OK status / adapter throw) surface
171
+ * via onError; the download() call itself never throws into the caller.
172
+ */
173
+ async download(url, hintFilename) {
174
+ const adapter = this.options.adapter;
175
+ if (!adapter.saveFile) {
176
+ this.failCapability("saveFile", `download from "${url}"`);
177
+ return;
178
+ }
179
+ try {
180
+ const extraHeaders = this.options.getRequestHeaders
181
+ ? await this.options.getRequestHeaders()
182
+ : {};
183
+ const res = await fetch(url, { headers: extraHeaders });
184
+ if (!res.ok) {
185
+ throw new Error(`Download from ${url} failed: ${res.status} ${res.statusText}`);
186
+ }
187
+ const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
188
+ const filename = parseContentDispositionFilename(res.headers.get("Content-Disposition")) ??
189
+ hintFilename ??
190
+ basenameFromUrl(url) ??
191
+ "download";
192
+ const blob = await res.blob();
193
+ await adapter.saveFile(blob, filename, contentType);
194
+ }
195
+ catch (err) {
196
+ const error = err instanceof Error ? err : new Error(String(err));
197
+ this.options.onError ? this.options.onError(error) : console.error("[ViewModelShell]", error);
198
+ }
199
+ }
200
+ }
201
+ // ─── Download helpers (file-private; no platform globals) ────────────────────
202
+ // `Blob` and `URL` are universals (browser, Node 18+, Deno, Bun) — confirmed
203
+ // off the check:core-globals denylist. `URL.createObjectURL` is browser-only
204
+ // and stays in BrowserAdapter behind the capability seam.
205
+ /**
206
+ * Parse a `Content-Disposition` header for a filename. RFC 5987 `filename*`
207
+ * (UTF-8, percent-encoded, internationalized) wins over the plain `filename`
208
+ * parameter when both are present — that's the spec-canonical preference.
209
+ * Returns null when the header is absent or no filename parameter is found.
210
+ */
211
+ function parseContentDispositionFilename(header) {
212
+ if (header == null)
213
+ return null;
214
+ // RFC 5987 extended form: filename*=UTF-8''<percent-encoded>
215
+ const ext = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(header);
216
+ if (ext) {
217
+ try {
218
+ const decoded = decodeURIComponent(ext[2].trim());
219
+ if (decoded.length > 0)
220
+ return decoded;
221
+ }
222
+ catch {
223
+ // Malformed percent-encoding — fall through to plain `filename`.
224
+ }
225
+ }
226
+ // Plain form: filename="..." or filename=...
227
+ const plain = /filename\s*=\s*"([^"]*)"|filename\s*=\s*([^;]+)/i.exec(header);
228
+ if (plain) {
229
+ const val = (plain[1] ?? plain[2] ?? "").trim();
230
+ if (val.length > 0)
231
+ return val;
232
+ }
233
+ return null;
234
+ }
235
+ /**
236
+ * Extract a basename from a URL (relative or absolute). Returns null if the
237
+ * path is empty or ends with `/`. Uses a dummy base so relative URLs parse;
238
+ * the base is discarded.
239
+ */
240
+ function basenameFromUrl(url) {
241
+ try {
242
+ const u = new URL(url, "http://_/");
243
+ const last = u.pathname.split("/").pop();
244
+ return last && last.length > 0 ? decodeURIComponent(last) : null;
245
+ }
246
+ catch {
247
+ return null;
248
+ }
153
249
  }
package/dist/server.d.ts CHANGED
@@ -25,6 +25,13 @@ export declare function shellRedirect<TState = unknown>(url: string): ShellRespo
25
25
  export declare const shellSideEffect: {
26
26
  setLocalStorage: (key: string, value: string) => ShellSideEffect;
27
27
  setSessionStorage: (key: string, value: string) => ShellSideEffect;
28
+ /** Server-decided authenticated download. The shell fetches `url` with
29
+ * getRequestHeaders() merged (Bearer / anti-forgery / etc.), parses
30
+ * Content-Disposition + Content-Type, and saves via Adapter.saveFile.
31
+ * `filename` is a fallback used only when Content-Disposition is absent.
32
+ * The conditional spread keeps `filename` ABSENT (not undefined) on the
33
+ * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
34
+ download: (url: string, filename?: string) => ShellSideEffect;
28
35
  };
29
36
  /**
30
37
  * Web Fetch API–native request handler factory. Auto-detects content-type
package/dist/server.js CHANGED
@@ -50,6 +50,13 @@ export function shellRedirect(url) {
50
50
  export const shellSideEffect = {
51
51
  setLocalStorage: (key, value) => ({ type: "set-local-storage", key, value }),
52
52
  setSessionStorage: (key, value) => ({ type: "set-session-storage", key, value }),
53
+ /** Server-decided authenticated download. The shell fetches `url` with
54
+ * getRequestHeaders() merged (Bearer / anti-forgery / etc.), parses
55
+ * Content-Disposition + Content-Type, and saves via Adapter.saveFile.
56
+ * `filename` is a fallback used only when Content-Disposition is absent.
57
+ * The conditional spread keeps `filename` ABSENT (not undefined) on the
58
+ * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
59
+ download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
53
60
  };
54
61
  // ─── Action handler factory ──────────────────────────────────────────────────
55
62
  /**
package/dist/tui.d.ts CHANGED
@@ -118,6 +118,13 @@ export declare class TuiAdapter implements Adapter {
118
118
  * or an unparseable EXISTING store surfaces the loud interstitial + a
119
119
  * stderr line and does NOT clobber a possibly-unrelated user file. */
120
120
  storage(scope: "local" | "session", key: string, value: string): void;
121
+ /** Save an authenticated-download blob to the terminal user's filesystem.
122
+ * Directory precedence: $XDG_DOWNLOAD_DIR → ~/Downloads (if it exists) →
123
+ * process.cwd(). The chosen path is printed to stderr so the operator
124
+ * can find it. The filename is sanitized (no path components / no `..`)
125
+ * so a hostile/buggy server can't escape the download dir; this is the
126
+ * TUI counterpart to the browser's built-in download-name sanitization. */
127
+ saveFile(data: Blob, filename: string, _contentType: string): Promise<void>;
121
128
  /** Test-only: read back a session value. The wire contract has no storage
122
129
  * read; this exists solely so a unit test can prove the write landed
123
130
  * (same rationale as exporting osc52 for a deterministic test). */
package/dist/tui.js CHANGED
@@ -4,7 +4,7 @@ import { render as inkRender, Box, Text, useInput, useStdin, useStdout, } from "
4
4
  import TextInput from "ink-text-input";
5
5
  import SelectInput from "ink-select-input";
6
6
  import { spawn } from "node:child_process";
7
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  const NO_CTX = {
@@ -1190,6 +1190,37 @@ export class TuiAdapter {
1190
1190
  this.showInterstitial(`Storage write FAILED — your data was NOT saved.\n\n key: ${key}\n error: ${m}`);
1191
1191
  }
1192
1192
  }
1193
+ /** Save an authenticated-download blob to the terminal user's filesystem.
1194
+ * Directory precedence: $XDG_DOWNLOAD_DIR → ~/Downloads (if it exists) →
1195
+ * process.cwd(). The chosen path is printed to stderr so the operator
1196
+ * can find it. The filename is sanitized (no path components / no `..`)
1197
+ * so a hostile/buggy server can't escape the download dir; this is the
1198
+ * TUI counterpart to the browser's built-in download-name sanitization. */
1199
+ async saveFile(data, filename, _contentType) {
1200
+ const xdg = process.env.XDG_DOWNLOAD_DIR;
1201
+ const home = homedir();
1202
+ const dir = xdg && xdg.trim()
1203
+ ? xdg
1204
+ : existsSync(join(home, "Downloads")) ? join(home, "Downloads") : process.cwd();
1205
+ mkdirSync(dir, { recursive: true });
1206
+ // Strip path separators (POSIX + Windows) and any leading dots so a
1207
+ // server-supplied "../etc/passwd" or "foo/bar" becomes a flat basename.
1208
+ // Empty/dot-only results fall back to literal "download".
1209
+ const sanitized = filename
1210
+ .split(/[/\\]/).pop() // drop everything before the last separator
1211
+ .replace(/^\.+/, "") // strip leading dots (no hidden files via traversal)
1212
+ .trim();
1213
+ const safeName = sanitized.length > 0 ? sanitized : "download";
1214
+ const out = join(dir, safeName);
1215
+ const buf = Buffer.from(await data.arrayBuffer());
1216
+ writeFileSync(out, buf);
1217
+ try {
1218
+ process.stderr.write(`vms-tui: saved ${out}\n`);
1219
+ }
1220
+ catch {
1221
+ /* stderr unavailable — the file landed regardless */
1222
+ }
1223
+ }
1193
1224
  /** Test-only: read back a session value. The wire contract has no storage
1194
1225
  * read; this exists solely so a unit test can prove the write landed
1195
1226
  * (same rationale as exporting osc52 for a deterministic test). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.4.9",
3
+ "version": "0.5.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 — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",