@ashley-shrok/viewmodel-shell 0.3.8 → 0.3.10
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/package.json +2 -1
- package/src/server.ts +126 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
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",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"exports": {
|
|
28
28
|
".": "./src/index.ts",
|
|
29
29
|
"./browser": "./src/browser.ts",
|
|
30
|
+
"./server": "./src/server.ts",
|
|
30
31
|
"./styles.css": "./styles/default.css",
|
|
31
32
|
"./themes/dark-blue.css": "./styles/themes/dark-blue.css",
|
|
32
33
|
"./themes/dark-green.css": "./styles/themes/dark-green.css",
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
}
|