@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.
package/src/index.ts DELETED
@@ -1,335 +0,0 @@
1
- // ─── Action ──────────────────────────────────────────────────────────────────
2
-
3
- export interface ActionEvent {
4
- name: string;
5
- context?: Record<string, unknown>;
6
- files?: Record<string, File>;
7
- }
8
-
9
- // ─── Adapter interface ────────────────────────────────────────────────────────
10
- // Implement this to target a new platform (browser, mobile, terminal, …).
11
- // The core never references HTMLElement, document, or any platform type.
12
-
13
- export interface Adapter {
14
- render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
15
- }
16
-
17
- // ─── Node types ───────────────────────────────────────────────────────────────
18
-
19
- export type ViewNode =
20
- | PageNode
21
- | SectionNode
22
- | ListNode
23
- | ListItemNode
24
- | FormNode
25
- | FieldNode
26
- | CheckboxNode
27
- | ButtonNode
28
- | TextNode
29
- | LinkNode
30
- | StatBarNode
31
- | TabsNode
32
- | ProgressNode
33
- | ModalNode
34
- | TableNode;
35
-
36
- export interface PageNode {
37
- type: "page";
38
- title?: string;
39
- children: ViewNode[];
40
- }
41
-
42
- export interface SectionNode {
43
- type: "section";
44
- heading?: string;
45
- children: ViewNode[];
46
- }
47
-
48
- export interface ListNode {
49
- type: "list";
50
- id?: string;
51
- children: ViewNode[];
52
- }
53
-
54
- export interface ListItemNode {
55
- type: "list-item";
56
- id?: string;
57
- /** Appended as a BEM modifier: vms-list-item--{variant} */
58
- variant?: string;
59
- children: ViewNode[];
60
- }
61
-
62
- export interface FormNode {
63
- type: "form";
64
- submitAction: ActionEvent;
65
- submitLabel?: string;
66
- children: ViewNode[];
67
- }
68
-
69
- export interface FieldNode {
70
- type: "field";
71
- name: string;
72
- inputType:
73
- | "text" | "email" | "password" | "number"
74
- | "date" | "time" | "datetime-local"
75
- | "textarea" | "hidden" | "file"
76
- | "select" | "select-multiple" | "checkbox"
77
- | "code";
78
- label?: string;
79
- placeholder?: string;
80
- value?: string;
81
- required?: boolean;
82
- options?: Array<{ value: string; label: string }>;
83
- /** Optional language hint for `inputType: "code"`. Emitted as a class
84
- * (`vms-field--code-{language}`) so apps can attach a syntax-highlighter
85
- * library (CodeMirror, Monaco, etc.) — the framework only ships
86
- * monospaced editable text, no coloring. */
87
- language?: string;
88
- /** Dispatched when Enter is pressed (text-like inputs only). Adapter merges { [name]: value } into context. */
89
- action?: ActionEvent;
90
- }
91
-
92
- export interface CheckboxNode {
93
- type: "checkbox";
94
- name: string;
95
- checked: boolean;
96
- label?: string;
97
- /** Dispatched immediately on change. Adapter merges { checked: boolean } into context. */
98
- action?: ActionEvent;
99
- }
100
-
101
- export interface ButtonNode {
102
- type: "button";
103
- label: string;
104
- action: ActionEvent;
105
- variant?: "primary" | "secondary" | "danger";
106
- }
107
-
108
- export interface TextNode {
109
- type: "text";
110
- value: string;
111
- style?: "heading" | "subheading" | "body" | "muted" | "strikethrough" | "error" | "pre";
112
- }
113
-
114
- export interface LinkNode {
115
- type: "link";
116
- label: string;
117
- href: string;
118
- /** true = open outside current app context (browser: new tab + noopener) */
119
- external?: boolean;
120
- }
121
-
122
- export interface StatBarNode {
123
- type: "stat-bar";
124
- stats: Array<{ label: string; value: string | number }>;
125
- }
126
-
127
- export interface TabsNode {
128
- type: "tabs";
129
- selected: string;
130
- /** Base action. Adapter merges { value: tab.value } into context on click. */
131
- action: ActionEvent;
132
- tabs: Array<{ value: string; label: string }>;
133
- }
134
-
135
- export interface ProgressNode {
136
- type: "progress";
137
- value: number; // 0–100
138
- }
139
-
140
- export interface ModalNode {
141
- type: "modal";
142
- title?: string;
143
- children: ViewNode[];
144
- /** Optional footer row (typically holds action buttons). Rendered as an inline row at the bottom of the modal. */
145
- footer?: ViewNode[];
146
- /** Dispatched when the close button is clicked. If omitted, no close button is rendered. */
147
- dismissAction?: ActionEvent;
148
- /** Width variant. Default is "medium" (~520px). "wide" (~800px) suits tables/dashboards;
149
- * "fullscreen" (~95vw/95vh) for content that needs the whole viewport. */
150
- size?: "narrow" | "medium" | "wide" | "fullscreen";
151
- }
152
-
153
- export interface TableColumn {
154
- key: string;
155
- label: string;
156
- sortable?: boolean;
157
- filterable?: boolean;
158
- filterValue?: string;
159
- /** If set, cell values render as <a href={value}>{linkLabel}</a> */
160
- linkLabel?: string;
161
- /** true = open outside current app context (browser: new tab + noopener) */
162
- linkExternal?: boolean;
163
- }
164
-
165
- export interface TableRow {
166
- id?: string;
167
- cells: Record<string, string>;
168
- action?: ActionEvent;
169
- variant?: string;
170
- }
171
-
172
- export interface TableNode {
173
- type: "table";
174
- columns: TableColumn[];
175
- rows: TableRow[];
176
- sortColumn?: string;
177
- sortDirection?: "asc" | "desc";
178
- /** Base action. Adapter merges { column, direction } into context on header click. */
179
- sortAction?: ActionEvent;
180
- /** Base action. Adapter merges { column, value, filters } into context on Enter. */
181
- filterAction?: ActionEvent;
182
- }
183
-
184
- // ─── Shell ────────────────────────────────────────────────────────────────────
185
- // Owns the fetch → render → action → fetch cycle.
186
- // fetch is universal (browsers, Node 18+, Deno) so it belongs in the core.
187
-
188
- export interface ShellOptions {
189
- endpoint: string;
190
- actionEndpoint: string;
191
- adapter: Adapter;
192
- onError?: (err: Error) => void;
193
- onLoading?: (loading: boolean) => void;
194
- /** Called before each dispatch — merge the returned headers into every POST request. */
195
- getRequestHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
196
- /** Called when the server responds with a redirect URL. Defaults to window.location.href = url. */
197
- onRedirect?: (url: string) => void;
198
- /** When set, the shell dispatches a "poll" action at this interval (ms) after every load/dispatch.
199
- * The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
200
- * omitting nextPollIn when no pollInterval is configured. */
201
- pollInterval?: number;
202
- }
203
-
204
- export interface ShellSideEffect {
205
- /** "set-local-storage" | "set-session-storage" — unknown types are silently ignored. */
206
- type: string;
207
- key?: string;
208
- value?: string;
209
- }
210
-
211
- export interface ShellResponse {
212
- vm: ViewNode;
213
- state: unknown;
214
- /** When set, the shell navigates to this URL instead of re-rendering. */
215
- redirect?: string;
216
- /** Applied in order before redirect or re-render. */
217
- sideEffects?: ShellSideEffect[];
218
- /** When set, schedules the next poll at this delay (ms). Overrides pollInterval for one tick. */
219
- nextPollIn?: number;
220
- }
221
-
222
- export class ViewModelShell {
223
- private currentVm: ViewNode | null = null;
224
- private currentState: unknown = null;
225
- private dispatching = false;
226
- private pollTimer: ReturnType<typeof setTimeout> | null = null;
227
-
228
- constructor(private options: ShellOptions) {}
229
-
230
- async load(params?: Record<string, string>): Promise<void> {
231
- const { endpoint, adapter, onError, onLoading } = this.options;
232
- this.stopPolling();
233
- try {
234
- onLoading?.(true);
235
- const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
236
- const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
237
- const res = await fetch(url, { headers: { Accept: "application/json", ...extraHeaders } });
238
- if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
239
- const body = (await res.json()) as ShellResponse;
240
- this.currentVm = body.vm;
241
- this.currentState = body.state;
242
- adapter.render(body.vm, (action) => this.dispatch(action));
243
- this.schedulePoll(body.nextPollIn);
244
- } catch (err) {
245
- const error = err instanceof Error ? err : new Error(String(err));
246
- onError ? onError(error) : console.error("[ViewModelShell]", error);
247
- } finally {
248
- onLoading?.(false);
249
- }
250
- }
251
-
252
- async dispatch(action: ActionEvent, silent = false): Promise<void> {
253
- if (this.dispatching) return;
254
- const { actionEndpoint, onError, onLoading } = this.options;
255
- if (this.currentState === null) {
256
- const err = new Error(
257
- `Cannot dispatch '${action.name}' before initial load completes. ` +
258
- `Call shell.load() and wait for it before allowing user interaction.`
259
- );
260
- onError ? onError(err) : console.error("[ViewModelShell]", err);
261
- return;
262
- }
263
- try {
264
- this.dispatching = true;
265
- if (!silent) onLoading?.(true);
266
- const form = new FormData();
267
- form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
268
- form.append("_state", JSON.stringify(this.currentState));
269
- if (action.files) {
270
- for (const [name, file] of Object.entries(action.files)) {
271
- form.append(name, file);
272
- }
273
- }
274
- const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
275
- const res = await fetch(actionEndpoint, {
276
- method: "POST",
277
- headers: { Accept: "application/json", ...extraHeaders },
278
- body: form,
279
- });
280
- if (!res.ok) throw new Error(`Action '${action.name}' failed: ${res.status}`);
281
- this.processResponse((await res.json()) as ShellResponse);
282
- } catch (err) {
283
- const error = err instanceof Error ? err : new Error(String(err));
284
- onError ? onError(error) : console.error("[ViewModelShell]", error);
285
- } finally {
286
- this.dispatching = false;
287
- if (!silent) onLoading?.(false);
288
- }
289
- }
290
-
291
- /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
292
- push(response: ShellResponse): void {
293
- if (this.dispatching) return;
294
- this.processResponse(response);
295
- }
296
-
297
- stopPolling(): void {
298
- if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
299
- }
300
-
301
- getCurrentVm(): ViewNode | null { return this.currentVm; }
302
- getCurrentState(): unknown { return this.currentState; }
303
-
304
- private processResponse(body: ShellResponse): void {
305
- for (const effect of body.sideEffects ?? []) {
306
- if (effect.type === "set-local-storage" && effect.key != null) {
307
- localStorage.setItem(effect.key, effect.value ?? "");
308
- } else if (effect.type === "set-session-storage" && effect.key != null) {
309
- sessionStorage.setItem(effect.key, effect.value ?? "");
310
- }
311
- }
312
- if (body.redirect) {
313
- if (this.options.onRedirect) {
314
- this.options.onRedirect(body.redirect);
315
- } else {
316
- window.location.href = body.redirect;
317
- }
318
- return;
319
- }
320
- this.currentVm = body.vm!;
321
- this.currentState = body.state;
322
- this.options.adapter.render(body.vm!, (a) => this.dispatch(a));
323
- this.schedulePoll(body.nextPollIn);
324
- }
325
-
326
- private schedulePoll(nextPollIn?: number): void {
327
- const delay = nextPollIn ?? this.options.pollInterval;
328
- if (delay == null) return;
329
- if (this.pollTimer) clearTimeout(this.pollTimer);
330
- this.pollTimer = setTimeout(() => {
331
- this.pollTimer = null;
332
- this.dispatch({ name: "poll" }, true);
333
- }, delay);
334
- }
335
- }
package/src/server.ts DELETED
@@ -1,126 +0,0 @@
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
-
9
- import type { ViewNode, ShellSideEffect } from "./index";
10
-
11
- // Re-export the ViewNode hierarchy and wire types so a backend can import
12
- // everything it needs from one place.
13
- export * from "./index";
14
-
15
- // ─── Action payload ──────────────────────────────────────────────────────────
16
-
17
- export interface ActionPayload<TState> {
18
- name: string;
19
- context: Record<string, unknown> | null;
20
- state: TState;
21
- /** Populated only on multipart submissions (FormData). Empty for JSON bodies. */
22
- files: Record<string, File>;
23
- }
24
-
25
- /** Parse a multipart/form-data action body — the wire format the TypeScript shell uses. */
26
- export function parseFormDataAction<TState>(formData: FormData): ActionPayload<TState> {
27
- const actionRaw = formData.get("_action");
28
- const stateRaw = formData.get("_state");
29
- if (typeof actionRaw !== "string" || typeof stateRaw !== "string") {
30
- throw new Error("Missing _action or _state form field");
31
- }
32
- const action = JSON.parse(actionRaw) as { name: string; context?: Record<string, unknown> };
33
- const state = JSON.parse(stateRaw) as TState;
34
- const files: Record<string, File> = {};
35
- for (const [key, value] of formData.entries()) {
36
- if (key !== "_action" && key !== "_state" && value instanceof File) {
37
- files[key] = value;
38
- }
39
- }
40
- return {
41
- name: action.name,
42
- context: action.context ?? null,
43
- state,
44
- files,
45
- };
46
- }
47
-
48
- /** Parse a flat JSON action body — { name, context, state }. For curl/agent callers. */
49
- export function parseJsonAction<TState>(body: string | object): ActionPayload<TState> {
50
- const parsed = typeof body === "string"
51
- ? (JSON.parse(body) as { name: string; context?: Record<string, unknown>; state: TState })
52
- : (body as { name: string; context?: Record<string, unknown>; state: TState });
53
- return {
54
- name: parsed.name,
55
- context: parsed.context ?? null,
56
- state: parsed.state,
57
- files: {},
58
- };
59
- }
60
-
61
- // ─── ShellResponse ───────────────────────────────────────────────────────────
62
-
63
- /** What an action handler returns. All fields are optional — see ShellResponse reference in AGENTS.md. */
64
- export interface ShellResponseBody<TState> {
65
- vm?: ViewNode | null;
66
- state?: TState | null;
67
- redirect?: string;
68
- sideEffects?: ShellSideEffect[];
69
- nextPollIn?: number;
70
- }
71
-
72
- /** Build a redirect response (Vm and State omitted; shell navigates the browser). */
73
- export function shellRedirect<TState = unknown>(url: string): ShellResponseBody<TState> {
74
- return { redirect: url };
75
- }
76
-
77
- /** Side-effect factories matching the C# ShellSideEffect static methods. */
78
- export const shellSideEffect = {
79
- setLocalStorage: (key: string, value: string): ShellSideEffect =>
80
- ({ type: "set-local-storage", key, value }),
81
- setSessionStorage: (key: string, value: string): ShellSideEffect =>
82
- ({ type: "set-session-storage", key, value }),
83
- };
84
-
85
- // ─── Action handler factory ──────────────────────────────────────────────────
86
-
87
- /**
88
- * Web Fetch API–native request handler factory. Auto-detects content-type
89
- * (application/json vs multipart/form-data), parses the body, calls your
90
- * handler, and returns the JSON response.
91
- *
92
- * Works directly with Hono, Bun.serve, Deno.serve, Cloudflare Workers, or
93
- * any Request → Response runtime. For Express, wrap with a small adapter
94
- * that constructs a Request from (req) and writes the Response back to res.
95
- *
96
- * @example
97
- * app.post("/api/tasks/action", createAction<TasksState>(async (payload) => {
98
- * const state = applyAction(payload);
99
- * return { vm: buildVm(state), state };
100
- * }));
101
- */
102
- export function createAction<TState>(
103
- handler: (payload: ActionPayload<TState>) =>
104
- Promise<ShellResponseBody<TState>> | ShellResponseBody<TState>
105
- ): (request: Request) => Promise<Response> {
106
- return async (request: Request): Promise<Response> => {
107
- const contentType = request.headers.get("content-type") ?? "";
108
- let payload: ActionPayload<TState>;
109
- try {
110
- if (contentType.includes("application/json")) {
111
- payload = parseJsonAction<TState>(await request.text());
112
- } else {
113
- payload = parseFormDataAction<TState>(await request.formData());
114
- }
115
- } catch (err) {
116
- return new Response(JSON.stringify({ error: (err as Error).message }), {
117
- status: 400,
118
- headers: { "Content-Type": "application/json" },
119
- });
120
- }
121
- const result = await handler(payload);
122
- return new Response(JSON.stringify(result), {
123
- headers: { "Content-Type": "application/json" },
124
- });
125
- };
126
- }