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.
- package/README.md +48 -342
- package/elm-src/ElmSsr/Island/Sse.elm +151 -0
- package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Loader.elm +177 -0
- package/package.json +54 -24
- package/{packages/elm-ssr/src → src}/client-runtime/islands.ts +113 -15
- package/src/jobs/index.ts +7 -0
- package/src/jobs/runner.ts +137 -0
- package/src/jobs/store.ts +60 -0
- package/src/jobs/types.ts +41 -0
- package/src/sse.ts +162 -0
- package/AGENTS.md +0 -289
- package/CHANGELOG.md +0 -87
- package/LICENSE +0 -21
- package/bun.lock +0 -259
- package/docker-compose.yml +0 -33
- package/docs/README.md +0 -51
- package/docs/backends.md +0 -146
- package/docs/cli.md +0 -117
- package/docs/effects.md +0 -91
- package/docs/getting-started.md +0 -94
- package/docs/islands.md +0 -197
- package/docs/loaders-and-actions.md +0 -241
- package/docs/middleware.md +0 -93
- package/docs/migrations.md +0 -143
- package/docs/routing.md +0 -108
- package/docs/sessions.md +0 -218
- package/docs/tasks.md +0 -149
- package/docs/testing.md +0 -84
- package/elm-ssr.config.json +0 -14
- package/examples/basic/elm.json +0 -27
- package/examples/basic/migrations/0001_guestbook.down.sql +0 -1
- package/examples/basic/migrations/0001_guestbook.sql +0 -10
- package/examples/basic/package.json +0 -10
- package/examples/basic/runtime.ts +0 -148
- package/examples/basic/src/Example/Basic/Islands/Counter.elm +0 -110
- package/examples/basic/src/Example/Basic/Islands/Observer.elm +0 -67
- package/examples/basic/src/Example/Basic/Islands/Tasks.elm +0 -151
- package/examples/basic/src/Example/Basic/Routes/Chart.elm +0 -87
- package/examples/basic/src/Example/Basic/Routes/Counter.elm +0 -42
- package/examples/basic/src/Example/Basic/Routes/Echo.elm +0 -76
- package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +0 -37
- package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +0 -86
- package/examples/basic/src/Example/Basic/Routes/Index.elm +0 -41
- package/examples/basic/src/Example/Basic/Routes/NotFound.elm +0 -37
- package/examples/basic/src/Example/Basic/Routes/Profile.elm +0 -112
- package/examples/basic/src/Example/Basic/Routes/Session.elm +0 -89
- package/examples/basic/src/Example/Basic/Routes/Status.elm +0 -90
- package/examples/basic/src/Example/Basic/View/Shared.elm +0 -60
- package/examples/basic/styles.ts +0 -204
- package/examples/basic/worker.ts +0 -3
- package/examples/crypto-dashboard/elm.json +0 -30
- package/examples/crypto-dashboard/package.json +0 -10
- package/examples/crypto-dashboard/runtime.ts +0 -97
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +0 -204
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +0 -200
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +0 -67
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +0 -30
- package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +0 -39
- package/examples/crypto-dashboard/styles.ts +0 -23
- package/examples/crypto-dashboard/worker.ts +0 -3
- package/llms.txt +0 -69
- package/packages/elm-ssr/README.md +0 -67
- package/packages/elm-ssr/package.json +0 -61
- package/scripts/benchmark.mjs +0 -60
- package/test/action.test.ts +0 -81
- package/test/adapters.test.ts +0 -173
- package/test/advanced-robustness.test.ts +0 -75
- package/test/app.test.ts +0 -209
- package/test/browser-island.test.ts +0 -184
- package/test/cli-migrate.test.ts +0 -97
- package/test/cli.test.ts +0 -94
- package/test/cookies.test.ts +0 -156
- package/test/crypto-dashboard.test.ts +0 -35
- package/test/effects.test.ts +0 -117
- package/test/http.test.ts +0 -50
- package/test/integration/redis-postgres.test.ts +0 -174
- package/test/island-runtime.test.ts +0 -214
- package/test/middleware.test.ts +0 -134
- package/test/migrations.test.ts +0 -244
- package/test/profile.test.ts +0 -159
- package/test/robustness.test.ts +0 -135
- package/test/serialize.test.ts +0 -92
- package/test/sessions.test.ts +0 -429
- package/test/svg.test.ts +0 -65
- package/tsconfig.json +0 -20
- package/wrangler.jsonc +0 -11
- /package/{packages/elm-ssr/bin → bin}/elm-ssr.mjs +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Action.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Encode.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Events.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Attributes.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Events.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island/Shared.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Page.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Route.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Runtime.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg.elm +0 -0
- /package/{packages/elm-ssr/lib → lib}/build.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/migrate.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/scaffold.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/workspace.mjs +0 -0
- /package/{packages/elm-ssr/src → src}/app.ts +0 -0
- /package/{packages/elm-ssr/src → src}/backends.ts +0 -0
- /package/{packages/elm-ssr/src → src}/effects.ts +0 -0
- /package/{packages/elm-ssr/src → src}/http.ts +0 -0
- /package/{packages/elm-ssr/src → src}/middleware.ts +0 -0
- /package/{packages/elm-ssr/src → src}/migrations.ts +0 -0
- /package/{packages/elm-ssr/src → src}/protocol.ts +0 -0
- /package/{packages/elm-ssr/src → src}/render.ts +0 -0
- /package/{packages/elm-ssr/src → src}/request-handler.ts +0 -0
- /package/{packages/elm-ssr/src → src}/response-headers.ts +0 -0
- /package/{packages/elm-ssr/src → src}/serialize.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/crypto.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/effects.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/index.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/middleware.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/store.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/types.ts +0 -0
- /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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
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
|
-
"
|
|
27
|
-
"
|
|
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
|
-
|
|
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
|
|
132
|
-
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
copy.
|
|
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
|
+
};
|