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
|
@@ -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
|
+
};
|
package/src/serialize.ts
ADDED
|
@@ -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("&", "&")
|
|
7
|
+
.replaceAll("<", "<")
|
|
8
|
+
.replaceAll(">", ">")
|
|
9
|
+
.replaceAll("\"", """)
|
|
10
|
+
.replaceAll("'", "'");
|
|
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
|
+
};
|