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
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { Window } from "happy-dom";
|
|
3
|
-
import { islandsCoreSource } from "elm-ssr/islands-runtime";
|
|
4
|
-
|
|
5
|
-
// Exercises the real island client runtime (boot + persistence + navigation)
|
|
6
|
-
// against a happy-dom DOM, with the actual compiled island bundle injected.
|
|
7
|
-
// This covers the paths the browser-island suite can't: mounting into the
|
|
8
|
-
// <elm-ssr-island> marker, persistence transfer, head sync, and bus cleanup.
|
|
9
|
-
|
|
10
|
-
type IslandRuntime = {
|
|
11
|
-
bootIslands: () => Promise<void>;
|
|
12
|
-
navigate: (url: URL, push?: boolean) => Promise<void>;
|
|
13
|
-
handleLinkClick: (event: unknown) => void;
|
|
14
|
-
persistentIslands: Map<string, Element>;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const createRuntime = new Function(`${islandsCoreSource}\nreturn createIslandsRuntime;`)() as (
|
|
18
|
-
deps: unknown
|
|
19
|
-
) => IslandRuntime;
|
|
20
|
-
|
|
21
|
-
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
22
|
-
|
|
23
|
-
// happy-dom's querySelector throws on this version, so navigate the DOM by hand.
|
|
24
|
-
const firstMarker = (): Element | undefined =>
|
|
25
|
-
window.document.getElementsByTagName("elm-ssr-island")[0] as unknown as Element | undefined;
|
|
26
|
-
|
|
27
|
-
const findByClass = (root: Element, className: string): Element | null => {
|
|
28
|
-
if ((root.getAttribute("class") || "").split(/\s+/).includes(className)) {
|
|
29
|
-
return root;
|
|
30
|
-
}
|
|
31
|
-
for (const child of Array.from(root.children)) {
|
|
32
|
-
const match = findByClass(child as Element, className);
|
|
33
|
-
if (match) {
|
|
34
|
-
return match;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const hasMeta = (name: string): boolean =>
|
|
41
|
-
Array.from(window.document.head.getElementsByTagName("meta")).some(
|
|
42
|
-
(meta) => (meta as unknown as Element).getAttribute("name") === name
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
let window: Window;
|
|
46
|
-
let previousGlobals: Map<string, unknown>;
|
|
47
|
-
|
|
48
|
-
const GLOBAL_KEYS = [
|
|
49
|
-
"window",
|
|
50
|
-
"document",
|
|
51
|
-
"navigator",
|
|
52
|
-
"Node",
|
|
53
|
-
"Element",
|
|
54
|
-
"HTMLElement",
|
|
55
|
-
"Event",
|
|
56
|
-
"MouseEvent",
|
|
57
|
-
"CustomEvent",
|
|
58
|
-
"requestAnimationFrame",
|
|
59
|
-
"cancelAnimationFrame"
|
|
60
|
-
] as const;
|
|
61
|
-
|
|
62
|
-
const installGlobals = (next: Window) => {
|
|
63
|
-
const get = (key: string): unknown => {
|
|
64
|
-
const value = (next as unknown as Record<string, unknown>)[key];
|
|
65
|
-
return typeof value === "function" ? (value as (...args: unknown[]) => unknown).bind(next) : value;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
previousGlobals = new Map(GLOBAL_KEYS.map((key) => [key, Reflect.get(globalThis, key)]));
|
|
69
|
-
for (const key of GLOBAL_KEYS) {
|
|
70
|
-
Reflect.set(globalThis, key, get(key));
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const restoreGlobals = () => {
|
|
75
|
-
for (const [key, value] of previousGlobals.entries()) {
|
|
76
|
-
Reflect.set(globalThis, key, value);
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const loadCounterBundle = async () => {
|
|
81
|
-
Reflect.deleteProperty(globalThis, "Elm");
|
|
82
|
-
const moduleRef = await import(`../generated/examples/basic/islands.mjs?cache=${Date.now()}-${Math.random()}`);
|
|
83
|
-
return moduleRef.default;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const manifest = { Counter: { module: "Example.Basic.Islands.Counter" } };
|
|
87
|
-
|
|
88
|
-
const marker = (props: Record<string, unknown>, id?: string): string => {
|
|
89
|
-
const attrs = [
|
|
90
|
-
'data-elmssr-island="Counter"',
|
|
91
|
-
`data-elmssr-props='${JSON.stringify(props)}'`,
|
|
92
|
-
id ? `data-elmssr-id="${id}"` : ""
|
|
93
|
-
]
|
|
94
|
-
.filter(Boolean)
|
|
95
|
-
.join(" ");
|
|
96
|
-
return `<div id="elm-ssr-root"><elm-ssr-island ${attrs}>fallback text</elm-ssr-island></div>`;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const newRuntime = async (loadBundle?: () => Promise<unknown>): Promise<IslandRuntime> =>
|
|
100
|
-
createRuntime({
|
|
101
|
-
document: window.document,
|
|
102
|
-
window,
|
|
103
|
-
manifest,
|
|
104
|
-
loadBundle: loadBundle ?? loadCounterBundle
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
beforeEach(() => {
|
|
108
|
-
window = new Window({ url: "https://example.com/" });
|
|
109
|
-
installGlobals(window);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
afterEach(() => {
|
|
113
|
-
restoreGlobals();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe("island client runtime", () => {
|
|
117
|
-
it("mounts into a child so the marker (and its id) survives Browser.element", async () => {
|
|
118
|
-
window.document.body.innerHTML = marker({ start: 5 }, "counter-1");
|
|
119
|
-
const runtime = await newRuntime();
|
|
120
|
-
|
|
121
|
-
await runtime.bootIslands();
|
|
122
|
-
await tick();
|
|
123
|
-
|
|
124
|
-
const el = firstMarker();
|
|
125
|
-
// With a naive mount into the marker, Browser.element would replace it and
|
|
126
|
-
// this would be undefined. Child-mount keeps the marker as the persistent unit.
|
|
127
|
-
expect(el).toBeDefined();
|
|
128
|
-
expect(el?.getAttribute("data-elmssr-booted")).toBe("true");
|
|
129
|
-
expect(el?.textContent).toContain("5");
|
|
130
|
-
expect(el?.textContent).not.toContain("fallback text");
|
|
131
|
-
expect(runtime.persistentIslands.get("counter-1")).toBe(el as Element);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("transfers a persistent island across navigation, preserving state, and syncs head", async () => {
|
|
135
|
-
window.document.body.innerHTML = marker({ start: 5 }, "counter-1");
|
|
136
|
-
const html =
|
|
137
|
-
"<!doctype html><html><head><title>Next Page</title>" +
|
|
138
|
-
'<meta name="x" content="y"></head><body>' +
|
|
139
|
-
marker({ start: 999 }, "counter-1") +
|
|
140
|
-
"</body></html>";
|
|
141
|
-
|
|
142
|
-
window.fetch = (async () => ({ json: async () => ({ html }) })) as unknown as typeof window.fetch;
|
|
143
|
-
|
|
144
|
-
const runtime = await newRuntime();
|
|
145
|
-
await runtime.bootIslands();
|
|
146
|
-
await tick();
|
|
147
|
-
|
|
148
|
-
const live = firstMarker() as Element;
|
|
149
|
-
findByClass(live, "primary")?.dispatchEvent(new window.Event("click", { bubbles: true }) as unknown as Event);
|
|
150
|
-
await tick();
|
|
151
|
-
expect(live.textContent).toContain("6");
|
|
152
|
-
|
|
153
|
-
await runtime.navigate(new URL("https://example.com/next"));
|
|
154
|
-
await tick();
|
|
155
|
-
|
|
156
|
-
const afterNav = firstMarker() as Element;
|
|
157
|
-
expect(afterNav).toBe(live); // same node transferred
|
|
158
|
-
expect(afterNav.textContent).toContain("6"); // state preserved, not reset to 999
|
|
159
|
-
expect(window.document.title).toBe("Next Page");
|
|
160
|
-
expect(hasMeta("x")).toBe(true);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("does not register a duplicate bus listener when a persistent island transfers", async () => {
|
|
164
|
-
window.document.body.innerHTML = marker({ start: 0 }, "counter-1");
|
|
165
|
-
let busListeners = 0;
|
|
166
|
-
const realAdd = window.addEventListener.bind(window);
|
|
167
|
-
window.addEventListener = ((type: string, ...rest: unknown[]) => {
|
|
168
|
-
if (type === "elm-ssr-broadcast") {
|
|
169
|
-
busListeners += 1;
|
|
170
|
-
}
|
|
171
|
-
return (realAdd as (...a: unknown[]) => unknown)(type, ...rest);
|
|
172
|
-
}) as typeof window.addEventListener;
|
|
173
|
-
|
|
174
|
-
const html = "<!doctype html><html><head><title>N</title></head><body>" + marker({ start: 0 }, "counter-1") + "</body></html>";
|
|
175
|
-
window.fetch = (async () => ({ json: async () => ({ html }) })) as unknown as typeof window.fetch;
|
|
176
|
-
|
|
177
|
-
const runtime = await newRuntime();
|
|
178
|
-
await runtime.bootIslands();
|
|
179
|
-
await tick();
|
|
180
|
-
await runtime.navigate(new URL("https://example.com/a"));
|
|
181
|
-
await tick();
|
|
182
|
-
await runtime.navigate(new URL("https://example.com/b"));
|
|
183
|
-
await tick();
|
|
184
|
-
|
|
185
|
-
// Counter wires broadcastOut (not broadcastIn), so it registers no bus
|
|
186
|
-
// listener at all; the key assertion is that transfers never re-wire.
|
|
187
|
-
expect(busListeners).toBeLessThanOrEqual(1);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("ignores hash-only / same-page links (no SPA fetch)", async () => {
|
|
191
|
-
window.document.body.innerHTML = marker({ start: 0 });
|
|
192
|
-
let fetched = false;
|
|
193
|
-
window.fetch = (async () => {
|
|
194
|
-
fetched = true;
|
|
195
|
-
return { json: async () => ({}) };
|
|
196
|
-
}) as unknown as typeof window.fetch;
|
|
197
|
-
|
|
198
|
-
const runtime = await newRuntime();
|
|
199
|
-
|
|
200
|
-
let prevented = false;
|
|
201
|
-
const link = window.document.createElement("a");
|
|
202
|
-
link.setAttribute("href", "https://example.com/#section");
|
|
203
|
-
runtime.handleLinkClick({
|
|
204
|
-
target: link,
|
|
205
|
-
preventDefault: () => {
|
|
206
|
-
prevented = true;
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
await tick();
|
|
210
|
-
|
|
211
|
-
expect(prevented).toBe(false);
|
|
212
|
-
expect(fetched).toBe(false);
|
|
213
|
-
});
|
|
214
|
-
});
|
package/test/middleware.test.ts
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import type { AppContext, AppHandler, Middleware } from "elm-ssr/http";
|
|
3
|
-
import {
|
|
4
|
-
composeMiddleware,
|
|
5
|
-
errorMiddleware,
|
|
6
|
-
headMiddleware,
|
|
7
|
-
loggingMiddleware,
|
|
8
|
-
requestIdMiddleware,
|
|
9
|
-
timingMiddleware
|
|
10
|
-
} from "elm-ssr/middleware";
|
|
11
|
-
|
|
12
|
-
const contextFor = (request: Request): AppContext => ({
|
|
13
|
-
request,
|
|
14
|
-
url: new URL(request.url),
|
|
15
|
-
requestId: "",
|
|
16
|
-
startedAt: performance.now()
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
const ok = (): AppHandler => async () => new Response("ok", { status: 200, headers: { "content-type": "text/plain" } });
|
|
20
|
-
|
|
21
|
-
describe("composeMiddleware", () => {
|
|
22
|
-
it("runs middlewares outermost-first on the way in and innermost-first on the way out", async () => {
|
|
23
|
-
const order: string[] = [];
|
|
24
|
-
const a: Middleware = async (ctx, next) => {
|
|
25
|
-
order.push("a-in");
|
|
26
|
-
const res = await next(ctx);
|
|
27
|
-
order.push("a-out");
|
|
28
|
-
return res;
|
|
29
|
-
};
|
|
30
|
-
const b: Middleware = async (ctx, next) => {
|
|
31
|
-
order.push("b-in");
|
|
32
|
-
const res = await next(ctx);
|
|
33
|
-
order.push("b-out");
|
|
34
|
-
return res;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const handler = composeMiddleware(async () => {
|
|
38
|
-
order.push("handler");
|
|
39
|
-
return new Response("");
|
|
40
|
-
}, [a, b]);
|
|
41
|
-
|
|
42
|
-
await handler(contextFor(new Request("https://example.com/")));
|
|
43
|
-
expect(order).toEqual(["a-in", "b-in", "handler", "b-out", "a-out"]);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe("requestIdMiddleware", () => {
|
|
48
|
-
it("reuses an incoming x-request-id and echoes it back", async () => {
|
|
49
|
-
const handler = composeMiddleware(ok(), [requestIdMiddleware]);
|
|
50
|
-
const response = await handler(
|
|
51
|
-
contextFor(new Request("https://example.com/", { headers: { "x-request-id": "abc-123" } }))
|
|
52
|
-
);
|
|
53
|
-
expect(response.headers.get("x-request-id")).toBe("abc-123");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("generates an id when the header is missing", async () => {
|
|
57
|
-
const handler = composeMiddleware(ok(), [requestIdMiddleware]);
|
|
58
|
-
const response = await handler(contextFor(new Request("https://example.com/")));
|
|
59
|
-
expect(response.headers.get("x-request-id")).toMatch(/^[0-9a-f-]{36}$/);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("timingMiddleware", () => {
|
|
64
|
-
it("adds server-timing and x-response-time headers", async () => {
|
|
65
|
-
const handler = composeMiddleware(ok(), [timingMiddleware]);
|
|
66
|
-
const response = await handler(contextFor(new Request("https://example.com/")));
|
|
67
|
-
expect(response.headers.get("server-timing")).toMatch(/^app;dur=\d+(\.\d+)?$/);
|
|
68
|
-
expect(response.headers.get("x-response-time")).toMatch(/^\d+(\.\d+)?ms$/);
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe("headMiddleware", () => {
|
|
73
|
-
it("strips the body for HEAD while preserving status + headers", async () => {
|
|
74
|
-
const inner: AppHandler = async () =>
|
|
75
|
-
new Response("payload", { status: 200, headers: { "x-custom": "yes", "content-type": "text/plain" } });
|
|
76
|
-
const handler = composeMiddleware(inner, [headMiddleware]);
|
|
77
|
-
const response = await handler(contextFor(new Request("https://example.com/", { method: "HEAD" })));
|
|
78
|
-
expect(response.status).toBe(200);
|
|
79
|
-
expect(response.headers.get("x-custom")).toBe("yes");
|
|
80
|
-
expect(await response.text()).toBe("");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("leaves non-HEAD responses untouched", async () => {
|
|
84
|
-
const handler = composeMiddleware(ok(), [headMiddleware]);
|
|
85
|
-
const response = await handler(contextFor(new Request("https://example.com/")));
|
|
86
|
-
expect(await response.text()).toBe("ok");
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("errorMiddleware", () => {
|
|
91
|
-
const failingHandler: AppHandler = async () => {
|
|
92
|
-
throw new Error("boom");
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
it("returns plain-text 500 for non-/api/ routes when the handler throws", async () => {
|
|
96
|
-
const handler = composeMiddleware(failingHandler, [errorMiddleware]);
|
|
97
|
-
const response = await handler(contextFor(new Request("https://example.com/page")));
|
|
98
|
-
expect(response.status).toBe(500);
|
|
99
|
-
expect(response.headers.get("content-type") || "").toContain("text/plain");
|
|
100
|
-
expect(await response.text()).toBe("Internal Server Error");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("returns JSON 500 with requestId for /api/ routes when the handler throws", async () => {
|
|
104
|
-
const context = contextFor(new Request("https://example.com/api/things"));
|
|
105
|
-
context.requestId = "req-7";
|
|
106
|
-
const handler = composeMiddleware(failingHandler, [errorMiddleware]);
|
|
107
|
-
const response = await handler(context);
|
|
108
|
-
expect(response.status).toBe(500);
|
|
109
|
-
const body = await response.json();
|
|
110
|
-
expect(body).toEqual({ error: "internal_error", requestId: "req-7" });
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("loggingMiddleware", () => {
|
|
115
|
-
it("emits one JSON log line per completed request", async () => {
|
|
116
|
-
const lines: string[] = [];
|
|
117
|
-
const logger = (entry: string) => lines.push(entry);
|
|
118
|
-
const handler = composeMiddleware(ok(), [loggingMiddleware(logger)]);
|
|
119
|
-
|
|
120
|
-
await handler(contextFor(new Request("https://example.com/things?x=1", { method: "GET" })));
|
|
121
|
-
// The logger is dispatched detached; let microtasks settle.
|
|
122
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
123
|
-
|
|
124
|
-
expect(lines).toHaveLength(1);
|
|
125
|
-
const entry = JSON.parse(lines[0] as string);
|
|
126
|
-
expect(entry).toMatchObject({
|
|
127
|
-
event: "request_completed",
|
|
128
|
-
method: "GET",
|
|
129
|
-
path: "/things",
|
|
130
|
-
status: 200
|
|
131
|
-
});
|
|
132
|
-
expect(typeof entry.durationMs).toBe("number");
|
|
133
|
-
});
|
|
134
|
-
});
|
package/test/migrations.test.ts
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { Database } from "bun:sqlite";
|
|
3
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { resolve } from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
listMigrations,
|
|
8
|
-
revertMigrations,
|
|
9
|
-
runMigrations,
|
|
10
|
-
type MigrationsAdapter
|
|
11
|
-
} from "elm-ssr/migrations";
|
|
12
|
-
|
|
13
|
-
// The migration runner is backend-neutral; here we drive it against an
|
|
14
|
-
// in-memory SQLite database via bun:sqlite. The same `MigrationsAdapter`
|
|
15
|
-
// shape works for Postgres (Bun.sql / node-postgres) and D1 — the integration
|
|
16
|
-
// test in test/integration/ covers Postgres against a real server.
|
|
17
|
-
|
|
18
|
-
const sqliteAdapter = (db: Database): MigrationsAdapter => ({
|
|
19
|
-
exec: async (sql) => {
|
|
20
|
-
db.exec(sql);
|
|
21
|
-
},
|
|
22
|
-
list: async (sql) => db.query(sql).all() as Array<Record<string, unknown>>
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
let dir: string;
|
|
26
|
-
let db: Database;
|
|
27
|
-
let adapter: MigrationsAdapter;
|
|
28
|
-
|
|
29
|
-
const seed = async (files: Record<string, string>): Promise<void> => {
|
|
30
|
-
await Promise.all(Object.entries(files).map(([name, sql]) => writeFile(resolve(dir, name), sql)));
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
beforeEach(async () => {
|
|
34
|
-
dir = await mkdtemp(resolve(tmpdir(), "elm-ssr-mig-"));
|
|
35
|
-
db = new Database(":memory:");
|
|
36
|
-
adapter = sqliteAdapter(db);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
afterEach(async () => {
|
|
40
|
-
db.close();
|
|
41
|
-
await rm(dir, { recursive: true, force: true });
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("runMigrations", () => {
|
|
45
|
-
it("applies all pending migrations in alphabetical order and tracks them", async () => {
|
|
46
|
-
await seed({
|
|
47
|
-
"0001_users.sql": "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
|
|
48
|
-
"0002_posts.sql": "CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT);"
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
const result = await runMigrations(adapter, { dir, now: () => "2026-01-01T00:00:00Z" });
|
|
52
|
-
|
|
53
|
-
expect(result.applied).toEqual(["0001_users.sql", "0002_posts.sql"]);
|
|
54
|
-
expect(result.skipped).toEqual([]);
|
|
55
|
-
|
|
56
|
-
const tracked = db.query("SELECT name, applied_at FROM __elm_ssr_migrations ORDER BY name").all();
|
|
57
|
-
expect(tracked).toEqual([
|
|
58
|
-
{ name: "0001_users.sql", applied_at: "2026-01-01T00:00:00Z" },
|
|
59
|
-
{ name: "0002_posts.sql", applied_at: "2026-01-01T00:00:00Z" }
|
|
60
|
-
]);
|
|
61
|
-
// The migrations actually ran.
|
|
62
|
-
expect(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('users','posts') ORDER BY name").all())
|
|
63
|
-
.toEqual([{ name: "posts" }, { name: "users" }]);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("is idempotent: re-running skips already-applied migrations", async () => {
|
|
67
|
-
await seed({
|
|
68
|
-
"0001_init.sql": "CREATE TABLE t (id INTEGER);"
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
await runMigrations(adapter, { dir });
|
|
72
|
-
const second = await runMigrations(adapter, { dir });
|
|
73
|
-
|
|
74
|
-
expect(second.applied).toEqual([]);
|
|
75
|
-
expect(second.skipped).toEqual(["0001_init.sql"]);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("only applies pending migrations on subsequent runs", async () => {
|
|
79
|
-
await seed({ "0001_init.sql": "CREATE TABLE t1 (id INTEGER);" });
|
|
80
|
-
await runMigrations(adapter, { dir });
|
|
81
|
-
|
|
82
|
-
await seed({ "0002_more.sql": "CREATE TABLE t2 (id INTEGER);" });
|
|
83
|
-
const result = await runMigrations(adapter, { dir });
|
|
84
|
-
|
|
85
|
-
expect(result.applied).toEqual(["0002_more.sql"]);
|
|
86
|
-
expect(result.skipped).toEqual(["0001_init.sql"]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("rolls back a failing migration without leaving partial schema or tracking", async () => {
|
|
90
|
-
await seed({
|
|
91
|
-
"0001_good.sql": "CREATE TABLE good (id INTEGER);",
|
|
92
|
-
"0002_broken.sql": "CREATE TABLE broken (id INTEGER); INSERT INTO broken (no_such_column) VALUES (1);"
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
await expect(runMigrations(adapter, { dir })).rejects.toThrow(/0002_broken\.sql/);
|
|
96
|
-
|
|
97
|
-
// First migration was applied (it's in its own transaction).
|
|
98
|
-
expect(db.query("SELECT name FROM __elm_ssr_migrations").all()).toEqual([
|
|
99
|
-
{ name: "0001_good.sql" }
|
|
100
|
-
]);
|
|
101
|
-
// The broken migration's CREATE TABLE was rolled back.
|
|
102
|
-
expect(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='broken'").all()).toEqual([]);
|
|
103
|
-
// The good one stuck.
|
|
104
|
-
expect(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='good'").all())
|
|
105
|
-
.toEqual([{ name: "good" }]);
|
|
106
|
-
|
|
107
|
-
// After fixing the broken file, re-running applies only what's missing.
|
|
108
|
-
await writeFile(resolve(dir, "0002_broken.sql"), "CREATE TABLE broken (id INTEGER);");
|
|
109
|
-
const retry = await runMigrations(adapter, { dir });
|
|
110
|
-
expect(retry.applied).toEqual(["0002_broken.sql"]);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("ignores non-.sql files in the directory", async () => {
|
|
114
|
-
await seed({
|
|
115
|
-
"0001_real.sql": "CREATE TABLE r (id INTEGER);",
|
|
116
|
-
"README.md": "# Migrations",
|
|
117
|
-
"draft.txt": "not yet"
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
const result = await runMigrations(adapter, { dir });
|
|
121
|
-
expect(result.applied).toEqual(["0001_real.sql"]);
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("supports a custom tracking table name", async () => {
|
|
125
|
-
await seed({ "0001_init.sql": "CREATE TABLE t (id INTEGER);" });
|
|
126
|
-
await runMigrations(adapter, { dir, tableName: "schema_history" });
|
|
127
|
-
|
|
128
|
-
expect(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_history'").all())
|
|
129
|
-
.toEqual([{ name: "schema_history" }]);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("rejects unsafe tracking table names", async () => {
|
|
133
|
-
await seed({ "0001_init.sql": "CREATE TABLE t (id INTEGER);" });
|
|
134
|
-
await expect(runMigrations(adapter, { dir, tableName: "bad name; DROP TABLE x" })).rejects.toThrow(/Invalid/);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("escapes single quotes in filenames safely", async () => {
|
|
138
|
-
await seed({ "0001_o'clock.sql": "CREATE TABLE clk (id INTEGER);" });
|
|
139
|
-
const result = await runMigrations(adapter, { dir });
|
|
140
|
-
expect(result.applied).toEqual(["0001_o'clock.sql"]);
|
|
141
|
-
expect(db.query("SELECT name FROM __elm_ssr_migrations").all())
|
|
142
|
-
.toEqual([{ name: "0001_o'clock.sql" }]);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("ignores `.down.sql` files on the up pass", async () => {
|
|
146
|
-
await seed({
|
|
147
|
-
"0001_init.sql": "CREATE TABLE t (id INTEGER);",
|
|
148
|
-
"0001_init.down.sql": "DROP TABLE t;"
|
|
149
|
-
});
|
|
150
|
-
const result = await runMigrations(adapter, { dir });
|
|
151
|
-
expect(result.applied).toEqual(["0001_init.sql"]);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
describe("revertMigrations", () => {
|
|
156
|
-
it("reverts the most-recently-applied migration via its paired down file", async () => {
|
|
157
|
-
await seed({
|
|
158
|
-
"0001_users.sql": "CREATE TABLE users (id INTEGER);",
|
|
159
|
-
"0001_users.down.sql": "DROP TABLE users;",
|
|
160
|
-
"0002_posts.sql": "CREATE TABLE posts (id INTEGER);",
|
|
161
|
-
"0002_posts.down.sql": "DROP TABLE posts;"
|
|
162
|
-
});
|
|
163
|
-
await runMigrations(adapter, { dir });
|
|
164
|
-
|
|
165
|
-
const result = await revertMigrations(adapter, { dir });
|
|
166
|
-
|
|
167
|
-
expect(result.reverted).toEqual(["0002_posts.sql"]);
|
|
168
|
-
expect(db.query("SELECT name FROM __elm_ssr_migrations").all()).toEqual([{ name: "0001_users.sql" }]);
|
|
169
|
-
expect(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('users','posts')").all())
|
|
170
|
-
.toEqual([{ name: "users" }]);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("reverts multiple migrations when count > 1, most-recent first", async () => {
|
|
174
|
-
await seed({
|
|
175
|
-
"0001_users.sql": "CREATE TABLE users (id INTEGER);",
|
|
176
|
-
"0001_users.down.sql": "DROP TABLE users;",
|
|
177
|
-
"0002_posts.sql": "CREATE TABLE posts (id INTEGER);",
|
|
178
|
-
"0002_posts.down.sql": "DROP TABLE posts;"
|
|
179
|
-
});
|
|
180
|
-
await runMigrations(adapter, { dir });
|
|
181
|
-
|
|
182
|
-
const result = await revertMigrations(adapter, { dir, count: 2 });
|
|
183
|
-
|
|
184
|
-
expect(result.reverted).toEqual(["0002_posts.sql", "0001_users.sql"]);
|
|
185
|
-
expect(db.query("SELECT name FROM __elm_ssr_migrations").all()).toEqual([]);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("errors clearly when a down migration is missing", async () => {
|
|
189
|
-
await seed({ "0001_init.sql": "CREATE TABLE t (id INTEGER);" });
|
|
190
|
-
await runMigrations(adapter, { dir });
|
|
191
|
-
|
|
192
|
-
await expect(revertMigrations(adapter, { dir })).rejects.toThrow(/No down migration/);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("rolls back a failing revert without removing the tracking row", async () => {
|
|
196
|
-
await seed({
|
|
197
|
-
"0001_init.sql": "CREATE TABLE t (id INTEGER);",
|
|
198
|
-
"0001_init.down.sql": "DROP TABLE no_such_table;"
|
|
199
|
-
});
|
|
200
|
-
await runMigrations(adapter, { dir });
|
|
201
|
-
|
|
202
|
-
await expect(revertMigrations(adapter, { dir })).rejects.toThrow(/0001_init\.sql/);
|
|
203
|
-
|
|
204
|
-
// Tracking row is still there; original table still there.
|
|
205
|
-
expect(db.query("SELECT name FROM __elm_ssr_migrations").all()).toEqual([{ name: "0001_init.sql" }]);
|
|
206
|
-
expect(db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='t'").all())
|
|
207
|
-
.toEqual([{ name: "t" }]);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("is a no-op when there's nothing applied", async () => {
|
|
211
|
-
const result = await revertMigrations(adapter, { dir });
|
|
212
|
-
expect(result.reverted).toEqual([]);
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
describe("listMigrations", () => {
|
|
217
|
-
it("reports applied entries (with timestamps) and pending filenames", async () => {
|
|
218
|
-
await seed({
|
|
219
|
-
"0001_users.sql": "CREATE TABLE users (id INTEGER);",
|
|
220
|
-
"0002_posts.sql": "CREATE TABLE posts (id INTEGER);",
|
|
221
|
-
"0003_comments.sql": "CREATE TABLE comments (id INTEGER);"
|
|
222
|
-
});
|
|
223
|
-
await runMigrations(adapter, { dir, now: () => "2026-05-30T00:00:00Z" });
|
|
224
|
-
|
|
225
|
-
// Now add a pending one without applying.
|
|
226
|
-
await seed({ "0004_likes.sql": "CREATE TABLE likes (id INTEGER);" });
|
|
227
|
-
|
|
228
|
-
const status = await listMigrations(adapter, { dir });
|
|
229
|
-
|
|
230
|
-
expect(status.applied).toEqual([
|
|
231
|
-
{ name: "0001_users.sql", appliedAt: "2026-05-30T00:00:00Z" },
|
|
232
|
-
{ name: "0002_posts.sql", appliedAt: "2026-05-30T00:00:00Z" },
|
|
233
|
-
{ name: "0003_comments.sql", appliedAt: "2026-05-30T00:00:00Z" }
|
|
234
|
-
]);
|
|
235
|
-
expect(status.pending).toEqual(["0004_likes.sql"]);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("creates the tracking table on first call (returns empty applied + all pending)", async () => {
|
|
239
|
-
await seed({ "0001_init.sql": "CREATE TABLE t (id INTEGER);" });
|
|
240
|
-
const status = await listMigrations(adapter, { dir });
|
|
241
|
-
expect(status.applied).toEqual([]);
|
|
242
|
-
expect(status.pending).toEqual(["0001_init.sql"]);
|
|
243
|
-
});
|
|
244
|
-
});
|