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,472 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { readWorkspaceConfig, writeWorkspaceConfig } from "./workspace.mjs";
4
+
5
+ const toWords = (value) =>
6
+ value
7
+ .split(/[^a-zA-Z0-9]+/)
8
+ .map((part) => part.trim())
9
+ .filter(Boolean);
10
+
11
+ const toPascalCase = (value) =>
12
+ toWords(value)
13
+ .map((part) => part[0].toUpperCase() + part.slice(1).toLowerCase())
14
+ .join("");
15
+
16
+ const ensureValidName = (name) => {
17
+ if (!/^[a-z0-9-]+$/.test(name)) {
18
+ throw new Error("Example name must use lowercase letters, numbers, and dashes only.");
19
+ }
20
+ };
21
+
22
+ const ensureAppMissing = (config, name) => {
23
+ if (config.apps.some((app) => app.name === name)) {
24
+ throw new Error(`Example "${name}" already exists in elm-ssr.config.json.`);
25
+ }
26
+ };
27
+
28
+ const elmJsonTemplate = () => ({
29
+ type: "application",
30
+ "source-directories": [".elm-ssr", "src", ".elm-ssr/src"],
31
+ "elm-version": "0.19.1",
32
+ dependencies: {
33
+ direct: {
34
+ "elm/browser": "1.0.2",
35
+ "elm/core": "1.0.5",
36
+ "elm/html": "1.0.0",
37
+ "elm/json": "1.1.3",
38
+ "elm/url": "1.0.0"
39
+ },
40
+ indirect: {
41
+ "elm/time": "1.0.0",
42
+ "elm/virtual-dom": "1.0.3"
43
+ }
44
+ },
45
+ "test-dependencies": {
46
+ direct: {},
47
+ indirect: {}
48
+ }
49
+ });
50
+
51
+ const sharedTemplate = (namespace) => `module ${namespace}.View.Shared exposing (head, shell)
52
+
53
+ import ElmSsr.Html exposing (Node, div, h1, text)
54
+ import ElmSsr.Html.Attributes exposing (class)
55
+ import ElmSsr.Page as Page
56
+
57
+
58
+ head : List (Node msg)
59
+ head =
60
+ [ Page.metaCharset "utf-8"
61
+ , Page.metaViewport "width=device-width, initial-scale=1"
62
+ , Page.stylesheet "/styles.css"
63
+ ]
64
+
65
+
66
+ shell : String -> List (Node msg) -> Node msg
67
+ shell heading body =
68
+ div [ class "shell" ] (h1 [] [ text heading ] :: body)
69
+ `;
70
+
71
+ const indexRouteTemplate = (namespace) => `module ${namespace}.Routes.Index exposing (page, action)
72
+
73
+ -- File-based routing: this module maps to GET /.
74
+ -- A page is stateless (no Model/Msg) and ships no client JavaScript.
75
+
76
+ import ElmSsr.Action as Action exposing (Action)
77
+ import ElmSsr.Document exposing (Document)
78
+ import ElmSsr.Html exposing (a, p, text)
79
+ import ElmSsr.Html.Attributes exposing (class, href)
80
+ import ElmSsr.Loader as Loader exposing (Loader)
81
+ import ElmSsr.Page as Page
82
+ import ElmSsr.Route exposing (Request)
83
+ import ${namespace}.View.Shared as Shared
84
+
85
+
86
+ page : Request -> Loader (Document Never)
87
+ page _ =
88
+ Loader.succeed view
89
+
90
+
91
+ action : Request -> Action (Document Never)
92
+ action _ =
93
+ Action.fail 405 "Method not allowed"
94
+
95
+
96
+ view : Document Never
97
+ view =
98
+ Page.page
99
+ { title = "Starter | elm-ssr"
100
+ , head = Shared.head
101
+ , body =
102
+ [ Shared.shell "elm-ssr starter"
103
+ [ p [] [ text "This page is stateless and renders on the edge with no client runtime." ]
104
+ , p [] [ a [ class "link", href "/counter" ] [ text "Open the interactive counter" ] ]
105
+ ]
106
+ ]
107
+ }
108
+ `;
109
+
110
+ const counterRouteTemplate = (namespace) => `module ${namespace}.Routes.Counter exposing (page, action)
111
+
112
+ -- File-based routing: GET /counter. A static page that embeds an interactive
113
+ -- island. The page ships no client runtime; the browser mounts the island
114
+ -- separately, and only that root updates.
115
+
116
+ import ElmSsr.Action as Action exposing (Action)
117
+ import ElmSsr.Document exposing (Document)
118
+ import ElmSsr.Html exposing (p, text)
119
+ import ElmSsr.Loader as Loader exposing (Loader)
120
+ import ElmSsr.Page as Page
121
+ import ElmSsr.Route exposing (Request)
122
+ import ${namespace}.Islands.Counter as Counter
123
+ import ${namespace}.View.Shared as Shared
124
+
125
+
126
+ page : Request -> Loader (Document Never)
127
+ page _ =
128
+ Loader.succeed view
129
+
130
+
131
+ action : Request -> Action (Document Never)
132
+ action _ =
133
+ Action.fail 405 "Method not allowed"
134
+
135
+
136
+ view : Document Never
137
+ view =
138
+ Page.page
139
+ { title = "Counter | elm-ssr"
140
+ , head = Shared.head
141
+ , body =
142
+ [ Shared.shell "Counter"
143
+ [ p [] [ text "This page is static. Only the counter below is an interactive island." ]
144
+ , Counter.embed { start = 0 }
145
+ ]
146
+ ]
147
+ }
148
+ `;
149
+
150
+ const counterIslandTemplate = (namespace) => `module ${namespace}.Islands.Counter exposing
151
+ ( embed
152
+ , Flags, Model, Msg
153
+ , encodeFlags
154
+ , init, main, subscriptions, update, view
155
+ )
156
+
157
+ -- A standard Browser.element island. The page only embeds a marker and props;
158
+ -- the browser mounts this module normally using Elm's own runtime.
159
+
160
+ import ElmSsr.Island as Island
161
+ import ElmSsr.Html as SsrHtml exposing (Node)
162
+ import ElmSsr.Html.Attributes as SsrAttributes
163
+ import Browser
164
+ import Html exposing (Html, button, div, span, text)
165
+ import Html.Attributes exposing (class, type_)
166
+ import Html.Events exposing (onClick)
167
+ import Json.Encode as Encode
168
+
169
+
170
+ embed : Flags -> Node msg
171
+ embed =
172
+ Island.embed "Counter"
173
+ { encodeFlags = encodeFlags
174
+ , fallback = fallback
175
+ , id = Nothing
176
+ }
177
+
178
+
179
+ type alias Flags =
180
+ { start : Int }
181
+
182
+
183
+ type alias Model =
184
+ { count : Int }
185
+
186
+
187
+ type Msg
188
+ = Increment
189
+ | Decrement
190
+
191
+
192
+ encodeFlags : Flags -> Encode.Value
193
+ encodeFlags flags =
194
+ Encode.object [ ( "start", Encode.int flags.start ) ]
195
+
196
+
197
+ fallback : Flags -> List (Node msg)
198
+ fallback flags =
199
+ [ SsrHtml.div [ SsrAttributes.class "counter fallback" ]
200
+ [ SsrHtml.span [ SsrAttributes.class "value" ] [ SsrHtml.text (String.fromInt flags.start) ] ]
201
+ ]
202
+
203
+
204
+ main : Program Flags Model Msg
205
+ main =
206
+ Browser.element
207
+ { init = init
208
+ , update = update
209
+ , view = view
210
+ , subscriptions = subscriptions
211
+ }
212
+
213
+
214
+ init : Flags -> ( Model, Cmd Msg )
215
+ init flags =
216
+ ( { count = flags.start }, Cmd.none )
217
+
218
+
219
+ update : Msg -> Model -> ( Model, Cmd Msg )
220
+ update msg model =
221
+ case msg of
222
+ Increment ->
223
+ ( { model | count = model.count + 1 }, Cmd.none )
224
+
225
+ Decrement ->
226
+ ( { model | count = model.count - 1 }, Cmd.none )
227
+
228
+
229
+ subscriptions : Model -> Sub Msg
230
+ subscriptions _ =
231
+ Sub.none
232
+
233
+
234
+ view : Model -> Html Msg
235
+ view model =
236
+ div [ class "counter" ]
237
+ [ button [ class "button", type_ "button", onClick Decrement ] [ text "-" ]
238
+ , span [ class "value" ] [ text (String.fromInt model.count) ]
239
+ , button [ class "button primary", type_ "button", onClick Increment ] [ text "+" ]
240
+ ]
241
+ `;
242
+
243
+ const notFoundRouteTemplate = (namespace) => `module ${namespace}.Routes.NotFound exposing (page, action)
244
+
245
+ -- File-based routing: NotFound is the fallback when no other route matches.
246
+
247
+ import ElmSsr.Action as Action exposing (Action)
248
+ import ElmSsr.Document exposing (Document)
249
+ import ElmSsr.Html exposing (p, text)
250
+ import ElmSsr.Loader as Loader exposing (Loader)
251
+ import ElmSsr.Page as Page
252
+ import ElmSsr.Route exposing (Request)
253
+ import ${namespace}.View.Shared as Shared
254
+
255
+
256
+ page : Request -> Loader (Document Never)
257
+ page _ =
258
+ Loader.succeed view
259
+
260
+
261
+ action : Request -> Action (Document Never)
262
+ action _ =
263
+ Action.fail 405 "Method not allowed"
264
+
265
+
266
+ view : Document Never
267
+ view =
268
+ Page.notFound
269
+ { title = "Not Found | elm-ssr"
270
+ , head = Shared.head
271
+ , body = [ Shared.shell "404" [ p [] [ text "This route does not exist." ] ] ]
272
+ }
273
+ `;
274
+
275
+ const runtimeTemplate = (name) => `import { createWorkerApp } from "elm-ssr";
276
+ import { renderApp, type CompiledElmModule } from "elm-ssr/render";
277
+ import type { RouteCatalog } from "elm-ssr/http";
278
+ import { islands, bundleSource } from "../../generated/examples/${name}/islands-manifest";
279
+ import { stylesheet } from "./styles";
280
+ // @ts-expect-error Generated at build time.
281
+ import ElmRuntime from "../../generated/examples/${name}/app.mjs";
282
+
283
+ const elmModule = ElmRuntime as CompiledElmModule;
284
+
285
+ export const routes: RouteCatalog = {
286
+ pages: [
287
+ {
288
+ path: "/",
289
+ methods: ["GET", "HEAD"],
290
+ description: "Stateless starter page rendered from Elm (no client runtime)."
291
+ },
292
+ {
293
+ path: "/counter",
294
+ methods: ["GET", "HEAD"],
295
+ description: "Interactive counter route rendered from Elm."
296
+ }
297
+ ],
298
+ assets: [
299
+ {
300
+ path: "/styles.css",
301
+ methods: ["GET", "HEAD"],
302
+ description: "Starter stylesheet."
303
+ },
304
+ {
305
+ path: "/__elm-ssr/islands.js",
306
+ methods: ["GET", "HEAD"],
307
+ description: "Island loader runtime."
308
+ },
309
+ {
310
+ path: "/__elm-ssr/islands-bundle.js",
311
+ methods: ["GET", "HEAD"],
312
+ description: "Shared Browser.element island bundle."
313
+ }
314
+ ],
315
+ utility: [
316
+ {
317
+ path: "/health",
318
+ methods: ["GET", "HEAD"],
319
+ description: "Plain text liveness endpoint."
320
+ }
321
+ ],
322
+ api: [
323
+ {
324
+ path: "/api/health",
325
+ methods: ["GET", "HEAD"],
326
+ description: "JSON health payload."
327
+ },
328
+ {
329
+ path: "/api/routes",
330
+ methods: ["GET", "HEAD"],
331
+ description: "Route registry for the starter app."
332
+ },
333
+ {
334
+ path: "/api/render",
335
+ methods: ["GET", "HEAD"],
336
+ description: "SSR preview endpoint."
337
+ }
338
+ ]
339
+ };
340
+
341
+ export const createFlags = ({ request, path, formData }: { request?: Request; url?: URL; path: string; formData?: Record<string, string> }) => {
342
+ const [pathname, search = ""] = path.split("?");
343
+
344
+ return {
345
+ method: request?.method ?? "GET",
346
+ path: pathname,
347
+ query: Object.fromEntries(new URLSearchParams(search)),
348
+ formData: formData ?? {}
349
+ };
350
+ };
351
+
352
+ export const renderPath = async (path: string) =>
353
+ renderApp(elmModule, createFlags({ path }));
354
+
355
+ export const worker = createWorkerApp({
356
+ elmModule,
357
+ islands,
358
+ islandsBundle: bundleSource,
359
+ stylesheet,
360
+ routes,
361
+ createFlags
362
+ });
363
+ `;
364
+
365
+ const workerTemplate = () => `import { worker } from "./runtime";
366
+
367
+ export default worker;
368
+ `;
369
+
370
+ const stylesTemplate = () => `export const stylesheet = \`
371
+ :root {
372
+ color-scheme: light;
373
+ font-family: "IBM Plex Sans", sans-serif;
374
+ background: #f3efe7;
375
+ color: #18222f;
376
+ }
377
+
378
+ body {
379
+ margin: 0;
380
+ background:
381
+ radial-gradient(circle at top, rgba(255, 255, 255, 0.85), transparent 45%),
382
+ linear-gradient(180deg, #f8f3ea 0%, #efe7dc 100%);
383
+ }
384
+
385
+ .shell {
386
+ max-width: 44rem;
387
+ margin: 0 auto;
388
+ padding: 4rem 1.5rem;
389
+ }
390
+
391
+ .counter {
392
+ display: grid;
393
+ grid-template-columns: auto 1fr auto;
394
+ gap: 0.75rem;
395
+ align-items: center;
396
+ margin: 2rem 0;
397
+ }
398
+
399
+ .button,
400
+ .input {
401
+ border-radius: 999px;
402
+ border: 1px solid #18222f;
403
+ padding: 0.85rem 1.1rem;
404
+ font: inherit;
405
+ background: white;
406
+ }
407
+
408
+ .button {
409
+ cursor: pointer;
410
+ }
411
+
412
+ .value {
413
+ text-align: center;
414
+ font-size: 2rem;
415
+ font-weight: 700;
416
+ }
417
+
418
+ .form {
419
+ margin-top: 1rem;
420
+ }
421
+
422
+ .input {
423
+ width: 100%;
424
+ box-sizing: border-box;
425
+ }
426
+ \`;
427
+ `;
428
+
429
+ const filesForExample = (name) => {
430
+ const namespace = toPascalCase(name);
431
+
432
+ return {
433
+ configEntry: {
434
+ name,
435
+ root: `examples/${name}`,
436
+ module: namespace
437
+ },
438
+ files: [
439
+ { path: `examples/${name}/elm.json`, content: JSON.stringify(elmJsonTemplate(), null, 2) + "\n" },
440
+ { path: `examples/${name}/runtime.ts`, content: runtimeTemplate(name) },
441
+ { path: `examples/${name}/worker.ts`, content: workerTemplate() },
442
+ { path: `examples/${name}/styles.ts`, content: stylesTemplate() },
443
+ { path: `examples/${name}/src/${namespace}/View/Shared.elm`, content: sharedTemplate(namespace) },
444
+ { path: `examples/${name}/src/${namespace}/Routes/Index.elm`, content: indexRouteTemplate(namespace) },
445
+ { path: `examples/${name}/src/${namespace}/Routes/Counter.elm`, content: counterRouteTemplate(namespace) },
446
+ { path: `examples/${name}/src/${namespace}/Routes/NotFound.elm`, content: notFoundRouteTemplate(namespace) },
447
+ { path: `examples/${name}/src/${namespace}/Islands/Counter.elm`, content: counterIslandTemplate(namespace) }
448
+ ]
449
+ };
450
+ };
451
+
452
+ export const createExampleScaffold = async (rootPath, name) => {
453
+ ensureValidName(name);
454
+
455
+ const config = await readWorkspaceConfig(rootPath);
456
+ ensureAppMissing(config, name);
457
+
458
+ const { configEntry, files } = filesForExample(name);
459
+
460
+ for (const file of files) {
461
+ const filePath = resolve(rootPath, file.path);
462
+ await mkdir(dirname(filePath), { recursive: true });
463
+ await writeFile(filePath, file.content, "utf8");
464
+ }
465
+
466
+ await writeWorkspaceConfig(rootPath, {
467
+ ...config,
468
+ apps: [...config.apps, configEntry]
469
+ });
470
+
471
+ return configEntry;
472
+ };
@@ -0,0 +1,21 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ export const configFileName = "elm-ssr.config.json";
5
+
6
+ export const readJsonFile = async (filePath) =>
7
+ JSON.parse(await readFile(filePath, "utf8"));
8
+
9
+ export const writeJsonFile = async (filePath, value) => {
10
+ await mkdir(dirname(filePath), { recursive: true });
11
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
12
+ };
13
+
14
+ export const workspaceConfigPath = (rootPath) =>
15
+ resolve(rootPath, configFileName);
16
+
17
+ export const readWorkspaceConfig = async (rootPath) =>
18
+ readJsonFile(workspaceConfigPath(rootPath));
19
+
20
+ export const writeWorkspaceConfig = async (rootPath, config) =>
21
+ writeJsonFile(workspaceConfigPath(rootPath), config);
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "elm-ssr",
3
+ "version": "0.1.0",
4
+ "description": "Elm-first SSR library and framework for Cloudflare Workers (and Bun): file-based routes/islands, backend-neutral effect adapters (KV/D1/Redis/Postgres), background tasks (waitUntil/Queues), SQL-file migrations, CLI scaffold + build.",
5
+ "license": "MIT",
6
+ "author": "Michał Majchrzak <michmajchrzak@gmail.com>",
7
+ "homepage": "https://github.com/the-man-with-a-golden-mind/elm-ssr#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/the-man-with-a-golden-mind/elm-ssr/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/the-man-with-a-golden-mind/elm-ssr.git",
14
+ "directory": "packages/elm-ssr"
15
+ },
16
+ "keywords": [
17
+ "elm",
18
+ "ssr",
19
+ "server-side-rendering",
20
+ "cloudflare-workers",
21
+ "bun",
22
+ "islands",
23
+ "framework",
24
+ "edge",
25
+ "file-based-routing",
26
+ "migrations"
27
+ ],
28
+ "type": "module",
29
+ "scripts": {},
30
+ "bin": {
31
+ "elm-ssr": "bin/elm-ssr.mjs"
32
+ },
33
+ "main": "./src/app.ts",
34
+ "exports": {
35
+ ".": "./src/app.ts",
36
+ "./render": "./src/render.ts",
37
+ "./http": "./src/http.ts",
38
+ "./effects": "./src/effects.ts",
39
+ "./tasks": "./src/tasks.ts",
40
+ "./backends": "./src/backends.ts",
41
+ "./migrations": "./src/migrations.ts",
42
+ "./middleware": "./src/middleware.ts",
43
+ "./response-headers": "./src/response-headers.ts",
44
+ "./serialize": "./src/serialize.ts",
45
+ "./protocol": "./src/protocol.ts",
46
+ "./islands-runtime": "./src/client-runtime/islands.ts"
47
+ },
48
+ "files": [
49
+ "bin",
50
+ "lib",
51
+ "elm-src",
52
+ "src"
53
+ ],
54
+ "engines": {
55
+ "bun": ">=1.3"
56
+ },
57
+ "dependencies": {
58
+ "cookie": "^1.0.1"
59
+ }
60
+ }
package/src/app.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type {
2
+ RenderFlagsFactory,
3
+ RouteCatalog,
4
+ WorkerExecutionContext,
5
+ WorkerHandler
6
+ } from "./http";
7
+ import {
8
+ composeMiddleware,
9
+ errorMiddleware,
10
+ headMiddleware,
11
+ loggingMiddleware,
12
+ requestIdMiddleware,
13
+ timingMiddleware
14
+ } from "./middleware";
15
+ import { type EffectRunner } from "./effects";
16
+ import { createRequestHandler } from "./request-handler";
17
+ import { type CompiledElmModule } from "./render";
18
+
19
+ export interface IslandMetadata {
20
+ module: string;
21
+ }
22
+
23
+ export interface WorkerAppOptions {
24
+ elmModule: CompiledElmModule;
25
+ islands?: Record<string, IslandMetadata>;
26
+ islandsBundle?: string;
27
+ stylesheet: string;
28
+ routes: RouteCatalog;
29
+ createFlags: RenderFlagsFactory;
30
+ effects?: EffectRunner;
31
+ log?: (entry: string) => void;
32
+ }
33
+
34
+ export const createWorkerApp = ({
35
+ elmModule,
36
+ islands,
37
+ islandsBundle,
38
+ stylesheet,
39
+ routes,
40
+ createFlags,
41
+ effects,
42
+ log
43
+ }: WorkerAppOptions): WorkerHandler => {
44
+ const handler = createRequestHandler({
45
+ elmModule,
46
+ islands,
47
+ islandsBundle,
48
+ stylesheet,
49
+ routes,
50
+ createFlags,
51
+ effects
52
+ });
53
+
54
+ const appHandler = composeMiddleware(handler, [
55
+ errorMiddleware,
56
+ requestIdMiddleware,
57
+ timingMiddleware,
58
+ loggingMiddleware(log),
59
+ headMiddleware
60
+ ]);
61
+
62
+ return {
63
+ fetch(request: Request, env?: unknown, executionCtx?: WorkerExecutionContext) {
64
+ return appHandler({
65
+ request,
66
+ url: new URL(request.url),
67
+ requestId: "",
68
+ startedAt: performance.now(),
69
+ executionCtx,
70
+ env: (env ?? undefined) as Record<string, unknown> | undefined
71
+ });
72
+ }
73
+ };
74
+ };