elm-ssr 0.3.0 → 0.5.1
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 +11 -5
- package/bin/elm-ssr.mjs +20 -1
- package/elm-src/ElmSsr/Loader.elm +177 -0
- package/lib/scaffold.mjs +10 -1
- package/package.json +2 -1
- 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/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# elm-ssr
|
|
2
2
|
|
|
3
|
-
Elm-first SSR library and framework for
|
|
3
|
+
Elm-first SSR library and framework for Fetch-compatible runtimes.
|
|
4
4
|
One package, three pieces:
|
|
5
5
|
|
|
6
6
|
- A **CLI** (`elm-ssr build|new|migrate|dev`) that scans your routes/islands,
|
|
7
7
|
generates the router + manifest, and runs `elm make`.
|
|
8
|
-
- A **
|
|
8
|
+
- A **Fetch-compatible runtime** (`createWorkerApp`, `renderApp`, effect adapters,
|
|
9
9
|
background tasks, SQL migrations, middleware) exported via subpaths.
|
|
10
10
|
- A set of **Elm authoring modules** under `elm-src/ElmSsr/` (Route, Loader,
|
|
11
11
|
Action, Html, Svg, Island, Page, Document, Runtime) which the build syncs
|
|
@@ -17,8 +17,12 @@ One package, three pieces:
|
|
|
17
17
|
bun add elm-ssr
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Then
|
|
21
|
-
|
|
20
|
+
Then import the runtime via subpaths and run the CLI with `bunx`:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bunx elm-ssr new my-app
|
|
24
|
+
bunx elm-ssr build
|
|
25
|
+
```
|
|
22
26
|
|
|
23
27
|
## CLI commands
|
|
24
28
|
|
|
@@ -29,7 +33,9 @@ workspace) and import the runtime via subpaths.
|
|
|
29
33
|
(one combined island bundle exposing `Elm.<App>.Islands.<Name>`).
|
|
30
34
|
- **`elm-ssr new <name>`** — scaffold a new app and register it in
|
|
31
35
|
`elm-ssr.config.json`.
|
|
32
|
-
- **`elm-ssr dev`** — `build` then `wrangler dev
|
|
36
|
+
- **`elm-ssr dev`** — `build` then `wrangler dev` for Cloudflare-like local
|
|
37
|
+
development. For other hosts, build with `elm-ssr build` and adapt
|
|
38
|
+
`worker.fetch` in your own server/entrypoint.
|
|
33
39
|
- **`elm-ssr compress`** — pre-compress generated bundles with gzip.
|
|
34
40
|
- **`elm-ssr migrate <up|down|status>`** — apply / revert / inspect SQL-file
|
|
35
41
|
migrations. `--db postgres://…`, `sqlite://path`, or a plain SQLite file path;
|
package/bin/elm-ssr.mjs
CHANGED
|
@@ -56,15 +56,32 @@ const printHelp = () => {
|
|
|
56
56
|
`);
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
let config = null;
|
|
60
|
+
try {
|
|
61
|
+
config = await readWorkspaceConfig(rootPath);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (!err || typeof err !== "object" || !("code" in err) || err.code !== "ENOENT") {
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const requireConfig = () => {
|
|
69
|
+
if (!config) {
|
|
70
|
+
console.error(`Error: elm-ssr.config.json not found at ${rootPath}`);
|
|
71
|
+
console.error("Please run 'elm-ssr new <name>' to create a new workspace and app.");
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
60
75
|
|
|
61
76
|
switch (command) {
|
|
62
77
|
case "build":
|
|
63
78
|
case "compress":
|
|
79
|
+
requireConfig();
|
|
64
80
|
await build({ rootPath, config });
|
|
65
81
|
break;
|
|
66
82
|
|
|
67
83
|
case "dev":
|
|
84
|
+
requireConfig();
|
|
68
85
|
await run("bun", ["run", "build"], rootPath);
|
|
69
86
|
await run("./node_modules/.bin/wrangler", ["dev"], rootPath);
|
|
70
87
|
break;
|
|
@@ -87,12 +104,14 @@ switch (command) {
|
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
case "routes":
|
|
107
|
+
requireConfig();
|
|
90
108
|
for (const app of config.apps) {
|
|
91
109
|
console.log(`${app.name}: root=${app.root} module=${app.module} routes=src/${app.module.split(".").join("/")}/Routes`);
|
|
92
110
|
}
|
|
93
111
|
break;
|
|
94
112
|
|
|
95
113
|
case "info":
|
|
114
|
+
requireConfig();
|
|
96
115
|
console.log(`workspace: ${packageJson.name}`);
|
|
97
116
|
console.log(`apps: ${config.apps.map((app) => app.name).join(", ")}`);
|
|
98
117
|
break;
|
|
@@ -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/lib/scaffold.mjs
CHANGED
|
@@ -475,7 +475,16 @@ const normalizeAppRoot = (rawRoot, name) => {
|
|
|
475
475
|
export const createAppScaffold = async (rootPath, name, options = {}) => {
|
|
476
476
|
ensureValidName(name);
|
|
477
477
|
|
|
478
|
-
|
|
478
|
+
let config;
|
|
479
|
+
try {
|
|
480
|
+
config = await readWorkspaceConfig(rootPath);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
|
|
483
|
+
config = { apps: [] };
|
|
484
|
+
} else {
|
|
485
|
+
throw err;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
479
488
|
ensureAppMissing(config, name);
|
|
480
489
|
|
|
481
490
|
const appRoot = normalizeAppRoot(options.root, name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elm-ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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>;
|