@ashley-shrok/viewmodel-shell 0.3.10 → 0.3.11

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.
@@ -0,0 +1,192 @@
1
+ export interface ActionEvent {
2
+ name: string;
3
+ context?: Record<string, unknown>;
4
+ files?: Record<string, File>;
5
+ }
6
+ export interface Adapter {
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;
10
+ export interface PageNode {
11
+ type: "page";
12
+ title?: string;
13
+ children: ViewNode[];
14
+ }
15
+ export interface SectionNode {
16
+ type: "section";
17
+ heading?: string;
18
+ children: ViewNode[];
19
+ }
20
+ export interface ListNode {
21
+ type: "list";
22
+ id?: string;
23
+ children: ViewNode[];
24
+ }
25
+ export interface ListItemNode {
26
+ type: "list-item";
27
+ id?: string;
28
+ /** Appended as a BEM modifier: vms-list-item--{variant} */
29
+ variant?: string;
30
+ children: ViewNode[];
31
+ }
32
+ export interface FormNode {
33
+ type: "form";
34
+ submitAction: ActionEvent;
35
+ submitLabel?: string;
36
+ children: ViewNode[];
37
+ }
38
+ export interface FieldNode {
39
+ type: "field";
40
+ name: string;
41
+ inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "code";
42
+ label?: string;
43
+ placeholder?: string;
44
+ value?: string;
45
+ required?: boolean;
46
+ options?: Array<{
47
+ value: string;
48
+ label: string;
49
+ }>;
50
+ /** Optional language hint for `inputType: "code"`. Emitted as a class
51
+ * (`vms-field--code-{language}`) so apps can attach a syntax-highlighter
52
+ * library (CodeMirror, Monaco, etc.) — the framework only ships
53
+ * monospaced editable text, no coloring. */
54
+ language?: string;
55
+ /** Dispatched when Enter is pressed (text-like inputs only). Adapter merges { [name]: value } into context. */
56
+ action?: ActionEvent;
57
+ }
58
+ export interface CheckboxNode {
59
+ type: "checkbox";
60
+ name: string;
61
+ checked: boolean;
62
+ label?: string;
63
+ /** Dispatched immediately on change. Adapter merges { checked: boolean } into context. */
64
+ action?: ActionEvent;
65
+ }
66
+ export interface ButtonNode {
67
+ type: "button";
68
+ label: string;
69
+ action: ActionEvent;
70
+ variant?: "primary" | "secondary" | "danger";
71
+ }
72
+ export interface TextNode {
73
+ type: "text";
74
+ value: string;
75
+ style?: "heading" | "subheading" | "body" | "muted" | "strikethrough" | "error" | "pre";
76
+ }
77
+ export interface LinkNode {
78
+ type: "link";
79
+ label: string;
80
+ href: string;
81
+ /** true = open outside current app context (browser: new tab + noopener) */
82
+ external?: boolean;
83
+ }
84
+ export interface StatBarNode {
85
+ type: "stat-bar";
86
+ stats: Array<{
87
+ label: string;
88
+ value: string | number;
89
+ }>;
90
+ }
91
+ export interface TabsNode {
92
+ type: "tabs";
93
+ selected: string;
94
+ /** Base action. Adapter merges { value: tab.value } into context on click. */
95
+ action: ActionEvent;
96
+ tabs: Array<{
97
+ value: string;
98
+ label: string;
99
+ }>;
100
+ }
101
+ export interface ProgressNode {
102
+ type: "progress";
103
+ value: number;
104
+ }
105
+ export interface ModalNode {
106
+ type: "modal";
107
+ title?: string;
108
+ children: ViewNode[];
109
+ /** Optional footer row (typically holds action buttons). Rendered as an inline row at the bottom of the modal. */
110
+ footer?: ViewNode[];
111
+ /** Dispatched when the close button is clicked. If omitted, no close button is rendered. */
112
+ dismissAction?: ActionEvent;
113
+ /** Width variant. Default is "medium" (~520px). "wide" (~800px) suits tables/dashboards;
114
+ * "fullscreen" (~95vw/95vh) for content that needs the whole viewport. */
115
+ size?: "narrow" | "medium" | "wide" | "fullscreen";
116
+ }
117
+ export interface TableColumn {
118
+ key: string;
119
+ label: string;
120
+ sortable?: boolean;
121
+ filterable?: boolean;
122
+ filterValue?: string;
123
+ /** If set, cell values render as <a href={value}>{linkLabel}</a> */
124
+ linkLabel?: string;
125
+ /** true = open outside current app context (browser: new tab + noopener) */
126
+ linkExternal?: boolean;
127
+ }
128
+ export interface TableRow {
129
+ id?: string;
130
+ cells: Record<string, string>;
131
+ action?: ActionEvent;
132
+ variant?: string;
133
+ }
134
+ export interface TableNode {
135
+ type: "table";
136
+ columns: TableColumn[];
137
+ rows: TableRow[];
138
+ sortColumn?: string;
139
+ sortDirection?: "asc" | "desc";
140
+ /** Base action. Adapter merges { column, direction } into context on header click. */
141
+ sortAction?: ActionEvent;
142
+ /** Base action. Adapter merges { column, value, filters } into context on Enter. */
143
+ filterAction?: ActionEvent;
144
+ }
145
+ export interface ShellOptions {
146
+ endpoint: string;
147
+ actionEndpoint: string;
148
+ adapter: Adapter;
149
+ onError?: (err: Error) => void;
150
+ onLoading?: (loading: boolean) => void;
151
+ /** Called before each dispatch — merge the returned headers into every POST request. */
152
+ getRequestHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
153
+ /** Called when the server responds with a redirect URL. Defaults to window.location.href = url. */
154
+ onRedirect?: (url: string) => void;
155
+ /** When set, the shell dispatches a "poll" action at this interval (ms) after every load/dispatch.
156
+ * The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
157
+ * omitting nextPollIn when no pollInterval is configured. */
158
+ pollInterval?: number;
159
+ }
160
+ export interface ShellSideEffect {
161
+ /** "set-local-storage" | "set-session-storage" — unknown types are silently ignored. */
162
+ type: string;
163
+ key?: string;
164
+ value?: string;
165
+ }
166
+ export interface ShellResponse {
167
+ vm: ViewNode;
168
+ state: unknown;
169
+ /** When set, the shell navigates to this URL instead of re-rendering. */
170
+ redirect?: string;
171
+ /** Applied in order before redirect or re-render. */
172
+ sideEffects?: ShellSideEffect[];
173
+ /** When set, schedules the next poll at this delay (ms). Overrides pollInterval for one tick. */
174
+ nextPollIn?: number;
175
+ }
176
+ export declare class ViewModelShell {
177
+ private options;
178
+ private currentVm;
179
+ private currentState;
180
+ private dispatching;
181
+ private pollTimer;
182
+ constructor(options: ShellOptions);
183
+ load(params?: Record<string, string>): Promise<void>;
184
+ dispatch(action: ActionEvent, silent?: boolean): Promise<void>;
185
+ /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
186
+ push(response: ShellResponse): void;
187
+ stopPolling(): void;
188
+ getCurrentVm(): ViewNode | null;
189
+ getCurrentState(): unknown;
190
+ private processResponse;
191
+ private schedulePoll;
192
+ }
package/dist/index.js ADDED
@@ -0,0 +1,125 @@
1
+ // ─── Action ──────────────────────────────────────────────────────────────────
2
+ export class ViewModelShell {
3
+ options;
4
+ currentVm = null;
5
+ currentState = null;
6
+ dispatching = false;
7
+ pollTimer = null;
8
+ constructor(options) {
9
+ this.options = options;
10
+ }
11
+ async load(params) {
12
+ const { endpoint, adapter, onError, onLoading } = this.options;
13
+ this.stopPolling();
14
+ try {
15
+ onLoading?.(true);
16
+ const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
17
+ const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
18
+ const res = await fetch(url, { headers: { Accept: "application/json", ...extraHeaders } });
19
+ if (!res.ok)
20
+ throw new Error(`${res.status} ${res.statusText}`);
21
+ const body = (await res.json());
22
+ this.currentVm = body.vm;
23
+ this.currentState = body.state;
24
+ adapter.render(body.vm, (action) => this.dispatch(action));
25
+ this.schedulePoll(body.nextPollIn);
26
+ }
27
+ catch (err) {
28
+ const error = err instanceof Error ? err : new Error(String(err));
29
+ onError ? onError(error) : console.error("[ViewModelShell]", error);
30
+ }
31
+ finally {
32
+ onLoading?.(false);
33
+ }
34
+ }
35
+ async dispatch(action, silent = false) {
36
+ if (this.dispatching)
37
+ return;
38
+ const { actionEndpoint, onError, onLoading } = this.options;
39
+ if (this.currentState === null) {
40
+ const err = new Error(`Cannot dispatch '${action.name}' before initial load completes. ` +
41
+ `Call shell.load() and wait for it before allowing user interaction.`);
42
+ onError ? onError(err) : console.error("[ViewModelShell]", err);
43
+ return;
44
+ }
45
+ try {
46
+ this.dispatching = true;
47
+ if (!silent)
48
+ onLoading?.(true);
49
+ const form = new FormData();
50
+ form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
51
+ form.append("_state", JSON.stringify(this.currentState));
52
+ if (action.files) {
53
+ for (const [name, file] of Object.entries(action.files)) {
54
+ form.append(name, file);
55
+ }
56
+ }
57
+ const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
58
+ const res = await fetch(actionEndpoint, {
59
+ method: "POST",
60
+ headers: { Accept: "application/json", ...extraHeaders },
61
+ body: form,
62
+ });
63
+ if (!res.ok)
64
+ throw new Error(`Action '${action.name}' failed: ${res.status}`);
65
+ this.processResponse((await res.json()));
66
+ }
67
+ catch (err) {
68
+ const error = err instanceof Error ? err : new Error(String(err));
69
+ onError ? onError(error) : console.error("[ViewModelShell]", error);
70
+ }
71
+ finally {
72
+ this.dispatching = false;
73
+ if (!silent)
74
+ onLoading?.(false);
75
+ }
76
+ }
77
+ /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
78
+ push(response) {
79
+ if (this.dispatching)
80
+ return;
81
+ this.processResponse(response);
82
+ }
83
+ stopPolling() {
84
+ if (this.pollTimer) {
85
+ clearTimeout(this.pollTimer);
86
+ this.pollTimer = null;
87
+ }
88
+ }
89
+ getCurrentVm() { return this.currentVm; }
90
+ getCurrentState() { return this.currentState; }
91
+ processResponse(body) {
92
+ for (const effect of body.sideEffects ?? []) {
93
+ if (effect.type === "set-local-storage" && effect.key != null) {
94
+ localStorage.setItem(effect.key, effect.value ?? "");
95
+ }
96
+ else if (effect.type === "set-session-storage" && effect.key != null) {
97
+ sessionStorage.setItem(effect.key, effect.value ?? "");
98
+ }
99
+ }
100
+ if (body.redirect) {
101
+ if (this.options.onRedirect) {
102
+ this.options.onRedirect(body.redirect);
103
+ }
104
+ else {
105
+ window.location.href = body.redirect;
106
+ }
107
+ return;
108
+ }
109
+ this.currentVm = body.vm;
110
+ this.currentState = body.state;
111
+ this.options.adapter.render(body.vm, (a) => this.dispatch(a));
112
+ this.schedulePoll(body.nextPollIn);
113
+ }
114
+ schedulePoll(nextPollIn) {
115
+ const delay = nextPollIn ?? this.options.pollInterval;
116
+ if (delay == null)
117
+ return;
118
+ if (this.pollTimer)
119
+ clearTimeout(this.pollTimer);
120
+ this.pollTimer = setTimeout(() => {
121
+ this.pollTimer = null;
122
+ this.dispatch({ name: "poll" }, true);
123
+ }, delay);
124
+ }
125
+ }
@@ -0,0 +1,44 @@
1
+ import type { ViewNode, ShellSideEffect } from "./index.js";
2
+ export * from "./index.js";
3
+ export interface ActionPayload<TState> {
4
+ name: string;
5
+ context: Record<string, unknown> | null;
6
+ state: TState;
7
+ /** Populated only on multipart submissions (FormData). Empty for JSON bodies. */
8
+ files: Record<string, File>;
9
+ }
10
+ /** Parse a multipart/form-data action body — the wire format the TypeScript shell uses. */
11
+ export declare function parseFormDataAction<TState>(formData: FormData): ActionPayload<TState>;
12
+ /** Parse a flat JSON action body — { name, context, state }. For curl/agent callers. */
13
+ export declare function parseJsonAction<TState>(body: string | object): ActionPayload<TState>;
14
+ /** What an action handler returns. All fields are optional — see ShellResponse reference in AGENTS.md. */
15
+ export interface ShellResponseBody<TState> {
16
+ vm?: ViewNode | null;
17
+ state?: TState | null;
18
+ redirect?: string;
19
+ sideEffects?: ShellSideEffect[];
20
+ nextPollIn?: number;
21
+ }
22
+ /** Build a redirect response (Vm and State omitted; shell navigates the browser). */
23
+ export declare function shellRedirect<TState = unknown>(url: string): ShellResponseBody<TState>;
24
+ /** Side-effect factories matching the C# ShellSideEffect static methods. */
25
+ export declare const shellSideEffect: {
26
+ setLocalStorage: (key: string, value: string) => ShellSideEffect;
27
+ setSessionStorage: (key: string, value: string) => ShellSideEffect;
28
+ };
29
+ /**
30
+ * Web Fetch API–native request handler factory. Auto-detects content-type
31
+ * (application/json vs multipart/form-data), parses the body, calls your
32
+ * handler, and returns the JSON response.
33
+ *
34
+ * Works directly with Hono, Bun.serve, Deno.serve, Cloudflare Workers, or
35
+ * any Request → Response runtime. For Express, wrap with a small adapter
36
+ * that constructs a Request from (req) and writes the Response back to res.
37
+ *
38
+ * @example
39
+ * app.post("/api/tasks/action", createAction<TasksState>(async (payload) => {
40
+ * const state = applyAction(payload);
41
+ * return { vm: buildVm(state), state };
42
+ * }));
43
+ */
44
+ export declare function createAction<TState>(handler: (payload: ActionPayload<TState>) => Promise<ShellResponseBody<TState>> | ShellResponseBody<TState>): (request: Request) => Promise<Response>;
package/dist/server.js ADDED
@@ -0,0 +1,93 @@
1
+ // ─── ViewModel Shell — server subpath ────────────────────────────────────────
2
+ // Backend types and helpers for TypeScript/Node/Bun/Deno/Workers backends.
3
+ // Mirrors the C# ViewModelShell NuGet package — same wire format, same shapes.
4
+ //
5
+ // Web Fetch API–native: works directly with Hono, Bun.serve, Deno.serve,
6
+ // Cloudflare Workers. Express users can adapt createAction's (Request → Response)
7
+ // handler with a 3-line wrapper.
8
+ // Re-export the ViewNode hierarchy and wire types so a backend can import
9
+ // everything it needs from one place.
10
+ export * from "./index.js";
11
+ /** Parse a multipart/form-data action body — the wire format the TypeScript shell uses. */
12
+ export function parseFormDataAction(formData) {
13
+ const actionRaw = formData.get("_action");
14
+ const stateRaw = formData.get("_state");
15
+ if (typeof actionRaw !== "string" || typeof stateRaw !== "string") {
16
+ throw new Error("Missing _action or _state form field");
17
+ }
18
+ const action = JSON.parse(actionRaw);
19
+ const state = JSON.parse(stateRaw);
20
+ const files = {};
21
+ for (const [key, value] of formData.entries()) {
22
+ if (key !== "_action" && key !== "_state" && value instanceof File) {
23
+ files[key] = value;
24
+ }
25
+ }
26
+ return {
27
+ name: action.name,
28
+ context: action.context ?? null,
29
+ state,
30
+ files,
31
+ };
32
+ }
33
+ /** Parse a flat JSON action body — { name, context, state }. For curl/agent callers. */
34
+ export function parseJsonAction(body) {
35
+ const parsed = typeof body === "string"
36
+ ? JSON.parse(body)
37
+ : body;
38
+ return {
39
+ name: parsed.name,
40
+ context: parsed.context ?? null,
41
+ state: parsed.state,
42
+ files: {},
43
+ };
44
+ }
45
+ /** Build a redirect response (Vm and State omitted; shell navigates the browser). */
46
+ export function shellRedirect(url) {
47
+ return { redirect: url };
48
+ }
49
+ /** Side-effect factories matching the C# ShellSideEffect static methods. */
50
+ export const shellSideEffect = {
51
+ setLocalStorage: (key, value) => ({ type: "set-local-storage", key, value }),
52
+ setSessionStorage: (key, value) => ({ type: "set-session-storage", key, value }),
53
+ };
54
+ // ─── Action handler factory ──────────────────────────────────────────────────
55
+ /**
56
+ * Web Fetch API–native request handler factory. Auto-detects content-type
57
+ * (application/json vs multipart/form-data), parses the body, calls your
58
+ * handler, and returns the JSON response.
59
+ *
60
+ * Works directly with Hono, Bun.serve, Deno.serve, Cloudflare Workers, or
61
+ * any Request → Response runtime. For Express, wrap with a small adapter
62
+ * that constructs a Request from (req) and writes the Response back to res.
63
+ *
64
+ * @example
65
+ * app.post("/api/tasks/action", createAction<TasksState>(async (payload) => {
66
+ * const state = applyAction(payload);
67
+ * return { vm: buildVm(state), state };
68
+ * }));
69
+ */
70
+ export function createAction(handler) {
71
+ return async (request) => {
72
+ const contentType = request.headers.get("content-type") ?? "";
73
+ let payload;
74
+ try {
75
+ if (contentType.includes("application/json")) {
76
+ payload = parseJsonAction(await request.text());
77
+ }
78
+ else {
79
+ payload = parseFormDataAction(await request.formData());
80
+ }
81
+ }
82
+ catch (err) {
83
+ return new Response(JSON.stringify({ error: err.message }), {
84
+ status: 400,
85
+ headers: { "Content-Type": "application/json" },
86
+ });
87
+ }
88
+ const result = await handler(payload);
89
+ return new Response(JSON.stringify(result), {
90
+ headers: { "Content-Type": "application/json" },
91
+ });
92
+ };
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
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",
@@ -19,15 +19,31 @@
19
19
  "typescript"
20
20
  ],
21
21
  "files": [
22
- "src",
22
+ "dist",
23
23
  "styles",
24
24
  "README.md",
25
25
  "LICENSE"
26
26
  ],
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "devDependencies": {
32
+ "typescript": "^5.4.0"
33
+ },
27
34
  "exports": {
28
- ".": "./src/index.ts",
29
- "./browser": "./src/browser.ts",
30
- "./server": "./src/server.ts",
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "./browser": {
40
+ "types": "./dist/browser.d.ts",
41
+ "default": "./dist/browser.js"
42
+ },
43
+ "./server": {
44
+ "types": "./dist/server.d.ts",
45
+ "default": "./dist/server.js"
46
+ },
31
47
  "./styles.css": "./styles/default.css",
32
48
  "./themes/dark-blue.css": "./styles/themes/dark-blue.css",
33
49
  "./themes/dark-green.css": "./styles/themes/dark-green.css",