release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createAcknowledgement,
|
||||
createRealtimeHistory,
|
||||
createRealtimeMessage,
|
||||
realtimeSseResponse,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("realtime replay and acknowledgements", () => {
|
||||
test("bounds room history and resumes after a client acknowledgement", () => {
|
||||
const history = createRealtimeHistory({ limitPerRoom: 2 });
|
||||
const entries = [1, 2, 3].map((value) =>
|
||||
history.publish(
|
||||
"chat",
|
||||
createRealtimeMessage({ type: "message", room: "chat", data: { value } }),
|
||||
),
|
||||
);
|
||||
expect(history.replay("chat").map((entry) => entry.message.data)).toEqual([
|
||||
{ value: 2 },
|
||||
{ value: 3 },
|
||||
]);
|
||||
history.acknowledge("chat", "client-1", entries[1]!.sequence);
|
||||
expect(history.resume("chat", "client-1")).toHaveLength(1);
|
||||
expect(history.snapshot()).toMatchObject({ rooms: 1, messages: 2, acknowledgements: 1 });
|
||||
});
|
||||
test("acknowledgements are monotonic and reject impossible values", () => {
|
||||
const history = createRealtimeHistory();
|
||||
const entry = history.publish("chat", createRealtimeMessage({ type: "message", data: {} }));
|
||||
history.acknowledge("chat", "client", entry.sequence);
|
||||
history.acknowledge("chat", "client", 0);
|
||||
expect(history.acknowledged("chat", "client")).toBe(entry.sequence);
|
||||
expect(() => history.acknowledge("chat", "client", entry.sequence + 1)).toThrow();
|
||||
expect(createAcknowledgement("chat", entry.sequence, "client").type).toBe("ack");
|
||||
});
|
||||
test("encodes sequenced events as SSE", async () => {
|
||||
const message = createRealtimeMessage({ type: "notice", room: "chat", data: { ok: true } });
|
||||
const response = realtimeSseResponse(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({ sequence: 7, message });
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||
expect(await response.text()).toContain("id: 7\nevent: notice\ndata:");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
createPresenceEvent,
|
||||
createRealtimeMessage,
|
||||
createTypingEvent,
|
||||
parseRealtimeMessage,
|
||||
roomMemberSummary,
|
||||
roomQuery,
|
||||
} from "../src/index.ts";
|
||||
|
||||
test("creates and parses typed realtime messages", () => {
|
||||
const message = createRealtimeMessage({
|
||||
id: "message-1",
|
||||
type: "message",
|
||||
room: "support/general",
|
||||
data: { text: "Hi" },
|
||||
sentAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(parseRealtimeMessage(JSON.stringify(message), { room: "support/general" })).toEqual(
|
||||
message,
|
||||
);
|
||||
});
|
||||
|
||||
test("builds presence and typing events", () => {
|
||||
expect(createPresenceEvent("join", { userId: "u1" }).type).toBe("presence");
|
||||
expect(createTypingEvent("u1", true).data.typing).toBe(true);
|
||||
});
|
||||
|
||||
test("summarizes room members and query parameters", () => {
|
||||
expect(
|
||||
roomMemberSummary([
|
||||
{ userId: "1", status: "online" },
|
||||
{ userId: "2", status: "away" },
|
||||
]),
|
||||
).toEqual({ total: 2, online: 1, away: 1, busy: 0 });
|
||||
expect(roomQuery({ user: "u1", page: 2, active: true })).toBe("user=u1&page=2&active=true");
|
||||
});
|
||||
|
||||
test("rejects oversized, unsafe, mismatched, and unknown messages", () => {
|
||||
const message = createRealtimeMessage({
|
||||
id: "message-2",
|
||||
type: "message",
|
||||
room: "support",
|
||||
data: { text: "Hi" },
|
||||
});
|
||||
expect(() =>
|
||||
parseRealtimeMessage(JSON.stringify(message), { allowedTypes: ["presence"] }),
|
||||
).toThrow("not allowed");
|
||||
expect(() => parseRealtimeMessage(JSON.stringify(message), { room: "other" })).toThrow(
|
||||
"does not match",
|
||||
);
|
||||
expect(() => parseRealtimeMessage(JSON.stringify(message), { maxBytes: 16 })).toThrow(
|
||||
"maximum size",
|
||||
);
|
||||
expect(() => roomQuery({ "bad key": "x" })).toThrow("query key");
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createFileStreamReceiver, databaseChangeFeed, frameFileStream } from "../src/index.ts";
|
||||
|
||||
test("database feeds publish only allowed safe table changes", async () => {
|
||||
let handler: ((change: any) => void) | undefined;
|
||||
const published: string[] = [];
|
||||
databaseChangeFeed(
|
||||
{
|
||||
subscribe(next) {
|
||||
handler = next;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
(topic) => {
|
||||
published.push(topic);
|
||||
},
|
||||
{ allowTables: ["users"] },
|
||||
);
|
||||
await handler!({ table: "users", operation: "update", occurredAt: 1 });
|
||||
await handler!({ table: "secrets", operation: "update", occurredAt: 1 });
|
||||
await handler!({ table: "users;drop", operation: "delete", occurredAt: 1 });
|
||||
expect(published).toEqual(["db:users"]);
|
||||
});
|
||||
|
||||
test("file streams reassemble out of order and enforce bounds", () => {
|
||||
const bytes = new TextEncoder().encode("x".repeat(3000));
|
||||
const frames = frameFileStream("upload-1", bytes, { chunkBytes: 1024 });
|
||||
const receiver = createFileStreamReceiver({ maxBytes: 4000 });
|
||||
expect(receiver.accept(frames[2]!)).toBeNull();
|
||||
expect(receiver.accept(frames[0]!)).toBeNull();
|
||||
expect(receiver.accept(frames[1]!)).toEqual(bytes);
|
||||
expect(() => frameFileStream("bad/id", bytes)).toThrow("Invalid stream id");
|
||||
expect(() => frameFileStream("large", bytes, { maxBytes: 10 })).toThrow("FILE-LIMIT");
|
||||
});
|
||||
Reference in New Issue
Block a user