elm-ssr 0.2.0 → 0.3.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 (119) hide show
  1. package/README.md +48 -342
  2. package/elm-src/ElmSsr/Island/Sse.elm +151 -0
  3. package/package.json +53 -24
  4. package/{packages/elm-ssr/src → src}/client-runtime/islands.ts +113 -15
  5. package/src/sse.ts +162 -0
  6. package/AGENTS.md +0 -289
  7. package/CHANGELOG.md +0 -87
  8. package/LICENSE +0 -21
  9. package/bun.lock +0 -259
  10. package/docker-compose.yml +0 -33
  11. package/docs/README.md +0 -51
  12. package/docs/backends.md +0 -146
  13. package/docs/cli.md +0 -117
  14. package/docs/effects.md +0 -91
  15. package/docs/getting-started.md +0 -94
  16. package/docs/islands.md +0 -197
  17. package/docs/loaders-and-actions.md +0 -241
  18. package/docs/middleware.md +0 -93
  19. package/docs/migrations.md +0 -143
  20. package/docs/routing.md +0 -108
  21. package/docs/sessions.md +0 -218
  22. package/docs/tasks.md +0 -149
  23. package/docs/testing.md +0 -84
  24. package/elm-ssr.config.json +0 -14
  25. package/examples/basic/elm.json +0 -27
  26. package/examples/basic/migrations/0001_guestbook.down.sql +0 -1
  27. package/examples/basic/migrations/0001_guestbook.sql +0 -10
  28. package/examples/basic/package.json +0 -10
  29. package/examples/basic/runtime.ts +0 -148
  30. package/examples/basic/src/Example/Basic/Islands/Counter.elm +0 -110
  31. package/examples/basic/src/Example/Basic/Islands/Observer.elm +0 -67
  32. package/examples/basic/src/Example/Basic/Islands/Tasks.elm +0 -151
  33. package/examples/basic/src/Example/Basic/Routes/Chart.elm +0 -87
  34. package/examples/basic/src/Example/Basic/Routes/Counter.elm +0 -42
  35. package/examples/basic/src/Example/Basic/Routes/Echo.elm +0 -76
  36. package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +0 -37
  37. package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +0 -86
  38. package/examples/basic/src/Example/Basic/Routes/Index.elm +0 -41
  39. package/examples/basic/src/Example/Basic/Routes/NotFound.elm +0 -37
  40. package/examples/basic/src/Example/Basic/Routes/Profile.elm +0 -112
  41. package/examples/basic/src/Example/Basic/Routes/Session.elm +0 -89
  42. package/examples/basic/src/Example/Basic/Routes/Status.elm +0 -90
  43. package/examples/basic/src/Example/Basic/View/Shared.elm +0 -60
  44. package/examples/basic/styles.ts +0 -204
  45. package/examples/basic/worker.ts +0 -3
  46. package/examples/crypto-dashboard/elm.json +0 -30
  47. package/examples/crypto-dashboard/package.json +0 -10
  48. package/examples/crypto-dashboard/runtime.ts +0 -97
  49. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +0 -204
  50. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +0 -200
  51. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +0 -67
  52. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +0 -30
  53. package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +0 -39
  54. package/examples/crypto-dashboard/styles.ts +0 -23
  55. package/examples/crypto-dashboard/worker.ts +0 -3
  56. package/llms.txt +0 -69
  57. package/packages/elm-ssr/README.md +0 -67
  58. package/packages/elm-ssr/package.json +0 -61
  59. package/scripts/benchmark.mjs +0 -60
  60. package/test/action.test.ts +0 -81
  61. package/test/adapters.test.ts +0 -173
  62. package/test/advanced-robustness.test.ts +0 -75
  63. package/test/app.test.ts +0 -209
  64. package/test/browser-island.test.ts +0 -184
  65. package/test/cli-migrate.test.ts +0 -97
  66. package/test/cli.test.ts +0 -94
  67. package/test/cookies.test.ts +0 -156
  68. package/test/crypto-dashboard.test.ts +0 -35
  69. package/test/effects.test.ts +0 -117
  70. package/test/http.test.ts +0 -50
  71. package/test/integration/redis-postgres.test.ts +0 -174
  72. package/test/island-runtime.test.ts +0 -214
  73. package/test/middleware.test.ts +0 -134
  74. package/test/migrations.test.ts +0 -244
  75. package/test/profile.test.ts +0 -159
  76. package/test/robustness.test.ts +0 -135
  77. package/test/serialize.test.ts +0 -92
  78. package/test/sessions.test.ts +0 -429
  79. package/test/svg.test.ts +0 -65
  80. package/tsconfig.json +0 -20
  81. package/wrangler.jsonc +0 -11
  82. /package/{packages/elm-ssr/bin → bin}/elm-ssr.mjs +0 -0
  83. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Action.elm +0 -0
  84. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Encode.elm +0 -0
  85. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Events.elm +0 -0
  86. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document.elm +0 -0
  87. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Attributes.elm +0 -0
  88. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Events.elm +0 -0
  89. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html.elm +0 -0
  90. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island/Shared.elm +0 -0
  91. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island.elm +0 -0
  92. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Loader.elm +0 -0
  93. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Page.elm +0 -0
  94. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Route.elm +0 -0
  95. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Runtime.elm +0 -0
  96. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
  97. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg.elm +0 -0
  98. /package/{packages/elm-ssr/lib → lib}/build.mjs +0 -0
  99. /package/{packages/elm-ssr/lib → lib}/migrate.mjs +0 -0
  100. /package/{packages/elm-ssr/lib → lib}/scaffold.mjs +0 -0
  101. /package/{packages/elm-ssr/lib → lib}/workspace.mjs +0 -0
  102. /package/{packages/elm-ssr/src → src}/app.ts +0 -0
  103. /package/{packages/elm-ssr/src → src}/backends.ts +0 -0
  104. /package/{packages/elm-ssr/src → src}/effects.ts +0 -0
  105. /package/{packages/elm-ssr/src → src}/http.ts +0 -0
  106. /package/{packages/elm-ssr/src → src}/middleware.ts +0 -0
  107. /package/{packages/elm-ssr/src → src}/migrations.ts +0 -0
  108. /package/{packages/elm-ssr/src → src}/protocol.ts +0 -0
  109. /package/{packages/elm-ssr/src → src}/render.ts +0 -0
  110. /package/{packages/elm-ssr/src → src}/request-handler.ts +0 -0
  111. /package/{packages/elm-ssr/src → src}/response-headers.ts +0 -0
  112. /package/{packages/elm-ssr/src → src}/serialize.ts +0 -0
  113. /package/{packages/elm-ssr/src → src}/sessions/crypto.ts +0 -0
  114. /package/{packages/elm-ssr/src → src}/sessions/effects.ts +0 -0
  115. /package/{packages/elm-ssr/src → src}/sessions/index.ts +0 -0
  116. /package/{packages/elm-ssr/src → src}/sessions/middleware.ts +0 -0
  117. /package/{packages/elm-ssr/src → src}/sessions/store.ts +0 -0
  118. /package/{packages/elm-ssr/src → src}/sessions/types.ts +0 -0
  119. /package/{packages/elm-ssr/src → src}/tasks.ts +0 -0
package/llms.txt DELETED
@@ -1,69 +0,0 @@
1
- # elm-ssr
2
-
3
- > Elm-first SSR library and framework for Cloudflare Workers (and Bun locally).
4
- > Pages render server-side from a custom serialisable AST (because Workers has
5
- > no DOM). Islands are stock `Browser.element` programs mounted client-side.
6
- > A backend-neutral effect surface (cache/sql/env/cookie/fetchJson/enqueue) lets
7
- > the same Elm code run on Cloudflare KV/D1 or local Redis/Postgres/SQLite by
8
- > swapping the adapter. Background work via `waitUntil` or Cloudflare Queues.
9
- > SQL-file migrations bundled. One package, `elm-ssr`, ships CLI + runtime + Elm
10
- > authoring modules.
11
-
12
- ## Start here
13
-
14
- - [README](README.md): Project overview, quickstart, full authoring example.
15
- - [CHANGELOG](CHANGELOG.md): What changed in each release.
16
- - [docs/README.md](docs/README.md): Table of contents for the topic-by-topic
17
- docs.
18
- - [docs/getting-started.md](docs/getting-started.md): Install, scaffold, build,
19
- run.
20
-
21
- ## Core concepts
22
-
23
- - [docs/routing.md](docs/routing.md): File-based routing (Routes/, dynamic
24
- segments, NotFound).
25
- - [docs/loaders-and-actions.md](docs/loaders-and-actions.md): `Loader` (data
26
- fetching for pages) and `Action` (form handling / non-GET).
27
- - [docs/effects.md](docs/effects.md): The backend-neutral effect vocabulary
28
- (`fetchJson`, `cacheGet/Put`, `query/queryOne/execute`, `env`, `getCookie`,
29
- `enqueue`).
30
- - [docs/islands.md](docs/islands.md): Standard `Browser.element` islands;
31
- `Island.embed`; cross-island bus via `ElmSsr.Island.Shared`.
32
-
33
- ## Runtime / adapters
34
-
35
- - [docs/backends.md](docs/backends.md): Composing adapters
36
- (`inMemoryEffects`, `cloudflareEffects`, `withCache`, `redisCache`,
37
- `postgresSql`).
38
- - [docs/tasks.md](docs/tasks.md): Background work — `withTasks` (`waitUntil`)
39
- and Cloudflare Queues (`withQueueProducer` / `createQueueConsumer`).
40
- - [docs/migrations.md](docs/migrations.md): SQL-file migration runner
41
- (`runMigrations`, `revertMigrations`, `listMigrations`); transactional.
42
- - [docs/middleware.md](docs/middleware.md): The middleware stack
43
- (`composeMiddleware`, requestId, timing, logging, error, HEAD).
44
- - [docs/sessions.md](docs/sessions.md): Signed-cookie sessions + CSRF.
45
- Memory + cache stores. `Loader.session` / `csrfToken` / `setSession` /
46
- `clearSession`.
47
-
48
- ## Tooling
49
-
50
- - [docs/cli.md](docs/cli.md): `elm-ssr build|new|migrate|dev|compress|routes`.
51
- - [docs/testing.md](docs/testing.md): `bun run test` (Docker-managed PG+Redis),
52
- `bun run test:unit`, `bun run test:integration`.
53
-
54
- ## Conventions for AI agents
55
-
56
- - [AGENTS.md](AGENTS.md): Hard rules and footguns for LLMs editing this repo.
57
- Read this before changing anything — especially the rule that islands use
58
- `elm/html`, pages use `ElmSsr.Html`, and there is no `Generated.*` re-export
59
- in the author-facing API.
60
-
61
- ## Source of truth
62
-
63
- - TS runtime: [packages/elm-ssr/src/](packages/elm-ssr/src/)
64
- - CLI scripts: [packages/elm-ssr/lib/](packages/elm-ssr/lib/) (bin entry at
65
- [packages/elm-ssr/bin/elm-ssr.mjs](packages/elm-ssr/bin/elm-ssr.mjs))
66
- - Elm authoring modules: [packages/elm-ssr/elm-src/ElmSsr/](packages/elm-ssr/elm-src/ElmSsr/)
67
- - Reference app: [examples/basic/](examples/basic/)
68
- - Crypto dashboard (islands + svg + http + cross-island bus):
69
- [examples/crypto-dashboard/](examples/crypto-dashboard/)
@@ -1,67 +0,0 @@
1
- # elm-ssr
2
-
3
- Elm-first SSR library and framework for Cloudflare Workers (and Bun locally).
4
- One package, three pieces:
5
-
6
- - A **CLI** (`elm-ssr build|new|migrate|dev`) that scans your routes/islands,
7
- generates the router + manifest, and runs `elm make`.
8
- - A **Worker runtime** (`createWorkerApp`, `renderApp`, effect adapters,
9
- background tasks, SQL migrations, middleware) exported via subpaths.
10
- - A set of **Elm authoring modules** under `elm-src/ElmSsr/` (Route, Loader,
11
- Action, Html, Svg, Island, Page, Document, Runtime) which the build syncs
12
- into each app's `.elm-ssr/src/ElmSsr/`.
13
-
14
- ## Install
15
-
16
- ```sh
17
- bun add elm-ssr
18
- ```
19
-
20
- Then use the CLI as `elm-ssr <command>` (or `bun elm-ssr <command>` in a
21
- workspace) and import the runtime via subpaths.
22
-
23
- ## CLI commands
24
-
25
- - **`elm-ssr build`** — scans each configured app's `src/<Namespace>/Routes/`
26
- and `Islands/`, generates `.elm-ssr/Main.elm` (the router with file-based
27
- routes + dynamic segments) and the islands manifest, syncs the Elm authoring
28
- modules into the app's `.elm-ssr/src/ElmSsr/`, and compiles via `elm make`
29
- (one combined island bundle exposing `Elm.<App>.Islands.<Name>`).
30
- - **`elm-ssr new <name>`** — scaffold a new app and register it in
31
- `elm-ssr.config.json`.
32
- - **`elm-ssr dev`** — `build` then `wrangler dev`.
33
- - **`elm-ssr compress`** — pre-compress generated bundles with gzip.
34
- - **`elm-ssr migrate <up|down|status>`** — apply / revert / inspect SQL-file
35
- migrations. `--db postgres://…`, `sqlite://path`, or a plain SQLite file path;
36
- `--dir <path>` (default `./migrations`); `--count N` (for `down`, default 1).
37
- Reads `DATABASE_URL` if `--db` is omitted.
38
-
39
- Configuration lives in `elm-ssr.config.json` at the repo root:
40
-
41
- ```jsonc
42
- {
43
- "apps": [
44
- { "name": "basic", "root": "examples/basic", "module": "Example.Basic" }
45
- ]
46
- }
47
- ```
48
-
49
- ## Runtime exports
50
-
51
- ```ts
52
- import { createWorkerApp } from "elm-ssr";
53
- import { renderApp, type CompiledElmModule } from "elm-ssr/render";
54
- import type { RouteCatalog } from "elm-ssr/http";
55
- import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";
56
- import { withCache, redisCache, postgresSql } from "elm-ssr/backends";
57
- import { withTasks, withQueueProducer, createQueueConsumer } from "elm-ssr/tasks";
58
- import { runMigrations, revertMigrations, listMigrations } from "elm-ssr/migrations";
59
- import { composeMiddleware } from "elm-ssr/middleware";
60
- ```
61
-
62
- See the [top-level README](../../README.md) for end-to-end usage and the
63
- authoring guide.
64
-
65
- ## License
66
-
67
- MIT.
@@ -1,61 +0,0 @@
1
- {
2
- "name": "elm-ssr",
3
- "version": "0.2.0",
4
- "description": "Elm-first SSR library and framework for Cloudflare Workers (and Bun): file-based routes/islands, backend-neutral effect adapters (KV/D1/Redis/Postgres), background tasks (waitUntil/Queues), SQL-file migrations, CLI scaffold + build.",
5
- "license": "MIT",
6
- "author": "Michał Majchrzak <michmajchrzak@gmail.com>",
7
- "homepage": "https://github.com/the-man-with-a-golden-mind/elm-ssr#readme",
8
- "bugs": {
9
- "url": "https://github.com/the-man-with-a-golden-mind/elm-ssr/issues"
10
- },
11
- "repository": {
12
- "type": "git",
13
- "url": "git+https://github.com/the-man-with-a-golden-mind/elm-ssr.git",
14
- "directory": "packages/elm-ssr"
15
- },
16
- "keywords": [
17
- "elm",
18
- "ssr",
19
- "server-side-rendering",
20
- "cloudflare-workers",
21
- "bun",
22
- "islands",
23
- "framework",
24
- "edge",
25
- "file-based-routing",
26
- "migrations"
27
- ],
28
- "type": "module",
29
- "scripts": {},
30
- "bin": {
31
- "elm-ssr": "bin/elm-ssr.mjs"
32
- },
33
- "main": "./src/app.ts",
34
- "exports": {
35
- ".": "./src/app.ts",
36
- "./render": "./src/render.ts",
37
- "./http": "./src/http.ts",
38
- "./effects": "./src/effects.ts",
39
- "./tasks": "./src/tasks.ts",
40
- "./backends": "./src/backends.ts",
41
- "./migrations": "./src/migrations.ts",
42
- "./middleware": "./src/middleware.ts",
43
- "./sessions": "./src/sessions/index.ts",
44
- "./response-headers": "./src/response-headers.ts",
45
- "./serialize": "./src/serialize.ts",
46
- "./protocol": "./src/protocol.ts",
47
- "./islands-runtime": "./src/client-runtime/islands.ts"
48
- },
49
- "files": [
50
- "bin",
51
- "lib",
52
- "elm-src",
53
- "src"
54
- ],
55
- "engines": {
56
- "bun": ">=1.3"
57
- },
58
- "dependencies": {
59
- "cookie": "^1.0.1"
60
- }
61
- }
@@ -1,60 +0,0 @@
1
- import { createExampleWorker, renderPath } from "../examples/basic/runtime.ts";
2
-
3
- const worker = createExampleWorker({ log: () => {} });
4
-
5
- const quantile = (values, fraction) => {
6
- const sorted = [...values].sort((left, right) => left - right);
7
- const index = Math.min(sorted.length - 1, Math.floor(sorted.length * fraction));
8
- return sorted[index];
9
- };
10
-
11
- const formatMs = (value) => `${value.toFixed(2)} ms`;
12
- const formatOps = (value) => `${value.toFixed(1)} ops/s`;
13
-
14
- const runCase = async (label, iterations, task) => {
15
- const samples = [];
16
-
17
- for (let index = 0; index < iterations; index += 1) {
18
- const startedAt = performance.now();
19
- await task();
20
- samples.push(performance.now() - startedAt);
21
- }
22
-
23
- const total = samples.reduce((sum, sample) => sum + sample, 0);
24
- const avg = total / samples.length;
25
- const p95 = quantile(samples, 0.95);
26
-
27
- return {
28
- label,
29
- iterations,
30
- avg,
31
- p95,
32
- throughput: 1000 / avg
33
- };
34
- };
35
-
36
- const cases = [
37
- await runCase("renderPath(/)", 75, async () => {
38
- await renderPath("/");
39
- }),
40
- await runCase("renderPath(/counter)", 75, async () => {
41
- await renderPath("/counter");
42
- }),
43
- await runCase("worker.fetch(/api/health)", 150, async () => {
44
- const response = await worker.fetch(new Request("https://bench.local/api/health"));
45
- await response.text();
46
- }),
47
- await runCase("worker.fetch(/counter)", 75, async () => {
48
- const response = await worker.fetch(new Request("https://bench.local/counter"));
49
- await response.text();
50
- })
51
- ];
52
-
53
- console.log("Elm SSR benchmark");
54
- console.log("==================");
55
-
56
- for (const result of cases) {
57
- console.log(
58
- `${result.label}\n iterations: ${result.iterations}\n avg: ${formatMs(result.avg)}\n p95: ${formatMs(result.p95)}\n throughput: ${formatOps(result.throughput)}`
59
- );
60
- }
@@ -1,81 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { createExampleWorker, worker } from "../examples/basic/runtime";
3
- import { inMemoryEffects } from "elm-ssr/effects";
4
-
5
- // Phase 2: server Actions. A POST form submission runs the route's action, which
6
- // validates, performs a server effect, then redirects (Post/Redirect/Get). All
7
- // of this works with no client JavaScript.
8
-
9
- const postEcho = (body: string) =>
10
- worker.fetch(
11
- new Request("https://example.com/echo", {
12
- method: "POST",
13
- headers: { "content-type": "application/x-www-form-urlencoded" },
14
- body
15
- })
16
- );
17
-
18
- describe("server actions (POST -> action -> redirect)", () => {
19
- it("runs an effectful action and redirects (PRG), no client JS", async () => {
20
- const response = await postEcho("message=hello");
21
- expect(response.status).toBe(302);
22
- // region comes from the action's server effect, so its presence proves the
23
- // effect ran inside the action before the redirect.
24
- expect(response.headers.get("location")).toBe("/echo?status=received&region=edge");
25
- });
26
-
27
- it("fails validation with 422 when the field is empty", async () => {
28
- const response = await postEcho("message=");
29
- expect(response.status).toBe(422);
30
- });
31
-
32
- it("fails validation with 422 when the field is whitespace only", async () => {
33
- const response = await postEcho("message=%20%20");
34
- expect(response.status).toBe(422);
35
- });
36
-
37
- it("fails validation with 422 when the field is missing", async () => {
38
- const response = await postEcho("");
39
- expect(response.status).toBe(422);
40
- });
41
-
42
- it("renders the form on GET and ships no client JS", async () => {
43
- const response = await worker.fetch(new Request("https://example.com/echo"));
44
- expect(response.status).toBe(200);
45
- const html = await response.text();
46
- expect(html).toContain('<form class="echo-form" method="post" action="/echo">');
47
- expect(html).toContain('name="message"');
48
- expect(html).not.toContain("/__elm-ssr/islands.js");
49
- });
50
-
51
- it("shows the confirmation page after the redirect", async () => {
52
- const response = await worker.fetch(new Request("https://example.com/echo?status=received&region=edge"));
53
- expect(response.status).toBe(200);
54
- const html = await response.text();
55
- expect(html).toContain("Message received");
56
- expect(html).toContain("region: edge");
57
- });
58
-
59
- it("still rejects non-POST/GET methods", async () => {
60
- const response = await worker.fetch(new Request("https://example.com/echo", { method: "PUT" }));
61
- expect(response.status).toBe(405);
62
- });
63
-
64
- it("propagates an effect failure from the action's loader (network down -> 502)", async () => {
65
- const effects = inMemoryEffects({
66
- fetchJson: () => {
67
- throw new Error("network down");
68
- }
69
- });
70
- const app = createExampleWorker({ effects });
71
-
72
- const response = await app.fetch(
73
- new Request("https://example.com/echo", {
74
- method: "POST",
75
- headers: { "content-type": "application/x-www-form-urlencoded" },
76
- body: "message=hello"
77
- })
78
- );
79
- expect(response.status).toBe(502);
80
- });
81
- });
@@ -1,173 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { defaultEffectRunner, inMemoryEffects } from "elm-ssr/effects";
3
- import { postgresSql, redisCache, withCache, type CacheClient, type SqlClient } from "elm-ssr/backends";
4
- import { createQueueConsumer, withQueueProducer, type QueueBatch } from "elm-ssr/tasks";
5
-
6
- // Unit-level coverage for the adapter glue: cookie reading, redisCache over a
7
- // fake Redis-shaped client, postgresSql over a fake SQL client, and the CF
8
- // Queue producer + consumer. No external servers needed.
9
-
10
- describe("cookie effect (getCookie)", () => {
11
- const runEffect = (header: string | null, name: string) =>
12
- inMemoryEffects()(
13
- { kind: "cookie", payload: { name } },
14
- { request: new Request("https://example.com/", { headers: header ? { cookie: header } : {} }) }
15
- );
16
-
17
- it("returns the requested cookie value when present", async () => {
18
- const result = await runEffect("session=abc; theme=dark", "theme");
19
- expect(result).toEqual({ ok: true, value: "dark" });
20
- });
21
-
22
- it("returns null when the cookie is absent", async () => {
23
- const result = await runEffect("session=abc", "theme");
24
- expect(result).toEqual({ ok: true, value: null });
25
- });
26
-
27
- it("returns null when no Cookie header is sent", async () => {
28
- const result = await runEffect(null, "anything");
29
- expect(result).toEqual({ ok: true, value: null });
30
- });
31
-
32
- it("URL-decodes the value (default behavior of the cookie parser)", async () => {
33
- const result = await runEffect("user=John%20Doe", "user");
34
- expect(result).toEqual({ ok: true, value: "John Doe" });
35
- });
36
- });
37
-
38
- describe("redisCache + withCache (Redis adapter)", () => {
39
- const fakeRedis = (): CacheClient & { store: Map<string, string> } => {
40
- const store = new Map<string, string>();
41
- return {
42
- store,
43
- get: async (key) => store.get(key) ?? null,
44
- set: async (key, value) => {
45
- store.set(key, value);
46
- }
47
- };
48
- };
49
-
50
- it("round-trips cachePut → cacheGet via the client", async () => {
51
- const client = fakeRedis();
52
- const runner = withCache(defaultEffectRunner, redisCache(client));
53
-
54
- const put = await runner(
55
- { kind: "cachePut", payload: { key: "k", value: { hello: "world" } } },
56
- {}
57
- );
58
- expect(put).toEqual({ ok: true, value: null });
59
-
60
- const get = await runner({ kind: "cacheGet", payload: { key: "k" } }, {});
61
- expect(get).toEqual({ ok: true, value: { hello: "world" } });
62
- // The underlying client received a JSON-encoded string.
63
- expect(client.store.get("k")).toBe('{"hello":"world"}');
64
- });
65
-
66
- it("returns null on a miss", async () => {
67
- const runner = withCache(defaultEffectRunner, redisCache(fakeRedis()));
68
- const get = await runner({ kind: "cacheGet", payload: { key: "absent" } }, {});
69
- expect(get).toEqual({ ok: true, value: null });
70
- });
71
-
72
- it("forwards non-cache effects to the inner runner", async () => {
73
- const runner = withCache(defaultEffectRunner, redisCache(fakeRedis()));
74
- const result = await runner({ kind: "cookie", payload: { name: "x" } }, { request: new Request("https://example.com/") });
75
- expect(result.ok).toBe(true); // cookie handled by defaultEffectRunner
76
- });
77
- });
78
-
79
- describe("postgresSql (SQL adapter)", () => {
80
- const fakeSql = (rows: unknown[], rowCount: number): SqlClient & { calls: Array<{ sql: string; params: unknown[] }> } => {
81
- const calls: Array<{ sql: string; params: unknown[] }> = [];
82
- return {
83
- calls,
84
- run: async (sql, params) => {
85
- calls.push({ sql, params });
86
- return { rows, rowCount };
87
- }
88
- };
89
- };
90
-
91
- it("maps mode 'all' to the full row list", async () => {
92
- const client = fakeSql([{ id: 1 }, { id: 2 }], 2);
93
- const handler = postgresSql(client);
94
- const value = await handler({ sql: "SELECT * FROM t WHERE x = ?", params: [42], mode: "all" });
95
- expect(value).toEqual([{ id: 1 }, { id: 2 }]);
96
- expect(client.calls).toEqual([{ sql: "SELECT * FROM t WHERE x = ?", params: [42] }]);
97
- });
98
-
99
- it("maps mode 'first' to the first row (or null)", async () => {
100
- expect(await postgresSql(fakeSql([{ id: 1 }], 1))({ sql: "x", params: [], mode: "first" })).toEqual({ id: 1 });
101
- expect(await postgresSql(fakeSql([], 0))({ sql: "x", params: [], mode: "first" })).toBeNull();
102
- });
103
-
104
- it("maps mode 'run' to { rowsAffected }", async () => {
105
- const handler = postgresSql(fakeSql([], 3));
106
- const value = await handler({ sql: "DELETE FROM t", params: [], mode: "run" });
107
- expect(value).toEqual({ rowsAffected: 3 });
108
- });
109
- });
110
-
111
- describe("withQueueProducer + createQueueConsumer (CF Queues)", () => {
112
- it("sends an enqueue effect to the configured queue binding", async () => {
113
- const sent: unknown[] = [];
114
- const runner = withQueueProducer(defaultEffectRunner, { queueBinding: "JOBS" });
115
-
116
- const result = await runner(
117
- { kind: "enqueue", payload: { task: "ship", payload: { id: 1 } } },
118
- { env: { JOBS: { send: async (m: unknown) => { sent.push(m); } } } }
119
- );
120
-
121
- expect(result).toEqual({ ok: true, value: null });
122
- expect(sent).toEqual([{ task: "ship", payload: { id: 1 } }]);
123
- });
124
-
125
- it("fails clearly when the queue binding is missing", async () => {
126
- const runner = withQueueProducer(defaultEffectRunner, { queueBinding: "JOBS" });
127
- const result = await runner({ kind: "enqueue", payload: { task: "ship", payload: null } }, { env: {} });
128
- expect(result.ok).toBe(false);
129
- expect(result.error).toContain("JOBS");
130
- });
131
-
132
- it("forwards non-enqueue effects to the inner runner", async () => {
133
- const runner = withQueueProducer(defaultEffectRunner);
134
- const result = await runner({ kind: "cookie", payload: { name: "x" } }, { request: new Request("https://example.com/", { headers: { cookie: "x=1" } }) });
135
- expect(result).toEqual({ ok: true, value: "1" });
136
- });
137
-
138
- it("consumer dispatches messages to handlers and acks; retries on missing handler or error", async () => {
139
- const calls: Array<{ name: string; payload: unknown }> = [];
140
- const acked: string[] = [];
141
- const retried: string[] = [];
142
-
143
- const make = (name: string, body: unknown) => ({
144
- body,
145
- ack: () => acked.push(name),
146
- retry: () => retried.push(name)
147
- });
148
-
149
- const batch: QueueBatch = {
150
- queue: "JOBS",
151
- messages: [
152
- make("ok", { task: "doThing", payload: { id: 1 } }),
153
- make("missing", { task: "noSuchTask", payload: null }),
154
- make("boom", { task: "explode", payload: null })
155
- ]
156
- };
157
-
158
- const consumer = createQueueConsumer({
159
- doThing: (payload) => {
160
- calls.push({ name: "doThing", payload });
161
- },
162
- explode: () => {
163
- throw new Error("kaboom");
164
- }
165
- });
166
-
167
- await consumer(batch);
168
-
169
- expect(calls).toEqual([{ name: "doThing", payload: { id: 1 } }]);
170
- expect(acked).toEqual(["ok"]);
171
- expect(retried.sort()).toEqual(["boom", "missing"]);
172
- });
173
- });
@@ -1,75 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
- import { createWorkerApp } from "elm-ssr";
3
- import { routes, createFlags, exampleEffects } from "../examples/basic/runtime";
4
- // @ts-expect-error Generated at build time.
5
- import ElmRuntime from "../generated/examples/basic/app.mjs";
6
- import { islands, bundleSource } from "../generated/examples/basic/islands-manifest";
7
-
8
- const worker = createWorkerApp({
9
- elmModule: ElmRuntime,
10
- islands,
11
- islandsBundle: bundleSource,
12
- stylesheet: "",
13
- routes,
14
- createFlags,
15
- effects: exampleEffects
16
- });
17
-
18
- describe("Advanced Robustness: Server Actions", () => {
19
- it("handles empty POST body gracefully", async () => {
20
- const response = await worker.fetch(new Request("https://example.com/", {
21
- method: "POST",
22
- headers: { "Content-Type": "application/x-www-form-urlencoded" }
23
- }));
24
- // Should fall back to the action handler which currently returns 405 for root
25
- expect(response.status).toBe(405);
26
- });
27
-
28
- it("handles malformed JSON in POST body", async () => {
29
- const response = await worker.fetch(new Request("https://example.com/", {
30
- method: "POST",
31
- headers: { "Content-Type": "application/json" },
32
- body: "{ invalid json }"
33
- }));
34
- expect(response.status).toBe(405); // Still hits the action handler
35
- });
36
-
37
- it("ignores unknown Content-Types for form data", async () => {
38
- const response = await worker.fetch(new Request("https://example.com/", {
39
- method: "POST",
40
- headers: { "Content-Type": "text/plain" },
41
- body: "just some text"
42
- }));
43
- expect(response.status).toBe(405);
44
- });
45
- });
46
-
47
- describe("Advanced Robustness: Soft Routing API", () => {
48
- it("returns 400 for /api/render without a path", async () => {
49
- const response = await worker.fetch(new Request("https://example.com/api/render"));
50
- const body = await response.json() as { error: string };
51
- expect(response.status).toBe(400);
52
- expect(body.error).toBe("invalid_path");
53
- });
54
-
55
- it("returns 400 for /api/render with relative path", async () => {
56
- const response = await worker.fetch(new Request("https://example.com/api/render?path=relative"));
57
- expect(response.status).toBe(400);
58
- });
59
-
60
- it("handles routes that redirect in the render API", async () => {
61
- // We don't have a redirect route in example yet, but we can check the logic
62
- // in request-handler.ts: if (rendered.redirect) { return json({ redirect: ... }) }
63
- });
64
- });
65
-
66
- describe("Advanced Robustness: Global Event Bus (Worker-side logic)", () => {
67
- // Since the bus is client-side, we verify the Worker provides the necessary hooks
68
- it("injects the event bus wiring in the islands runtime", async () => {
69
- const response = await worker.fetch(new Request("https://example.com/__elm-ssr/islands.js"));
70
- const source = await response.text();
71
- expect(source).toContain("elm-ssr-broadcast");
72
- expect(source).toContain("window.dispatchEvent");
73
- expect(source).toContain("window.addEventListener");
74
- });
75
- });