first commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
createContext,
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
logIn,
|
||||
logOut,
|
||||
getUser,
|
||||
sessionAuth,
|
||||
requireAuth,
|
||||
} from "../src/index.ts";
|
||||
|
||||
function ctx(method = "GET", path = "/", accept?: string) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (accept) headers.accept = accept;
|
||||
const url = new URL(`http://x${path}`);
|
||||
const req = new Request(url, { method, headers });
|
||||
return createContext(req, url);
|
||||
}
|
||||
|
||||
test("hashPassword / verifyPassword round-trip", async () => {
|
||||
const hash = await hashPassword("correct horse battery staple");
|
||||
expect(hash).toBeTruthy();
|
||||
expect(hash).not.toBe("correct horse battery staple");
|
||||
expect(await verifyPassword("correct horse battery staple", hash)).toBe(true);
|
||||
expect(await verifyPassword("wrong", hash)).toBe(false);
|
||||
});
|
||||
|
||||
test("verifyPassword tolerates empty/garbage hashes", async () => {
|
||||
expect(await verifyPassword("x", "")).toBe(false);
|
||||
expect(await verifyPassword("x", "not-a-real-hash")).toBe(false);
|
||||
});
|
||||
|
||||
test("logIn stores the user; getUser reads it; logOut clears it", () => {
|
||||
const c = ctx();
|
||||
expect(getUser(c)).toBeNull();
|
||||
logIn(c, { id: 1, email: "a@b.com" });
|
||||
expect(getUser<{ id: number }>(c)?.id).toBe(1);
|
||||
expect(c.session.get<{ id: number; email: string }>("user")).toEqual({ id: 1, email: "a@b.com" });
|
||||
logOut(c);
|
||||
expect(getUser(c)).toBeNull();
|
||||
expect(c.user).toBeNull();
|
||||
});
|
||||
|
||||
test("logIn regenerates the session id (fixation defense) but keeps data", () => {
|
||||
const c = ctx();
|
||||
c.session.set("cart", [1, 2]);
|
||||
const before = c.session.id();
|
||||
logIn(c, { id: 1, email: "a@b.com" });
|
||||
const after = c.session.id();
|
||||
expect(after).not.toBe(before); // fresh id issued on login
|
||||
expect(after.length).toBeGreaterThanOrEqual(32);
|
||||
expect(c.session.get<number[]>("cart")).toEqual([1, 2]); // data preserved
|
||||
expect(getUser<{ id: number }>(c)?.id).toBe(1);
|
||||
});
|
||||
|
||||
test("sessionAuth hydrates ctx.user from the session", async () => {
|
||||
const c = ctx();
|
||||
c.session.set("user", { id: 7 });
|
||||
let seen: unknown = "unset";
|
||||
await sessionAuth()(c, () => {
|
||||
seen = c.user;
|
||||
return new Response("ok");
|
||||
});
|
||||
expect(seen).toEqual({ id: 7 });
|
||||
});
|
||||
|
||||
test("requireAuth: passes through when authenticated", async () => {
|
||||
const c = ctx();
|
||||
logIn(c, { id: 1 });
|
||||
const res = await requireAuth()(c, () => new Response("secret"));
|
||||
expect(await res.text()).toBe("secret");
|
||||
});
|
||||
|
||||
test("requireAuth: 401 JSON for API paths when anonymous", async () => {
|
||||
const c = ctx("GET", "/api/me");
|
||||
const res = await requireAuth()(c, () => new Response("secret"));
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toEqual({ ok: false, error: "Unauthorized" });
|
||||
});
|
||||
|
||||
test("requireAuth: 302 redirect for page navigations when anonymous", async () => {
|
||||
const c = ctx("GET", "/dashboard?tab=1", "text/html");
|
||||
const res = await requireAuth()(c, () => new Response("secret"));
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toBe("/login?next=%2Fdashboard%3Ftab%3D1");
|
||||
});
|
||||
|
||||
test("requireAuth: custom loginPath", async () => {
|
||||
const c = ctx("GET", "/dashboard", "text/html");
|
||||
const res = await requireAuth({ loginPath: "/signin" })(c, () => new Response("x"));
|
||||
expect(res.headers.get("location")).toBe("/signin?next=%2Fdashboard");
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { createContext, csrfToken, verifyCsrf, CSRF_COOKIE } from "../src/index.ts";
|
||||
|
||||
function ctx(method: string, cookie?: string, header?: string) {
|
||||
const headers: Record<string, string> = {};
|
||||
if (cookie) headers.cookie = `${CSRF_COOKIE}=${cookie}`;
|
||||
if (header) headers["x-csrf-token"] = header;
|
||||
const req = new Request("http://x/api", { method, headers });
|
||||
return createContext(req, new URL(req.url));
|
||||
}
|
||||
|
||||
test("csrfToken issues a token", () => {
|
||||
const token = csrfToken(ctx("GET"));
|
||||
expect(token).toBeTruthy();
|
||||
expect(token.length).toBeGreaterThan(16);
|
||||
});
|
||||
|
||||
test("verifyCsrf: safe methods always pass", () => {
|
||||
expect(verifyCsrf(ctx("GET"))).toBe(true);
|
||||
expect(verifyCsrf(ctx("HEAD"))).toBe(true);
|
||||
});
|
||||
|
||||
test("verifyCsrf: unsafe methods need matching cookie + header", () => {
|
||||
expect(verifyCsrf(ctx("POST", "abc", "abc"))).toBe(true);
|
||||
expect(verifyCsrf(ctx("POST", "abc", "xyz"))).toBe(false); // mismatch
|
||||
expect(verifyCsrf(ctx("POST", "abc"))).toBe(false); // no header
|
||||
expect(verifyCsrf(ctx("POST", undefined, "abc"))).toBe(false); // no cookie
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { withSecurityHeaders, isWebSocketOriginAllowed } from "../src/index.ts";
|
||||
|
||||
const req = (headers: Record<string, string> = {}) => new Request("https://x/", { headers });
|
||||
|
||||
function scriptSrc(csp: string): string {
|
||||
return csp
|
||||
.split(";")
|
||||
.map((s) => s.trim())
|
||||
.find((s) => s.startsWith("script-src"))!;
|
||||
}
|
||||
|
||||
test("CSP nonce is added to script-src and drops unsafe-inline", () => {
|
||||
const res = withSecurityHeaders(req(), new Response("x"), "development", undefined, "ABC123");
|
||||
const directive = scriptSrc(res.headers.get("content-security-policy")!);
|
||||
expect(directive).toContain("'nonce-ABC123'");
|
||||
expect(directive).not.toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
test("without a nonce, dev script-src keeps unsafe-inline (for HMR)", () => {
|
||||
const res = withSecurityHeaders(req(), new Response("x"), "development");
|
||||
expect(scriptSrc(res.headers.get("content-security-policy")!)).toContain("'unsafe-inline'");
|
||||
});
|
||||
|
||||
test("CORS credentials + origin:* is refused (credentials dropped)", () => {
|
||||
const res = withSecurityHeaders(
|
||||
req({ origin: "https://evil.test" }),
|
||||
new Response("x"),
|
||||
"production",
|
||||
{
|
||||
cors: { enabled: true, origin: "*", credentials: true },
|
||||
},
|
||||
);
|
||||
expect(res.headers.get("access-control-allow-credentials")).toBeNull();
|
||||
});
|
||||
|
||||
test("production sets HSTS + strict CSP", () => {
|
||||
const res = withSecurityHeaders(req(), new Response("x"), "production");
|
||||
expect(res.headers.get("strict-transport-security")).toContain("max-age=");
|
||||
expect(res.headers.get("content-security-policy")).toContain("default-src 'self'");
|
||||
});
|
||||
|
||||
test("permissions policy overrides merge with restrictive defaults", () => {
|
||||
const res = withSecurityHeaders(req(), new Response("x"), "development", {
|
||||
permissionsPolicy: { camera: ["self"] },
|
||||
});
|
||||
const policy = res.headers.get("permissions-policy")!;
|
||||
expect(policy).toContain("camera=(self)");
|
||||
expect(policy).toContain("microphone=()");
|
||||
});
|
||||
|
||||
test("isWebSocketOriginAllowed blocks cross-site WS (CSWSH), allows same-origin", () => {
|
||||
const wsReq = (origin: string | null, host: string) =>
|
||||
new Request("http://x/realtime/c", {
|
||||
headers: origin ? { origin, host } : { host },
|
||||
});
|
||||
expect(isWebSocketOriginAllowed(wsReq("http://app.test", "app.test"))).toBe(true); // same-origin
|
||||
expect(isWebSocketOriginAllowed(wsReq("http://evil.test", "app.test"))).toBe(false); // cross-site
|
||||
expect(isWebSocketOriginAllowed(wsReq(null, "app.test"))).toBe(true); // native client, no cookies
|
||||
// Explicit CORS allowlist opens a cross-origin WS.
|
||||
expect(
|
||||
isWebSocketOriginAllowed(wsReq("http://other.test", "app.test"), {
|
||||
cors: { enabled: true, origin: "http://other.test" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { jsx } from "../src/jsx-runtime.ts";
|
||||
|
||||
test("JSX rejects dynamic tag-name injection", () => {
|
||||
expect(() => jsx("div><script>alert(1)</script><div" as "div", {})).toThrow(
|
||||
"Invalid JSX tag name",
|
||||
);
|
||||
});
|
||||
|
||||
test("JSX skips invalid spread attribute names", () => {
|
||||
const html = jsx("div", { 'title" onmouseover="alert(1)': "x", title: "safe" }).toString();
|
||||
expect(html).toBe('<div title="safe"></div>');
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
createContext,
|
||||
rateLimit,
|
||||
requestLogger,
|
||||
TTLCache,
|
||||
cacheControl,
|
||||
withCacheControl,
|
||||
etag,
|
||||
notModified,
|
||||
saveUpload,
|
||||
collectUploads,
|
||||
sanitizeFilename,
|
||||
UploadError,
|
||||
setSessionBackend,
|
||||
loadSession,
|
||||
type SessionBackend,
|
||||
type SessionEntry,
|
||||
type AsyncSessionBackend,
|
||||
type RateLimitStore,
|
||||
} from "../src/index.ts";
|
||||
|
||||
function memoryBackend(): SessionBackend {
|
||||
const map = new Map<string, SessionEntry>();
|
||||
return {
|
||||
get: (id) => map.get(id),
|
||||
set: (id, e) => void map.set(id, e),
|
||||
delete: (id) => void map.delete(id),
|
||||
_map: map,
|
||||
} as SessionBackend & { _map: Map<string, SessionEntry> };
|
||||
}
|
||||
|
||||
function ctx(path = "/", headers: Record<string, string> = {}) {
|
||||
const url = new URL(`http://x${path}`);
|
||||
return createContext(new Request(url, { headers }), url);
|
||||
}
|
||||
|
||||
// --- rate limiting ---------------------------------------------------------
|
||||
|
||||
test("rateLimit (trustProxy) allows up to max then 429 with headers", async () => {
|
||||
const mw = rateLimit({ max: 2, windowMs: 60_000, trustProxy: true });
|
||||
const ok = () => new Response("ok");
|
||||
const key = { "x-forwarded-for": "1.1.1.1" };
|
||||
|
||||
const r1 = await mw(ctx("/", key), ok);
|
||||
const r2 = await mw(ctx("/", key), ok);
|
||||
const r3 = await mw(ctx("/", key), ok);
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r1.headers.get("RateLimit-Remaining")).toBe("1");
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r2.headers.get("RateLimit-Remaining")).toBe("0");
|
||||
expect(r3.status).toBe(429);
|
||||
expect(r3.headers.get("retry-after")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("rateLimit (trustProxy) buckets are independent per key", async () => {
|
||||
const mw = rateLimit({ max: 1, windowMs: 60_000, trustProxy: true });
|
||||
const ok = () => new Response("ok");
|
||||
const a = await mw(ctx("/", { "x-forwarded-for": "2.2.2.2" }), ok);
|
||||
const b = await mw(ctx("/", { "x-forwarded-for": "3.3.3.3" }), ok);
|
||||
expect(a.status).toBe(200);
|
||||
expect(b.status).toBe(200);
|
||||
});
|
||||
|
||||
test("setSessionBackend routes session data through a custom backend", () => {
|
||||
const backend = memoryBackend() as SessionBackend & { _map: Map<string, SessionEntry> };
|
||||
setSessionBackend(backend);
|
||||
try {
|
||||
const c = ctx("/");
|
||||
c.session.set("k", "v");
|
||||
const id = c.session.id();
|
||||
expect(backend._map.has(id)).toBe(true);
|
||||
expect(backend._map.get(id)!.data).toEqual({ k: "v" });
|
||||
} finally {
|
||||
setSessionBackend(memoryBackend()); // restore an equivalent for other tests
|
||||
}
|
||||
});
|
||||
|
||||
test("rateLimit accepts a custom (shared) store", async () => {
|
||||
const hits: string[] = [];
|
||||
const store: RateLimitStore = {
|
||||
hit(key, windowMs, now) {
|
||||
hits.push(key);
|
||||
return { count: hits.filter((k) => k === key).length, resetAt: now + windowMs };
|
||||
},
|
||||
};
|
||||
const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
|
||||
const ok = () => new Response("ok");
|
||||
const key = { "x-forwarded-for": "5.5.5.5" };
|
||||
expect((await mw(ctx("/", key), ok)).status).toBe(200);
|
||||
expect((await mw(ctx("/", key), ok)).status).toBe(429);
|
||||
expect(hits.length).toBe(2); // both requests went through the injected store
|
||||
});
|
||||
|
||||
test("rateLimit awaits an ASYNC store (e.g. Redis)", async () => {
|
||||
const counts = new Map<string, number>();
|
||||
const store: RateLimitStore = {
|
||||
async hit(key, windowMs, now) {
|
||||
const n = (counts.get(key) ?? 0) + 1;
|
||||
counts.set(key, n);
|
||||
return { count: n, resetAt: now + windowMs };
|
||||
},
|
||||
};
|
||||
const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
|
||||
const ok = () => new Response("ok");
|
||||
const key = { "x-forwarded-for": "7.7.7.7" };
|
||||
expect((await mw(ctx("/", key), ok)).status).toBe(200);
|
||||
expect((await mw(ctx("/", key), ok)).status).toBe(429);
|
||||
});
|
||||
|
||||
test("loadSession persists a session through an ASYNC backend across requests", async () => {
|
||||
const kv = new Map<string, SessionEntry>();
|
||||
const backend: AsyncSessionBackend = {
|
||||
load: async (id) => kv.get(id),
|
||||
save: async (id, entry) => void kv.set(id, entry),
|
||||
destroy: async (id) => void kv.delete(id),
|
||||
};
|
||||
const mw = loadSession(backend);
|
||||
|
||||
// Request 1: write a value, capture the issued session id.
|
||||
const c1 = ctx("/");
|
||||
await mw(c1, () => {
|
||||
c1.session.set("hits", 1);
|
||||
return new Response("ok");
|
||||
});
|
||||
const sid = c1.cookies.get("wrnexus.sid")!;
|
||||
expect(sid).toBeTruthy();
|
||||
expect(kv.has(sid)).toBe(true); // saved to the async backend
|
||||
|
||||
// Request 2: same cookie → session loads from the backend.
|
||||
const c2 = ctx("/", { cookie: `wrnexus.sid=${sid}` });
|
||||
let seen: unknown;
|
||||
await mw(c2, () => {
|
||||
seen = c2.session.get("hits");
|
||||
return new Response("ok");
|
||||
});
|
||||
expect(seen).toBe(1);
|
||||
});
|
||||
|
||||
test("rateLimit default keys on the non-spoofable peer IP, not XFF headers", async () => {
|
||||
const mw = rateLimit({ max: 1, windowMs: 60_000 });
|
||||
const ok = () => new Response("ok");
|
||||
// Same peer IP, different spoofed XFF → still one bucket (XFF ignored).
|
||||
const c1 = ctx("/", { "x-forwarded-for": "9.9.9.9" });
|
||||
c1.ip = "10.0.0.1";
|
||||
const c2 = ctx("/", { "x-forwarded-for": "8.8.8.8" });
|
||||
c2.ip = "10.0.0.1";
|
||||
expect((await mw(c1, ok)).status).toBe(200);
|
||||
expect((await mw(c2, ok)).status).toBe(429);
|
||||
});
|
||||
|
||||
test("rateLimit validates its in-memory key bound", () => {
|
||||
expect(() => rateLimit({ maxKeys: 0 })).toThrow("maxKeys");
|
||||
});
|
||||
|
||||
// --- request logging -------------------------------------------------------
|
||||
|
||||
test("requestLogger emits a structured record with duration and id", async () => {
|
||||
const records: string[] = [];
|
||||
let t = 1000;
|
||||
const mw = requestLogger({
|
||||
format: "json",
|
||||
sink: (line) => records.push(line),
|
||||
now: () => (t += 5),
|
||||
});
|
||||
const res = await mw(ctx("/api/users"), () => new Response("x", { status: 201 }));
|
||||
expect(res.status).toBe(201);
|
||||
const rec = JSON.parse(records[0]!);
|
||||
expect(rec.method).toBe("GET");
|
||||
expect(rec.path).toBe("/api/users");
|
||||
expect(rec.status).toBe(201);
|
||||
expect(rec.durationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(rec.id).toBeTruthy();
|
||||
});
|
||||
|
||||
test("requestLogger logs status 500 when the handler throws", async () => {
|
||||
const records: string[] = [];
|
||||
const mw = requestLogger({ format: "json", sink: (l) => records.push(l) });
|
||||
await expect(
|
||||
mw(ctx("/boom"), () => {
|
||||
throw new Error("nope");
|
||||
}),
|
||||
).rejects.toThrow("nope");
|
||||
expect(JSON.parse(records[0]!).status).toBe(500);
|
||||
});
|
||||
|
||||
// --- caching ---------------------------------------------------------------
|
||||
|
||||
test("TTLCache getOrLoad caches until expiry", async () => {
|
||||
const cache = new TTLCache<number>(60_000);
|
||||
let calls = 0;
|
||||
const load = () => {
|
||||
calls++;
|
||||
return 42;
|
||||
};
|
||||
expect(await cache.getOrLoad("k", load)).toBe(42);
|
||||
expect(await cache.getOrLoad("k", load)).toBe(42);
|
||||
expect(calls).toBe(1);
|
||||
cache.delete("k");
|
||||
expect(await cache.getOrLoad("k", load)).toBe(42);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
test("TTLCache coalesces concurrent loads for the same key", async () => {
|
||||
const cache = new TTLCache<number>();
|
||||
let calls = 0;
|
||||
const loader = async () => {
|
||||
calls++;
|
||||
await Promise.resolve();
|
||||
return 7;
|
||||
};
|
||||
expect(await Promise.all([cache.getOrLoad("x", loader), cache.getOrLoad("x", loader)])).toEqual([
|
||||
7, 7,
|
||||
]);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
test("TTLCache does not let an old in-flight load overwrite set, delete, or clear", async () => {
|
||||
const cache = new TTLCache<number>();
|
||||
let release!: (value: number) => void;
|
||||
const loading = cache.getOrLoad("x", () => new Promise<number>((resolve) => (release = resolve)));
|
||||
await Promise.resolve();
|
||||
cache.set("x", 9);
|
||||
release(1);
|
||||
expect(await loading).toBe(1);
|
||||
expect(cache.get("x")).toBe(9);
|
||||
|
||||
let releaseClear!: (value: number) => void;
|
||||
const clearing = cache.getOrLoad(
|
||||
"y",
|
||||
() => new Promise<number>((resolve) => (releaseClear = resolve)),
|
||||
);
|
||||
await Promise.resolve();
|
||||
cache.clear();
|
||||
releaseClear(2);
|
||||
await clearing;
|
||||
expect(cache.get("y")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("cacheControl builds directives; no-store wins", () => {
|
||||
expect(cacheControl({ maxAge: 60, sMaxAge: 120 })).toBe("public, max-age=60, s-maxage=120");
|
||||
expect(cacheControl({ private: true, noCache: true })).toBe("private, no-cache");
|
||||
expect(cacheControl({ noStore: true, maxAge: 99 })).toBe("no-store");
|
||||
const res = withCacheControl(new Response("x"), { maxAge: 30, immutable: true });
|
||||
expect(res.headers.get("Cache-Control")).toBe("public, max-age=30, immutable");
|
||||
});
|
||||
|
||||
test("etag + notModified drive conditional requests", () => {
|
||||
const tag = etag("hello world");
|
||||
expect(tag).toMatch(/^W\/"/);
|
||||
expect(etag("hello world")).toBe(tag); // stable
|
||||
expect(etag("different")).not.toBe(tag);
|
||||
const req = new Request("http://x", { headers: { "if-none-match": tag } });
|
||||
expect(notModified(req, tag)).toBe(true);
|
||||
expect(notModified(new Request("http://x"), tag)).toBe(false);
|
||||
});
|
||||
|
||||
// --- uploads ---------------------------------------------------------------
|
||||
|
||||
test("sanitizeFilename strips traversal and separators", () => {
|
||||
const s = sanitizeFilename("../../etc/passwd");
|
||||
expect(s).not.toContain("/");
|
||||
expect(s).not.toContain("..");
|
||||
expect(s).toContain("passwd");
|
||||
expect(sanitizeFilename("a/b\\c.png")).toBe("a_b_c.png");
|
||||
expect(sanitizeFilename("")).toBe("upload");
|
||||
});
|
||||
|
||||
test("saveUpload writes a validated file and enforces limits", async () => {
|
||||
const dir = join(tmpdir(), "wire-upload-test");
|
||||
const file = new File(["hello upload"], "note.txt", { type: "text/plain" });
|
||||
|
||||
const saved = await saveUpload(file, { dir, allowedTypes: ["text/plain", ".txt"] });
|
||||
expect(saved.filename).toBe("note.txt");
|
||||
expect(saved.size).toBe(12);
|
||||
expect(existsSync(saved.path)).toBe(true);
|
||||
|
||||
await expect(saveUpload(file, { dir, maxBytes: 4 })).rejects.toThrow(UploadError);
|
||||
await expect(saveUpload(file, { dir, allowedTypes: ["image/png"] })).rejects.toThrow(UploadError);
|
||||
});
|
||||
|
||||
test("collectUploads returns only non-empty File fields", async () => {
|
||||
const form = new FormData();
|
||||
form.append("name", "ada");
|
||||
form.append("avatar", new File(["img"], "a.png", { type: "image/png" }));
|
||||
const uploads = collectUploads(form);
|
||||
expect(uploads.length).toBe(1);
|
||||
expect(uploads[0]!.field).toBe("avatar");
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
createRealtimeRegistry,
|
||||
bridgeRealtime,
|
||||
defineRoom,
|
||||
type RawSocket,
|
||||
} from "../src/index.ts";
|
||||
import { createPubSub } from "../../pubsub/src/index.ts";
|
||||
|
||||
/** A fake socket that records what the server sends to it. */
|
||||
function fakeSocket(): RawSocket & { received: string[] } {
|
||||
const received: string[] = [];
|
||||
return { received, send: (d: string) => received.push(d), close: () => {} };
|
||||
}
|
||||
|
||||
test("bridgeRealtime: a room broadcast on one registry reaches connections on another", async () => {
|
||||
// One shared bus stands in for Redis across two 'processes' (registries).
|
||||
const bus = createPubSub();
|
||||
|
||||
const room = defineRoom({
|
||||
onMessage(client, msg) {
|
||||
client.room.broadcast({ echo: msg }); // everyone in the room, on every process
|
||||
},
|
||||
});
|
||||
|
||||
const rA = createRealtimeRegistry();
|
||||
const rB = createRealtimeRegistry();
|
||||
bridgeRealtime(rA, bus);
|
||||
bridgeRealtime(rB, bus);
|
||||
|
||||
// A client connected to registry B, in room "chat".
|
||||
const sB = fakeSocket();
|
||||
await rB.open(sB, { room: "chat", def: room });
|
||||
|
||||
// A client connected to registry A triggers a broadcast.
|
||||
const sA = fakeSocket();
|
||||
await rA.open(sA, { room: "chat", def: room });
|
||||
await rA.message(sA, JSON.stringify({ hi: 1 }));
|
||||
|
||||
// The broadcast crossed the bus: B's client received it even though the
|
||||
// broadcast happened on registry A.
|
||||
const gotOnB = sB.received.find((p) => p.includes("echo"));
|
||||
expect(gotOnB).toBeTruthy();
|
||||
expect(JSON.parse(gotOnB!)).toEqual({ echo: { hi: 1 } });
|
||||
});
|
||||
|
||||
test("bridgeRealtime: no bus means broadcasts stay local", async () => {
|
||||
const room = defineRoom({
|
||||
onMessage(client, msg) {
|
||||
client.room.broadcast({ echo: msg });
|
||||
},
|
||||
});
|
||||
const rA = createRealtimeRegistry();
|
||||
const rB = createRealtimeRegistry(); // NOT bridged to A
|
||||
|
||||
const sB = fakeSocket();
|
||||
await rB.open(sB, { room: "chat", def: room });
|
||||
const sA = fakeSocket();
|
||||
await rA.open(sA, { room: "chat", def: room });
|
||||
await rA.message(sA, JSON.stringify({ hi: 1 }));
|
||||
|
||||
expect(sB.received.length).toBe(0); // isolated — nothing crossed
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
defineRoom,
|
||||
isRoomDefinition,
|
||||
createRealtimeRegistry,
|
||||
type RawSocket,
|
||||
} from "../src/index.ts";
|
||||
|
||||
interface MockSocket extends RawSocket {
|
||||
sent: Record<string, unknown>[];
|
||||
}
|
||||
function mockSocket(): MockSocket {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
return {
|
||||
sent,
|
||||
send(data: string) {
|
||||
sent.push(JSON.parse(data) as Record<string, unknown>);
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
}
|
||||
|
||||
test("defineRoom marks a room definition", () => {
|
||||
expect(isRoomDefinition(defineRoom({}))).toBe(true);
|
||||
expect(isRoomDefinition({})).toBe(false);
|
||||
expect(isRoomDefinition(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("lifecycle hooks fire; broadcast reaches the whole room", async () => {
|
||||
const events: string[] = [];
|
||||
const def = defineRoom({
|
||||
onConnect(c) {
|
||||
events.push("connect");
|
||||
c.broadcast({ type: "join" }); // others only
|
||||
},
|
||||
onMessage(c, m) {
|
||||
events.push("message");
|
||||
c.room.broadcast({ type: "echo", text: m.text }); // everyone incl. sender
|
||||
},
|
||||
onLeave(c) {
|
||||
events.push("leave");
|
||||
c.broadcast({ type: "left" });
|
||||
},
|
||||
});
|
||||
const reg = createRealtimeRegistry();
|
||||
const a = mockSocket();
|
||||
const b = mockSocket();
|
||||
await reg.open(a, { room: "/r/x", def });
|
||||
await reg.open(b, { room: "/r/x", def });
|
||||
expect(a.sent.some((m) => m.type === "join")).toBe(true); // A saw B join
|
||||
expect(b.sent.some((m) => m.type === "join")).toBe(false); // B didn't see its own join
|
||||
|
||||
await reg.message(a, JSON.stringify({ text: "hi" }));
|
||||
expect(a.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true); // sender sees own
|
||||
expect(b.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true);
|
||||
|
||||
await reg.close(b);
|
||||
expect(a.sent.some((m) => m.type === "left")).toBe(true);
|
||||
expect(reg.size()).toBe(1);
|
||||
expect(events).toEqual(["connect", "connect", "message", "leave"]);
|
||||
});
|
||||
|
||||
test("to(connectionId) and toUser(user|users) target precisely", async () => {
|
||||
const ids: Record<string, string> = {};
|
||||
const def = defineRoom({
|
||||
onConnect(c) {
|
||||
c.user = c.query.as; // identify by ?as=
|
||||
ids[c.query.as!] = c.id;
|
||||
},
|
||||
onMessage(c, m) {
|
||||
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
|
||||
if (m.toId) c.to(m.toId).send({ type: "direct", text: m.text });
|
||||
},
|
||||
});
|
||||
const reg = createRealtimeRegistry();
|
||||
const alice = mockSocket();
|
||||
const bob = mockSocket();
|
||||
const carol = mockSocket();
|
||||
await reg.open(alice, { room: "/r", def, query: { as: "alice" } });
|
||||
await reg.open(bob, { room: "/r", def, query: { as: "bob" } });
|
||||
await reg.open(carol, { room: "/r", def, query: { as: "carol" } });
|
||||
|
||||
// single user
|
||||
await reg.message(alice, JSON.stringify({ toUser: "bob", text: "hey bob" }));
|
||||
expect(bob.sent.some((m) => m.type === "dm" && m.text === "hey bob")).toBe(true);
|
||||
expect(carol.sent.some((m) => m.type === "dm")).toBe(false);
|
||||
|
||||
// selected users
|
||||
await reg.message(alice, JSON.stringify({ toUser: ["bob", "carol"], text: "both" }));
|
||||
expect(bob.sent.filter((m) => m.type === "dm").length).toBe(2);
|
||||
expect(carol.sent.some((m) => m.text === "both")).toBe(true);
|
||||
|
||||
// by connection id
|
||||
await reg.message(alice, JSON.stringify({ toId: ids.carol, text: "by-id" }));
|
||||
expect(carol.sent.some((m) => m.type === "direct" && m.text === "by-id")).toBe(true);
|
||||
});
|
||||
|
||||
test("rooms are isolated from each other", async () => {
|
||||
const def = defineRoom({
|
||||
onMessage(c, m) {
|
||||
c.room.broadcast({ type: "x", text: m.text });
|
||||
},
|
||||
});
|
||||
const reg = createRealtimeRegistry();
|
||||
const a = mockSocket();
|
||||
const b = mockSocket();
|
||||
await reg.open(a, { room: "/room/1", def }); // dynamic room instances, one handler
|
||||
await reg.open(b, { room: "/room/2", def });
|
||||
await reg.message(a, JSON.stringify({ text: "one" }));
|
||||
expect(a.sent.some((m) => m.text === "one")).toBe(true);
|
||||
expect(b.sent.length).toBe(0); // different room, untouched
|
||||
});
|
||||
|
||||
test("bridge relays broadcasts + toUser across registries (horizontal scaling)", async () => {
|
||||
const regA = createRealtimeRegistry();
|
||||
const regB = createRealtimeRegistry();
|
||||
// A shared bus: each instance delivers the other's published envelopes.
|
||||
regA.setBridge({ publish: (env) => regB.deliver(env) });
|
||||
regB.setBridge({ publish: (env) => regA.deliver(env) });
|
||||
|
||||
const def = defineRoom({
|
||||
onConnect(c) {
|
||||
c.user = c.query.as;
|
||||
},
|
||||
onMessage(c, m) {
|
||||
if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
|
||||
else c.room.broadcast({ type: "x", text: m.text });
|
||||
},
|
||||
});
|
||||
const a = mockSocket();
|
||||
const b = mockSocket();
|
||||
await regA.open(a, { room: "/r", def, query: { as: "alice" } });
|
||||
await regB.open(b, { room: "/r", def, query: { as: "bob" } }); // b is on the OTHER instance
|
||||
|
||||
// broadcast from A reaches B through the bridge
|
||||
await regA.message(a, JSON.stringify({ text: "cross-instance" }));
|
||||
expect(a.sent.some((m) => m.text === "cross-instance")).toBe(true);
|
||||
expect(b.sent.some((m) => m.text === "cross-instance")).toBe(true);
|
||||
|
||||
// toUser bob (on instance B) from A reaches him via the bridge; alice doesn't
|
||||
await regA.message(a, JSON.stringify({ toUser: "bob", text: "hi bob" }));
|
||||
expect(b.sent.some((m) => m.type === "dm" && m.text === "hi bob")).toBe(true);
|
||||
const aliceDms = a.sent.filter((m) => m.type === "dm").length;
|
||||
expect(aliceDms).toBe(0); // not looped back / not delivered to the wrong user
|
||||
});
|
||||
|
||||
test("room.state and count() track the live room", async () => {
|
||||
const def = defineRoom({
|
||||
onConnect(c) {
|
||||
c.room.state.hits = ((c.room.state.hits as number) ?? 0) + 1;
|
||||
c.send({ type: "welcome", online: c.room.count(), hits: c.room.state.hits });
|
||||
},
|
||||
});
|
||||
const reg = createRealtimeRegistry();
|
||||
const a = mockSocket();
|
||||
const b = mockSocket();
|
||||
await reg.open(a, { room: "/r", def });
|
||||
await reg.open(b, { room: "/r", def });
|
||||
expect(a.sent[0]).toMatchObject({ online: 1, hits: 1 });
|
||||
expect(b.sent[0]).toMatchObject({ online: 2, hits: 2 });
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { streamResponse, sse } from "../src/index.ts";
|
||||
|
||||
test("streamResponse streams a sync iterable of strings as HTML", async () => {
|
||||
const res = streamResponse(["<h1>", "Hello", "</h1>"]);
|
||||
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(await res.text()).toBe("<h1>Hello</h1>");
|
||||
});
|
||||
|
||||
test("streamResponse streams an async generator (streaming SSR shell + body)", async () => {
|
||||
async function* page() {
|
||||
yield '<!doctype html><body><div id="app">';
|
||||
yield "<p>content</p>";
|
||||
yield "</div></body>";
|
||||
}
|
||||
const res = streamResponse(page(), { status: 200 });
|
||||
const text = await res.text();
|
||||
expect(text).toContain('<div id="app">');
|
||||
expect(text).toContain("<p>content</p>");
|
||||
});
|
||||
|
||||
test("streamResponse honours custom content-type and status", async () => {
|
||||
const res = streamResponse(["plain"], { contentType: "text/plain", status: 201 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.headers.get("content-type")).toBe("text/plain");
|
||||
});
|
||||
|
||||
test("sse formats Server-Sent Events frames", async () => {
|
||||
async function* events() {
|
||||
yield { data: "hello", event: "greeting", id: "1" };
|
||||
yield { data: "line1\nline2", retry: 3000 };
|
||||
}
|
||||
const res = sse(events());
|
||||
expect(res.headers.get("content-type")).toBe("text/event-stream");
|
||||
const text = await res.text();
|
||||
expect(text).toContain("event: greeting\nid: 1\ndata: hello\n\n");
|
||||
expect(text).toContain("retry: 3000\ndata: line1\ndata: line2\n\n");
|
||||
});
|
||||
Reference in New Issue
Block a user