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.
@@ -0,0 +1,208 @@
1
+ import type { IslandMetadata } from "./app";
2
+ import { createIslandsRuntimeSource } from "./client-runtime/islands";
3
+ import type { AppContext, AppHandler, RenderFlagsFactory, RouteCatalog } from "./http";
4
+ import { json, text } from "./http";
5
+ import { type EffectRunner } from "./effects";
6
+ import { renderApp, type CompiledElmModule } from "./render";
7
+ import { renderHtmlDocument } from "./serialize";
8
+ import { assetHeaders, cssHeaders, htmlHeaders, jsonHeaders } from "./response-headers";
9
+
10
+ const isReadMethod = (method: string): boolean => method === "GET" || method === "HEAD";
11
+
12
+ const isSupportedMethod = (method: string): boolean => method === "GET" || method === "HEAD" || method === "POST";
13
+
14
+ const methodNotAllowed = (context: AppContext): Response => {
15
+ if (context.url.pathname.startsWith("/api/")) {
16
+ return json(
17
+ {
18
+ error: "method_not_allowed",
19
+ allowed: ["GET", "HEAD", "POST"]
20
+ },
21
+ { status: 405, headers: jsonHeaders }
22
+ );
23
+ }
24
+
25
+ return text("Method Not Allowed", { status: 405 });
26
+ };
27
+
28
+ const parseFormData = async (request: Request): Promise<Record<string, string>> => {
29
+ if (request.method !== "POST") {
30
+ return {};
31
+ }
32
+
33
+ const contentType = request.headers.get("content-type") || "";
34
+
35
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
36
+ try {
37
+ const formData = await request.formData();
38
+ const data: Record<string, string> = {};
39
+ for (const [key, value] of formData.entries()) {
40
+ if (typeof value === "string") {
41
+ data[key] = value;
42
+ }
43
+ }
44
+ return data;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ if (contentType.includes("application/json")) {
51
+ try {
52
+ const data = await request.json();
53
+ return typeof data === "object" && data !== null ? (data as Record<string, string>) : {};
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ return {};
60
+ };
61
+
62
+ const createFlagsFromContext = async (
63
+ context: AppContext,
64
+ path: string,
65
+ createFlags: RenderFlagsFactory
66
+ ): Promise<Record<string, unknown>> => {
67
+ const formData = await parseFormData(context.request);
68
+ return createFlags({
69
+ request: context.request,
70
+ url: context.url,
71
+ path,
72
+ formData
73
+ });
74
+ };
75
+
76
+ export interface RequestHandlerOptions {
77
+ elmModule: CompiledElmModule;
78
+ islands?: Record<string, IslandMetadata>;
79
+ islandsBundle?: string;
80
+ stylesheet: string;
81
+ routes: RouteCatalog;
82
+ createFlags: RenderFlagsFactory;
83
+ effects?: EffectRunner;
84
+ }
85
+
86
+ export const createRequestHandler = ({
87
+ elmModule,
88
+ islands,
89
+ islandsBundle,
90
+ stylesheet,
91
+ routes,
92
+ createFlags,
93
+ effects
94
+ }: RequestHandlerOptions): AppHandler =>
95
+ async (context) => {
96
+ if (!isSupportedMethod(context.request.method)) {
97
+ return methodNotAllowed(context);
98
+ }
99
+
100
+ if (context.url.pathname === "/health") {
101
+ return text("ok", { status: 200 });
102
+ }
103
+
104
+ if (context.url.pathname === "/styles.css") {
105
+ return new Response(stylesheet, {
106
+ status: 200,
107
+ headers: cssHeaders
108
+ });
109
+ }
110
+
111
+ if (context.url.pathname === "/__elm-ssr/islands.js" && islands && Object.keys(islands).length > 0) {
112
+ return new Response(createIslandsRuntimeSource(islands), {
113
+ status: 200,
114
+ headers: assetHeaders
115
+ });
116
+ }
117
+
118
+ if (context.url.pathname === "/__elm-ssr/islands-bundle.js" && islandsBundle) {
119
+ return new Response(islandsBundle, {
120
+ status: 200,
121
+ headers: assetHeaders
122
+ });
123
+ }
124
+
125
+ if (context.url.pathname === "/api/health") {
126
+ return json(
127
+ {
128
+ ok: true,
129
+ service: "elmssr",
130
+ runtime: "cloudflare-worker",
131
+ requestId: context.requestId
132
+ },
133
+ { status: 200, headers: jsonHeaders }
134
+ );
135
+ }
136
+
137
+ if (context.url.pathname === "/api/routes") {
138
+ return json(routes, {
139
+ status: 200,
140
+ headers: jsonHeaders
141
+ });
142
+ }
143
+
144
+ if (context.url.pathname === "/api/render") {
145
+ const targetPath = context.url.searchParams.get("path");
146
+
147
+ if (!targetPath || !targetPath.startsWith("/")) {
148
+ return json(
149
+ {
150
+ error: "invalid_path",
151
+ message: "Use /api/render?path=/some-route"
152
+ },
153
+ { status: 400, headers: jsonHeaders }
154
+ );
155
+ }
156
+
157
+ const flags = await createFlagsFromContext(context, targetPath, createFlags);
158
+ const rendered = await renderApp(elmModule, flags, {
159
+ effects,
160
+ effectContext: {
161
+ env: context.env,
162
+ request: context.request,
163
+ waitUntil: context.executionCtx ? (promise) => context.executionCtx?.waitUntil(promise) : undefined
164
+ }
165
+ });
166
+
167
+ if (rendered.redirect) {
168
+ return json({ redirect: rendered.redirect }, { status: 200, headers: jsonHeaders });
169
+ }
170
+
171
+ if (rendered.json) {
172
+ return json(rendered.json, { status: 200, headers: jsonHeaders });
173
+ }
174
+
175
+ return json(
176
+ {
177
+ path: targetPath,
178
+ status: rendered.status,
179
+ html: renderHtmlDocument(rendered.document)
180
+ },
181
+ { status: 200, headers: jsonHeaders }
182
+ );
183
+ }
184
+
185
+ const flags = await createFlagsFromContext(context, context.url.pathname + context.url.search, createFlags);
186
+ const rendered = await renderApp(elmModule, flags, {
187
+ effects,
188
+ effectContext: { env: context.env, request: context.request }
189
+ });
190
+
191
+ if (rendered.redirect) {
192
+ return new Response(null, {
193
+ status: 302,
194
+ headers: { location: rendered.redirect }
195
+ });
196
+ }
197
+
198
+ if (rendered.json) {
199
+ return json(rendered.json, { status: 200, headers: jsonHeaders });
200
+ }
201
+
202
+ const html = renderHtmlDocument(rendered.document);
203
+
204
+ return new Response(html, {
205
+ status: rendered.status,
206
+ headers: htmlHeaders
207
+ });
208
+ };
@@ -0,0 +1,18 @@
1
+ export const htmlHeaders = {
2
+ "content-type": "text/html; charset=utf-8",
3
+ "cache-control": "public, max-age=0, s-maxage=60"
4
+ };
5
+
6
+ export const cssHeaders = {
7
+ "content-type": "text/css; charset=utf-8",
8
+ "cache-control": "public, max-age=300"
9
+ };
10
+
11
+ export const assetHeaders = {
12
+ "content-type": "text/javascript; charset=utf-8",
13
+ "cache-control": "public, max-age=300"
14
+ };
15
+
16
+ export const jsonHeaders = {
17
+ "cache-control": "no-store"
18
+ };
@@ -0,0 +1,47 @@
1
+ import type { SsrAttribute, SsrDocument, SsrNode } from "./protocol";
2
+ import { toDomAttribute } from "./protocol";
3
+
4
+ const escapeHtml = (value: string): string =>
5
+ value
6
+ .replaceAll("&", "&amp;")
7
+ .replaceAll("<", "&lt;")
8
+ .replaceAll(">", "&gt;")
9
+ .replaceAll("\"", "&quot;")
10
+ .replaceAll("'", "&#39;");
11
+
12
+ const renderNodeAttributes = (attributes: SsrAttribute[]): string => {
13
+ if (attributes.length === 0) {
14
+ return "";
15
+ }
16
+
17
+ return attributes
18
+ .map((attribute) => {
19
+ const domAttribute = toDomAttribute(attribute);
20
+ return ` ${domAttribute.name}="${escapeHtml(domAttribute.value)}"`;
21
+ })
22
+ .join("");
23
+ };
24
+
25
+ const renderNode = (node: SsrNode): string => {
26
+ if (node.kind === "text") {
27
+ return escapeHtml(node.text);
28
+ }
29
+
30
+ const attrs = renderNodeAttributes(node.attrs);
31
+
32
+ if (node.kind === "void") {
33
+ return `<${node.tag}${attrs}>`;
34
+ }
35
+
36
+ return `<${node.tag}${attrs}>${node.children.map(renderNode).join("")}</${node.tag}>`;
37
+ };
38
+
39
+ export const renderNodes = (nodes: SsrNode[]): string =>
40
+ nodes.map(renderNode).join("");
41
+
42
+ // A page ships no JS unless it embeds an island; islands pull their own bootstrap.
43
+ const renderIslandShell = (document: SsrDocument): string =>
44
+ document.hasIslands ? `<script type="module" src="/__elm-ssr/islands.js"></script>` : "";
45
+
46
+ export const renderHtmlDocument = (document: SsrDocument): string =>
47
+ `<!doctype html><html lang="${escapeHtml(document.lang)}"><head>${renderNodes(document.head)}</head><body><div id="elm-ssr-root">${renderNodes(document.body)}</div>${renderIslandShell(document)}</body></html>`;
package/src/tasks.ts ADDED
@@ -0,0 +1,139 @@
1
+ import type { EffectContext, EffectRunner } from "./effects";
2
+ import type { WorkerExecutionContext } from "./http";
3
+
4
+ /**
5
+ * A background task handler. It receives the payload the Elm side enqueued and
6
+ * the request's effect context (env, request, …). Its result is ignored — tasks
7
+ * are fire-and-forget.
8
+ */
9
+ export type TaskHandler = (payload: unknown, context: EffectContext) => unknown | Promise<unknown>;
10
+
11
+ export type TaskHandlers = Record<string, TaskHandler>;
12
+
13
+ /**
14
+ * Wrap an effect runner so `Loader.enqueue { task, payload }` schedules a named
15
+ * task to run AFTER the response. On Cloudflare this uses `ctx.waitUntil` to keep
16
+ * the isolate alive; locally (no waitUntil) it runs fire-and-forget. All other
17
+ * effects pass through to the wrapped runner unchanged.
18
+ */
19
+ export const withTasks = (runner: EffectRunner, tasks: TaskHandlers): EffectRunner =>
20
+ async (effect, context) => {
21
+ if (effect.kind !== "enqueue") {
22
+ return runner(effect, context);
23
+ }
24
+
25
+ const name = String(effect.payload.task);
26
+ const handler = tasks[name];
27
+
28
+ if (!handler) {
29
+ return { ok: false, error: `No task handler registered for "${name}".` };
30
+ }
31
+
32
+ const job = Promise.resolve()
33
+ .then(() => handler(effect.payload.payload, context))
34
+ .catch((error) => {
35
+ console.error(`elm-ssr: background task "${name}" failed`, error);
36
+ });
37
+
38
+ if (typeof context.waitUntil === "function") {
39
+ context.waitUntil(job);
40
+ } else {
41
+ void job;
42
+ }
43
+
44
+ return { ok: true, value: null };
45
+ };
46
+
47
+ // === Cloudflare Queues (durable background jobs) ===
48
+
49
+ interface QueueLike {
50
+ send(message: unknown): Promise<void>;
51
+ }
52
+
53
+ export interface QueueProducerConfig {
54
+ /** env binding name of the Cloudflare Queue. Default `"QUEUE"`. */
55
+ queueBinding?: string;
56
+ }
57
+
58
+ /**
59
+ * Composable alternative to `withTasks` for durable jobs: an `enqueue` effect
60
+ * sends `{ task, payload }` to a Cloudflare Queue via `context.env[binding]`
61
+ * instead of running it inline via `waitUntil`. All other effects pass through.
62
+ *
63
+ * effects: withQueueProducer(inMemoryEffects({...}), { queueBinding: "JOBS" })
64
+ *
65
+ * Consume the queue in your CF entry with `createQueueConsumer(tasks)`.
66
+ */
67
+ export const withQueueProducer = (runner: EffectRunner, config: QueueProducerConfig = {}): EffectRunner => {
68
+ const binding = config.queueBinding ?? "QUEUE";
69
+
70
+ return async (effect, context) => {
71
+ if (effect.kind !== "enqueue") {
72
+ return runner(effect, context);
73
+ }
74
+
75
+ const env = (context.env ?? {}) as Record<string, unknown>;
76
+ const queue = env[binding] as QueueLike | undefined;
77
+
78
+ if (!queue) {
79
+ return { ok: false, error: `Missing queue binding "${binding}"` };
80
+ }
81
+
82
+ try {
83
+ await queue.send({ task: String(effect.payload.task), payload: effect.payload.payload });
84
+ return { ok: true, value: null };
85
+ } catch (error) {
86
+ return { ok: false, error: `Queue send failed: ${String(error)}` };
87
+ }
88
+ };
89
+ };
90
+
91
+ interface QueueMessage {
92
+ body: unknown;
93
+ ack(): void;
94
+ retry(): void;
95
+ }
96
+
97
+ export interface QueueBatch {
98
+ messages: QueueMessage[];
99
+ queue: string;
100
+ }
101
+
102
+ /**
103
+ * Build the `queue` handler for a Cloudflare consumer worker. Maps each message
104
+ * `{ task, payload }` (produced by `withQueueProducer` or `Loader.enqueue` on
105
+ * any producer) to the named handler in `tasks`. Acks on success, retries on
106
+ * failure or missing handler.
107
+ *
108
+ * export default {
109
+ * fetch: worker.fetch,
110
+ * queue: createQueueConsumer({ sendEmail, warmCache })
111
+ * }
112
+ */
113
+ export const createQueueConsumer = (tasks: TaskHandlers) =>
114
+ async (batch: QueueBatch, env?: unknown, executionCtx?: WorkerExecutionContext): Promise<void> => {
115
+ const context: EffectContext = {
116
+ env: (env ?? undefined) as Record<string, unknown> | undefined,
117
+ waitUntil: executionCtx ? (promise) => executionCtx.waitUntil(promise) : undefined
118
+ };
119
+
120
+ for (const message of batch.messages) {
121
+ const body = (message.body ?? {}) as { task?: string; payload?: unknown };
122
+ const name = typeof body.task === "string" ? body.task : "";
123
+ const handler = tasks[name];
124
+
125
+ if (!handler) {
126
+ console.error(`elm-ssr: queue consumer has no handler for "${name}"`);
127
+ message.retry();
128
+ continue;
129
+ }
130
+
131
+ try {
132
+ await handler(body.payload, context);
133
+ message.ack();
134
+ } catch (error) {
135
+ console.error(`elm-ssr: queue task "${name}" failed`, error);
136
+ message.retry();
137
+ }
138
+ }
139
+ };