elm-ssr 0.2.0 → 0.5.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.
Files changed (123) hide show
  1. package/README.md +48 -342
  2. package/elm-src/ElmSsr/Island/Sse.elm +151 -0
  3. package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Loader.elm +177 -0
  4. package/package.json +54 -24
  5. package/{packages/elm-ssr/src → src}/client-runtime/islands.ts +113 -15
  6. package/src/jobs/index.ts +7 -0
  7. package/src/jobs/runner.ts +137 -0
  8. package/src/jobs/store.ts +60 -0
  9. package/src/jobs/types.ts +41 -0
  10. package/src/sse.ts +162 -0
  11. package/AGENTS.md +0 -289
  12. package/CHANGELOG.md +0 -87
  13. package/LICENSE +0 -21
  14. package/bun.lock +0 -259
  15. package/docker-compose.yml +0 -33
  16. package/docs/README.md +0 -51
  17. package/docs/backends.md +0 -146
  18. package/docs/cli.md +0 -117
  19. package/docs/effects.md +0 -91
  20. package/docs/getting-started.md +0 -94
  21. package/docs/islands.md +0 -197
  22. package/docs/loaders-and-actions.md +0 -241
  23. package/docs/middleware.md +0 -93
  24. package/docs/migrations.md +0 -143
  25. package/docs/routing.md +0 -108
  26. package/docs/sessions.md +0 -218
  27. package/docs/tasks.md +0 -149
  28. package/docs/testing.md +0 -84
  29. package/elm-ssr.config.json +0 -14
  30. package/examples/basic/elm.json +0 -27
  31. package/examples/basic/migrations/0001_guestbook.down.sql +0 -1
  32. package/examples/basic/migrations/0001_guestbook.sql +0 -10
  33. package/examples/basic/package.json +0 -10
  34. package/examples/basic/runtime.ts +0 -148
  35. package/examples/basic/src/Example/Basic/Islands/Counter.elm +0 -110
  36. package/examples/basic/src/Example/Basic/Islands/Observer.elm +0 -67
  37. package/examples/basic/src/Example/Basic/Islands/Tasks.elm +0 -151
  38. package/examples/basic/src/Example/Basic/Routes/Chart.elm +0 -87
  39. package/examples/basic/src/Example/Basic/Routes/Counter.elm +0 -42
  40. package/examples/basic/src/Example/Basic/Routes/Echo.elm +0 -76
  41. package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +0 -37
  42. package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +0 -86
  43. package/examples/basic/src/Example/Basic/Routes/Index.elm +0 -41
  44. package/examples/basic/src/Example/Basic/Routes/NotFound.elm +0 -37
  45. package/examples/basic/src/Example/Basic/Routes/Profile.elm +0 -112
  46. package/examples/basic/src/Example/Basic/Routes/Session.elm +0 -89
  47. package/examples/basic/src/Example/Basic/Routes/Status.elm +0 -90
  48. package/examples/basic/src/Example/Basic/View/Shared.elm +0 -60
  49. package/examples/basic/styles.ts +0 -204
  50. package/examples/basic/worker.ts +0 -3
  51. package/examples/crypto-dashboard/elm.json +0 -30
  52. package/examples/crypto-dashboard/package.json +0 -10
  53. package/examples/crypto-dashboard/runtime.ts +0 -97
  54. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +0 -204
  55. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +0 -200
  56. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +0 -67
  57. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +0 -30
  58. package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +0 -39
  59. package/examples/crypto-dashboard/styles.ts +0 -23
  60. package/examples/crypto-dashboard/worker.ts +0 -3
  61. package/llms.txt +0 -69
  62. package/packages/elm-ssr/README.md +0 -67
  63. package/packages/elm-ssr/package.json +0 -61
  64. package/scripts/benchmark.mjs +0 -60
  65. package/test/action.test.ts +0 -81
  66. package/test/adapters.test.ts +0 -173
  67. package/test/advanced-robustness.test.ts +0 -75
  68. package/test/app.test.ts +0 -209
  69. package/test/browser-island.test.ts +0 -184
  70. package/test/cli-migrate.test.ts +0 -97
  71. package/test/cli.test.ts +0 -94
  72. package/test/cookies.test.ts +0 -156
  73. package/test/crypto-dashboard.test.ts +0 -35
  74. package/test/effects.test.ts +0 -117
  75. package/test/http.test.ts +0 -50
  76. package/test/integration/redis-postgres.test.ts +0 -174
  77. package/test/island-runtime.test.ts +0 -214
  78. package/test/middleware.test.ts +0 -134
  79. package/test/migrations.test.ts +0 -244
  80. package/test/profile.test.ts +0 -159
  81. package/test/robustness.test.ts +0 -135
  82. package/test/serialize.test.ts +0 -92
  83. package/test/sessions.test.ts +0 -429
  84. package/test/svg.test.ts +0 -65
  85. package/tsconfig.json +0 -20
  86. package/wrangler.jsonc +0 -11
  87. /package/{packages/elm-ssr/bin → bin}/elm-ssr.mjs +0 -0
  88. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Action.elm +0 -0
  89. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Encode.elm +0 -0
  90. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Events.elm +0 -0
  91. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document.elm +0 -0
  92. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Attributes.elm +0 -0
  93. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Events.elm +0 -0
  94. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html.elm +0 -0
  95. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island/Shared.elm +0 -0
  96. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island.elm +0 -0
  97. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Page.elm +0 -0
  98. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Route.elm +0 -0
  99. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Runtime.elm +0 -0
  100. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
  101. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg.elm +0 -0
  102. /package/{packages/elm-ssr/lib → lib}/build.mjs +0 -0
  103. /package/{packages/elm-ssr/lib → lib}/migrate.mjs +0 -0
  104. /package/{packages/elm-ssr/lib → lib}/scaffold.mjs +0 -0
  105. /package/{packages/elm-ssr/lib → lib}/workspace.mjs +0 -0
  106. /package/{packages/elm-ssr/src → src}/app.ts +0 -0
  107. /package/{packages/elm-ssr/src → src}/backends.ts +0 -0
  108. /package/{packages/elm-ssr/src → src}/effects.ts +0 -0
  109. /package/{packages/elm-ssr/src → src}/http.ts +0 -0
  110. /package/{packages/elm-ssr/src → src}/middleware.ts +0 -0
  111. /package/{packages/elm-ssr/src → src}/migrations.ts +0 -0
  112. /package/{packages/elm-ssr/src → src}/protocol.ts +0 -0
  113. /package/{packages/elm-ssr/src → src}/render.ts +0 -0
  114. /package/{packages/elm-ssr/src → src}/request-handler.ts +0 -0
  115. /package/{packages/elm-ssr/src → src}/response-headers.ts +0 -0
  116. /package/{packages/elm-ssr/src → src}/serialize.ts +0 -0
  117. /package/{packages/elm-ssr/src → src}/sessions/crypto.ts +0 -0
  118. /package/{packages/elm-ssr/src → src}/sessions/effects.ts +0 -0
  119. /package/{packages/elm-ssr/src → src}/sessions/index.ts +0 -0
  120. /package/{packages/elm-ssr/src → src}/sessions/middleware.ts +0 -0
  121. /package/{packages/elm-ssr/src → src}/sessions/store.ts +0 -0
  122. /package/{packages/elm-ssr/src → src}/sessions/types.ts +0 -0
  123. /package/{packages/elm-ssr/src → src}/tasks.ts +0 -0
@@ -9,6 +9,8 @@ module ElmSsr.Loader exposing
9
9
  , getCookie
10
10
  , enqueue
11
11
  , session, csrfToken, setSession, clearSession
12
+ , custom
13
+ , JobId, JobStatus(..), startJob, jobStatus
12
14
  , Effect, Step(..), step, encodeEffect
13
15
  )
14
16
 
@@ -45,6 +47,31 @@ request time.
45
47
  @docs session, csrfToken, setSession, clearSession
46
48
 
47
49
 
50
+ # Custom effects
51
+
52
+ Escape hatch for emitting a `kind` your own `EffectRunner` adapter handles.
53
+ Use this to plug in things the framework does not cover natively — fan-out
54
+ SQL with `Promise.all`, external services, vector search, queues. The shape
55
+ of `payload` and the decoder for the result are entirely your contract with
56
+ your adapter.
57
+
58
+ @docs custom
59
+
60
+
61
+ # Background jobs
62
+
63
+ Long-running work that exceeds a single request budget. Submit a job with
64
+ [`startJob`](#startJob) (returns an id immediately), poll progress with
65
+ [`jobStatus`](#jobStatus). The TS-side `withJobs(runner, { store,
66
+ handlers })` adapter persists records and runs handlers via `ctx.waitUntil`.
67
+
68
+ A typical PRG flow: form action calls `startJob`, redirects to
69
+ `/jobs/<id>`, that page polls `jobStatus` (or subscribes via SSE) and
70
+ renders progress until `JobDone`.
71
+
72
+ @docs JobId, JobStatus, startJob, jobStatus
73
+
74
+
48
75
  # Runtime interpretation
49
76
 
50
77
  These are used by the elm-ssr runtime to drive a loader. Application authors do
@@ -309,6 +336,156 @@ clearSession =
309
336
  (\_ -> Done ())
310
337
 
311
338
 
339
+ {-| Emit a custom effect that your TS-side `EffectRunner` handles. The runner
340
+ must inspect `effect.kind === <your kind>` and return
341
+ `{ ok: true, value: <data> }`; the `decoder` runs against that value.
342
+
343
+ The common use case is server-side fan-out — run several heavy SQL queries
344
+ or external fetches with `Promise.all` inside one effect call, then return a
345
+ combined payload so the route only awaits once:
346
+
347
+ -- Elm:
348
+ import Json.Decode as Decode
349
+
350
+ type alias Dashboard =
351
+ { totals : Int, recent : List Order, byCountry : List CountryStat }
352
+
353
+ dashboard : Loader Dashboard
354
+ dashboard =
355
+ Loader.custom
356
+ { kind = "parallelDashboard"
357
+ , payload = Encode.object []
358
+ , decoder = dashboardDecoder
359
+ }
360
+
361
+ -- TS adapter (wrap your existing runner):
362
+ const myEffects = async (effect, ctx) => {
363
+ if (effect.kind === "parallelDashboard") {
364
+ const [totals, recent, byCountry] = await Promise.all([
365
+ pg.unsafe("SELECT count(*) AS c FROM orders"),
366
+ pg.unsafe("SELECT * FROM orders ORDER BY created DESC LIMIT 10"),
367
+ pg.unsafe("SELECT country, sum(total) AS s FROM orders GROUP BY 1"),
368
+ ]);
369
+ return { ok: true, value: { totals: totals[0].c, recent, byCountry } };
370
+ }
371
+ return baseRunner(effect, ctx);
372
+ };
373
+
374
+ See `docs/recipes/parallel-queries.md` for the full pattern.
375
+ -}
376
+ custom : { kind : String, payload : Encode.Value, decoder : Decoder a } -> Loader a
377
+ custom config =
378
+ Pending
379
+ { kind = config.kind, payload = config.payload }
380
+ (\result -> resumeFetchJson config.decoder result)
381
+
382
+
383
+ {-| Opaque handle to a submitted background job. Treat as a string for routing
384
+ or storage; the TS-side store assigns it. -}
385
+ type alias JobId =
386
+ String
387
+
388
+
389
+ {-| The state of a job as the TS-side `JobStore` sees it. Decoded from the
390
+ record by [`jobStatus`](#jobStatus); unknown ids decode as `JobMissing`.
391
+
392
+ - `JobQueued` — accepted, not yet running.
393
+ - `JobRunning { progress }` — currently executing; `progress` carries
394
+ whatever the handler last reported (or `Nothing`).
395
+ - `JobDone result` — handler resolved; `result` is the decoded value.
396
+ - `JobFailed { reason }` — handler threw; `reason` is the error message.
397
+ - `JobMissing` — no record under this id (TTL expired, never existed).
398
+
399
+ -}
400
+ type JobStatus a
401
+ = JobQueued
402
+ | JobRunning { progress : Maybe Decode.Value }
403
+ | JobDone a
404
+ | JobFailed { reason : String }
405
+ | JobMissing
406
+
407
+
408
+ {-| Submit a background job. Returns the assigned `JobId` immediately —
409
+ the handler runs after the response goes out (via `ctx.waitUntil` on
410
+ Cloudflare, fire-and-forget locally).
411
+
412
+ Pair `kind` with a handler registered in your TS-side
413
+ `withJobs(runner, { handlers })`. `payload` is your handler's input;
414
+ decoder for the result is on [`jobStatus`](#jobStatus).
415
+
416
+ submit : Action (Document Never)
417
+ submit =
418
+ Action.fromLoader
419
+ (Loader.startJob
420
+ { kind = "generateReport"
421
+ , payload = Encode.object [ ( "month", Encode.string "2026-05" ) ]
422
+ }
423
+ )
424
+ |> Action.andThen (\id -> Action.redirect ("/reports/" ++ id))
425
+
426
+ -}
427
+ startJob : { kind : String, payload : Encode.Value } -> Loader JobId
428
+ startJob config =
429
+ Pending
430
+ { kind = "startJob"
431
+ , payload =
432
+ Encode.object
433
+ [ ( "kind", Encode.string config.kind )
434
+ , ( "payload", config.payload )
435
+ ]
436
+ }
437
+ (\result -> resumeFetchJson Decode.string result)
438
+
439
+
440
+ {-| Read the current state of a submitted job. Decoded into [`JobStatus`](#JobStatus).
441
+ The result decoder runs against the handler's return value when `status === "done"`.
442
+
443
+ statusLoader : JobId -> Loader (JobStatus Report)
444
+ statusLoader id =
445
+ Loader.jobStatus { jobId = id, decoder = reportDecoder }
446
+
447
+ -}
448
+ jobStatus : { jobId : JobId, decoder : Decoder a } -> Loader (JobStatus a)
449
+ jobStatus config =
450
+ Pending
451
+ { kind = "jobStatus"
452
+ , payload = Encode.object [ ( "jobId", Encode.string config.jobId ) ]
453
+ }
454
+ (\result -> resumeFetchJson (jobStatusDecoder config.decoder) result)
455
+
456
+
457
+ jobStatusDecoder : Decoder a -> Decoder (JobStatus a)
458
+ jobStatusDecoder resultDecoder =
459
+ Decode.oneOf
460
+ [ Decode.null JobMissing
461
+ , Decode.field "status" Decode.string
462
+ |> Decode.andThen
463
+ (\status ->
464
+ case status of
465
+ "queued" ->
466
+ Decode.succeed JobQueued
467
+
468
+ "running" ->
469
+ Decode.map (\progress -> JobRunning { progress = progress })
470
+ (Decode.maybe (Decode.field "progress" Decode.value))
471
+
472
+ "done" ->
473
+ Decode.map JobDone (Decode.field "result" resultDecoder)
474
+
475
+ "failed" ->
476
+ Decode.map (\reason -> JobFailed { reason = reason })
477
+ (Decode.oneOf
478
+ [ Decode.field "error" Decode.string
479
+ , Decode.succeed "Job failed without a message"
480
+ ]
481
+ )
482
+
483
+ other ->
484
+ Decode.fail ("Unknown job status: " ++ other)
485
+ )
486
+ ]
487
+
488
+
312
489
  sqlPayload : String -> List Encode.Value -> Encode.Value
313
490
  sqlPayload sql params =
314
491
  Encode.object
package/package.json CHANGED
@@ -1,33 +1,63 @@
1
1
  {
2
2
  "name": "elm-ssr",
3
- "version": "0.2.0",
4
- "type": "module",
5
- "workspaces": [
6
- "packages/*",
7
- "examples/*"
3
+ "version": "0.5.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"
8
27
  ],
9
- "scripts": {
10
- "build:elm": "bun packages/elm-ssr/bin/elm-ssr.mjs build",
11
- "build": "bun run build:elm",
12
- "check": "bun run build && bun ./node_modules/typescript/bin/tsc --noEmit",
13
- "ssr:build": "bun packages/elm-ssr/bin/elm-ssr.mjs build",
14
- "ssr:new": "bun packages/elm-ssr/bin/elm-ssr.mjs new",
15
- "ssr:routes": "bun packages/elm-ssr/bin/elm-ssr.mjs routes",
16
- "test": "docker compose up -d --wait && (DATABASE_URL=postgres://elmssr:elmssr@localhost:5432/elmssr REDIS_URL=redis://localhost:6379 sh -c 'bun run build && bun test'; ec=$?; docker compose down; exit $ec)",
17
- "test:unit": "bun run build && bun test $(find test -name '*.test.ts' -not -path '*/integration/*')",
18
- "test:integration": "docker compose up -d --wait && (DATABASE_URL=postgres://elmssr:elmssr@localhost:5432/elmssr REDIS_URL=redis://localhost:6379 sh -c 'bun run build && bun test test/integration/'; ec=$?; docker compose down; exit $ec)",
19
- "bench": "bun run build && bun run scripts/benchmark.mjs",
20
- "dev": "bun run build && ./node_modules/.bin/wrangler dev",
21
- "deploy": "bun run build && ./node_modules/.bin/wrangler deploy"
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
+ "./sessions": "./src/sessions/index.ts",
44
+ "./sse": "./src/sse.ts",
45
+ "./jobs": "./src/jobs/index.ts",
46
+ "./response-headers": "./src/response-headers.ts",
47
+ "./serialize": "./src/serialize.ts",
48
+ "./protocol": "./src/protocol.ts",
49
+ "./islands-runtime": "./src/client-runtime/islands.ts"
22
50
  },
51
+ "files": [
52
+ "bin",
53
+ "lib",
54
+ "elm-src",
55
+ "src"
56
+ ],
23
57
  "engines": {
24
58
  "bun": ">=1.3"
25
59
  },
26
- "devDependencies": {
27
- "elm-ssr": "workspace:*",
28
- "elm": "^0.19.1-6",
29
- "happy-dom": "^20.9.0",
30
- "typescript": "^5.9.0",
31
- "wrangler": "^4.0.0"
60
+ "dependencies": {
61
+ "cookie": "^1.0.1"
32
62
  }
33
63
  }
@@ -23,6 +23,8 @@ function createIslandsRuntime(deps) {
23
23
  return;
24
24
  }
25
25
 
26
+ const teardowns = [];
27
+
26
28
  if (app.ports.broadcastOut) {
27
29
  app.ports.broadcastOut.subscribe((event) => {
28
30
  window.dispatchEvent(new window.CustomEvent("elm-ssr-broadcast", { detail: event }));
@@ -32,7 +34,69 @@ function createIslandsRuntime(deps) {
32
34
  if (app.ports.broadcastIn) {
33
35
  const handler = (event) => app.ports.broadcastIn.send(event.detail);
34
36
  window.addEventListener("elm-ssr-broadcast", handler);
35
- cleanups.set(marker, () => window.removeEventListener("elm-ssr-broadcast", handler));
37
+ teardowns.push(() => window.removeEventListener("elm-ssr-broadcast", handler));
38
+ }
39
+
40
+ // Server-Sent Events ports. One EventSource per (url, island); the island
41
+ // opens/closes via sseOpen/sseClose, and receives raw frames via sseEventIn.
42
+ if (app.ports.sseOpen || app.ports.sseClose || app.ports.sseEventIn || app.ports.sseErrorIn) {
43
+ const sources = new Map(); // url -> EventSource
44
+
45
+ const open = (url) => {
46
+ if (sources.has(url)) {
47
+ return;
48
+ }
49
+ let source;
50
+ try {
51
+ source = new window.EventSource(url);
52
+ } catch (error) {
53
+ if (app.ports.sseErrorIn) {
54
+ app.ports.sseErrorIn.send({ url, message: String(error) });
55
+ }
56
+ return;
57
+ }
58
+ source.onmessage = (event) => {
59
+ if (app.ports.sseEventIn) {
60
+ app.ports.sseEventIn.send({ url, data: typeof event.data === "string" ? event.data : "" });
61
+ }
62
+ };
63
+ source.onerror = () => {
64
+ if (app.ports.sseErrorIn) {
65
+ app.ports.sseErrorIn.send({ url, message: "EventSource error" });
66
+ }
67
+ };
68
+ sources.set(url, source);
69
+ };
70
+
71
+ const close = (url) => {
72
+ const source = sources.get(url);
73
+ if (source) {
74
+ source.close();
75
+ sources.delete(url);
76
+ }
77
+ };
78
+
79
+ if (app.ports.sseOpen) {
80
+ app.ports.sseOpen.subscribe(open);
81
+ }
82
+ if (app.ports.sseClose) {
83
+ app.ports.sseClose.subscribe(close);
84
+ }
85
+
86
+ teardowns.push(() => {
87
+ for (const source of sources.values()) {
88
+ source.close();
89
+ }
90
+ sources.clear();
91
+ });
92
+ }
93
+
94
+ if (teardowns.length > 0) {
95
+ cleanups.set(marker, () => {
96
+ for (const fn of teardowns) {
97
+ fn();
98
+ }
99
+ });
36
100
  }
37
101
  };
38
102
 
@@ -128,30 +192,64 @@ function createIslandsRuntime(deps) {
128
192
  }
129
193
  };
130
194
 
131
- const managedHeadNodes = (head) => {
132
- const metas = Array.prototype.slice.call(head.getElementsByTagName("meta"));
133
- const links = Array.prototype.slice
195
+ const stylesheetLinks = (head) =>
196
+ Array.prototype.slice
134
197
  .call(head.getElementsByTagName("link"))
135
198
  .filter((link) => link.getAttribute("rel") === "stylesheet");
136
- return metas.concat(links);
137
- };
138
199
 
139
- const syncHead = (sourceDoc) => {
140
- document.title = sourceDoc.title;
200
+ const metaNodes = (head) => Array.prototype.slice.call(head.getElementsByTagName("meta"));
201
+
202
+ // Stable signature for diffing: href (+ media if set) for stylesheets,
203
+ // full attribute set for metas. Two nodes with the same signature are
204
+ // treated as identical; we never tear one down and re-create it.
205
+ const stylesheetKey = (link) => (link.getAttribute("href") || "") + "|" + (link.getAttribute("media") || "");
206
+ const metaKey = (meta) =>
207
+ meta
208
+ .getAttributeNames()
209
+ .sort()
210
+ .map((name) => name + "=" + meta.getAttribute(name))
211
+ .join("|");
212
+
213
+ // Diff-based sync: only add what's new, only remove what's gone. Avoids the
214
+ // flash of unstyled content that came from removing-then-readding the
215
+ // <link rel=stylesheet>, even when the href was identical across pages.
216
+ const syncCollection = (currentNodes, incomingNodes, keyOf) => {
217
+ const currentByKey = new Map();
218
+ for (const node of currentNodes) {
219
+ currentByKey.set(keyOf(node), node);
220
+ }
221
+ const incomingByKey = new Map();
222
+ for (const node of incomingNodes) {
223
+ incomingByKey.set(keyOf(node), node);
224
+ }
141
225
 
142
- for (const node of managedHeadNodes(document.head)) {
143
- node.remove();
226
+ // Remove only what's not in the incoming doc.
227
+ for (const [key, node] of currentByKey) {
228
+ if (!incomingByKey.has(key)) {
229
+ node.remove();
230
+ }
144
231
  }
145
232
 
146
- for (const node of managedHeadNodes(sourceDoc.head)) {
147
- const copy = document.createElement(node.tagName);
148
- for (const name of node.getAttributeNames()) {
149
- copy.setAttribute(name, node.getAttribute(name));
233
+ // Add only what's not already present.
234
+ for (const [key, node] of incomingByKey) {
235
+ if (!currentByKey.has(key)) {
236
+ const copy = document.createElement(node.tagName);
237
+ for (const name of node.getAttributeNames()) {
238
+ copy.setAttribute(name, node.getAttribute(name));
239
+ }
240
+ document.head.appendChild(copy);
150
241
  }
151
- document.head.appendChild(copy);
152
242
  }
153
243
  };
154
244
 
245
+ const syncHead = (sourceDoc) => {
246
+ if (document.title !== sourceDoc.title) {
247
+ document.title = sourceDoc.title;
248
+ }
249
+ syncCollection(stylesheetLinks(document.head), stylesheetLinks(sourceDoc.head), stylesheetKey);
250
+ syncCollection(metaNodes(document.head), metaNodes(sourceDoc.head), metaKey);
251
+ };
252
+
155
253
  const navigate = async (url, push = true) => {
156
254
  try {
157
255
  const response = await window.fetch("/api/render?path=" + encodeURIComponent(url.pathname + url.search));
@@ -0,0 +1,7 @@
1
+ // Public API for the background-job layer. Wrap your effect runner with
2
+ // `withJobs(runner, { store, handlers })` to enable `Loader.startJob` /
3
+ // `Loader.jobStatus` on the Elm side.
4
+
5
+ export { memoryJobStore, cacheJobStore, type CacheJobStoreOptions } from "./store";
6
+ export { withJobs, type JobsConfig } from "./runner";
7
+ export type { JobRecord, JobStore, JobContext, JobHandler, JobHandlers } from "./types";
@@ -0,0 +1,137 @@
1
+ import type { EffectContext, EffectRunner } from "../effects";
2
+ import type { JobContext, JobHandlers, JobRecord, JobStore } from "./types";
3
+
4
+ export interface JobsConfig {
5
+ /** Where to persist job records. Use `memoryJobStore()` in dev/tests, `cacheJobStore(redisCache(...))` in prod. */
6
+ store: JobStore;
7
+ /** Named job handlers. Each maps `kind` → async function returning the result. */
8
+ handlers: JobHandlers;
9
+ /** Default record TTL in seconds (default `60 * 60 * 24` = 24h). */
10
+ defaultTtlSeconds?: number;
11
+ }
12
+
13
+ const DEFAULT_TTL = 60 * 60 * 24;
14
+
15
+ const generateJobId = (): string => crypto.randomUUID();
16
+
17
+ const runJob = (
18
+ store: JobStore,
19
+ handler: JobHandlers[string] | undefined,
20
+ record: JobRecord,
21
+ context: EffectContext
22
+ ): Promise<void> =>
23
+ Promise.resolve().then(async () => {
24
+ if (!handler) {
25
+ await store.set(record.id, {
26
+ ...record,
27
+ status: "failed",
28
+ error: `No handler registered for kind "${record.kind}"`,
29
+ finishedAt: Date.now()
30
+ });
31
+ return;
32
+ }
33
+
34
+ const aborter = new AbortController();
35
+ // If the request's signal is still around (e.g. Bun fetch), wire it through;
36
+ // otherwise the job runs to completion regardless.
37
+ if (context.request) {
38
+ const reqSignal = context.request.signal;
39
+ if (reqSignal.aborted) {
40
+ aborter.abort();
41
+ } else {
42
+ reqSignal.addEventListener("abort", () => aborter.abort(), { once: true });
43
+ }
44
+ }
45
+
46
+ let progressVersion = 0;
47
+ const jobContext: JobContext = {
48
+ jobId: record.id,
49
+ reportProgress: async (value) => {
50
+ progressVersion += 1;
51
+ const current = await store.get(record.id);
52
+ if (!current) return;
53
+ await store.set(record.id, { ...current, progress: value });
54
+ },
55
+ signal: aborter.signal
56
+ };
57
+
58
+ const startedAt = Date.now();
59
+ await store.set(record.id, { ...record, status: "running", startedAt });
60
+
61
+ try {
62
+ const result = await handler(record.payload, jobContext);
63
+ await store.set(record.id, {
64
+ ...record,
65
+ status: "done",
66
+ startedAt,
67
+ finishedAt: Date.now(),
68
+ result
69
+ });
70
+ } catch (error) {
71
+ console.error(`elm-ssr: job "${record.kind}" (${record.id}) failed`, error);
72
+ await store.set(record.id, {
73
+ ...record,
74
+ status: "failed",
75
+ startedAt,
76
+ finishedAt: Date.now(),
77
+ error: String(error instanceof Error ? error.message : error)
78
+ });
79
+ }
80
+
81
+ // Mark "finished progress version" so concurrent reportProgress calls after completion are no-ops.
82
+ void progressVersion;
83
+ });
84
+
85
+ /**
86
+ * Wraps an effect runner so `Loader.startJob` schedules a background job and
87
+ * `Loader.jobStatus` reads its current record. Jobs run via `ctx.waitUntil`
88
+ * on Cloudflare (isolate stays alive); locally (no waitUntil) they run
89
+ * fire-and-forget. All other effects pass through unchanged.
90
+ *
91
+ * Use a durable `JobStore` (cacheJobStore over Redis/KV) in production —
92
+ * `memoryJobStore` is process-local and lost on isolate restart.
93
+ */
94
+ export const withJobs = (runner: EffectRunner, config: JobsConfig): EffectRunner => {
95
+ const ttlSeconds = config.defaultTtlSeconds ?? DEFAULT_TTL;
96
+
97
+ return async (effect, context) => {
98
+ if (effect.kind === "startJob") {
99
+ const kind = String(effect.payload.kind ?? "");
100
+ if (!kind) {
101
+ return { ok: false, error: 'startJob requires a non-empty "kind"' };
102
+ }
103
+
104
+ const id = generateJobId();
105
+ const record: JobRecord = {
106
+ id,
107
+ kind,
108
+ payload: effect.payload.payload ?? null,
109
+ status: "queued",
110
+ expiresAt: Date.now() + ttlSeconds * 1000
111
+ };
112
+ await config.store.set(id, record);
113
+
114
+ const work = runJob(config.store, config.handlers[kind], record, context);
115
+ if (typeof context.waitUntil === "function") {
116
+ context.waitUntil(work);
117
+ } else {
118
+ void work.catch((error) => console.error("elm-ssr: job scheduling failed", error));
119
+ }
120
+
121
+ return { ok: true, value: id };
122
+ }
123
+
124
+ if (effect.kind === "jobStatus") {
125
+ const id = String(effect.payload.jobId ?? "");
126
+ if (!id) {
127
+ return { ok: false, error: 'jobStatus requires a non-empty "jobId"' };
128
+ }
129
+ const record = await config.store.get(id);
130
+ // Returning the raw record (or null for unknown id). Elm-side decoder
131
+ // turns this into JobStatus a.
132
+ return { ok: true, value: record };
133
+ }
134
+
135
+ return runner(effect, context);
136
+ };
137
+ };
@@ -0,0 +1,60 @@
1
+ import type { CacheBackend } from "../backends";
2
+ import type { JobRecord, JobStore } from "./types";
3
+
4
+ /** In-memory job store. Useful for dev/tests; lost on process restart. */
5
+ export const memoryJobStore = (initial?: Map<string, JobRecord>): JobStore => {
6
+ const store = initial ?? new Map<string, JobRecord>();
7
+ return {
8
+ get: async (id) => {
9
+ const record = store.get(id);
10
+ if (!record) return null;
11
+ if (record.expiresAt !== undefined && record.expiresAt <= Date.now()) {
12
+ store.delete(id);
13
+ return null;
14
+ }
15
+ return record;
16
+ },
17
+ set: async (id, record) => {
18
+ store.set(id, record);
19
+ },
20
+ delete: async (id) => {
21
+ store.delete(id);
22
+ }
23
+ };
24
+ };
25
+
26
+ export interface CacheJobStoreOptions {
27
+ /** Cache-key prefix (default `"elm-ssr:job:"`). */
28
+ keyPrefix?: string;
29
+ /** Default TTL in seconds when the record has no `expiresAt`. */
30
+ defaultTtlSeconds?: number;
31
+ }
32
+
33
+ /**
34
+ * Job store backed by an existing `CacheBackend` — wire it on
35
+ * `redisCache(...)`, a KV-backed cache, or anything matching the interface.
36
+ * Records are namespaced via the key prefix to avoid colliding with your
37
+ * other cache uses.
38
+ */
39
+ export const cacheJobStore = (backend: CacheBackend, options: CacheJobStoreOptions = {}): JobStore => {
40
+ const prefix = options.keyPrefix ?? "elm-ssr:job:";
41
+ const defaultTtl = options.defaultTtlSeconds;
42
+
43
+ return {
44
+ get: async (id) => {
45
+ const value = await backend.get(prefix + id);
46
+ return (value ?? null) as JobRecord | null;
47
+ },
48
+ set: async (id, record) => {
49
+ const ttl =
50
+ record.expiresAt !== undefined
51
+ ? Math.max(1, Math.ceil((record.expiresAt - Date.now()) / 1000))
52
+ : defaultTtl;
53
+ await backend.put(prefix + id, record, ttl);
54
+ },
55
+ delete: async (id) => {
56
+ // CacheBackend has no delete; tombstone with TTL=1s.
57
+ await backend.put(prefix + id, null, 1);
58
+ }
59
+ };
60
+ };