elm-ssr 0.1.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 +67 -0
- package/bin/elm-ssr.mjs +102 -0
- package/elm-src/ElmSsr/Action.elm +210 -0
- package/elm-src/ElmSsr/Document/Encode.elm +83 -0
- package/elm-src/ElmSsr/Document/Events.elm +125 -0
- package/elm-src/ElmSsr/Document.elm +26 -0
- package/elm-src/ElmSsr/Html/Attributes.elm +344 -0
- package/elm-src/ElmSsr/Html/Events.elm +95 -0
- package/elm-src/ElmSsr/Html.elm +706 -0
- package/elm-src/ElmSsr/Island/Shared.elm +38 -0
- package/elm-src/ElmSsr/Island.elm +49 -0
- package/elm-src/ElmSsr/Loader.elm +297 -0
- package/elm-src/ElmSsr/Page.elm +102 -0
- package/elm-src/ElmSsr/Route.elm +136 -0
- package/elm-src/ElmSsr/Runtime.elm +170 -0
- package/elm-src/ElmSsr/Svg/Attributes.elm +1208 -0
- package/elm-src/ElmSsr/Svg.elm +309 -0
- package/lib/build.mjs +256 -0
- package/lib/migrate.mjs +146 -0
- package/lib/scaffold.mjs +472 -0
- package/lib/workspace.mjs +21 -0
- package/package.json +60 -0
- package/src/app.ts +74 -0
- package/src/backends.ts +116 -0
- package/src/client-runtime/islands.ts +247 -0
- package/src/effects.ts +267 -0
- package/src/http.ts +86 -0
- package/src/middleware.ts +104 -0
- package/src/migrations.ts +225 -0
- package/src/protocol.ts +119 -0
- package/src/render.ts +111 -0
- package/src/request-handler.ts +208 -0
- package/src/response-headers.ts +18 -0
- package/src/serialize.ts +47 -0
- package/src/tasks.ts +139 -0
package/src/http.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface RenderFlagsContext {
|
|
2
|
+
request: Request;
|
|
3
|
+
url: URL;
|
|
4
|
+
path: string;
|
|
5
|
+
formData?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface WorkerExecutionContext {
|
|
9
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface WorkerHandler {
|
|
13
|
+
fetch(request: Request, env?: unknown, executionCtx?: WorkerExecutionContext): Promise<Response>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AppContext {
|
|
17
|
+
request: Request;
|
|
18
|
+
url: URL;
|
|
19
|
+
requestId: string;
|
|
20
|
+
startedAt: number;
|
|
21
|
+
executionCtx?: WorkerExecutionContext;
|
|
22
|
+
env?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RouteDefinition {
|
|
26
|
+
path: string;
|
|
27
|
+
methods: string[];
|
|
28
|
+
description: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RouteCatalog {
|
|
32
|
+
pages: RouteDefinition[];
|
|
33
|
+
assets: RouteDefinition[];
|
|
34
|
+
utility: RouteDefinition[];
|
|
35
|
+
api: RouteDefinition[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RenderResult {
|
|
39
|
+
html: string;
|
|
40
|
+
status: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type RenderFlagsFactory = (context: RenderFlagsContext) => Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
export type AppHandler = (context: AppContext) => Promise<Response>;
|
|
46
|
+
|
|
47
|
+
export type Middleware = (context: AppContext, next: AppHandler) => Promise<Response>;
|
|
48
|
+
|
|
49
|
+
export const withHeaders = (response: Response, headers: HeadersInit): Response => {
|
|
50
|
+
const merged = new Headers(response.headers);
|
|
51
|
+
new Headers(headers).forEach((value, key) => {
|
|
52
|
+
merged.set(key, value);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return new Response(response.body, {
|
|
56
|
+
status: response.status,
|
|
57
|
+
statusText: response.statusText,
|
|
58
|
+
headers: merged
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const json = (body: unknown, init: ResponseInit = {}): Response => {
|
|
63
|
+
const headers = new Headers(init.headers);
|
|
64
|
+
|
|
65
|
+
if (!headers.has("content-type")) {
|
|
66
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Response(JSON.stringify(body, null, 2), {
|
|
70
|
+
...init,
|
|
71
|
+
headers
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const text = (body: string, init: ResponseInit = {}): Response => {
|
|
76
|
+
const headers = new Headers(init.headers);
|
|
77
|
+
|
|
78
|
+
if (!headers.has("content-type")) {
|
|
79
|
+
headers.set("content-type", "text/plain; charset=utf-8");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return new Response(body, {
|
|
83
|
+
...init,
|
|
84
|
+
headers
|
|
85
|
+
});
|
|
86
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { AppContext, AppHandler, Middleware } from "./http";
|
|
2
|
+
import { json, text, withHeaders } from "./http";
|
|
3
|
+
|
|
4
|
+
const runDetached = (context: AppContext, task: Promise<unknown>): void => {
|
|
5
|
+
if (context.executionCtx) {
|
|
6
|
+
context.executionCtx.waitUntil(task);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
void task.catch((error) => {
|
|
11
|
+
console.error("detached_middleware_task_failed", error);
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const composeMiddleware = (handler: AppHandler, middlewares: Middleware[]): AppHandler =>
|
|
16
|
+
middlewares.reduceRight<AppHandler>(
|
|
17
|
+
(next, middleware) => (context) => middleware(context, next),
|
|
18
|
+
handler
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export const requestIdMiddleware: Middleware = async (context, next) => {
|
|
22
|
+
const existing = context.request.headers.get("x-request-id");
|
|
23
|
+
context.requestId = existing && existing.length > 0 ? existing : crypto.randomUUID();
|
|
24
|
+
|
|
25
|
+
const response = await next(context);
|
|
26
|
+
|
|
27
|
+
return withHeaders(response, {
|
|
28
|
+
"x-request-id": context.requestId
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const timingMiddleware: Middleware = async (context, next) => {
|
|
33
|
+
const response = await next(context);
|
|
34
|
+
const durationMs = performance.now() - context.startedAt;
|
|
35
|
+
|
|
36
|
+
return withHeaders(response, {
|
|
37
|
+
"server-timing": `app;dur=${durationMs.toFixed(2)}`,
|
|
38
|
+
"x-response-time": `${durationMs.toFixed(2)}ms`
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const headMiddleware: Middleware = async (context, next) => {
|
|
43
|
+
const response = await next(context);
|
|
44
|
+
|
|
45
|
+
if (context.request.method !== "HEAD") {
|
|
46
|
+
return response;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new Response(null, {
|
|
50
|
+
status: response.status,
|
|
51
|
+
statusText: response.statusText,
|
|
52
|
+
headers: response.headers
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const errorMiddleware: Middleware = async (context, next) => {
|
|
57
|
+
try {
|
|
58
|
+
return await next(context);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error("elm_ssr_request_failed", {
|
|
61
|
+
requestId: context.requestId,
|
|
62
|
+
path: context.url.pathname,
|
|
63
|
+
error
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (context.url.pathname.startsWith("/api/")) {
|
|
67
|
+
return json(
|
|
68
|
+
{
|
|
69
|
+
error: "internal_error",
|
|
70
|
+
requestId: context.requestId
|
|
71
|
+
},
|
|
72
|
+
{ status: 500 }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return text("Internal Server Error", {
|
|
77
|
+
status: 500
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const loggingMiddleware = (logger: (entry: string) => void = console.log): Middleware =>
|
|
83
|
+
async (context, next) => {
|
|
84
|
+
const response = await next(context);
|
|
85
|
+
const durationMs = performance.now() - context.startedAt;
|
|
86
|
+
|
|
87
|
+
runDetached(
|
|
88
|
+
context,
|
|
89
|
+
Promise.resolve().then(() => {
|
|
90
|
+
logger(
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
event: "request_completed",
|
|
93
|
+
requestId: context.requestId,
|
|
94
|
+
method: context.request.method,
|
|
95
|
+
path: context.url.pathname,
|
|
96
|
+
status: response.status,
|
|
97
|
+
durationMs: Number(durationMs.toFixed(2))
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return response;
|
|
104
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SQL-file migration runner. Backend-neutral over a minimal `MigrationsAdapter`:
|
|
6
|
+
* wire it to bun:sqlite, Postgres (Bun.sql / node-postgres), Cloudflare D1, etc.
|
|
7
|
+
*
|
|
8
|
+
* Migrations live in `<dir>/*.sql`, ordered alphabetically (use a numeric
|
|
9
|
+
* prefix: `0001_init.sql`, `0002_add_users.sql`). Applied migrations are tracked
|
|
10
|
+
* in `__elm_ssr_migrations(name PRIMARY KEY, applied_at)`; re-runs are
|
|
11
|
+
* idempotent. Each migration runs inside a single `BEGIN…COMMIT` transaction
|
|
12
|
+
* together with its tracking insert, so a failing migration rolls back without
|
|
13
|
+
* leaving the schema partially applied (assuming a transactional backend).
|
|
14
|
+
*/
|
|
15
|
+
export interface MigrationsAdapter {
|
|
16
|
+
/** Execute possibly-multi-statement SQL (no params). */
|
|
17
|
+
exec(sql: string): Promise<void>;
|
|
18
|
+
/** Run a query and return the result rows as plain objects. */
|
|
19
|
+
list(sql: string): Promise<Array<Record<string, unknown>>>;
|
|
20
|
+
/** Optional: Run a set of operations in a transaction. */
|
|
21
|
+
runInTransaction?(fn: () => Promise<void>): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RunMigrationsOptions {
|
|
25
|
+
/** Directory containing `*.sql` migration files. */
|
|
26
|
+
dir: string;
|
|
27
|
+
/** Override the tracking table name (default `"__elm_ssr_migrations"`). */
|
|
28
|
+
tableName?: string;
|
|
29
|
+
/** Override the timestamp generator (useful for deterministic tests). */
|
|
30
|
+
now?: () => string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MigrationResult {
|
|
34
|
+
/** Migration filenames applied during this run, in order. */
|
|
35
|
+
applied: string[];
|
|
36
|
+
/** Migration filenames that were already applied. */
|
|
37
|
+
skipped: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const escapeLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
41
|
+
|
|
42
|
+
const validateTableName = (name: string): void => {
|
|
43
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
44
|
+
throw new Error(`Invalid migrations table name: ${name}`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const isUpMigration = (name: string): boolean =>
|
|
49
|
+
name.endsWith(".sql") && !name.endsWith(".down.sql");
|
|
50
|
+
|
|
51
|
+
const ensureTable = async (adapter: MigrationsAdapter, table: string): Promise<void> => {
|
|
52
|
+
await adapter.exec(
|
|
53
|
+
`CREATE TABLE IF NOT EXISTS ${table} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const runTransaction = async (adapter: MigrationsAdapter, work: () => Promise<void>): Promise<void> => {
|
|
58
|
+
if (adapter.runInTransaction) {
|
|
59
|
+
await adapter.runInTransaction(work);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await adapter.exec("BEGIN");
|
|
64
|
+
try {
|
|
65
|
+
await work();
|
|
66
|
+
await adapter.exec("COMMIT");
|
|
67
|
+
} catch (error) {
|
|
68
|
+
try {
|
|
69
|
+
await adapter.exec("ROLLBACK");
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore — backend may have already rolled back
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const runMigrations = async (
|
|
78
|
+
adapter: MigrationsAdapter,
|
|
79
|
+
options: RunMigrationsOptions
|
|
80
|
+
): Promise<MigrationResult> => {
|
|
81
|
+
const table = options.tableName ?? "__elm_ssr_migrations";
|
|
82
|
+
validateTableName(table);
|
|
83
|
+
|
|
84
|
+
const now = options.now ?? (() => new Date().toISOString());
|
|
85
|
+
|
|
86
|
+
await ensureTable(adapter, table);
|
|
87
|
+
|
|
88
|
+
const rows = await adapter.list(`SELECT name FROM ${table}`);
|
|
89
|
+
const alreadyApplied = new Set(rows.map((row) => String(row.name)));
|
|
90
|
+
|
|
91
|
+
const allFiles = (await readdir(options.dir)).filter(isUpMigration).sort();
|
|
92
|
+
|
|
93
|
+
const applied: string[] = [];
|
|
94
|
+
const skipped: string[] = [];
|
|
95
|
+
|
|
96
|
+
for (const file of allFiles) {
|
|
97
|
+
if (alreadyApplied.has(file)) {
|
|
98
|
+
skipped.push(file);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const raw = await readFile(resolve(options.dir, file), "utf8");
|
|
103
|
+
// Strip trailing whitespace + trailing semicolons so we can compose without `;;`.
|
|
104
|
+
const trimmed = raw.replace(/[\s;]+$/, "");
|
|
105
|
+
const insertSql = `INSERT INTO ${table} (name, applied_at) VALUES (${escapeLiteral(file)}, ${escapeLiteral(now())})`;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await runTransaction(adapter, async () => {
|
|
109
|
+
if (trimmed.length > 0) {
|
|
110
|
+
await adapter.exec(trimmed);
|
|
111
|
+
}
|
|
112
|
+
await adapter.exec(insertSql);
|
|
113
|
+
});
|
|
114
|
+
applied.push(file);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw new Error(`Migration "${file}" failed: ${String(error)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { applied, skipped };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// === Down migrations ===
|
|
124
|
+
|
|
125
|
+
export interface RevertMigrationsOptions {
|
|
126
|
+
/** Directory containing the matching `<name>.down.sql` files. */
|
|
127
|
+
dir: string;
|
|
128
|
+
/** Override the tracking table name (default `"__elm_ssr_migrations"`). */
|
|
129
|
+
tableName?: string;
|
|
130
|
+
/** How many of the most-recently-applied migrations to revert. Default `1`. */
|
|
131
|
+
count?: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface RevertResult {
|
|
135
|
+
/** Migration filenames reverted in this run, most-recent-first. */
|
|
136
|
+
reverted: string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Revert the most-recently-applied migrations. For each one, runs the paired
|
|
141
|
+
* `<name>.down.sql` from `dir` and removes the tracking row, transactionally.
|
|
142
|
+
* Errors if a paired down file is missing — migrations are forward-only by
|
|
143
|
+
* default; opt in to reverts by shipping `*.down.sql` alongside `*.sql`.
|
|
144
|
+
*/
|
|
145
|
+
export const revertMigrations = async (
|
|
146
|
+
adapter: MigrationsAdapter,
|
|
147
|
+
options: RevertMigrationsOptions
|
|
148
|
+
): Promise<RevertResult> => {
|
|
149
|
+
const table = options.tableName ?? "__elm_ssr_migrations";
|
|
150
|
+
validateTableName(table);
|
|
151
|
+
|
|
152
|
+
await ensureTable(adapter, table);
|
|
153
|
+
|
|
154
|
+
const rows = await adapter.list(`SELECT name FROM ${table} ORDER BY name DESC`);
|
|
155
|
+
const applied = rows.map((row) => String(row.name));
|
|
156
|
+
const count = Math.max(0, options.count ?? 1);
|
|
157
|
+
const toRevert = applied.slice(0, count);
|
|
158
|
+
const reverted: string[] = [];
|
|
159
|
+
|
|
160
|
+
for (const file of toRevert) {
|
|
161
|
+
const downName = file.replace(/\.sql$/, ".down.sql");
|
|
162
|
+
let downSql: string;
|
|
163
|
+
try {
|
|
164
|
+
downSql = await readFile(resolve(options.dir, downName), "utf8");
|
|
165
|
+
} catch {
|
|
166
|
+
throw new Error(`No down migration for "${file}" (expected ${downName})`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const trimmed = downSql.replace(/[\s;]+$/, "");
|
|
170
|
+
const deleteSql = `DELETE FROM ${table} WHERE name = ${escapeLiteral(file)}`;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await runTransaction(adapter, async () => {
|
|
174
|
+
if (trimmed.length > 0) {
|
|
175
|
+
await adapter.exec(trimmed);
|
|
176
|
+
}
|
|
177
|
+
await adapter.exec(deleteSql);
|
|
178
|
+
});
|
|
179
|
+
reverted.push(file);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new Error(`Reverting "${file}" failed: ${String(error)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { reverted };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// === Status ===
|
|
189
|
+
|
|
190
|
+
export interface ListMigrationsOptions {
|
|
191
|
+
/** Directory containing `*.sql` migration files. */
|
|
192
|
+
dir: string;
|
|
193
|
+
/** Override the tracking table name (default `"__elm_ssr_migrations"`). */
|
|
194
|
+
tableName?: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface MigrationsStatus {
|
|
198
|
+
/** Already-applied migrations, oldest first. */
|
|
199
|
+
applied: Array<{ name: string; appliedAt: string }>;
|
|
200
|
+
/** On-disk migrations that haven't been applied yet. */
|
|
201
|
+
pending: string[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Inspect the migration state: what's applied (with timestamps) and what
|
|
206
|
+
* remains. Creates the tracking table on first use.
|
|
207
|
+
*/
|
|
208
|
+
export const listMigrations = async (
|
|
209
|
+
adapter: MigrationsAdapter,
|
|
210
|
+
options: ListMigrationsOptions
|
|
211
|
+
): Promise<MigrationsStatus> => {
|
|
212
|
+
const table = options.tableName ?? "__elm_ssr_migrations";
|
|
213
|
+
validateTableName(table);
|
|
214
|
+
|
|
215
|
+
await ensureTable(adapter, table);
|
|
216
|
+
|
|
217
|
+
const rows = await adapter.list(`SELECT name, applied_at FROM ${table} ORDER BY name`);
|
|
218
|
+
const applied = rows.map((row) => ({ name: String(row.name), appliedAt: String(row.applied_at) }));
|
|
219
|
+
const appliedSet = new Set(applied.map((entry) => entry.name));
|
|
220
|
+
|
|
221
|
+
const allFiles = (await readdir(options.dir)).filter(isUpMigration).sort();
|
|
222
|
+
const pending = allFiles.filter((file) => !appliedSet.has(file));
|
|
223
|
+
|
|
224
|
+
return { applied, pending };
|
|
225
|
+
};
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export interface SsrPropertyAttribute {
|
|
2
|
+
kind: "attribute";
|
|
3
|
+
name: string;
|
|
4
|
+
value: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface SsrEventAttribute {
|
|
8
|
+
kind: "event";
|
|
9
|
+
name: string;
|
|
10
|
+
payload: unknown;
|
|
11
|
+
capture: "none" | "value";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SsrAttribute = SsrPropertyAttribute | SsrEventAttribute;
|
|
15
|
+
|
|
16
|
+
export interface SsrElementNode {
|
|
17
|
+
kind: "element";
|
|
18
|
+
tag: string;
|
|
19
|
+
attrs: SsrAttribute[];
|
|
20
|
+
children: SsrNode[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SsrVoidNode {
|
|
24
|
+
kind: "void";
|
|
25
|
+
tag: string;
|
|
26
|
+
attrs: SsrAttribute[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SsrTextNode {
|
|
30
|
+
kind: "text";
|
|
31
|
+
text: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SsrNode = SsrElementNode | SsrVoidNode | SsrTextNode;
|
|
35
|
+
|
|
36
|
+
export interface SsrDocument {
|
|
37
|
+
status: number;
|
|
38
|
+
lang: string;
|
|
39
|
+
hasIslands: boolean;
|
|
40
|
+
head: SsrNode[];
|
|
41
|
+
body: SsrNode[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
45
|
+
typeof value === "object" && value !== null;
|
|
46
|
+
|
|
47
|
+
const isAttribute = (value: unknown): value is SsrAttribute => {
|
|
48
|
+
if (!isRecord(value) || typeof value.kind !== "string" || typeof value.name !== "string") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (value.kind === "attribute") {
|
|
53
|
+
return typeof value.value === "string";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (value.kind === "event") {
|
|
57
|
+
return "payload" in value && (value.capture === "none" || value.capture === "value");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return false;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const isNode = (value: unknown): value is SsrNode => {
|
|
64
|
+
if (!isRecord(value) || typeof value.kind !== "string") {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (value.kind === "text") {
|
|
69
|
+
return typeof value.text === "string";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if ((value.kind === "element" || value.kind === "void") && typeof value.tag === "string" && Array.isArray(value.attrs)) {
|
|
73
|
+
if (!value.attrs.every(isAttribute)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (value.kind === "element") {
|
|
78
|
+
return Array.isArray(value.children) && value.children.every(isNode);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return false;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const assertDocument = (value: unknown): SsrDocument => {
|
|
88
|
+
if (
|
|
89
|
+
!isRecord(value)
|
|
90
|
+
|| typeof value.status !== "number"
|
|
91
|
+
|| typeof value.lang !== "string"
|
|
92
|
+
|| typeof value.hasIslands !== "boolean"
|
|
93
|
+
|| !Array.isArray(value.head)
|
|
94
|
+
|| !Array.isArray(value.body)
|
|
95
|
+
|| !value.head.every(isNode)
|
|
96
|
+
|| !value.body.every(isNode)
|
|
97
|
+
) {
|
|
98
|
+
throw new Error("Elm SSR returned an invalid document payload.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return value as unknown as SsrDocument;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const toDomAttribute = (attribute: SsrAttribute): { name: string; value: string } => {
|
|
105
|
+
if (attribute.kind === "attribute") {
|
|
106
|
+
return {
|
|
107
|
+
name: attribute.name,
|
|
108
|
+
value: attribute.value
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
name: `data-elmssr-event-${attribute.name}`,
|
|
114
|
+
value: encodeURIComponent(JSON.stringify({
|
|
115
|
+
...((isRecord(attribute.payload) ? attribute.payload : {}) as Record<string, unknown>),
|
|
116
|
+
capture: attribute.capture
|
|
117
|
+
}))
|
|
118
|
+
};
|
|
119
|
+
};
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { defaultEffectRunner, normalizeEffect, type EffectContext, type EffectRunner } from "./effects";
|
|
2
|
+
import { assertDocument, type SsrDocument } from "./protocol";
|
|
3
|
+
|
|
4
|
+
export interface ElmPorts {
|
|
5
|
+
rendered: {
|
|
6
|
+
subscribe(callback: (value: unknown) => void): void;
|
|
7
|
+
};
|
|
8
|
+
start: {
|
|
9
|
+
send(value: boolean): void;
|
|
10
|
+
};
|
|
11
|
+
effectRequest: {
|
|
12
|
+
subscribe(callback: (value: unknown) => void): void;
|
|
13
|
+
};
|
|
14
|
+
effectResult: {
|
|
15
|
+
send(value: unknown): void;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ElmRuntimeInstance {
|
|
20
|
+
ports: ElmPorts;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CompiledElmModule {
|
|
24
|
+
Main: {
|
|
25
|
+
init(options: { flags: Record<string, unknown> }): ElmRuntimeInstance;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RenderedDocument {
|
|
30
|
+
document: SsrDocument;
|
|
31
|
+
status: number;
|
|
32
|
+
redirect?: string;
|
|
33
|
+
json?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RenderOptions {
|
|
37
|
+
effects?: EffectRunner;
|
|
38
|
+
effectContext?: EffectContext;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ActionStep {
|
|
43
|
+
kind: "resolved" | "errored" | "redirect" | "json";
|
|
44
|
+
status?: number;
|
|
45
|
+
message?: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
value?: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const renderApp = async (
|
|
51
|
+
elmModule: CompiledElmModule,
|
|
52
|
+
flags: Record<string, unknown>,
|
|
53
|
+
options: RenderOptions = {}
|
|
54
|
+
): Promise<RenderedDocument> => {
|
|
55
|
+
const runEffect = options.effects ?? defaultEffectRunner;
|
|
56
|
+
const effectContext = options.effectContext ?? {};
|
|
57
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
58
|
+
|
|
59
|
+
const payload = await new Promise<unknown>((resolve, reject) => {
|
|
60
|
+
const app = elmModule.Main.init({ flags });
|
|
61
|
+
const timeout = setTimeout(() => reject(new Error("Elm SSR render timed out.")), timeoutMs);
|
|
62
|
+
|
|
63
|
+
app.ports.rendered.subscribe((value: unknown) => {
|
|
64
|
+
clearTimeout(timeout);
|
|
65
|
+
resolve(value);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.ports.effectRequest.subscribe((request: unknown) => {
|
|
69
|
+
void Promise.resolve()
|
|
70
|
+
.then(() => runEffect(normalizeEffect(request), effectContext))
|
|
71
|
+
.then((result) => app.ports.effectResult.send(result))
|
|
72
|
+
.catch((error: unknown) => app.ports.effectResult.send({ ok: false, error: String(error) }));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
app.ports.start.send(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const step = payload as ActionStep;
|
|
79
|
+
|
|
80
|
+
if (step.kind === "redirect") {
|
|
81
|
+
return {
|
|
82
|
+
status: 302,
|
|
83
|
+
redirect: step.url,
|
|
84
|
+
document: { status: 302, lang: "en", hasIslands: false, head: [], body: [] }
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (step.kind === "json") {
|
|
89
|
+
return {
|
|
90
|
+
status: 200,
|
|
91
|
+
json: step.value,
|
|
92
|
+
document: { status: 200, lang: "en", hasIslands: false, head: [], body: [] }
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (step.kind === "errored") {
|
|
97
|
+
const status = step.status ?? 500;
|
|
98
|
+
const document = step.value ? assertDocument(step.value) : { status, lang: "en", hasIslands: false, head: [], body: [] };
|
|
99
|
+
return {
|
|
100
|
+
status,
|
|
101
|
+
document
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const document = assertDocument(step.value);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
document,
|
|
109
|
+
status: document.status
|
|
110
|
+
};
|
|
111
|
+
};
|