elm-ssr 0.1.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 +67 -0
- package/bin/elm-ssr.mjs +102 -0
- package/elm-src/ElmSsr/Action.elm +210 -0
- package/elm-src/ElmSsr/Document/Encode.elm +83 -0
- package/elm-src/ElmSsr/Document/Events.elm +125 -0
- package/elm-src/ElmSsr/Document.elm +26 -0
- package/elm-src/ElmSsr/Html/Attributes.elm +344 -0
- package/elm-src/ElmSsr/Html/Events.elm +95 -0
- package/elm-src/ElmSsr/Html.elm +706 -0
- package/elm-src/ElmSsr/Island/Shared.elm +38 -0
- package/elm-src/ElmSsr/Island.elm +49 -0
- package/elm-src/ElmSsr/Loader.elm +297 -0
- package/elm-src/ElmSsr/Page.elm +102 -0
- package/elm-src/ElmSsr/Route.elm +136 -0
- package/elm-src/ElmSsr/Runtime.elm +170 -0
- package/elm-src/ElmSsr/Svg/Attributes.elm +1208 -0
- package/elm-src/ElmSsr/Svg.elm +309 -0
- package/lib/build.mjs +256 -0
- package/lib/migrate.mjs +146 -0
- package/lib/scaffold.mjs +472 -0
- package/lib/workspace.mjs +21 -0
- package/package.json +60 -0
- package/src/app.ts +74 -0
- package/src/backends.ts +116 -0
- package/src/client-runtime/islands.ts +247 -0
- package/src/effects.ts +267 -0
- package/src/http.ts +86 -0
- package/src/middleware.ts +104 -0
- package/src/migrations.ts +225 -0
- package/src/protocol.ts +119 -0
- package/src/render.ts +111 -0
- package/src/request-handler.ts +208 -0
- package/src/response-headers.ts +18 -0
- package/src/serialize.ts +47 -0
- package/src/tasks.ts +139 -0
package/src/backends.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { EffectRunner, SqlQuery } from "./effects";
|
|
2
|
+
|
|
3
|
+
// === Cache (KV / Redis / …) ===
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A driver-agnostic cache backend. Stores arbitrary JSON-serialisable values
|
|
7
|
+
* with an optional TTL in seconds. Returns `null` on a miss.
|
|
8
|
+
*/
|
|
9
|
+
export interface CacheBackend {
|
|
10
|
+
get(key: string): Promise<unknown>;
|
|
11
|
+
put(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wrap an effect runner so `cacheGet`/`cachePut` are served by `backend`. All
|
|
16
|
+
* other effects pass through to the wrapped runner unchanged. Compose with
|
|
17
|
+
* `withTasks`, `withQueueProducer`, etc.:
|
|
18
|
+
*
|
|
19
|
+
* withTasks(
|
|
20
|
+
* withCache(inMemoryEffects({ sql: postgresSql(pg) }), redisCache(redis)),
|
|
21
|
+
* { sendEmail }
|
|
22
|
+
* )
|
|
23
|
+
*/
|
|
24
|
+
export const withCache = (runner: EffectRunner, backend: CacheBackend): EffectRunner =>
|
|
25
|
+
async (effect, context) => {
|
|
26
|
+
if (effect.kind === "cacheGet") {
|
|
27
|
+
const value = await backend.get(String(effect.payload.key));
|
|
28
|
+
return { ok: true, value: value ?? null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (effect.kind === "cachePut") {
|
|
32
|
+
const ttl = typeof effect.payload.ttlSeconds === "number" ? effect.payload.ttlSeconds : undefined;
|
|
33
|
+
await backend.put(String(effect.payload.key), effect.payload.value ?? null, ttl);
|
|
34
|
+
return { ok: true, value: null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return runner(effect, context);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Minimal Redis-like client interface — values are strings; the adapter handles
|
|
42
|
+
* JSON encoding/decoding. Wire any matching client:
|
|
43
|
+
*
|
|
44
|
+
* // Bun built-in:
|
|
45
|
+
* redisCache({
|
|
46
|
+
* get: (k) => Bun.redis.get(k),
|
|
47
|
+
* set: (k, v, ttl) => ttl !== undefined ? Bun.redis.set(k, v, "EX", ttl) : Bun.redis.set(k, v)
|
|
48
|
+
* })
|
|
49
|
+
*
|
|
50
|
+
* // ioredis:
|
|
51
|
+
* redisCache({
|
|
52
|
+
* get: (k) => ioredis.get(k),
|
|
53
|
+
* set: (k, v, ttl) => ttl !== undefined ? ioredis.set(k, v, "EX", ttl) : ioredis.set(k, v)
|
|
54
|
+
* })
|
|
55
|
+
*/
|
|
56
|
+
export interface CacheClient {
|
|
57
|
+
get(key: string): Promise<string | null>;
|
|
58
|
+
set(key: string, value: string, ttlSeconds?: number): Promise<unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A `CacheBackend` backed by a Redis-like client. JSON-encodes values on store. */
|
|
62
|
+
export const redisCache = (client: CacheClient): CacheBackend => ({
|
|
63
|
+
get: async (key) => {
|
|
64
|
+
const raw = await client.get(key);
|
|
65
|
+
if (raw === null) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
return raw;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
put: async (key, value, ttlSeconds) => {
|
|
75
|
+
await client.set(key, JSON.stringify(value), ttlSeconds);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// === SQL (D1 / Postgres / SQLite / …) ===
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Minimal SQL client interface. Wire any driver:
|
|
83
|
+
*
|
|
84
|
+
* // Bun built-in (Bun.sql):
|
|
85
|
+
* postgresSql({
|
|
86
|
+
* run: async (q, p) => {
|
|
87
|
+
* const rows = await Bun.sql.unsafe(q, p);
|
|
88
|
+
* return { rows: [...rows], rowCount: (rows as { count?: number }).count ?? rows.length };
|
|
89
|
+
* }
|
|
90
|
+
* })
|
|
91
|
+
*
|
|
92
|
+
* // node-postgres:
|
|
93
|
+
* postgresSql({
|
|
94
|
+
* run: (q, p) => pool.query(q, p).then((r) => ({ rows: r.rows, rowCount: r.rowCount ?? 0 }))
|
|
95
|
+
* })
|
|
96
|
+
*/
|
|
97
|
+
export interface SqlClient {
|
|
98
|
+
run(sql: string, params: unknown[]): Promise<{ rows: unknown[]; rowCount: number }>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A SqlQuery handler (use as `inMemoryEffects({ sql: postgresSql(client) })`)
|
|
103
|
+
* backed by any client matching `SqlClient`. Maps `query`/`queryOne`/`execute`
|
|
104
|
+
* to the client's single `run` method.
|
|
105
|
+
*/
|
|
106
|
+
export const postgresSql = (client: SqlClient) =>
|
|
107
|
+
async ({ sql, params, mode }: SqlQuery): Promise<unknown> => {
|
|
108
|
+
const { rows, rowCount } = await client.run(sql, params);
|
|
109
|
+
if (mode === "first") {
|
|
110
|
+
return rows[0] ?? null;
|
|
111
|
+
}
|
|
112
|
+
if (mode === "run") {
|
|
113
|
+
return { rowsAffected: rowCount };
|
|
114
|
+
}
|
|
115
|
+
return rows;
|
|
116
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { IslandMetadata } from "../app";
|
|
2
|
+
|
|
3
|
+
// The island client runtime, as a source string shipped to the browser. The
|
|
4
|
+
// core is factored into `createIslandsRuntime(deps)` so it can be exercised in
|
|
5
|
+
// tests against a real DOM with an injected Elm bundle; the browser tail just
|
|
6
|
+
// wires real globals and kicks it off.
|
|
7
|
+
export const islandsCoreSource = `
|
|
8
|
+
function lookupModule(elm, moduleName) {
|
|
9
|
+
return moduleName.split(".").reduce((current, part) => (current ? current[part] : undefined), elm);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function createIslandsRuntime(deps) {
|
|
13
|
+
const document = deps.document;
|
|
14
|
+
const window = deps.window;
|
|
15
|
+
const manifest = deps.manifest;
|
|
16
|
+
const loadBundle = deps.loadBundle;
|
|
17
|
+
|
|
18
|
+
const persistentIslands = new Map(); // id -> live marker element (kept across navigations)
|
|
19
|
+
const cleanups = new Map(); // marker element -> () => void (remove its bus listener)
|
|
20
|
+
|
|
21
|
+
const wireBus = (app, marker) => {
|
|
22
|
+
if (!app || !app.ports) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (app.ports.broadcastOut) {
|
|
27
|
+
app.ports.broadcastOut.subscribe((event) => {
|
|
28
|
+
window.dispatchEvent(new window.CustomEvent("elm-ssr-broadcast", { detail: event }));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (app.ports.broadcastIn) {
|
|
33
|
+
const handler = (event) => app.ports.broadcastIn.send(event.detail);
|
|
34
|
+
window.addEventListener("elm-ssr-broadcast", handler);
|
|
35
|
+
cleanups.set(marker, () => window.removeEventListener("elm-ssr-broadcast", handler));
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const bootMarker = (ElmModule, marker) => {
|
|
40
|
+
const id = marker.getAttribute("data-elmssr-id");
|
|
41
|
+
|
|
42
|
+
// A live persistent instance already exists: move it into this marker's
|
|
43
|
+
// place instead of mounting a fresh one, preserving its state.
|
|
44
|
+
if (id && persistentIslands.has(id)) {
|
|
45
|
+
const live = persistentIslands.get(id);
|
|
46
|
+
if (live !== marker) {
|
|
47
|
+
marker.replaceWith(live);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const name = marker.getAttribute("data-elmssr-island");
|
|
53
|
+
const entry = name ? manifest[name] : undefined;
|
|
54
|
+
|
|
55
|
+
if (!entry) {
|
|
56
|
+
throw new Error("Unknown island: " + name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const islandModule = lookupModule(ElmModule, entry.module);
|
|
60
|
+
|
|
61
|
+
if (!islandModule || typeof islandModule.init !== "function") {
|
|
62
|
+
throw new Error("Elm island module did not expose init(): " + entry.module);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const flags = JSON.parse(marker.getAttribute("data-elmssr-props") || "{}");
|
|
66
|
+
|
|
67
|
+
// Browser.element replaces the node it mounts into, so mount into a child:
|
|
68
|
+
// the <elm-ssr-island> marker stays in the DOM and is the persistent unit.
|
|
69
|
+
while (marker.firstChild) {
|
|
70
|
+
marker.removeChild(marker.firstChild);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const mount = document.createElement("div");
|
|
74
|
+
marker.appendChild(mount);
|
|
75
|
+
|
|
76
|
+
const app = islandModule.init({ node: mount, flags });
|
|
77
|
+
marker.setAttribute("data-elmssr-booted", "true");
|
|
78
|
+
|
|
79
|
+
if (id) {
|
|
80
|
+
persistentIslands.set(id, marker);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
wireBus(app, marker);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const findMarkers = (root) => Array.prototype.slice.call(root.getElementsByTagName("elm-ssr-island"));
|
|
87
|
+
|
|
88
|
+
const bootIslands = async () => {
|
|
89
|
+
const markers = findMarkers(document);
|
|
90
|
+
|
|
91
|
+
if (markers.length === 0) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ElmModule = await loadBundle();
|
|
96
|
+
|
|
97
|
+
for (const marker of markers) {
|
|
98
|
+
if (marker.getAttribute("data-elmssr-booted")) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
bootMarker(ElmModule, marker);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error("elm-ssr: failed to boot island", marker.getAttribute("data-elmssr-island"), error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Tear down islands that are about to be removed. Persistent islands are kept
|
|
111
|
+
// alive (their refs live in persistentIslands and transfer to the next page),
|
|
112
|
+
// so only non-persistent ones are cleaned up. Elm has no program shutdown, so
|
|
113
|
+
// the controllable leak is the global bus listener — remove it here.
|
|
114
|
+
const cleanupRemovedIslands = (container) => {
|
|
115
|
+
for (const marker of findMarkers(container)) {
|
|
116
|
+
const id = marker.getAttribute("data-elmssr-id");
|
|
117
|
+
|
|
118
|
+
if (id && persistentIslands.get(id) === marker) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const cleanup = cleanups.get(marker);
|
|
123
|
+
|
|
124
|
+
if (cleanup) {
|
|
125
|
+
cleanup();
|
|
126
|
+
cleanups.delete(marker);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const managedHeadNodes = (head) => {
|
|
132
|
+
const metas = Array.prototype.slice.call(head.getElementsByTagName("meta"));
|
|
133
|
+
const links = Array.prototype.slice
|
|
134
|
+
.call(head.getElementsByTagName("link"))
|
|
135
|
+
.filter((link) => link.getAttribute("rel") === "stylesheet");
|
|
136
|
+
return metas.concat(links);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const syncHead = (sourceDoc) => {
|
|
140
|
+
document.title = sourceDoc.title;
|
|
141
|
+
|
|
142
|
+
for (const node of managedHeadNodes(document.head)) {
|
|
143
|
+
node.remove();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const node of managedHeadNodes(sourceDoc.head)) {
|
|
147
|
+
const copy = document.createElement(node.tagName);
|
|
148
|
+
for (const name of node.getAttributeNames()) {
|
|
149
|
+
copy.setAttribute(name, node.getAttribute(name));
|
|
150
|
+
}
|
|
151
|
+
document.head.appendChild(copy);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const navigate = async (url, push = true) => {
|
|
156
|
+
try {
|
|
157
|
+
const response = await window.fetch("/api/render?path=" + encodeURIComponent(url.pathname + url.search));
|
|
158
|
+
const result = await response.json();
|
|
159
|
+
|
|
160
|
+
if (result.redirect) {
|
|
161
|
+
window.location.href = result.redirect;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!result.html) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const incoming = new window.DOMParser().parseFromString(result.html, "text/html");
|
|
170
|
+
const newRoot = incoming.getElementById("elm-ssr-root");
|
|
171
|
+
const currentRoot = document.getElementById("elm-ssr-root");
|
|
172
|
+
|
|
173
|
+
if (!newRoot || !currentRoot) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
syncHead(incoming);
|
|
178
|
+
cleanupRemovedIslands(currentRoot);
|
|
179
|
+
currentRoot.innerHTML = newRoot.innerHTML;
|
|
180
|
+
|
|
181
|
+
if (push) {
|
|
182
|
+
window.history.pushState({}, "", url.href);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await bootIslands();
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.error("elm-ssr: navigation failed", error);
|
|
188
|
+
window.location.href = url.href;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const handleLinkClick = (event) => {
|
|
193
|
+
const target = event.target;
|
|
194
|
+
let link = target && target.nodeType === 1 ? target : target ? target.parentElement : null;
|
|
195
|
+
|
|
196
|
+
while (link && link.tagName !== "A") {
|
|
197
|
+
link = link.parentElement;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!link) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (link.getAttribute("target") === "_blank" || link.getAttribute("download") !== null) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const url = new URL(link.href);
|
|
213
|
+
|
|
214
|
+
if (url.origin !== window.location.origin) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Same page (hash-only / in-page anchor): let the browser handle it.
|
|
219
|
+
if (url.pathname === window.location.pathname && url.search === window.location.search) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
event.preventDefault();
|
|
224
|
+
navigate(url);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return { bootIslands, navigate, handleLinkClick, persistentIslands, cleanups };
|
|
228
|
+
}
|
|
229
|
+
`;
|
|
230
|
+
|
|
231
|
+
const encodeManifest = (islands: Record<string, IslandMetadata>): string => JSON.stringify(islands);
|
|
232
|
+
|
|
233
|
+
export const createIslandsRuntimeSource = (islands: Record<string, IslandMetadata>): string => `
|
|
234
|
+
${islandsCoreSource}
|
|
235
|
+
|
|
236
|
+
const runtime = createIslandsRuntime({
|
|
237
|
+
document,
|
|
238
|
+
window,
|
|
239
|
+
manifest: ${encodeManifest(islands)},
|
|
240
|
+
loadBundle: () => import("/__elm-ssr/islands-bundle.js").then((module) => module.default)
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
window.addEventListener("click", runtime.handleLinkClick);
|
|
244
|
+
window.addEventListener("popstate", () => runtime.navigate(new URL(window.location.href), false));
|
|
245
|
+
|
|
246
|
+
runtime.bootIslands();
|
|
247
|
+
`;
|
package/src/effects.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { parse as parseCookieHeader } from "cookie";
|
|
2
|
+
|
|
3
|
+
export interface LoaderEffect {
|
|
4
|
+
kind: string;
|
|
5
|
+
payload: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface LoaderEffectResult {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
value?: unknown;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Per-request context handed to the effect runner (e.g. Cloudflare bindings). */
|
|
15
|
+
export interface EffectContext {
|
|
16
|
+
env?: Record<string, unknown>;
|
|
17
|
+
request?: Request;
|
|
18
|
+
/** Keeps the runtime alive for background work scheduled after the response. */
|
|
19
|
+
waitUntil?: (promise: Promise<unknown>) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Runs a single side effect requested by an Elm loader or action. This is the
|
|
24
|
+
* only place a loader's IO actually happens — the Elm side just describes what
|
|
25
|
+
* it needs, with backend-neutral kinds (`cacheGet`, `query`, …) so the same app
|
|
26
|
+
* runs on Cloudflare (KV/D1) or locally (Redis/SQLite) by swapping the adapter.
|
|
27
|
+
*/
|
|
28
|
+
export type EffectRunner = (effect: LoaderEffect, context: EffectContext) => Promise<LoaderEffectResult>;
|
|
29
|
+
|
|
30
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
31
|
+
typeof value === "object" && value !== null;
|
|
32
|
+
|
|
33
|
+
export const normalizeEffect = (value: unknown): LoaderEffect => {
|
|
34
|
+
if (isRecord(value) && typeof value.kind === "string") {
|
|
35
|
+
return {
|
|
36
|
+
kind: value.kind,
|
|
37
|
+
payload: isRecord(value.payload) ? value.payload : {}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { kind: "unknown", payload: {} };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const cookieEffect = (effect: LoaderEffect, context: EffectContext): LoaderEffectResult => {
|
|
45
|
+
const header = context.request?.headers.get("cookie") ?? "";
|
|
46
|
+
const cookies = header ? parseCookieHeader(header) : {};
|
|
47
|
+
const name = String(effect.payload.name);
|
|
48
|
+
const value = cookies[name];
|
|
49
|
+
return { ok: true, value: typeof value === "string" ? value : null };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const fetchJsonEffect = async (effect: LoaderEffect): Promise<LoaderEffectResult> => {
|
|
53
|
+
const url = typeof effect.payload.url === "string" ? effect.payload.url : "";
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(url);
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
return { ok: false, error: `fetchJson received ${response.status} from ${url}` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { ok: true, value: await response.json() };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { ok: false, error: `fetchJson failed for ${url}: ${String(error)}` };
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The fallback runner: `fetchJson` works everywhere, but backend effects report
|
|
70
|
+
* that no adapter is configured rather than silently succeeding.
|
|
71
|
+
*/
|
|
72
|
+
export const defaultEffectRunner: EffectRunner = async (effect, context) => {
|
|
73
|
+
if (effect.kind === "fetchJson") {
|
|
74
|
+
return fetchJsonEffect(effect);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (effect.kind === "cookie") {
|
|
78
|
+
return cookieEffect(effect, context);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: `Effect "${effect.kind}" has no configured backend. Pass an adapter (cloudflareEffects or inMemoryEffects) as \`effects\`.`
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Minimal shapes of the Cloudflare bindings we use, to avoid a types dependency.
|
|
88
|
+
interface KVNamespaceLike {
|
|
89
|
+
get(key: string, type: "json"): Promise<unknown>;
|
|
90
|
+
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
|
|
91
|
+
delete(key: string): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface D1ResultLike {
|
|
95
|
+
results?: unknown[];
|
|
96
|
+
meta?: { changes?: number };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface D1StatementLike {
|
|
100
|
+
bind(...values: unknown[]): D1StatementLike;
|
|
101
|
+
all(): Promise<D1ResultLike>;
|
|
102
|
+
first(): Promise<unknown>;
|
|
103
|
+
run(): Promise<D1ResultLike>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface D1DatabaseLike {
|
|
107
|
+
prepare(sql: string): D1StatementLike;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface CloudflareEffectsConfig {
|
|
111
|
+
/** KV binding used for `cacheGet`/`cachePut`. Default `"CACHE"`. */
|
|
112
|
+
cacheBinding?: string;
|
|
113
|
+
/** D1 binding used for `query`/`queryOne`/`execute`. Default `"DB"`. */
|
|
114
|
+
dbBinding?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sqlParams = (effect: LoaderEffect): unknown[] =>
|
|
118
|
+
Array.isArray(effect.payload.params) ? effect.payload.params : [];
|
|
119
|
+
|
|
120
|
+
/** Cloudflare adapter: maps neutral effects to KV, D1, and env bindings. */
|
|
121
|
+
export const cloudflareEffects = (config: CloudflareEffectsConfig = {}): EffectRunner => {
|
|
122
|
+
const cacheBinding = config.cacheBinding ?? "CACHE";
|
|
123
|
+
const dbBinding = config.dbBinding ?? "DB";
|
|
124
|
+
|
|
125
|
+
return async (effect, context) => {
|
|
126
|
+
const env = context.env ?? {};
|
|
127
|
+
|
|
128
|
+
switch (effect.kind) {
|
|
129
|
+
case "fetchJson":
|
|
130
|
+
return fetchJsonEffect(effect);
|
|
131
|
+
|
|
132
|
+
case "cacheGet": {
|
|
133
|
+
const kv = env[cacheBinding] as KVNamespaceLike | undefined;
|
|
134
|
+
if (!kv) {
|
|
135
|
+
return { ok: false, error: `Missing KV binding "${cacheBinding}"` };
|
|
136
|
+
}
|
|
137
|
+
const value = await kv.get(String(effect.payload.key), "json");
|
|
138
|
+
return { ok: true, value: value ?? null };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case "cachePut": {
|
|
142
|
+
const kv = env[cacheBinding] as KVNamespaceLike | undefined;
|
|
143
|
+
if (!kv) {
|
|
144
|
+
return { ok: false, error: `Missing KV binding "${cacheBinding}"` };
|
|
145
|
+
}
|
|
146
|
+
const ttl = typeof effect.payload.ttlSeconds === "number" ? { expirationTtl: effect.payload.ttlSeconds } : undefined;
|
|
147
|
+
await kv.put(String(effect.payload.key), JSON.stringify(effect.payload.value ?? null), ttl);
|
|
148
|
+
return { ok: true, value: null };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case "query":
|
|
152
|
+
case "queryOne":
|
|
153
|
+
case "execute": {
|
|
154
|
+
const db = env[dbBinding] as D1DatabaseLike | undefined;
|
|
155
|
+
if (!db) {
|
|
156
|
+
return { ok: false, error: `Missing D1 binding "${dbBinding}"` };
|
|
157
|
+
}
|
|
158
|
+
const statement = db.prepare(String(effect.payload.sql)).bind(...sqlParams(effect));
|
|
159
|
+
if (effect.kind === "query") {
|
|
160
|
+
const result = await statement.all();
|
|
161
|
+
return { ok: true, value: result.results ?? [] };
|
|
162
|
+
}
|
|
163
|
+
if (effect.kind === "queryOne") {
|
|
164
|
+
const row = await statement.first();
|
|
165
|
+
return { ok: true, value: row ?? null };
|
|
166
|
+
}
|
|
167
|
+
const result = await statement.run();
|
|
168
|
+
return { ok: true, value: { rowsAffected: result.meta?.changes ?? 0 } };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case "env": {
|
|
172
|
+
const value = env[String(effect.payload.name)];
|
|
173
|
+
return { ok: true, value: typeof value === "string" ? value : null };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case "cookie":
|
|
177
|
+
return cookieEffect(effect, context);
|
|
178
|
+
|
|
179
|
+
default:
|
|
180
|
+
return { ok: false, error: `Unknown effect: ${effect.kind}` };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export interface SqlQuery {
|
|
186
|
+
sql: string;
|
|
187
|
+
params: unknown[];
|
|
188
|
+
mode: "all" | "first" | "run";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface InMemoryEffectsOptions {
|
|
192
|
+
/** Values returned by the `env` effect. */
|
|
193
|
+
env?: Record<string, string>;
|
|
194
|
+
/** Backing store for the cache; pass one in to share/inspect it. */
|
|
195
|
+
cache?: Map<string, { value: unknown; expiresAt?: number }>;
|
|
196
|
+
/** SQL backend, e.g. a bun:sqlite or Postgres handler. Required for query/execute. */
|
|
197
|
+
sql?: (query: SqlQuery) => unknown | Promise<unknown>;
|
|
198
|
+
/** Override `fetchJson` (e.g. fixtures in tests); defaults to a real fetch. */
|
|
199
|
+
fetchJson?: (url: string) => unknown | Promise<unknown>;
|
|
200
|
+
now?: () => number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Local/test adapter: in-memory cache + env, with a pluggable SQL backend. */
|
|
204
|
+
export const inMemoryEffects = (options: InMemoryEffectsOptions = {}): EffectRunner => {
|
|
205
|
+
const cache = options.cache ?? new Map<string, { value: unknown; expiresAt?: number }>();
|
|
206
|
+
const env = options.env ?? {};
|
|
207
|
+
const now = options.now ?? (() => Date.now());
|
|
208
|
+
|
|
209
|
+
return async (effect, context) => {
|
|
210
|
+
switch (effect.kind) {
|
|
211
|
+
case "fetchJson": {
|
|
212
|
+
if (options.fetchJson) {
|
|
213
|
+
try {
|
|
214
|
+
return { ok: true, value: await options.fetchJson(String(effect.payload.url)) };
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return { ok: false, error: String(error) };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return fetchJsonEffect(effect);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case "cacheGet": {
|
|
223
|
+
const entry = cache.get(String(effect.payload.key));
|
|
224
|
+
if (!entry) {
|
|
225
|
+
return { ok: true, value: null };
|
|
226
|
+
}
|
|
227
|
+
if (entry.expiresAt !== undefined && entry.expiresAt <= now()) {
|
|
228
|
+
cache.delete(String(effect.payload.key));
|
|
229
|
+
return { ok: true, value: null };
|
|
230
|
+
}
|
|
231
|
+
return { ok: true, value: entry.value };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
case "cachePut": {
|
|
235
|
+
const ttl = typeof effect.payload.ttlSeconds === "number" ? effect.payload.ttlSeconds : undefined;
|
|
236
|
+
cache.set(String(effect.payload.key), {
|
|
237
|
+
value: effect.payload.value ?? null,
|
|
238
|
+
expiresAt: ttl !== undefined ? now() + ttl * 1000 : undefined
|
|
239
|
+
});
|
|
240
|
+
return { ok: true, value: null };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case "query":
|
|
244
|
+
case "queryOne":
|
|
245
|
+
case "execute": {
|
|
246
|
+
if (!options.sql) {
|
|
247
|
+
return { ok: false, error: 'The "sql" handler is not configured in inMemoryEffects.' };
|
|
248
|
+
}
|
|
249
|
+
const mode = effect.kind === "query" ? "all" : effect.kind === "queryOne" ? "first" : "run";
|
|
250
|
+
try {
|
|
251
|
+
const value = await options.sql({ sql: String(effect.payload.sql), params: sqlParams(effect), mode });
|
|
252
|
+
return { ok: true, value };
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return { ok: false, error: String(error) };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case "env": {
|
|
259
|
+
const value = env[String(effect.payload.name)];
|
|
260
|
+
return { ok: true, value: typeof value === "string" ? value : null };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
default:
|
|
264
|
+
return defaultEffectRunner(effect, context);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
};
|