,
- E = ExtractParamsBasic,
-> = Resolve<
- Pick & TParamTypes, Extract>
->;
-
-const ALL: Method[] = ["GET", "POST", "PATCH", "PUT", "DELETE"];
-
-export interface RouteMatcher {
- method: Method;
- matchMethod(req: { method?: string }): boolean;
- matchURL(url: URL): Record | null;
-}
-
-function segmentAsVariable(s: string): string | null {
- if (s.startsWith("<") && s.endsWith(">")) {
- const identifier = s.slice(1, -1);
- return identifierRe.test(identifier) ? identifier : null;
- }
- return null;
-}
-
-function splitMethodAndPattern(
- pattern: string
-): [method: Method, pattern: string] {
- for (const method of ALL) {
- if (pattern.startsWith(method)) {
- return [method, pattern.slice(method.length).trimStart()];
- }
- }
- throw new Error(
- `Invalid route pattern: ${JSON.stringify(pattern)}${
- pattern.startsWith("/")
- ? `. Did you mean ${JSON.stringify(`GET ${pattern}`)}?`
- : ""
- }`
- );
-}
-
-function makePathMatcher(pattern: string, options: { exact: boolean }): RegExp {
- const exact = options.exact;
- if (pattern === "/") {
- return exact ? /^\/$/ : /^\//;
- }
-
- if (!pattern.startsWith("/")) {
- // istanbul ignore next -- @preserve
- throw new Error(
- `Route must start with '/', but got ${JSON.stringify(pattern)}`
- );
- }
-
- if (pattern.endsWith("/")) {
- // istanbul ignore next -- @preserve
- throw new Error(
- `Route may not end with '/', but got ${JSON.stringify(pattern)}`
- );
- }
-
- const segments = pattern.slice(1).split("/");
-
- let index = 1;
- const regexString: string[] = [];
- for (const segment of segments) {
- const placeholder = segmentAsVariable(segment);
- if (placeholder !== null) {
- regexString.push(`(?<${placeholder}>[^/]+)`);
- } else if (cleanSegmentRe.test(segment)) {
- regexString.push(segment);
- } else {
- return raise(`Invalid pattern: ${pattern} (error at position ${index + 1})`); // prettier-ignore
- }
-
- index += segment.length + 1;
- }
-
- return new RegExp("^/" + regexString.join("/") + (exact ? "/?$" : "(/|$)"));
-}
-
-export function makePrefixPathMatcher(prefix: string): RegExp {
- pathPrefixRegex.test(prefix) || raise(`Invalid path prefix: ${prefix}`);
- prefix = prefix.slice(0, -2); // Remove the "/*" suffix
- prefix ||= "/"; // If the remaining prefix is "" (empty string), use a "/" instead
-
- // Register the prefix matcher
- return makePathMatcher(prefix, { exact: false });
-}
-
-export function routeMatcher(input: string): RouteMatcher {
- const [method, pattern] = splitMethodAndPattern(input);
- const regex = makePathMatcher(pattern, { exact: true });
- return {
- method,
- matchMethod(req: Request): boolean {
- return method === req.method;
- },
- matchURL(url: URL) {
- const matches = url.pathname.match(regex);
- if (matches === null) {
- return null;
- }
- return matches.groups ?? {};
- },
- };
-}
diff --git a/packages/liveblocks-zenrouter/src/lib/otel.ts b/packages/liveblocks-zenrouter/src/lib/otel.ts
deleted file mode 100644
index 565add5775..0000000000
--- a/packages/liveblocks-zenrouter/src/lib/otel.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export interface OtelSpan {
- setAttribute(key: string, value: string): void;
-}
-
-export interface OtelConfig {
- getActiveSpan: () => OtelSpan | undefined;
-}
diff --git a/packages/liveblocks-zenrouter/src/lib/standard-schema.d.ts b/packages/liveblocks-zenrouter/src/lib/standard-schema.d.ts
deleted file mode 100644
index eba310cfcf..0000000000
--- a/packages/liveblocks-zenrouter/src/lib/standard-schema.d.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-// #########################
-// ### Standard Typed ###
-// #########################
-
-/** The Standard Typed interface. This is a base type extended by other specs. */
-export interface StandardTypedV1 {
- /** The Standard properties. */
- readonly "~standard": StandardTypedV1.Props ;
-}
-
-export declare namespace StandardTypedV1 {
- /** The Standard Typed properties interface. */
- export interface Props {
- /** The version number of the standard. */
- readonly version: 1;
- /** The vendor name of the schema library. */
- readonly vendor: string;
- /** Inferred types associated with the schema. */
- readonly types?: Types | undefined;
- }
-
- /** The Standard Typed types interface. */
- export interface Types {
- /** The input type of the schema. */
- readonly input: Input;
- /** The output type of the schema. */
- readonly output: Output;
- }
-
- /** Infers the input type of a Standard Typed. */
- export type InferInput = NonNullable<
- Schema["~standard"]["types"]
- >["input"];
-
- /** Infers the output type of a Standard Typed. */
- export type InferOutput = NonNullable<
- Schema["~standard"]["types"]
- >["output"];
-}
-
-// ##########################
-// ### Standard Schema ###
-// ##########################
-
-/** The Standard Schema interface. */
-export interface StandardSchemaV1 {
- /** The Standard Schema properties. */
- readonly "~standard": StandardSchemaV1.Props ;
-}
-
-export declare namespace StandardSchemaV1 {
- /** The Standard Schema properties interface. */
- export interface Props
- extends StandardTypedV1.Props {
- /** Validates unknown input values. */
- readonly validate: (
- value: unknown,
- options?: StandardSchemaV1.Options | undefined
- ) => Result | Promise>;
- }
-
- /** The result interface of the validate function. */
- export type Result = SuccessResult | FailureResult;
-
- /** The result interface if validation succeeds. */
- export interface SuccessResult {
- /** The typed output value. */
- readonly value: Output;
- /** A falsy value for `issues` indicates success. */
- readonly issues?: undefined;
- }
-
- export interface Options {
- /** Explicit support for additional vendor-specific parameters, if needed. */
- readonly libraryOptions?: Record | undefined;
- }
-
- /** The result interface if validation fails. */
- export interface FailureResult {
- /** The issues of failed validation. */
- readonly issues: ReadonlyArray;
- }
-
- /** The issue interface of the failure output. */
- export interface Issue {
- /** The error message of the issue. */
- readonly message: string;
- /** The path of the issue, if any. */
- readonly path?: ReadonlyArray | undefined;
- }
-
- /** The path segment interface of the issue. */
- export interface PathSegment {
- /** The key representing a path segment. */
- readonly key: PropertyKey;
- }
-
- /** The Standard types interface. */
- export interface Types
- extends StandardTypedV1.Types {}
-
- /** Infers the input type of a Standard. */
- export type InferInput =
- StandardTypedV1.InferInput;
-
- /** Infers the output type of a Standard. */
- export type InferOutput =
- StandardTypedV1.InferOutput;
-}
-
-// ###############################
-// ### Standard JSON Schema ###
-// ###############################
-
-/** The Standard JSON Schema interface. */
-export interface StandardJSONSchemaV1 {
- /** The Standard JSON Schema properties. */
- readonly "~standard": StandardJSONSchemaV1.Props ;
-}
-
-export declare namespace StandardJSONSchemaV1 {
- /** The Standard JSON Schema properties interface. */
- export interface Props
- extends StandardTypedV1.Props {
- /** Methods for generating the input/output JSON Schema. */
- readonly jsonSchema: StandardJSONSchemaV1.Converter;
- }
-
- /** The Standard JSON Schema converter interface. */
- export interface Converter {
- /** Converts the input type to JSON Schema. May throw if conversion is not supported. */
- readonly input: (
- options: StandardJSONSchemaV1.Options
- ) => Record;
- /** Converts the output type to JSON Schema. May throw if conversion is not supported. */
- readonly output: (
- options: StandardJSONSchemaV1.Options
- ) => Record;
- }
-
- /**
- * The target version of the generated JSON Schema.
- *
- * It is *strongly recommended* that implementers support `"draft-2020-12"` and `"draft-07"`, as they are both in wide use. All other targets can be implemented on a best-effort basis. Libraries should throw if they don't support a specified target.
- *
- * The `"openapi-3.0"` target is intended as a standardized specifier for OpenAPI 3.0 which is a superset of JSON Schema `"draft-04"`.
- */
- export type Target =
- | "draft-2020-12"
- | "draft-07"
- | "openapi-3.0"
- // Accepts any string for future targets while preserving autocomplete
- | ({} & string);
-
- /** The options for the input/output methods. */
- export interface Options {
- /** Specifies the target version of the generated JSON Schema. Support for all versions is on a best-effort basis. If a given version is not supported, the library should throw. */
- readonly target: Target;
-
- /** Explicit support for additional vendor-specific parameters, if needed. */
- readonly libraryOptions?: Record | undefined;
- }
-
- /** The Standard types interface. */
- export interface Types
- extends StandardTypedV1.Types {}
-
- /** Infers the input type of a Standard. */
- export type InferInput =
- StandardTypedV1.InferInput;
-
- /** Infers the output type of a Standard. */
- export type InferOutput =
- StandardTypedV1.InferOutput;
-}
diff --git a/packages/liveblocks-zenrouter/src/lib/utils.ts b/packages/liveblocks-zenrouter/src/lib/utils.ts
deleted file mode 100644
index 92c83aaea1..0000000000
--- a/packages/liveblocks-zenrouter/src/lib/utils.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-export function raise(message: string): never {
- throw new Error(message);
-}
-
-export function mapv(
- obj: Record,
- mapper: (value: T, key: string) => U
-): Record {
- const rv: Record = {};
- for (const key of Object.keys(obj)) {
- rv[key] = mapper(obj[key], key);
- }
- return rv;
-}
diff --git a/packages/liveblocks-zenrouter/src/responses/HttpError.ts b/packages/liveblocks-zenrouter/src/responses/HttpError.ts
deleted file mode 100644
index b5240d3295..0000000000
--- a/packages/liveblocks-zenrouter/src/responses/HttpError.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { raise } from "~/lib/utils.js";
-
-import type { HeadersInit } from "./compat.js";
-
-export class HttpError extends Error {
- static readonly codes: { [code: number]: string | undefined } = {
- 400: "Bad Request",
- 401: "Unauthorized",
- // 402: "Payment Required",
- 403: "Forbidden",
- 404: "Not Found",
- 405: "Method Not Allowed",
- 406: "Not Acceptable",
- // 407: "Proxy Authentication Required",
- // 408: "Request Timeout",
- 409: "Conflict",
- // 410: "Gone",
- 411: "Length Required",
- // 412: "Precondition Failed",
- 413: "Payload Too Large",
- // 414: "URI Too Long",
- 415: "Unsupported Media Type",
- // 416: "Range Not Satisfiable",
- // 417: "Expectation Failed",
- // 418: "I'm a teapot",
- // 421: "Misdirected Request",
- 422: "Unprocessable Entity",
- // 423: "Locked",
- // 424: "Failed Dependency",
- // 425: "Too Early",
- 426: "Upgrade Required",
- // 428: "Precondition Required",
- // 429: "Too Many Requests",
- // 431: "Request Header Fields Too Large",
- // 451: "Unavailable For Legal Reasons",
- // 500: "Internal Server Error",
- };
-
- // TODO Add support for "public reason" details?
- public readonly status: number;
- public readonly headers?: HeadersInit;
-
- constructor(status: number, message?: string, headers?: HeadersInit) {
- if (typeof status !== "number" || status < 100 || status >= 600) {
- raise(`Invalid HTTP status code: ${status}`);
- }
-
- if (status >= 500) {
- raise("Don't use HttpError for 5xx errors");
- }
-
- if (status >= 200 && status < 300) {
- raise("Cannot create an HTTP error for a success code");
- }
-
- message ??=
- HttpError.codes[status] ??
- raise(`Unknown error code ${status}, provide a message`);
- super(message);
-
- if (status === 422 && !(this instanceof ValidationError)) {
- raise("Don't use HttpError for 422 errors, use ValidationError");
- }
-
- this.status = status;
- this.headers = headers;
- }
-}
-
-export class ValidationError extends HttpError {
- public readonly status = 422;
- public readonly reason: string;
-
- constructor(reason: string) {
- super(422);
- this.reason = reason;
- }
-}
diff --git a/packages/liveblocks-zenrouter/src/responses/compat.ts b/packages/liveblocks-zenrouter/src/responses/compat.ts
deleted file mode 100644
index edec35fc3f..0000000000
--- a/packages/liveblocks-zenrouter/src/responses/compat.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// HeadersInit is normally a global from lib.dom.d.ts (for DOM or Node
-// environments), or from @cloudflare/workers-types. In this case, we want to
-// be able to use it from _both_ environments, so we're defining it here inline
-// as the intersection of the two.
-export type HeadersInit = Record | Headers;
diff --git a/packages/liveblocks-zenrouter/src/responses/index.ts b/packages/liveblocks-zenrouter/src/responses/index.ts
deleted file mode 100644
index 713230fea2..0000000000
--- a/packages/liveblocks-zenrouter/src/responses/index.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import type { Json, JsonObject } from "@liveblocks/core";
-
-import type { HeadersInit } from "./compat.js";
-import { HttpError, ValidationError } from "./HttpError.js";
-
-const KB = 1024;
-
-/**
- * A simple shim for ReadableStream.from(). Uses the native implementation if
- * available.
- *
- * This polyfill does not guarantee spec-compliance, and merely exists so we
- * can use ReadableStream.from() in environments that don't support this API
- * yet, like Bun, or old Node versions.
- *
- * This API is available in the following runtimes:
- * - Node.js (since v20.6+)
- * - Cloudflare Workers (since Apr 4, 2024)
- *
- * But not supported yet in:
- * - Bun - see https://github.com/oven-sh/bun/issues/3700
- */
-/* istanbul ignore next -- @preserve polyfill only used in environments without ReadableStream.from (e.g. Bun) */
-function ReadableStream_from_shim(iterable: Iterable): ReadableStream {
- const iterator = iterable[Symbol.iterator]();
- return new ReadableStream({
- pull(controller) {
- const res = iterator.next();
- if (res.done) {
- controller.close();
- } else {
- controller.enqueue(res.value);
- }
- },
- cancel() {
- iterator.return?.();
- },
- });
-}
-
-/* eslint-disable */
-/* istanbul ignore next -- @preserve only one branch reachable per environment */
-const ReadableStream_from =
- typeof (ReadableStream as any).from === "function"
- ? ((ReadableStream as any).from.bind(ReadableStream) as (
- iterable: Iterable
- ) => ReadableStream)
- : ReadableStream_from_shim;
-/* eslint-enable */
-
-function* imap(iterable: Iterable, fn: (x: T) => U): Iterable {
- for (const x of iterable) {
- yield fn(x);
- }
-}
-
-/**
- * WeakSet tracking "generic" abort responses.
- * Generic responses can be replaced by the error handler with custom error formatting.
- * Non-generic responses (e.g., custom json() responses) are returned verbatim.
- */
-const genericAborts = new WeakSet();
-
-/**
- * Checks if a Response is a generic abort response (created by abort()).
- */
-export function isGenericAbort(resp: Response): boolean {
- return genericAborts.has(resp);
-}
-
-/**
- * Returns an empty HTTP 204 response.
- */
-export function empty(): Response {
- return new Response(null, { status: 204 });
-}
-
-/**
- * Return a JSON response.
- */
-export function json(
- value: JsonObject,
- status = 200,
- headers?: HeadersInit
-): Response {
- return new Response(JSON.stringify(value), {
- status,
- headers: { ...headers, "Content-Type": "application/json; charset=utf-8" },
- });
-}
-
-/**
- * Return an HTML response.
- */
-export function html(
- content: string,
- status = 200,
- headers?: HeadersInit
-): Response {
- return new Response(content, {
- status,
- headers: { ...headers, "Content-Type": "text/html; charset=utf-8" },
- });
-}
-
-/**
- * Throws a generic abort Response for the given status code. Use this to
- * terminate the handling of a route and return an HTTP error to the user.
- *
- * The response body will be determined by the configured error handler.
- * To return a custom error body that won't be replaced, throw a json() response instead.
- */
-export function abort(status: number, headers?: HeadersInit): never {
- const resp = new Response(null, { status, headers });
- genericAborts.add(resp);
- throw resp;
-}
-
-const encoder = new TextEncoder();
-
-/**
- * Batch small string chunks into larger blocks of at least `minSize`
- * characters before yielding. Reduces per-chunk overhead when the source
- * generator yields many tiny strings.
- */
-function* buffered(iterable: Iterable, size: number): Iterable {
- let buf = "";
- for (const s of iterable) {
- buf += s;
- if (buf.length >= size) {
- yield buf;
- buf = "";
- }
- }
- if (buf) yield buf;
-}
-
-/**
- * Return a streaming text response from a generator that yields strings. The
- * stream will be encoded as UTF-8.
- *
- * Small string chunks will get buffered into emitted chunks of `bufSize` bytes (defaults to 64 kB)
- * at least 64kB that many characters before being encoded and enqueued,
- * reducing per-chunk transfer overhead. (By default buffers chunks of at least
- * 64kB size.)
- */
-export function textStream(
- iterable: Iterable,
- headers?: HeadersInit,
- options?: {
- bufSize: number;
- }
-): Response {
- const source = buffered(iterable, options?.bufSize ?? 64 * KB);
- const chunks = imap(source, (s) => encoder.encode(s));
- return new Response(
- ReadableStream_from(chunks) as unknown as string,
- // ^^^^^^^^^^^^^^^^^^^^
- // This ugly cast needed due to Node.js vs Cloudflare
- // Workers ReadableStream type mismatch :(
- { headers }
- );
-}
-
-/**
- * Return a streaming NDJSON (Newline Delimited JSON) response from a generator
- * that yields JSON values. Each value will be serialized as a single line.
- */
-export function ndjsonStream(
- iterable: Iterable,
- headers?: HeadersInit
-): Response {
- const lines = imap(iterable, (value) => `${JSON.stringify(value)}\n`);
- return textStream(lines, {
- ...headers,
- "Content-Type": "application/x-ndjson",
- });
-}
-
-/**
- * Return a streaming JSON array response from a generator that yields JSON
- * values. The output will be a valid JSON array: [value1,value2,...]\n
- */
-export function jsonArrayStream(
- iterable: Iterable,
- headers?: HeadersInit
-): Response {
- function* chunks() {
- yield "[";
- let first = true;
- for (const value of iterable) {
- if (!first) yield ",";
- first = false;
- yield JSON.stringify(value);
- }
- yield "]\n";
- }
- return textStream(chunks(), {
- ...headers,
- "Content-Type": "application/json; charset=utf-8",
- });
-}
-
-export { HttpError, ValidationError };
diff --git a/packages/liveblocks-zenrouter/test-d/Relay.test-d.ts b/packages/liveblocks-zenrouter/test-d/Relay.test-d.ts
deleted file mode 100644
index 5f6972d8f7..0000000000
--- a/packages/liveblocks-zenrouter/test-d/Relay.test-d.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { expectType } from "tsd";
-import { ZenRelay, ZenRouter } from "@liveblocks/zenrouter";
-
-declare const req: Request;
-
-async () => {
- const app = new ZenRelay();
- app
- .relay("/foo/*", new ZenRouter())
- .relay("/bar/*", new ZenRouter())
- .relay("/qux/*", new ZenRouter())
- .relay("/*", new ZenRouter());
- expectType(await app.fetch(req, 1, "a", true));
-};
diff --git a/packages/liveblocks-zenrouter/test-d/Router.test-d.ts b/packages/liveblocks-zenrouter/test-d/Router.test-d.ts
deleted file mode 100644
index 99acd68d27..0000000000
--- a/packages/liveblocks-zenrouter/test-d/Router.test-d.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { expectError, expectType } from "tsd";
-import { number, numeric, object, string } from "decoders";
-
-import { HttpError, ValidationError, ZenRouter } from "@liveblocks/zenrouter";
-
-declare const req: Request;
-
-async () => {
- const app = new ZenRouter();
-
- app.route("GET /", ({ ctx, p }) => {
- expectType>(ctx);
- expectType(p.foo);
- fail("no implementation");
- });
-
- expectType(await app.fetch(req, 1, "a", true));
-};
-
-// With a getContext() function
-async () => {
- const app = new ZenRouter({
- getContext: (request, ...args) => ({ hello: "world", request, args }),
- });
-
- app.route("GET /", ({ ctx }) => {
- expectType(ctx.hello); // "world"
- expectType(ctx.request.url);
- expectType(ctx.args);
- fail("no implementation");
- });
-};
-
-// With a authorize() function
-async () => {
- const app = new ZenRouter({
- authorize: ({ ctx }) => ({
- userId: "user-123",
- passThrough: { ctx },
- }),
- });
-
- app.route("GET /", ({ ctx, auth }) => {
- expectType>(ctx);
- expectType>(auth.passThrough.ctx); // same thing
- expectType(auth.userId); // "user-123"
- fail("no implementation");
- });
-};
-
-// With a getContext() + authorize() function
-async () => {
- const app = new ZenRouter({
- getContext: () => ({ abc: 123 }),
- authorize: ({ ctx }) => ({
- userId: "user-456",
- passThrough: { ctx },
- }),
- });
-
- app.route("GET /", ({ ctx, auth }) => {
- expectType>(ctx);
- expectType>(auth.passThrough.ctx); // same thing
- expectType(auth.userId); // "user-456"
- fail("no implementation");
- });
-};
-
-// With centralized param validation
-async () => {
- const app = new ZenRouter({
- params: {
- id: numeric,
- hex: string.transform((x) => parseInt(x, 16)),
- },
- });
-
- app.route("GET /rooms/", ({ p }) => {
- expectType(p.id);
- fail("no implementation");
- });
-
- app.route("GET /foo//bar/", ({ p }) => {
- expectType(p.id);
- expectType(p.name);
- expectError(p.hex); // Not part of the pattern, so not available
- fail("no implementation");
- });
-
- app.route("GET /foo//bar//hex/", ({ p }) => {
- expectType(p.id);
- expectType(p.name);
- expectType(p.hex); // Compare to the prev test, here hex *is* available
- fail("no implementation");
- });
-
- // Check body decoding
- app.route(
- "POST /foo/",
-
- ({ body }) => {
- expectType(body);
- fail("no implementation");
- }
- );
-
- // Check body decoding
- app.route(
- "POST /foo/",
-
- object({ foo: string, bar: number }),
-
- ({ body }) => {
- expectType(body.foo);
- expectType(body.bar);
- expectError(body.qux);
- fail("no implementation");
- }
- );
-
- // Accessing query params
- app.route(
- "GET /foo",
-
- ({ q }) => {
- // Accessing query params like ?foo=123&bar=hi are always optional
- expectType(q.foo);
- expectType(q.bar);
- expectType(q.i_do_not_exist);
- fail("no implementation");
- }
- );
-};
-
-// Type-safety of error handlers
-async () => {
- const app = new ZenRouter();
-
- app.onUncaughtError((e) => {
- expectType(e);
- fail();
- });
-
- app.onError((e) => {
- expectType(e);
- if (e instanceof ValidationError) {
- expectType(e.reason);
- }
- fail();
- });
-};
diff --git a/packages/liveblocks-zenrouter/test/ErrorHandler.test.ts b/packages/liveblocks-zenrouter/test/ErrorHandler.test.ts
deleted file mode 100644
index 156f3234c1..0000000000
--- a/packages/liveblocks-zenrouter/test/ErrorHandler.test.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-import { describe, expect, test, vi } from "vitest";
-
-import type { ErrorContext } from "~/ErrorHandler.js";
-import { ErrorHandler } from "~/ErrorHandler.js";
-import { abort, HttpError } from "~/index.js";
-
-import {
- captureConsole,
- disableConsole,
- expectResponse,
- fail,
-} from "./utils.js";
-
-class CustomHttpError extends HttpError {
- constructor() {
- super(432, "Custom Error");
- }
-}
-
-class RandomError extends Error {
- x = 42;
-}
-
-// A stub to use in tests that want to handle an error, but aren't interested
-// in testing error context details
-const unused = Symbol() as any as ErrorContext;
-
-describe("ErrorHandler", () => {
- test("can only define a handler once", () => {
- const eh = new ErrorHandler();
-
- // First call works
- eh.onError(fail);
-
- // Second one fails
- expect(() => eh.onError(fail)).toThrow(
- "An error handler was already registered"
- );
- });
-
- test("can only define an uncaught handler once", () => {
- const eh = new ErrorHandler();
-
- // First call works
- eh.onUncaughtError(fail);
-
- // Second one fails
- expect(() => eh.onUncaughtError(fail)).toThrow(
- "An uncaught error handler was already registered"
- );
- });
-
- test("invokes default error handlers when no error handlers are provided", async () => {
- disableConsole();
-
- // Setup
- const eh = new ErrorHandler();
-
- const errA = new Error("Test1");
- const errB = new RandomError("Test2");
- const errC = new CustomHttpError();
-
- // Test
- await expectResponse(
- await eh.handle(errA, unused),
- { error: "Internal Server Error" },
- 500
- );
-
- await expectResponse(
- await eh.handle(errB, unused),
- { error: "Internal Server Error" },
- 500
- );
-
- await expectResponse(
- await eh.handle(errC, unused),
- { error: "Custom Error" },
- 432
- );
- });
-
- test("invokes the correct error handler", async () => {
- // Setup
- const m1 = vi.fn(
- (e: HttpError) => new Response("normal error", { status: e.status })
- );
- const m2 = vi.fn(() => new Response("uncaught error", { status: 500 }));
-
- const eh = new ErrorHandler();
- eh.onError(m1);
- eh.onUncaughtError(m2);
-
- const errA = new Error("Test1");
- const errB = new RandomError("Test1");
- const errC = new CustomHttpError();
-
- // Test with errA
- await expectResponse(await eh.handle(errA, unused), "uncaught error", 500);
- expect(m1).not.toBeCalled();
- expect(m2).toBeCalledWith(errA, unused);
- m1.mockClear();
- m2.mockClear();
-
- // Test with errB
- await expectResponse(await eh.handle(errB, unused), "uncaught error", 500);
- expect(m1).not.toBeCalled();
- expect(m2).toBeCalledWith(errB, unused);
- m1.mockClear();
- m2.mockClear();
-
- // Test with errB
- await expectResponse(await eh.handle(errC, unused), "normal error", 432);
- expect(m1).toBeCalledWith(errC, unused);
- expect(m2).not.toBeCalled();
- m1.mockClear();
- m2.mockClear();
- });
-
- test("handling non-errors will use fallback", async () => {
- const konsole = captureConsole();
-
- const eh = new ErrorHandler();
- const notAnError = new Date();
-
- await expectResponse(
- await eh.handle(notAnError, unused),
- { error: "Internal Server Error" },
- 500
- );
-
- expect(konsole.error).toHaveBeenNthCalledWith(
- 1,
- expect.stringMatching(/^Uncaught error: .*/)
- );
- expect(konsole.error).toHaveBeenNthCalledWith(
- 2,
- "...but no uncaught error handler was set up for this router."
- );
- });
-
- test("handles generic abort with status code unsupported by HttpError", async () => {
- const eh = new ErrorHandler();
-
- // abort(500) creates a generic abort Response. When the handler tries to
- // convert it to an HttpError, the constructor throws because 5xx is not
- // allowed. The catch fallback returns a generic error response.
- let abortResponse: Response;
- try {
- abort(500);
- } catch (e) {
- abortResponse = e as Response;
- }
-
- const res = await eh.handle(abortResponse!, unused);
- await expectResponse(res, { error: "Unknown" }, 500);
- });
-
- test("handles bugs in error handler itself", async () => {
- const konsole = captureConsole();
-
- const eh = new ErrorHandler();
- eh.onError(() => {
- throw new Error("Oops, I'm a broken error handler");
- });
-
- // Trigger a 404, but the broken error handler will not handle that correctly
- const res = await eh.handle(new HttpError(404), unused);
- await expectResponse(res, { error: "Internal Server Error" }, 500);
-
- expect(konsole.error).toHaveBeenNthCalledWith(
- 1,
- expect.stringMatching(
- /^Uncaught error: Error: Oops, I'm a broken error handler/
- )
- );
- });
-});
diff --git a/packages/liveblocks-zenrouter/test/HttpError.test.ts b/packages/liveblocks-zenrouter/test/HttpError.test.ts
deleted file mode 100644
index 1f58945fc1..0000000000
--- a/packages/liveblocks-zenrouter/test/HttpError.test.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { describe, expect, test } from "vitest";
-
-import { HttpError, ValidationError } from "~/responses/index.js";
-
-describe("HttpError", () => {
- test("construct custom http errors", () => {
- const err = new HttpError(490, "Custom");
- expect(err.status).toEqual(490);
- expect(err.message).toEqual("Custom");
- });
-
- test("cannot construct http errors with invalid error codes", () => {
- expect(() => new HttpError(-1)).toThrow("Invalid HTTP status code: -1");
- expect(() => new HttpError(-1, "Custom")).toThrow(
- "Invalid HTTP status code: -1"
- );
-
- expect(() => new HttpError(200)).toThrow(
- "Cannot create an HTTP error for a success code"
- );
- expect(() => new HttpError(200, "Custom")).toThrow(
- "Cannot create an HTTP error for a success code"
- );
-
- expect(() => new HttpError(299)).toThrow(
- "Cannot create an HTTP error for a success code"
- );
- expect(() => new HttpError(299, "Custom")).toThrow(
- "Cannot create an HTTP error for a success code"
- );
-
- expect(() => new HttpError(500)).toThrow(
- "Don't use HttpError for 5xx errors"
- );
- expect(() => new HttpError(500, "Custom")).toThrow(
- "Don't use HttpError for 5xx errors"
- );
-
- expect(() => new HttpError(502)).toThrow(
- "Don't use HttpError for 5xx errors"
- );
- expect(() => new HttpError(502, "Custom")).toThrow(
- "Don't use HttpError for 5xx errors"
- );
-
- expect(() => new HttpError(600)).toThrow("Invalid HTTP status code: 600");
- expect(() => new HttpError(600, "Custom")).toThrow(
- "Invalid HTTP status code: 600"
- );
-
- expect(() => new HttpError(650)).toThrow("Invalid HTTP status code: 650");
- expect(() => new HttpError(650, "Custom")).toThrow(
- "Invalid HTTP status code: 650"
- );
-
- expect(() => new HttpError(1234)).toThrow("Invalid HTTP status code: 1234");
- expect(() => new HttpError(1234, "Custom")).toThrow(
- "Invalid HTTP status code: 1234"
- );
- });
-
- test("cannot construct non-standard http errors without a custom description", () => {
- expect(() => new HttpError(450)).toThrow(
- "Unknown error code 450, provide a message"
- );
-
- const err = new HttpError(450, "Custom");
- expect(err.status).toEqual(450);
- expect(err.message).toEqual("Custom");
- });
-});
-
-describe("ValidationError", () => {
- test("construct custom validation errors", () => {
- const err = new ValidationError("Invalid value 123 for field xyz");
- expect(err.status).toEqual(422);
- expect(err.message).toEqual("Unprocessable Entity");
- expect(err.reason).toEqual("Invalid value 123 for field xyz");
- });
-
- test("cannot use HttpError for 422 responses", () => {
- expect(() => new HttpError(422)).toThrow(
- "Don't use HttpError for 422 errors, use ValidationError"
- );
- expect(() => new HttpError(422, "Yo")).toThrow(
- "Don't use HttpError for 422 errors, use ValidationError"
- );
- });
-});
diff --git a/packages/liveblocks-zenrouter/test/Relay.test.ts b/packages/liveblocks-zenrouter/test/Relay.test.ts
deleted file mode 100644
index 1544e60f46..0000000000
--- a/packages/liveblocks-zenrouter/test/Relay.test.ts
+++ /dev/null
@@ -1,430 +0,0 @@
-import { describe, expect, test } from "vitest";
-
-import { abort, json, ZenRelay, ZenRouter } from "~/index.js";
-import {
- captureConsole,
- disableConsole,
- expectResponse,
- ok,
-} from "~test/utils.js";
-
-const WITHOUT_AUTH = {
- authorize: () => Promise.resolve(true),
-};
-
-describe("Relay basic setup", () => {
- test("no configured relays", async () => {
- disableConsole();
-
- const relay = new ZenRelay();
- const req = new Request("http://example.org/");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404);
- });
-
- test("unused prefixes", async () => {
- disableConsole();
-
- const foo = new ZenRouter(WITHOUT_AUTH);
- foo.route("GET /foo/bar", ok("From bar"));
-
- const relay = new ZenRelay();
- relay.relay("/foo/*", foo);
-
- {
- const req = new Request("http://example.org/");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/bar");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/foo");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Router, not by Relay!
- }
-
- {
- const req = new Request("http://example.org/foo/bar");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { message: "From bar" });
- }
- });
-
- test("no partial url segment matching", async () => {
- disableConsole();
-
- const foo = new ZenRouter(WITHOUT_AUTH);
- foo.route("GET /foo/bar", ok("From bar"));
-
- const relay = new ZenRelay();
- relay.relay("/foo/*", foo);
- relay.relay("/*", () => fail("Nope"));
-
- {
- const req = new Request("http://example.org/foooooo");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Internal Server Error" }, 500); // thrown by the nope
- }
-
- {
- const req = new Request("http://example.org/fo");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Internal Server Error" }, 500); // thrown by the nope
- }
-
- {
- const req = new Request("http://example.org/foo");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Router, not by Relay!
- }
-
- {
- const req = new Request("http://example.org/foo/bar");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { message: "From bar" });
- }
- });
-
- test("dynamic placeholders #1", async () => {
- disableConsole();
-
- const foo = new ZenRouter(WITHOUT_AUTH);
- foo.route("GET /foo/bar", ok("From bar"));
- foo.route("GET /foo/qux/hello", ok("From hello"));
- foo.route("GET /foo/baz/mutt", ok("From mutt"));
-
- const relay = new ZenRelay();
- relay.relay("/foo//*", foo);
-
- {
- const req = new Request("http://example.org/");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/bar");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/foo");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Router, not by Relay!
- }
-
- {
- const req = new Request("http://example.org/foo/");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Router, not by Relay!
- }
-
- {
- const req = new Request("http://example.org/foo/bar");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { message: "From bar" });
- }
-
- {
- const req = new Request("http://example.org/foo/bar/");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { message: "From bar" });
- }
- });
-
- test("dynamic placeholders #2", async () => {
- disableConsole();
-
- const foo = new ZenRouter(WITHOUT_AUTH);
- foo.route("GET /foo/bar", ok("From bar"));
- foo.route("GET /foo/qux/hello", ok("From hello"));
- foo.route("GET /foo/baz/mutt", ok("From mutt"));
-
- const relay = new ZenRelay();
- relay.relay("//baz/*", foo);
-
- {
- const req = new Request("http://example.org/");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/bar");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/foo");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/foo");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/foo/bar");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/foo/baz/mutt");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { message: "From mutt" });
- }
- });
-
- test("catchalls", async () => {
- disableConsole();
-
- const foo = new ZenRouter(WITHOUT_AUTH);
- foo.route("GET /foo/bar", ok("From router 1"));
-
- const bar = new ZenRouter(WITHOUT_AUTH);
- bar.route("GET /bar/baz", ok("From router 2"));
-
- const qux = new ZenRouter(WITHOUT_AUTH);
- qux.route("GET /qux/mutt", ok("From router 3"));
-
- const relay = new ZenRelay();
- relay.relay("/foo/*", foo);
- relay.relay("/bar/*", bar);
- relay.relay("/*", qux);
-
- {
- const req = new Request("http://example.org/");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/foo/bar");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { message: "From router 1" });
- }
-
- {
- const req = new Request("http://example.org/bar/baz");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { message: "From router 2" });
- }
-
- {
- const req = new Request("http://example.org/qux/mutt");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { message: "From router 3" });
- }
-
- {
- const req = new Request("http://example.org/foo/i-do-not-exist");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // From router 1
- }
-
- {
- const req = new Request("http://example.org/bar/i-do-not-exist");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // From router 2
- }
-
- {
- const req = new Request("http://example.org/qux/i-do-not-exist");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // From router 3
- }
- });
-
- test("catchalls (order matters)", async () => {
- disableConsole();
-
- const foo = new ZenRouter(WITHOUT_AUTH);
- foo.route("GET /foo/bar", ok("From router 1"));
-
- const bar = new ZenRouter(WITHOUT_AUTH);
- bar.route("GET /bar/baz", ok("From router 2"));
-
- const qux = new ZenRouter(WITHOUT_AUTH);
- qux.route("GET /qux/mutt", ok("From router 3"));
-
- const relay = new ZenRelay();
- relay.relay("/*", qux); // 🔑 NOTE: Catchall defined before all other routes!
- relay.relay("/foo/*", foo);
- relay.relay("/bar/*", bar);
-
- {
- const req = new Request("http://example.org/");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // thrown by Relay
- }
-
- {
- const req = new Request("http://example.org/foo/bar");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // From router 3
- }
-
- {
- const req = new Request("http://example.org/bar/baz");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404); // From router 3
- }
-
- {
- const req = new Request("http://example.org/qux/mutt");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { message: "From router 3" });
- }
- });
-});
-
-describe("Misconfigured Relay instance", () => {
- test("invalid match prefix #1", () => {
- const relay = new ZenRelay();
- expect(() => relay.relay("GET /foo" as any, new ZenRouter())).toThrow(
- "Invalid path prefix: GET /foo"
- );
- });
-
- test("invalid match prefix #2", () => {
- const relay = new ZenRelay();
- expect(() => relay.relay("/foo /bar" as any, new ZenRouter())).toThrow(
- "Invalid path prefix: /foo /bar"
- );
- });
-
- test("invalid match prefix #3", () => {
- const relay = new ZenRelay();
- expect(() => relay.relay("/foo" as any, new ZenRouter())).toThrow(
- "Invalid path prefix: /foo"
- );
- });
-
- test("invalid match prefix #4", () => {
- const relay = new ZenRelay();
- expect(() => relay.relay("/foo*" as any, new ZenRouter())).toThrow(
- "Invalid path prefix: /foo*"
- );
- });
-});
-
-describe("Error handling behavior", () => {
- test("aborting vs throwing custom error (which remains uncaught)", async () => {
- const konsole = captureConsole();
- const router = new ZenRouter(WITHOUT_AUTH);
- router.route("GET /test/403", () => abort(403));
- router.route("GET /test/oops", () => {
- throw new Error("Oops");
- });
-
- const relay = new ZenRelay().relay("/test/*", router);
-
- {
- const req = new Request("http://example.org/test/403");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Forbidden" }, 403);
- }
- {
- const req = new Request("http://example.org/test/oops");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Internal Server Error" }, 500);
- }
-
- expect(konsole.log).not.toHaveBeenCalled();
- expect(konsole.warn).not.toHaveBeenCalled();
- expect(konsole.error).toHaveBeenCalledWith(
- expect.stringMatching("Uncaught error: Error: Oops")
- );
- expect(konsole.error).toHaveBeenCalledWith(
- "...but no uncaught error handler was set up for this router."
- );
- });
-
- test("same, but now uncaught handler is defined (at the Router level)", async () => {
- const konsole = captureConsole();
- const router = new ZenRouter(WITHOUT_AUTH);
- router.onUncaughtError(() => json({ custom: "error" }, 500));
-
- router.route("GET /test/403", () => abort(403));
- router.route("GET /test/oops", () => {
- throw new Error("Oops");
- });
-
- const relay = new ZenRelay().relay("/test/*", router);
-
- {
- const req = new Request("http://example.org/test/403");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { error: "Forbidden" }, 403);
- }
- {
- const req = new Request("http://example.org/test/oops");
- const resp = await relay.fetch(req);
- await expectResponse(resp, { custom: "error" }, 500);
- }
-
- expect(konsole.log).not.toHaveBeenCalled();
- expect(konsole.warn).not.toHaveBeenCalled();
- expect(konsole.error).not.toHaveBeenCalled();
- expect(konsole.error).not.toHaveBeenCalled();
- });
-
- test("same, but now there is no Router (we're using a custom handler function) #1", async () => {
- const app = new ZenRelay().relay(
- "/oops/*",
- // NOTE! *Not* using a Router instance here, instead using a custom
- // handler function directly! This is NOT recommended, but currently
- // supported only to allow using itty-router here!
- () => {
- throw new Error("Oops");
- }
- );
-
- const req = new Request("http://example.org/haha"); // NOTE: Not calling /oops here!
- const resp = await app.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404);
- });
-
- test("same, but now there is no Router (we're using a custom handler function) #2", async () => {
- const app = new ZenRelay().relay(
- "/oops/*",
- // NOTE! *Not* using a Router instance here, instead using a custom
- // handler function directly! This is NOT recommended, but currently
- // supported only to allow using itty-router here!
- () => {
- throw new Error("Oops");
- }
- );
-
- const konsole = captureConsole();
-
- const req = new Request("http://example.org/oops");
- const resp = await app.fetch(req);
- await expectResponse(resp, { error: "Internal Server Error" }, 500);
-
- expect(konsole.log).not.toHaveBeenCalled();
- expect(konsole.warn).not.toHaveBeenCalled();
- expect(konsole.error).toHaveBeenCalledTimes(3);
- expect(konsole.error).toHaveBeenCalledWith(
- "Relayer caught error in subrouter! This should never happen, as routers should never throw an unexpected error! Error: Oops"
- );
- expect(konsole.error).toHaveBeenCalledWith(
- expect.stringMatching("Uncaught error: Error: Oops")
- );
- expect(konsole.error).toHaveBeenCalledWith(
- "...but no uncaught error handler was set up for this router."
- );
- });
-});
diff --git a/packages/liveblocks-zenrouter/test/Router.test.ts b/packages/liveblocks-zenrouter/test/Router.test.ts
deleted file mode 100644
index 01d0ce05dc..0000000000
--- a/packages/liveblocks-zenrouter/test/Router.test.ts
+++ /dev/null
@@ -1,955 +0,0 @@
-import type { Json } from "@liveblocks/core";
-import {
- json as jsonDecoder,
- number,
- numeric,
- object,
- optional,
-} from "decoders";
-import { nanoid } from "nanoid";
-import { beforeEach, describe, expect, test } from "vitest";
-
-import { ErrorHandler } from "~/ErrorHandler.js";
-import { abort, empty, HttpError, json, ZenRouter } from "~/index.js";
-import {
- captureConsole,
- disableConsole,
- expectEmptyResponse,
- expectResponse,
- fail,
-} from "~test/utils.js";
-
-// Extend Response to be able to generate 101 responses. This is
-// what the Cloudflare workers platform support, but you cannot
-// construct such a Response in Node.
-class WebSocketResponse extends Response {
- constructor(headers?: Record) {
- super(null, { headers });
- }
- get status() {
- return 101;
- }
- get statusText() {
- return "Switching Protocols";
- }
-}
-
-const IGNORE_AUTH_FOR_THIS_TEST = () => Promise.resolve(true);
-const TEST_ORIGIN = "https://example-origin.org";
-const ANOTHER_ORIGIN = "https://another-origin.org";
-
-/**
- * Generates the simplest router you can think of.
- */
-function simplestRouter() {
- return new ZenRouter({ authorize: IGNORE_AUTH_FOR_THIS_TEST });
-}
-
-describe("starting from scratch gives guided experience", () => {
- test("take 0", () => {
- const r = new ZenRouter();
- expect(() => r.fetch).toThrow("No routes configured yet. Try adding one?");
- });
-
- test("take 1", () => {
- const r = new ZenRouter();
- expect(() =>
- // @ts-expect-error deliberate type error
- r.route("/", fail)
- ).toThrow('Invalid route pattern: "/". Did you mean "GET /"?');
- });
-});
-
-describe("Router setup errors", () => {
- test("default context is null when not specified", async () => {
- const r = simplestRouter();
- r.route("GET /", ({ ctx }) => json({ ctx } as any));
-
- const req = new Request("http://example.org/");
- const resp = await r.fetch(req);
- await expectResponse(resp, { ctx: null });
- });
-
- test("unless you specifically define how to authorize, the default router will reject all requests", async () => {
- const konsole = captureConsole();
-
- const r = new ZenRouter();
- r.route("GET /", ({ ctx }) => json({ ctx } as any));
-
- const req = new Request("http://example.org/");
- const resp = await r.fetch(req);
- await expectResponse(resp, { error: "Forbidden" }, 403);
-
- expect(konsole.error).toHaveBeenCalledWith(
- "This request was not checked for authorization. Please configure a generic `authorize` function in the ZenRouter constructor."
- );
- });
-
- test("fails for patterns without method", () => {
- const r = simplestRouter();
- // @ts-expect-error Not starting with an HTTP method
- expect(() => r.route("i am not valid", fail)).toThrow(
- 'Invalid route pattern: "i am not valid"'
- );
- });
-
- test("fails for patterns without method", () => {
- const r = simplestRouter();
- // @ts-expect-error Not starting with an HTTP method
- expect(() => r.route("/foo", fail)).toThrow(
- 'Invalid route pattern: "/foo". Did you mean "GET /foo"?'
- );
- });
-
- test("fails for patterns with invalid method", () => {
- const r = simplestRouter();
- // @ts-expect-error Not starting with a valid HTTP method
- expect(() => r.route("GRAB /foo", fail)).toThrow(
- 'Invalid route pattern: "GRAB /foo"'
- );
- });
-
- test("fails with duplicate placeholder", () => {
- const r = simplestRouter();
- expect(() => r.route("GET /foo//", fail)).toThrow(
- "Duplicate capture group name"
- );
- });
-
- test("fails with placeholder names that aren’t valid JS names", () => {
- const r = simplestRouter();
- expect(() => r.route("GET /foo/", fail)).toThrow(
- "Invalid pattern: /foo/ (error at position 6)"
- );
- });
-});
-
-describe("Basic Router", () => {
- const r = new ZenRouter({
- errorHandler: new ErrorHandler(),
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- getContext: () => null,
- params: {
- x: numeric,
- y: numeric,
- },
- });
-
- r.onUncaughtError(() =>
- json(
- { error: "Internal server error", details: "Please try again later" },
- 500
- )
- );
-
- r.route("GET /ping", () => json({ data: "pong" }));
- r.route("GET /echo/", ({ p }) => json({ name: p.name }));
- r.route("GET /concat//", ({ p }) => json({ result: `${p.a}${p.b}` }));
- r.route("GET /add//", ({ p }) => json({ result: p.x + p.y, p }));
- r.route("GET /custom-error", () => {
- throw new Response("I'm a custom response", { status: 499 });
- });
- r.route("GET /custom-http-error", () => {
- throw new HttpError(488, "Custom Error");
- });
- r.route("GET /broken", () => {
- throw new Error("Random error");
- });
- r.route("GET /echo-query", ({ q }) => json({ q }));
- r.route("GET /empty", () => empty());
- r.route("POST /empty", optional(object({ a: number })), () => ({ ok: true }));
-
- r.route("GET /test", fail);
- r.route("POST /test", fail);
- r.route("PATCH /test", fail);
- r.route("PUT /test", fail);
- r.route("DELETE /test", fail);
-
- test("without placeholders", async () => {
- const req = new Request("http://example.org/ping");
- expect(await (await r.fetch(req)).json()).toEqual({
- data: "pong",
- });
- });
-
- test("one placeholder", async () => {
- const req1 = new Request("http://example.org/echo/foo");
- expect(await (await r.fetch(req1)).json()).toEqual({
- name: "foo",
- });
-
- const req2 = new Request("http://example.org/echo/bar");
- expect(await (await r.fetch(req2)).json()).toEqual({
- name: "bar",
- });
- });
-
- test("test paths with multiple dynamic placeholders", async () => {
- const req1 = new Request("http://example.org/concat/foo/bar");
- expect(await (await r.fetch(req1)).json()).toEqual({
- result: "foobar",
- });
-
- const req2 = new Request("http://example.org/concat/bar/foo");
- expect(await (await r.fetch(req2)).json()).toEqual({
- result: "barfoo",
- });
- });
-
- test("placeholders are automatically decoded", async () => {
- const req1 = new Request("http://example.org/echo/foo%2Fbar%2Fqux");
- expect(await (await r.fetch(req1)).json()).toEqual({
- name: "foo/bar/qux",
- });
-
- const req2 = new Request("http://example.org/echo/foo%2F😂");
- expect(await (await r.fetch(req2)).json()).toEqual({
- name: "foo/😂",
- });
- });
-
- test("placeholders are automatically decoded + validated", async () => {
- const req = new Request("http://example.org/add/1337/42");
- expect(await (await r.fetch(req)).json()).toEqual({
- result: 1379,
- p: { x: 1337, y: 42 },
- });
- });
-
- test("placeholders that cannot be URI decoded will throw a 400 error", async () => {
- const req = new Request("http://example.org/echo/foo%2Xbar%2Xqux");
- // ^^^ ^^^ Malformed URL
- const resp = await r.fetch(req);
- await expectResponse(resp, { error: "Bad Request" }, 400);
- });
-
- test("placeholders that cannot be decoded/transformed will throw a 400 error", async () => {
- const req = new Request("http://example.org/add/1/one");
- // ^^^ Not a valid number
- const resp = await r.fetch(req);
- await expectResponse(resp, { error: "Bad Request" }, 400);
- });
-
- test("non-matching paths will return 404", async () => {
- const req = new Request("http://example.org/i/don't/exist");
- const resp = await r.fetch(req);
- await expectResponse(resp, { error: "Not Found" }, 404);
- });
-
- test("matching paths but non-matching methods will return 405", async () => {
- const req = new Request("http://example.org/echo/bar", { method: "POST" });
- const resp = await r.fetch(req);
- await expectResponse(resp, { error: "Method Not Allowed" }, 405);
- expect(resp.headers.get("Allow")).toEqual("GET, OPTIONS");
- });
-
- test("accessing the query string", async () => {
- const req = new Request(
- "http://example.org/echo-query?a=1&b=2&c=3&c=4&d[]=d1&d[]=d2&x="
- );
- const resp = await r.fetch(req);
- await expectResponse(resp, {
- q: { a: "1", b: "2", c: "4", "d[]": "d2", x: "" },
- });
- });
-
- test("can accept empty bodies", async () => {
- {
- const req = new Request("http://example.org/empty", {
- method: "POST",
- body: "nah-ah", // Invalid body ← 🔑
- });
- await expectResponse(await r.fetch(req), { error: "Bad Request" }, 400);
- }
-
- {
- const req = new Request("http://example.org/empty", {
- method: "POST",
- body: '{"a": 123}', // Valid body
- });
- await expectResponse(await r.fetch(req), { ok: true });
- }
-
- {
- const req = new Request("http://example.org/empty", {
- method: "POST",
- // No body here ← 🔑
- });
- await expectResponse(await r.fetch(req), { ok: true });
- }
- });
-
- test("return empty response", async () => {
- const req = new Request("http://example.org/empty");
- const resp = await r.fetch(req);
- expectEmptyResponse(resp);
- });
-
- test("return custom response", async () => {
- const req = new Request("http://example.org/custom-error");
- const resp = await r.fetch(req);
- await expectResponse(resp, "I'm a custom response", 499);
- });
-
- test("broken endpoint returns 500", async () => {
- const req = new Request("http://example.org/broken");
- const resp = await r.fetch(req);
- await expectResponse(
- resp,
- {
- error: "Internal server error",
- details: "Please try again later",
- },
- 500
- );
- });
-
- test("custom status error handling", async () => {
- const req = new Request("http://example.org/custom-http-error");
- const resp = await r.fetch(req);
- await expectResponse(resp, { error: "Custom Error" }, 488);
- });
-});
-
-describe("Router authentication", () => {
- test("Authorized when returning truthy value", async () => {
- const r = new ZenRouter({
- authorize: ({ req }) => {
- return req.headers.get("Authorization") === "v3ry-s3cr3t!";
- },
- });
-
- r.route("GET /", () => json({ ok: true }));
-
- const req1 = new Request("http://example.org/");
- await expectResponse(await r.fetch(req1), { error: "Forbidden" }, 403);
-
- const req2 = new Request("http://example.org/", {
- headers: { Authorization: "v3ry-s3cr3t!" },
- });
- await expectResponse(await r.fetch(req2), { ok: true });
- });
-
- test("Returning parsed auth data", async () => {
- const r = new ZenRouter({
- authorize: ({ req }) => {
- const header = req.headers.get("Authorization");
- if (!header?.startsWith("v3ry-s3cr3t!")) {
- return false;
- }
-
- return { userId: header.substring("v3ry-s3cr3t!".length).trim() };
- },
- });
-
- r.route("GET /", ({ auth }) => json({ auth }));
-
- const req1 = new Request("http://example.org/");
- await expectResponse(await r.fetch(req1), { error: "Forbidden" }, 403);
-
- const req2 = new Request("http://example.org/", {
- headers: { Authorization: "v3ry-s3cr3t! user-123" },
- });
- await expectResponse(await r.fetch(req2), { auth: { userId: "user-123" } });
-
- const req3 = new Request("http://example.org/", {
- headers: { Authorization: "v3ry-s3cr3t! user-456" },
- });
- await expectResponse(await r.fetch(req3), { auth: { userId: "user-456" } });
- });
-});
-
-describe("Router body validation", () => {
- const r = new ZenRouter({ authorize: IGNORE_AUTH_FOR_THIS_TEST });
-
- r.route(
- "POST /add",
- object({ x: number, y: number }),
-
- ({ body }) => ({ result: body.x + body.y })
- );
-
- // r.registerErrorHandler(ValidationError, (e) => json({ crap: true }, 422));
- // r.registerErrorHandler(422, () => json({ crap: true }, 422));
-
- test("accepts correct body", async () => {
- const req = new Request("http://example.org/add", {
- method: "POST",
- body: '{"x":41,"y":1}',
- });
- await expectResponse(await r.fetch(req), { result: 42 });
- });
-
- test("rejects invalid body", async () => {
- const req = new Request("http://example.org/add", {
- method: "POST",
- body: '{"x":41,"y":"not a number"}',
- });
- await expectResponse(
- await r.fetch(req),
- {
- error: "Unprocessable Entity",
- reason: "Value at key 'y': Must be number",
- },
- 422
- );
- });
-
- test("broken JSON bodies lead to 400", async () => {
- const req = new Request("http://example.org/add", {
- method: "POST",
- body: "I'm no JSON",
- });
- await expectResponse(await r.fetch(req), { error: "Bad Request" }, 400);
- });
-
- test("accessing body without defining a decoder is an error", async () => {
- const konsole = captureConsole();
-
- const r = new ZenRouter({ authorize: IGNORE_AUTH_FOR_THIS_TEST });
-
- r.route("POST /", (input) => {
- // Simply accessing the `body` without defining a decoder should fail
- input.body;
- return { ok: true };
- });
-
- const req = new Request("http://example.org/", { method: "POST" });
- await expectResponse(
- await r.fetch(req),
- { error: "Internal Server Error" },
- 500
- );
-
- expect(konsole.error).toHaveBeenCalledWith(
- expect.stringMatching(
- /^Uncaught error: Error: Cannot access body: this endpoint did not define a body schema/
- )
- );
- });
-});
-
-function createMiniDbRouter(options: { cors: boolean }) {
- const r = new ZenRouter({ authorize: IGNORE_AUTH_FOR_THIS_TEST, ...options });
-
- // Simulate a mini DB
- const db = new Map();
- beforeEach(() => db.clear());
-
- // Implement standard REST methods
- r.route("GET /thing/", ({ p }) => {
- const { key } = p;
- const value = db.get(p.key);
- return value !== undefined ? { key, value } : abort(404);
- });
- r.route("POST /thing", jsonDecoder, ({ body }) => {
- const key = nanoid();
- const value = body;
- db.set(key, value);
- return { key, value };
- });
- r.route("PUT /thing/", jsonDecoder, ({ p, body }) => {
- const { key } = p;
- const value = body;
- db.set(key, value);
- return { key, value };
- });
- r.route("DELETE /thing/", ({ p }) => ({ deleted: db.delete(p.key) }));
-
- return r;
-}
-
-describe("Router automatic OPTIONS responses (without CORS)", () => {
- const r = createMiniDbRouter({ cors: false });
-
- test("empty db returns 404s", async () => {
- const resp1 = await r.fetch(new Request("http://example.org/thing/foo"));
- await expectResponse(resp1, { error: "Not Found" }, 404);
- expect(Object.fromEntries(resp1.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- });
-
- const resp2 = await r.fetch(new Request("http://example.org/thing/bar"));
- await expectResponse(resp2, { error: "Not Found" }, 404);
- expect(Object.fromEntries(resp2.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- });
- });
-
- test("writing and reading from db works", async () => {
- const resp1 = await r.fetch(
- new Request("http://example.org/thing/foo", {
- method: "PUT",
- body: "123",
- })
- );
- await expectResponse(resp1, { key: "foo", value: 123 });
- expect(Object.fromEntries(resp1.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- });
-
- const resp2 = await r.fetch(new Request("http://example.org/thing/foo"));
- await expectResponse(resp2, { key: "foo", value: 123 });
- expect(Object.fromEntries(resp2.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- });
-
- const resp3 = await r.fetch(
- new Request("http://example.org/thing", { method: "POST", body: '"xyz"' })
- );
- await expectResponse(resp3, { key: expect.any(String), value: "xyz" });
- expect(Object.fromEntries(resp3.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- });
- });
-
- test("http 405 responses will include allow header #1", async () => {
- const resp = await r.fetch(new Request("http://example.org/thing"));
- await expectResponse(resp, { error: "Method Not Allowed" }, 405);
- expect(Object.fromEntries(resp.headers)).toEqual({
- allow: "POST, OPTIONS",
- "content-type": "application/json; charset=utf-8",
- });
- });
-
- test("http 405 responses will include allow header #2", async () => {
- const resp = await r.fetch(
- new Request("http://example.org/thing/blablabla", {
- method: "POST", // Method not valid for this URL
- })
- );
- await expectResponse(resp, { error: "Method Not Allowed" }, 405);
- expect(Object.fromEntries(resp.headers)).toEqual({
- allow: "GET, PUT, DELETE, OPTIONS",
- "content-type": "application/json; charset=utf-8",
- });
- });
-
- test("responds to non-CORS OPTIONS requests", async () => {
- const resp = await r.fetch(
- new Request("http://example.org/thing/blablabla", {
- method: "OPTIONS",
- })
- );
- expectEmptyResponse(resp);
- expect(Object.fromEntries(resp.headers)).toEqual({
- allow: "GET, PUT, DELETE, OPTIONS",
- });
- });
-
- test("responds to non-CORS OPTIONS requests", async () => {
- const resp = await r.fetch(
- new Request("http://example.org/thing/blablabla", {
- method: "OPTIONS",
- })
- );
- expectEmptyResponse(resp);
- expect(Object.fromEntries(resp.headers)).toEqual({
- allow: "GET, PUT, DELETE, OPTIONS",
- });
- });
-
- test("responds to CORS preflight requests", async () => {
- const resp = await r.fetch(
- new Request("http://example.org/thing/blablabla", {
- method: "OPTIONS",
- headers: { Origin: TEST_ORIGIN },
- })
- );
- expectEmptyResponse(resp);
- expect(Object.fromEntries(resp.headers)).toEqual({
- // NOTE: No Access-Control-* headers here: CORS isn't enabled on this router!
- allow: "GET, PUT, DELETE, OPTIONS",
- });
- });
-});
-
-describe("Router automatic OPTIONS responses (with CORS)", () => {
- const r = createMiniDbRouter({ cors: true });
-
- test("empty db returns 404s", async () => {
- const resp1 = await r.fetch(new Request("http://example.org/thing/foo"));
- await expectResponse(resp1, { error: "Not Found" }, 404);
- expect(Object.fromEntries(resp1.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": "*", // Because alwaysSend defaults to true in CORS config
- });
-
- const resp2 = await r.fetch(new Request("http://example.org/thing/bar"));
- await expectResponse(resp2, { error: "Not Found" }, 404);
- expect(Object.fromEntries(resp2.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": "*", // Because alwaysSend defaults to true in CORS config
- });
- });
-
- test("writing and reading from db works", async () => {
- const resp1 = await r.fetch(
- new Request("http://example.org/thing/foo", {
- method: "PUT",
- body: "123",
- })
- );
- await expectResponse(resp1, { key: "foo", value: 123 });
- expect(Object.fromEntries(resp1.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": "*", // Because alwaysSend defaults to true in CORS config
- });
-
- const resp2 = await r.fetch(new Request("http://example.org/thing/foo"));
- await expectResponse(resp2, { key: "foo", value: 123 });
- expect(Object.fromEntries(resp2.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": "*", // Because alwaysSend defaults to true in CORS config
- });
-
- const resp3 = await r.fetch(
- new Request("http://example.org/thing", { method: "POST", body: '"xyz"' })
- );
- await expectResponse(resp3, { key: expect.any(String), value: "xyz" });
- expect(Object.fromEntries(resp3.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": "*", // Because alwaysSend defaults to true in CORS config
- });
- });
-
- test("http 405 responses will include allow header #1", async () => {
- const resp = await r.fetch(new Request("http://example.org/thing"));
- await expectResponse(resp, { error: "Method Not Allowed" }, 405);
- expect(Object.fromEntries(resp.headers)).toEqual({
- allow: "POST, OPTIONS",
- "access-control-allow-origin": "*", // Because alwaysSend defaults to true in CORS config
- "content-type": "application/json; charset=utf-8",
- });
- });
-
- test("http 405 responses will include allow header #2", async () => {
- const resp = await r.fetch(
- new Request("http://example.org/thing/blablabla", {
- method: "POST", // Method not valid for this URL
- })
- );
- await expectResponse(resp, { error: "Method Not Allowed" }, 405);
- expect(Object.fromEntries(resp.headers)).toEqual({
- allow: "GET, PUT, DELETE, OPTIONS",
- "access-control-allow-origin": "*", // Because alwaysSend defaults to true in CORS config
- "content-type": "application/json; charset=utf-8",
- });
- });
-
- test("responds to non-CORS OPTIONS requests", async () => {
- disableConsole();
-
- const resp = await r.fetch(
- new Request("http://example.org/thing/blablabla", {
- method: "OPTIONS",
- })
- );
- expectEmptyResponse(resp);
- expect(Object.fromEntries(resp.headers)).toEqual({
- allow: "GET, PUT, DELETE, OPTIONS",
- "access-control-allow-origin": "*", // Because alwaysSend defaults to true in CORS config
- });
- });
-
- test("responds to non-CORS OPTIONS requests", async () => {
- disableConsole();
-
- const resp = await r.fetch(
- new Request("http://example.org/thing/blablabla", {
- method: "OPTIONS",
- })
- );
- expectEmptyResponse(resp);
- expect(Object.fromEntries(resp.headers)).toEqual({
- allow: "GET, PUT, DELETE, OPTIONS",
- "access-control-allow-origin": "*", // Because alwaysSend defaults to true in CORS config
- });
- });
-
- test("responds to CORS preflight requests", async () => {
- const resp = await r.fetch(
- new Request("http://example.org/thing/blablabla", {
- method: "OPTIONS",
- headers: {
- Origin: TEST_ORIGIN,
- "Access-Control-Request-Method": "POST",
- },
- })
- );
- expectEmptyResponse(resp);
- expect(Object.fromEntries(resp.headers)).toEqual({
- allow: "GET, PUT, DELETE, OPTIONS",
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- vary: "Origin",
- });
- });
-
- test("CORS response with explicitly allowed origin", async () => {
- const r = new ZenRouter({
- cors: { allowedOrigins: [TEST_ORIGIN, ANOTHER_ORIGIN] },
- // ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 🔑
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- });
- r.route("GET /", () => ({ ok: true }));
-
- const resp = await r.fetch(
- new Request("http://example.org", {
- headers: {
- // NOTE: This is *NOT* a CORS request!
- // Origin: TEST_ORIGIN,
- },
- })
- );
- await expectResponse(resp, { ok: true });
- expect(Object.fromEntries(resp.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": TEST_ORIGIN, // NOTE: Only the first allowed origin is returned
- vary: "Origin",
- });
- });
-
- test("will not override custom Vary header", async () => {
- const r = new ZenRouter({
- cors: true,
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- });
- r.route("GET /", () => json({ ok: true }, 200, { Vary: "X-Custom" }));
-
- // Non-CORS
- const resp1 = await r.fetch(
- new Request("http://example.org", {
- headers: {
- // NOTE: This is *NOT* a CORS request!
- // Origin: TEST_ORIGIN,
- },
- })
- );
- await expectResponse(resp1, { ok: true });
- expect(Object.fromEntries(resp1.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": "*",
- vary: "X-Custom",
- });
-
- // CORS
- const resp2 = await r.fetch(
- new Request("http://example.org", {
- headers: { Origin: TEST_ORIGIN },
- })
- );
- await expectResponse(resp2, { ok: true });
- expect(Object.fromEntries(resp2.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": TEST_ORIGIN,
- vary: "X-Custom, Origin",
- });
- });
-});
-
-describe("CORS edge cases", () => {
- test("won’t add CORS headers if no Origin header on incoming request and sendWildcard isn't set", async () => {
- const r = new ZenRouter({
- cors: { alwaysSend: false },
- // ^^^^^^^^^^^^^^^^^ 🔑
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- });
- r.route("GET /", () => ({ ok: true }));
-
- const resp = await r.fetch(new Request("http://example.org"));
- await expectResponse(resp, { ok: true });
- expect(Object.fromEntries(resp.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- // No CORS headers!
- });
- });
-
- test("won’t add CORS headers if no Origin header on incoming request allowCredentials is set", async () => {
- const r = new ZenRouter({
- cors: { allowCredentials: true },
- // ^^^^^^^^^^^^^^^^^ 🔑
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- });
- r.route("GET /", () => ({ ok: true }));
-
- const resp = await r.fetch(new Request("http://example.org"));
- await expectResponse(resp, { ok: true });
- expect(Object.fromEntries(resp.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- // No CORS headers!
- });
- });
-
- test("won’t add CORS headers if Origin is not allowed", async () => {
- const r = new ZenRouter({
- cors: { allowedOrigins: [ANOTHER_ORIGIN] },
- // ^^^^^^^^^^^^^^ 🔑
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- });
- r.route("GET /", () => ({ ok: true }));
-
- const resp = await r.fetch(
- new Request("http://example.org", {
- headers: {
- Origin: "https://invalid-origin",
- },
- })
- );
- await expectResponse(resp, { ok: true });
- expect(Object.fromEntries(resp.headers)).toEqual({
- "content-type": "application/json; charset=utf-8",
- // No CORS headers!
- });
- });
-
- test("won’t add CORS headers to (101 response)", async () => {
- const r = new ZenRouter({
- cors: true,
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- });
- r.route("GET /socket", () => {
- // Return a 101 response, a socket accept
- const resp = new WebSocketResponse({
- "X-Custom": "my-custom-header",
- });
- return resp;
- });
-
- const resp = await r.fetch(new Request("http://example.org/socket"));
- expect(resp.status).toEqual(101);
- // expect(resp.status).toEqual(101);
- expect(Object.fromEntries(resp.headers)).toEqual({
- // NOTE: No Access-Control-* headers here: CORS headers should not be on 101 responses!
- "x-custom": "my-custom-header",
- });
- });
-
- test("won’t add CORS headers to (3xx responses)", async () => {
- const r = new ZenRouter({
- cors: true,
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- });
- r.route("GET /301", () => new Response(null, { status: 301 }));
- r.route("GET /303", () => new Response(null, { status: 303 }));
- r.route("GET /308", () => new Response(null, { status: 308 }));
-
- const resp1 = await r.fetch(new Request("http://example.org/301"));
- expect(resp1.status).toEqual(301);
- expect(Object.fromEntries(resp1.headers)).toEqual({
- // Note: *no* CORS headers to be found here!
- });
-
- const resp2 = await r.fetch(new Request("http://example.org/303"));
- expect(resp2.status).toEqual(303);
- expect(Object.fromEntries(resp2.headers)).toEqual({
- // Note: *no* CORS headers to be found here!
- });
-
- const resp3 = await r.fetch(new Request("http://example.org/308"));
- expect(resp3.status).toEqual(308);
- expect(Object.fromEntries(resp3.headers)).toEqual({
- // Note: *no* CORS headers to be found here!
- });
- });
-
- test("won’t add CORS headers to responses that already contain them", async () => {
- const r = new ZenRouter({
- cors: true,
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- });
- r.route("GET /custom-cors", () =>
- json({ ok: true }, 200, {
- "Access-Control-Allow-Origin": "I am set manually",
- })
- );
-
- const resp = await r.fetch(new Request("http://example.org/custom-cors"));
-
- // NOTE: Arguably, we could make this an uncaught error, because clearly we
- // don't want a route to be setting their own CORS headers and conflict
- // with Zen Router's logic :(
- expect(resp.status).toEqual(200);
-
- expect(Object.fromEntries(resp.headers)).toEqual({
- // Note: *no* CORS headers to be found here!
- "content-type": "application/json; charset=utf-8",
- "access-control-allow-origin": "I am set manually",
- });
- });
-});
-
-describe("Error handling setup", () => {
- test("every router has its own error handler", async () => {
- const app1 = new ZenRouter();
- const app2 = new ZenRouter();
-
- // Configured in r1...
- app1.onError((e) => {
- switch (e.status) {
- case 404:
- return json({ quote: "One does not simply..." }, e.status);
- default:
- return fail();
- }
- });
-
- // r1 will use the custom defined 404 handler
- app1.route("GET /", fail);
- const resp1 = await app1.fetch(new Request("http://example.org/foo"));
- await expectResponse(resp1, { quote: "One does not simply..." }, 404);
-
- // ...but r2 will not use it
- app2.route("GET /", fail);
- const resp2 = await app2.fetch(new Request("http://example.org/foo"));
- await expectResponse(resp2, { error: "Not Found" }, 404);
- });
-
- test("multiple routers can share the same error handler", async () => {
- const errorHandler = new ErrorHandler();
- const app1 = new ZenRouter({ errorHandler });
- const app2 = new ZenRouter({ errorHandler });
-
- // Configured in r1...
- app1.onError((e) => {
- switch (e.status) {
- case 404:
- return json({ quote: "One does not simply..." }, e.status);
- default:
- return fail();
- }
- });
-
- // r1 will use the custom defined 404 handler
- app1.route("GET /", fail);
- const resp1 = await app1.fetch(new Request("http://example.org/foo"));
- await expectResponse(resp1, { quote: "One does not simply..." }, 404);
-
- // ...and now r2 will also use it
- app2.route("GET /", fail);
- const resp2 = await app2.fetch(new Request("http://example.org/foo"));
- await expectResponse(resp2, { quote: "One does not simply..." }, 404);
- });
-
- test("handles bugs in http error handler itself", async () => {
- const konsole = captureConsole();
-
- const app = new ZenRouter();
- app.onError(() => {
- throw new Error("Oops, I'm a broken error handler");
- });
-
- app.route("GET /", fail);
-
- // Trigger a 404, but the broken error handler will not handle that correctly
- const res = await app.fetch(new Request("http://example.org/foo"));
- await expectResponse(res, { error: "Internal Server Error" }, 500);
-
- expect(konsole.error).toHaveBeenNthCalledWith(
- 1,
- expect.stringMatching(
- /^Uncaught error: Error: Oops, I'm a broken error handler/
- )
- );
- });
-});
diff --git a/packages/liveblocks-zenrouter/test/Router.zod.test.ts b/packages/liveblocks-zenrouter/test/Router.zod.test.ts
deleted file mode 100644
index 8f98b6befa..0000000000
--- a/packages/liveblocks-zenrouter/test/Router.zod.test.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-import { describe, expect, test } from "vitest";
-import { z } from "zod";
-
-import { json, ZenRouter } from "~/index.js";
-import { expectResponse } from "~test/utils.js";
-
-const IGNORE_AUTH_FOR_THIS_TEST = () => Promise.resolve(true);
-
-describe("Param decoders (zod)", () => {
- // Zod equivalent of decoders' `numeric`: coerce a string input to a number
- const zodNumeric = z.coerce.number();
-
- const r = new ZenRouter({
- authorize: IGNORE_AUTH_FOR_THIS_TEST,
- params: {
- x: zodNumeric,
- y: zodNumeric,
- },
- });
-
- r.route("GET /add//", ({ p }) => json({ result: p.x + p.y, p }));
- r.route("GET /echo/", ({ p }) => json({ name: p.name }));
-
- test("placeholders are automatically decoded + validated", async () => {
- const req = new Request("http://example.org/add/1337/42");
- expect(await (await r.fetch(req)).json()).toEqual({
- result: 1379,
- p: { x: 1337, y: 42 },
- });
- });
-
- test("placeholders that cannot be decoded/transformed will throw a 400 error", async () => {
- const req = new Request("http://example.org/add/1/one");
- const resp = await r.fetch(req);
- await expectResponse(resp, { error: "Bad Request" }, 400);
- });
-
- test("untyped placeholders still work as strings", async () => {
- const req = new Request("http://example.org/echo/hello");
- expect(await (await r.fetch(req)).json()).toEqual({ name: "hello" });
- });
-});
-
-describe("Router body validation (zod)", () => {
- const r = new ZenRouter({ authorize: IGNORE_AUTH_FOR_THIS_TEST });
-
- r.route(
- "POST /add",
- z.object({ x: z.number(), y: z.number() }),
- ({ body }) => ({ result: body.x + body.y })
- );
-
- r.route(
- "POST /empty",
- z.object({ a: z.number() }).optional(),
- () => ({ ok: true })
- );
-
- test("accepts correct body", async () => {
- const req = new Request("http://example.org/add", {
- method: "POST",
- body: '{"x":41,"y":1}',
- });
- await expectResponse(await r.fetch(req), { result: 42 });
- });
-
- test("rejects invalid body with actual error message", async () => {
- const req = new Request("http://example.org/add", {
- method: "POST",
- body: '{"x":41,"y":"not a number"}',
- });
- await expectResponse(
- await r.fetch(req),
- {
- error: "Unprocessable Entity",
- reason:
- "Value at key 'y': Invalid input: expected number, received string",
- },
- 422
- );
- });
-
- test("rejects body with missing fields", async () => {
- const req = new Request("http://example.org/add", {
- method: "POST",
- body: '{"x":41}',
- });
- await expectResponse(
- await r.fetch(req),
- {
- error: "Unprocessable Entity",
- reason:
- "Value at key 'y': Invalid input: expected number, received undefined",
- },
- 422
- );
- });
-
- test("broken JSON bodies lead to 400", async () => {
- const req = new Request("http://example.org/add", {
- method: "POST",
- body: "I'm no JSON",
- });
- await expectResponse(await r.fetch(req), { error: "Bad Request" }, 400);
- });
-
- test("can accept empty bodies", async () => {
- {
- const req = new Request("http://example.org/empty", {
- method: "POST",
- body: "nah-ah", // Invalid body
- });
- await expectResponse(await r.fetch(req), { error: "Bad Request" }, 400);
- }
-
- {
- const req = new Request("http://example.org/empty", {
- method: "POST",
- body: '{"a": 123}', // Valid body
- });
- await expectResponse(await r.fetch(req), { ok: true });
- }
-
- {
- const req = new Request("http://example.org/empty", {
- method: "POST",
- // No body here
- });
- await expectResponse(await r.fetch(req), { ok: true });
- }
- });
-});
diff --git a/packages/liveblocks-zenrouter/test/cors.test.ts b/packages/liveblocks-zenrouter/test/cors.test.ts
deleted file mode 100644
index 0e9c29632b..0000000000
--- a/packages/liveblocks-zenrouter/test/cors.test.ts
+++ /dev/null
@@ -1,408 +0,0 @@
-import { describe, expect, test } from "vitest";
-
-import type { CorsOptions } from "~/cors.js";
-import { getCorsHeaders } from "~/cors.js";
-
-import { disableConsole } from "./utils.js";
-
-const url = "https://example.org";
-const TEST_ORIGIN = "https://my-example-app.org";
-
-function configureCors(options: Partial = {}) {
- return (req: Request) => {
- return Object.fromEntries(getCorsHeaders(req, options) ?? []);
- };
-}
-
-/** Builds a "normal" (non-preflight) request, from TEST_ORIGIN */
-function makeNormalRequest(method: string, headers: Record) {
- return new Request(url, {
- method,
- headers: {
- Origin: TEST_ORIGIN,
- ...headers,
- },
- });
-}
-
-/** Builds a preflight request, from TEST_ORIGIN */
-function makePreflightRequest(forMethod: string) {
- return new Request(url, {
- method: "OPTIONS",
- headers: {
- Origin: TEST_ORIGIN,
- "Access-Control-Request-Method": forMethod,
- "Access-Control-Request-Headers":
- "CoNtEnT-tYpE,X-Foo,Custom-Header,Accept",
- },
- });
-}
-
-describe("Basic CORS responses", () => {
- test("default config", () => {
- const cors = configureCors(/* default */);
-
- const preq = makePreflightRequest("PUT");
- expect(cors(preq)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- "access-control-allow-headers":
- "content-type, x-foo, custom-header, accept",
- vary: "Origin",
- });
-
- const req = makeNormalRequest("POST", {});
- expect(cors(req)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- vary: "Origin",
- });
- });
-
- test("only allow whitelisted headers", () => {
- const cors = configureCors({
- allowedHeaders: ["x-foo", "accept"],
- });
-
- const preq = makePreflightRequest("PUT");
- expect(cors(preq)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- "access-control-allow-headers": "x-foo, accept",
- vary: "Origin",
- });
- });
-
- test("with max age", () => {
- const cors = configureCors({ maxAge: 600 });
-
- const preq = makePreflightRequest("PUT");
- expect(cors(preq)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- "access-control-allow-headers":
- "content-type, x-foo, custom-header, accept",
- "access-control-max-age": "600",
- vary: "Origin",
- });
-
- const req = makeNormalRequest("POST", {});
- expect(cors(req)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- vary: "Origin",
- });
- });
-
- test("with expose headers", () => {
- const cors = configureCors({
- exposeHeaders: ["Content-Encoding", "X-Custom"],
- });
-
- const preq = makePreflightRequest("PUT");
- expect(cors(preq)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- "access-control-allow-headers":
- "content-type, x-foo, custom-header, accept",
- "access-control-expose-headers": "Content-Encoding, X-Custom",
- vary: "Origin",
- });
-
- const req = makeNormalRequest("POST", {});
- expect(cors(req)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-expose-headers": "Content-Encoding, X-Custom",
- vary: "Origin",
- });
- });
-
- test("with fixed origins", () => {
- const cors = configureCors({
- allowedOrigins: [TEST_ORIGIN, "https://another-origin.org"],
- });
-
- const preq = makePreflightRequest("PUT");
- expect(cors(preq)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- "access-control-allow-headers":
- "content-type, x-foo, custom-header, accept",
- vary: "Origin",
- });
-
- const req = makeNormalRequest("POST", {});
- expect(cors(req)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- vary: "Origin",
- });
- });
-
- test.each([false, true])(
- "with rejected origin, sendWildcard %",
- (sendWildcard) => {
- const cors = configureCors({
- allowedOrigins: [
- "https://another-origin.org",
- "https://yet-another-origin.org",
- ],
- sendWildcard,
- });
-
- const preq = makePreflightRequest("PUT");
- expect(cors(preq)).toEqual({
- // NOTE: No CORS headers will be sent for this request!
- });
-
- const req = makeNormalRequest("POST", {});
- expect(cors(req)).toEqual({
- // NOTE: No CORS headers will be sent for this request!
- });
- }
- );
-
- test("sending wildcard", () => {
- const cors = configureCors({ sendWildcard: true });
-
- const preq = makePreflightRequest("PUT");
- expect(cors(preq)).toEqual({
- "access-control-allow-origin": "*",
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- "access-control-allow-headers":
- "content-type, x-foo, custom-header, accept",
- // NOTE: No Vary header!
- });
-
- const req = makeNormalRequest("POST", {});
- expect(cors(req)).toEqual({
- "access-control-allow-origin": "*",
- // NOTE: No Vary header!
- });
- });
-
- test("sends CORS headers, even for non-CORS requests (default)", () => {
- disableConsole();
-
- const cors = configureCors({
- // These are the defaults
- // allowedOrigins: "*",
- // alwaysSend: true,
- // supportsCredentials: false,
- });
-
- // Make requests without an Origin header
- const preq = new Request(url, { method: "OPTIONS" });
- const req1 = new Request(url, { method: "GET" });
- const req2 = new Request(url, { method: "POST" });
-
- expect(cors(preq)).toEqual({ "access-control-allow-origin": "*" });
- expect(cors(req1)).toEqual({ "access-control-allow-origin": "*" });
- expect(cors(req2)).toEqual({ "access-control-allow-origin": "*" });
- });
-
- test("sends CORS headers, even for non-CORS requests (except when credentials)", () => {
- const cors = configureCors({
- // These are the defaults
- // allowedOrigins: "*",
- // alwaysSend: true,
- allowCredentials: true,
- });
-
- // Make requests without an Origin header
- const preq = new Request(url, { method: "OPTIONS" });
- const req1 = new Request(url, { method: "GET" });
- const req2 = new Request(url, { method: "POST" });
-
- expect(cors(preq)).toEqual(/* no CORS here! */ {});
- expect(cors(req1)).toEqual(/* no CORS here! */ {});
- expect(cors(req2)).toEqual(/* no CORS here! */ {});
- });
-
- test("sends CORS headers, even for non-CORS requests (except when credentials, except when fixed)", () => {
- disableConsole();
-
- const cors = configureCors({
- // These are the defaults
- allowedOrigins: ["https://fixed.org", "https://dev.fixed.org"],
- // alwaysSend: true,
- allowCredentials: true,
- });
-
- // Make requests without an Origin header
- const preq = new Request(url, { method: "OPTIONS" });
- const req1 = new Request(url, { method: "GET" });
- const req2 = new Request(url, { method: "POST" });
-
- expect(cors(preq)).toEqual({
- "access-control-allow-credentials": "true",
- "access-control-allow-origin": "https://fixed.org", // Only the first one will be returned
- vary: "Origin",
- });
- expect(cors(req1)).toEqual({
- "access-control-allow-credentials": "true",
- "access-control-allow-origin": "https://fixed.org", // Only the first one will be returned
- vary: "Origin",
- });
- expect(cors(req2)).toEqual({
- "access-control-allow-credentials": "true",
- "access-control-allow-origin": "https://fixed.org", // Only the first one will be returned
- vary: "Origin",
- });
- });
-
- test("won’t send CORS headers, if alwaysSend = false (not the default), fixed", () => {
- const cors = configureCors({
- // These are the defaults
- allowedOrigins: ["https://fixed.org", "https://dev.fixed.org"],
- alwaysSend: false,
- allowCredentials: true,
- });
-
- // Make requests without an Origin header
- const preq = new Request(url, { method: "OPTIONS" });
- const req1 = new Request(url, { method: "GET" });
- const req2 = new Request(url, { method: "POST" });
-
- expect(cors(preq)).toEqual(/* no CORS */ {});
- expect(cors(req1)).toEqual(/* no CORS */ {});
- expect(cors(req2)).toEqual(/* no CORS */ {});
- });
-
- test("won’t send CORS headers, if alwaysSend = false (not the default), wildcard", () => {
- const cors = configureCors({
- // These are the defaults
- // allowedOrigins: '*',
- alwaysSend: false,
- allowCredentials: true,
- });
-
- // Make requests without an Origin header
- const preq = new Request(url, { method: "OPTIONS" });
- const req1 = new Request(url, { method: "GET" });
- const req2 = new Request(url, { method: "POST" });
-
- expect(cors(preq)).toEqual(/* no CORS */ {});
- expect(cors(req1)).toEqual(/* no CORS */ {});
- expect(cors(req2)).toEqual(/* no CORS */ {});
- });
-
- test("won’t send CORS headers, if alwaysSend = false (not the default), wildcard, no credentials", () => {
- const cors = configureCors({
- // These are the defaults
- // allowedOrigins: '*',
- alwaysSend: false,
- // supportsCredentials: false,
- });
-
- // Make requests without an Origin header
- const preq = new Request(url, { method: "OPTIONS" });
- const req1 = new Request(url, { method: "GET" });
- const req2 = new Request(url, { method: "POST" });
-
- expect(cors(preq)).toEqual(/* no CORS */ {});
- expect(cors(req1)).toEqual(/* no CORS */ {});
- expect(cors(req2)).toEqual(/* no CORS */ {});
- });
-
- test("without Vary header", () => {
- const cors = configureCors({ varyHeader: false });
-
- const preq = makePreflightRequest("PUT");
- expect(cors(preq)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- "access-control-allow-headers":
- "content-type, x-foo, custom-header, accept",
- // No Vary header
- });
-
- const req = makeNormalRequest("POST", {});
- expect(cors(req)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- // No Vary header
- });
- });
-});
-
-describe("Invalid CORS configurations", () => {
- test("allowing wildcard + allowing credentials is not allowed by the spec", () => {
- const cors = configureCors({
- allowCredentials: true,
- sendWildcard: true,
- });
-
- const preq = makePreflightRequest("PUT");
- expect(() => cors(preq)).toThrow("Invalid CORS configuration");
-
- const req = makeNormalRequest("POST", {});
- expect(() => cors(req)).toThrow("Invalid CORS configuration");
- });
-});
-
-describe("Non-standard, Liveblocks-specific, CORS responses", () => {
- test("Custom X-Relay-Origin header", () => {
- const cors = configureCors(/* default */);
-
- const preq = new Request(url, {
- method: "OPTIONS",
- headers: {
- // NOTE: Non-standard X-Relay-Origin header works as Origin
- "X-Relay-Origin": TEST_ORIGIN,
- "Access-Control-Request-Method": "OPTIONS",
- "Access-Control-Request-Headers": "x-foo", // Must be lower-cased, without spaces
- },
- });
-
- expect(cors(preq)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- "access-control-allow-headers": "x-foo",
- vary: "Origin",
- });
-
- const req = new Request(url, {
- method: "POST",
- headers: {
- // NOTE: Non-standard X-Relay-Origin header works as Origin
- "X-Relay-Origin": TEST_ORIGIN,
- },
- });
- expect(cors(req)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- vary: "Origin",
- });
- });
-
- test("Origin takes precedence over Custom X-Relay-Origin header when both are used", () => {
- const cors = configureCors(/* default */);
-
- const preq = new Request(url, {
- method: "OPTIONS",
- headers: {
- // NOTE: Non-standard X-Relay-Origin header works as Origin
- "X-Relay-Origin": "https://my-custom-app.org",
- Origin: TEST_ORIGIN,
- "Access-Control-Request-Method": "OPTIONS",
- "Access-Control-Request-Headers": "X-Foo,Custom-Header", // Must be lower-cased, without spaces
- },
- });
-
- expect(cors(preq)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
- "access-control-allow-headers": "x-foo, custom-header",
- vary: "Origin",
- });
-
- const req = new Request(url, {
- method: "POST",
- headers: {
- // NOTE: Non-standard X-Relay-Origin header works as Origin
- Origin: TEST_ORIGIN,
- "X-Relay-Origin": "https://my-custom-app.org",
- },
- });
- expect(cors(req)).toEqual({
- "access-control-allow-origin": TEST_ORIGIN,
- vary: "Origin",
- });
- });
-});
diff --git a/packages/liveblocks-zenrouter/test/lib/matchers.test.ts b/packages/liveblocks-zenrouter/test/lib/matchers.test.ts
deleted file mode 100644
index eeccc5aba2..0000000000
--- a/packages/liveblocks-zenrouter/test/lib/matchers.test.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import * as fc from "fast-check";
-import { describe, expect, test } from "vitest";
-
-import { routeMatcher } from "~/lib/matchers.js";
-
-function _(pathname: string, base = "https://example.com") {
- return new URL(pathname, base);
-}
-
-describe("routeMatcher", () => {
- test("simple paths (without dynamic segments)", () =>
- fc.assert(
- fc.property(
- fc.webUrl().map((u) => new URL(u)),
-
- (input) => {
- fc.pre(input.pathname !== "/");
- expect(routeMatcher("GET /").matchURL(input)).toBeNull();
- }
- )
- ));
-
- test("simple paths (without dynamic segments)", () => {
- expect(
- routeMatcher("GET /").matchURL(new URL("https://example.com"))
- ).toEqual({});
- expect(
- routeMatcher("GET /").matchURL(new URL("https://example.com/"))
- ).toEqual({});
-
- expect(routeMatcher("GET /foo").matchURL(_("/foo"))).toEqual({});
- expect(routeMatcher("GET /foo").matchURL(_("/foo/"))).toEqual({});
- });
-
- test.each(
- // [pattern, input, result]
- [
- ["/", "/foo", null],
- ["/foo/", "/foo/bar", { bar: "bar" }],
- ["/foo/", "/foo/qux", { bar: "qux" }],
- ["/foo/", "/foo/bar", { qux: "bar" }],
- ["/foo//", "/foo/bar", null],
- ["/foo//", "/foo/bar/qux", { a: "bar", b: "qux" }],
- ["/foo//", "/foo/bar/qux/baz", null],
- ]
- )("path with dynamic segment: %p %p", (pattern, input, result) => {
- expect(routeMatcher("GET " + pattern).matchURL(_(input))).toEqual(result);
- });
-
- test.each([
- ["", "Route must start with '/'"],
- [" ", "Route must start with '/'"],
- ["must/start/with/slash/", "Route must start with '/'"],
- ["//", "Route may not end with '/'"],
- ["/must/not/end/with/slash/", "Route may not end with '/'"],
- ["/spaces are not allowed", "Invalid pattern"],
- ["/cannot/have//double/slashes", "Invalid pattern"],
- ["//x", "Invalid pattern"],
- ["/", "Invalid pattern"],
- ["/", "Invalid pattern"],
- ["/", "Invalid pattern"],
- ["/foo/%bar", "Invalid pattern"],
- ["/foo\\bar", "Invalid pattern"],
- ["/foo/:bar", "Invalid pattern"],
- ])("throws when initialized with invalid path: %p", (invalid, errmsg) => {
- expect(() => routeMatcher("GET " + invalid)).toThrow(errmsg);
- });
-});
diff --git a/packages/liveblocks-zenrouter/test/streams.test.ts b/packages/liveblocks-zenrouter/test/streams.test.ts
deleted file mode 100644
index 32fdcca8f4..0000000000
--- a/packages/liveblocks-zenrouter/test/streams.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { describe, expect, test } from "vitest";
-
-import {
- html,
- jsonArrayStream,
- ndjsonStream,
- textStream,
-} from "~/responses/index.js";
-
-async function readStream(response: Response): Promise {
- const reader = response.body!.getReader();
- const decoder = new TextDecoder();
- let result = "";
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- result += decoder.decode(value);
- }
- return result;
-}
-
-describe("html", () => {
- test("returns html response with correct content-type", async () => {
- const response = html("Hello ");
- expect(response.status).toBe(200);
- expect(response.headers.get("Content-Type")).toBe(
- "text/html; charset=utf-8"
- );
- expect(await response.text()).toBe("Hello ");
- });
-
- test("accepts custom status and headers", async () => {
- const response = html("Not Found
", 404, { "X-Custom": "value" });
- expect(response.status).toBe(404);
- expect(response.headers.get("Content-Type")).toBe(
- "text/html; charset=utf-8"
- );
- expect(response.headers.get("X-Custom")).toBe("value");
- expect(await response.text()).toBe("Not Found
");
- });
-});
-
-describe("textStream", () => {
- test("streams from iterable", async () => {
- function* chunks() {
- yield "hello";
- yield " ";
- yield "world";
- }
-
- const response = textStream(chunks());
- expect(response.status).toBe(200);
- await expect(response.text()).resolves.toBe("hello world");
- });
-
- test("respects custom headers", () => {
- const response = textStream(["ok"], { "X-Custom": "value" });
- expect(response.headers.get("X-Custom")).toBe("value");
- });
-
- test("flushes buffer when it exceeds bufSize", async () => {
- // With bufSize=5, "hello" (5 chars) triggers a flush, then " world" (6 chars) triggers another
- function* chunks() {
- yield "hello";
- yield " world";
- }
-
- const response = textStream(chunks(), undefined, { bufSize: 5 });
- expect(await response.text()).toBe("hello world");
- });
-
- test("handles empty iterable", async () => {
- const response = textStream([]);
- expect(await readStream(response)).toBe("");
- });
-
- test("works with JSON content-type header", async () => {
- function* generate() {
- yield "[";
- yield JSON.stringify({ id: 1 });
- yield ",";
- yield JSON.stringify({ id: 2 });
- yield "]";
- }
-
- const response = textStream(generate(), {
- "Content-Type": "application/json; charset=utf-8",
- });
- expect(response.headers.get("Content-Type")).toBe(
- "application/json; charset=utf-8"
- );
- await expect(response.json()).resolves.toEqual([{ id: 1 }, { id: 2 }]);
- });
-
- test("response.json() fails for invalid JSON content", async () => {
- const response = textStream(["not valid json"]);
- await expect(response.json()).rejects.toThrow(SyntaxError);
- });
-});
-
-describe("ndjsonStream", () => {
- test("sets ndjson content-type", () => {
- const response = ndjsonStream([]);
- expect(response.headers.get("Content-Type")).toBe("application/x-ndjson");
- });
-
- test("streams iterable as newline-delimited JSON", async () => {
- function* values() {
- yield { id: 1, name: "Alice" };
- yield { id: 2, name: "Bob" };
- yield [1, 2, 3];
- }
-
- const response = ndjsonStream(values());
- const body = await response.text();
- expect(body).toBe(
- '{"id":1,"name":"Alice"}\n{"id":2,"name":"Bob"}\n[1,2,3]\n'
- );
- });
-
- test("handles primitive JSON values", async () => {
- const response = ndjsonStream([42, "hello", true, null]);
- const body = await response.text();
- expect(body).toBe('42\n"hello"\ntrue\nnull\n');
- });
-
- test("preserves custom headers", () => {
- const response = ndjsonStream([], { "X-Custom": "value" });
- expect(response.headers.get("Content-Type")).toBe("application/x-ndjson");
- expect(response.headers.get("X-Custom")).toBe("value");
- });
-});
-
-describe("jsonArrayStream", () => {
- test("sets json content-type", () => {
- const response = jsonArrayStream([]);
- expect(response.headers.get("Content-Type")).toBe(
- "application/json; charset=utf-8"
- );
- });
-
- test("streams empty array", async () => {
- const response = jsonArrayStream([]);
- await expect(response.json()).resolves.toEqual([]);
- });
-
- test("streams single value", async () => {
- const response = jsonArrayStream([{ id: 1 }]);
- await expect(response.json()).resolves.toEqual([{ id: 1 }]);
- });
-
- test("streams multiple values with commas", async () => {
- function* values() {
- yield { id: 1, name: "Alice" };
- yield { id: 2, name: "Bob" };
- yield { id: 3, name: "Charlie" };
- }
-
- const response = jsonArrayStream(values());
- await expect(response.json()).resolves.toEqual([
- { id: 1, name: "Alice" },
- { id: 2, name: "Bob" },
- { id: 3, name: "Charlie" },
- ]);
- });
-
- test("handles primitive JSON values", async () => {
- const response = jsonArrayStream([42, "hello", true, null]);
- await expect(response.json()).resolves.toEqual([42, "hello", true, null]);
- });
-
- test("preserves custom headers", () => {
- const response = jsonArrayStream([], { "X-Custom": "value" });
- expect(response.headers.get("Content-Type")).toBe(
- "application/json; charset=utf-8"
- );
- expect(response.headers.get("X-Custom")).toBe("value");
- });
-});
diff --git a/packages/liveblocks-zenrouter/test/utils.ts b/packages/liveblocks-zenrouter/test/utils.ts
deleted file mode 100644
index 3d7b1452fb..0000000000
--- a/packages/liveblocks-zenrouter/test/utils.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import type { Json } from "@liveblocks/core";
-import { expect, onTestFinished, vi } from "vitest";
-
-import { json } from "~/index.js";
-
-export function fail(): never {
- throw new Error("I should not get invoked");
-}
-
-export function ok(message: string) {
- return () => json({ message }, 200);
-}
-
-export function expectEmptyResponse(
- resp: Response,
- expectedStatus = 204
-): void {
- try {
- if (!(resp instanceof Response)) {
- throw new Error(`Expected a Response, but found: ${String(resp)}`);
- }
- expect(resp.status).toEqual(expectedStatus);
- } catch (err) {
- // Hack the stack for better error messages, see https://kentcdodds.com/blog/improve-test-error-messages-of-your-abstractions
- Error.captureStackTrace(err as Error, expectEmptyResponse);
- throw err;
- }
-}
-
-export async function expectResponse(
- resp: Response,
- expectedBody: Json,
- expectedStatus = 200
-): Promise {
- try {
- if (!(resp instanceof Response)) {
- throw new Error(`Expected a Response, but found: ${String(resp)}`);
- }
-
- const mimeType = resp.headers.get("Content-Type")?.split(";")[0];
- if (mimeType === "application/json") {
- const json = await resp.json();
- expect(json).toEqual(expectedBody);
- expect(resp.status).toEqual(expectedStatus);
- } else if (mimeType === "text/plain") {
- const text = (await resp.text()) as unknown;
- expect(text).toEqual(expectedBody);
- expect(resp.status).toEqual(expectedStatus);
- } else {
- throw new Error("Unexpected content type: " + mimeType);
- }
- } catch (err) {
- // Hack the stack for better error messages, see https://kentcdodds.com/blog/improve-test-error-messages-of-your-abstractions
- Error.captureStackTrace(err as Error, expectResponse);
- throw err;
- }
-}
-
-type Consolish = Pick;
-
-/**
- * Installs a console spy for the duration of this test.
- */
-export function captureConsole(): Consolish {
- /* eslint-disable @typescript-eslint/unbound-method */
- const log = vi.spyOn(console, "log").mockImplementation(() => void 0);
- onTestFinished(log.mockRestore);
-
- const info = vi.spyOn(console, "info").mockImplementation(() => void 0);
- onTestFinished(info.mockRestore);
-
- const warn = vi.spyOn(console, "warn").mockImplementation(() => void 0);
- onTestFinished(warn.mockRestore);
-
- const error = vi.spyOn(console, "error").mockImplementation(() => void 0);
- onTestFinished(error.mockRestore);
- /* eslint-enable @typescript-eslint/unbound-method */
-
- return { log, info, warn, error } as unknown as Consolish;
-}
-
-/**
- * Disables the console for the duration of this test. Similar to
- * `captureConsole()`, but its puropse is different: we're not interested in
- * any console output here.
- */
-export function disableConsole() {
- captureConsole();
-}
diff --git a/packages/liveblocks-zenrouter/tsconfig.json b/packages/liveblocks-zenrouter/tsconfig.json
deleted file mode 100644
index 473131b356..0000000000
--- a/packages/liveblocks-zenrouter/tsconfig.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- // Use the same settings for all packages
- "extends": "../../shared/tsconfig.common.json",
-
- "compilerOptions": {
- "noEmit": false,
- "outDir": "dist",
- "module": "node16",
- "moduleResolution": "node16",
-
- "target": "es2020",
- "lib": [],
- "skipLibCheck": true,
-
- "noUncheckedIndexedAccess": false, // overwritten from base tsconfig to relax a bit
-
- "paths": {
- "~": ["./src"],
- "~/*": ["./src/*"],
- "~test": ["./test"],
- "~test/*": ["./test/*"]
- }
- },
- "include": ["src", "test"],
- "exclude": ["node_modules", "dist"]
-}
diff --git a/packages/liveblocks-zenrouter/tsup.config.ts b/packages/liveblocks-zenrouter/tsup.config.ts
deleted file mode 100644
index b23301d54a..0000000000
--- a/packages/liveblocks-zenrouter/tsup.config.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { defineConfig } from "tsup";
-
-export default defineConfig({
- entry: ["src/index.ts"],
- dts: true,
- splitting: true,
- clean: true,
- target: "esnext",
- format: ["esm", "cjs"],
- minify: true,
- sourcemap: true,
- esbuildOptions(options, _context) {
- // Replace __VERSION__ globals with concrete version
- const pkg = require("./package.json");
- options.define.__VERSION__ = JSON.stringify(pkg.version);
- },
-});
diff --git a/packages/liveblocks-zenrouter/vitest.config.ts b/packages/liveblocks-zenrouter/vitest.config.ts
deleted file mode 100644
index 20b769612f..0000000000
--- a/packages/liveblocks-zenrouter/vitest.config.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { defineConfig } from "vitest/config";
-import tsconfigPaths from "vite-tsconfig-paths";
-
-export default defineConfig({
- plugins: [tsconfigPaths()],
-
- test: {
- coverage: {
- provider: "istanbul",
- reporter: ["text", "html"],
- include: ["src/"],
-
- // Coverage percentages
- // <90% of coverage is considered sub-optimal
- // >98% coverage is considered healthy
- watermarks: {
- branches: [90, 98],
- functions: [90, 98],
- lines: [90, 98],
- statements: [90, 98],
- },
- },
- },
-});
diff --git a/packages/liveblocks-zenrouter/zen-router-diagram.png b/packages/liveblocks-zenrouter/zen-router-diagram.png
deleted file mode 100644
index d79c34dcd3..0000000000
Binary files a/packages/liveblocks-zenrouter/zen-router-diagram.png and /dev/null differ
diff --git a/tools/liveblocks-cli/package.json b/tools/liveblocks-cli/package.json
index 9ad924ae90..de5768d48f 100644
--- a/tools/liveblocks-cli/package.json
+++ b/tools/liveblocks-cli/package.json
@@ -1,6 +1,6 @@
{
"name": "liveblocks",
- "version": "1.0.12",
+ "version": "1.0.14",
"description": "Liveblocks command line interface",
"type": "module",
"bin": {
@@ -22,6 +22,7 @@
"test:typescript": "tsc --noEmit"
},
"license": "AGPL-3.0-or-later",
+ "author": "Liveblocks Inc.",
"keywords": [
"Liveblocks",
"CLI",
@@ -39,8 +40,8 @@
},
"dependencies": {
"@liveblocks/core": "3.14.0",
- "@liveblocks/server": "1.0.12",
- "@liveblocks/zenrouter": "1.0.12",
+ "@liveblocks/server": "1.0.14",
+ "@liveblocks/zenrouter": "^1.0.17",
"decoders": "^2.8.0",
"js-base64": "^3.7.5",
"yjs": "^13.6.10"