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.
- package/README.md +48 -342
- package/elm-src/ElmSsr/Island/Sse.elm +151 -0
- package/package.json +53 -24
- package/{packages/elm-ssr/src → src}/client-runtime/islands.ts +113 -15
- 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/Loader.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
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
|
-
}
|
package/scripts/benchmark.mjs
DELETED
|
@@ -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
|
-
}
|
package/test/action.test.ts
DELETED
|
@@ -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®ion=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®ion=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
|
-
});
|
package/test/adapters.test.ts
DELETED
|
@@ -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
|
-
});
|