@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 +16 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +20 -0
- package/dist/index.d.ts +31 -1
- package/dist/index.js +99 -3
- package/dist/server.d.ts +7 -0
- package/dist/server.js +7 -0
- package/dist/tui.d.ts +7 -0
- package/dist/tui.js +32 -1
- package/package.json +1 -1
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,
|
|
105
|
-
`redirect that never happens
|
|
106
|
-
|
|
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.
|
|
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",
|