elm-ssr 0.3.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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "elm-ssr",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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
5
  "license": "MIT",
6
6
  "author": "Michał Majchrzak <michmajchrzak@gmail.com>",
@@ -42,6 +42,7 @@
42
42
  "./middleware": "./src/middleware.ts",
43
43
  "./sessions": "./src/sessions/index.ts",
44
44
  "./sse": "./src/sse.ts",
45
+ "./jobs": "./src/jobs/index.ts",
45
46
  "./response-headers": "./src/response-headers.ts",
46
47
  "./serialize": "./src/serialize.ts",
47
48
  "./protocol": "./src/protocol.ts",
@@ -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
+ };
@@ -0,0 +1,41 @@
1
+ /** A persisted background job. The lifecycle is queued → running → done | failed. */
2
+ export interface JobRecord {
3
+ id: string;
4
+ kind: string;
5
+ /** Payload the job was started with. */
6
+ payload: unknown;
7
+ status: "queued" | "running" | "done" | "failed";
8
+ /** User-supplied progress payload (set via `reportProgress`). */
9
+ progress?: unknown;
10
+ /** Result payload when `status === "done"`. */
11
+ result?: unknown;
12
+ /** Error message when `status === "failed"`. */
13
+ error?: string;
14
+ /** Epoch ms when the handler started executing. */
15
+ startedAt?: number;
16
+ /** Epoch ms when the handler resolved or rejected. */
17
+ finishedAt?: number;
18
+ /** Epoch ms at which the store may evict the record. */
19
+ expiresAt?: number;
20
+ }
21
+
22
+ /** Driver-agnostic job storage. Wire memory / cache / SQL behind it. */
23
+ export interface JobStore {
24
+ get(id: string): Promise<JobRecord | null>;
25
+ set(id: string, record: JobRecord): Promise<void>;
26
+ delete(id: string): Promise<void>;
27
+ }
28
+
29
+ /** Per-job context passed to the handler. */
30
+ export interface JobContext {
31
+ jobId: string;
32
+ /** Update the record's `progress` field. Safe to call concurrently with the handler's work. */
33
+ reportProgress: (value: unknown) => Promise<void>;
34
+ /** Fires when the request context is gone (Worker isolate teardown) or store TTL elapses. */
35
+ signal: AbortSignal;
36
+ }
37
+
38
+ /** A handler for a named job kind. Returns the result; throw to mark failed. */
39
+ export type JobHandler = (payload: unknown, context: JobContext) => Promise<unknown> | unknown;
40
+
41
+ export type JobHandlers = Record<string, JobHandler>;