diff --git a/CLAUDE.md b/CLAUDE.md index 48464427a4..24c1f976c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,5 +58,4 @@ ease of changes. If you have access to the Liveblocks backend repo, avoid making changes to them directly here — prefer editing the source in the backend repo. - `packages/liveblocks-server` -- `packages/liveblocks-zenrouter` - `tools/liveblocks-cli` diff --git a/package-lock.json b/package-lock.json index 958213a81f..7c2ba3b405 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "shared/*", "packages/*", "!packages/liveblocks-server", - "!packages/liveblocks-zenrouter", "tools/*", "!tools/liveblocks-cli", "e2e/next-ai-kitchen-sink", diff --git a/package.json b/package.json index 51afab98cc..800e2b7c83 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "shared/*", "packages/*", "!packages/liveblocks-server", - "!packages/liveblocks-zenrouter", "tools/*", "!tools/liveblocks-cli", "e2e/next-ai-kitchen-sink", diff --git a/packages/liveblocks-server/package.json b/packages/liveblocks-server/package.json index 20c0d570fe..6b78a4ee02 100644 --- a/packages/liveblocks-server/package.json +++ b/packages/liveblocks-server/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/server", - "version": "1.0.12", + "version": "1.0.14", "description": "Liveblocks backend server foundation.", "type": "module", "main": "./dist/index.js", @@ -43,6 +43,7 @@ "test:e2e": "jest --silent --verbose --color=always --config=./jest.config.e2e.js" }, "license": "AGPL-3.0-or-later", + "author": "Liveblocks Inc.", "devDependencies": { "@liveblocks/eslint-config": "*", "@liveblocks/jest-config": "*", diff --git a/packages/liveblocks-zenrouter/.eslintrc.cjs b/packages/liveblocks-zenrouter/.eslintrc.cjs deleted file mode 100644 index 2b3a584c2b..0000000000 --- a/packages/liveblocks-zenrouter/.eslintrc.cjs +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - root: true, - extends: ["@liveblocks/eslint-config"], - - rules: { - // Disable these for this library specifically - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-explicit-any": "off", - }, - - overrides: [ - { - files: ["test/**", "*.test.ts", "*.test.tsx"], - - // Special config for test files - rules: { - "@typescript-eslint/explicit-module-boundary-types": "off", - - // Allow using `any` in unit tests - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - }, - }, - ], -}; diff --git a/packages/liveblocks-zenrouter/.lvimrc b/packages/liveblocks-zenrouter/.lvimrc deleted file mode 100644 index bde765b0a6..0000000000 --- a/packages/liveblocks-zenrouter/.lvimrc +++ /dev/null @@ -1,3 +0,0 @@ -" Needed for Vincent to have the Vim coc-tsserver plugin pick up the right -" TypeScript server in monorepo projects -let g:coc_user_config['tsserver.tsdk'] = '../../node_modules/typescript/lib' diff --git a/packages/liveblocks-zenrouter/README.md b/packages/liveblocks-zenrouter/README.md deleted file mode 100644 index c4caaebd5d..0000000000 --- a/packages/liveblocks-zenrouter/README.md +++ /dev/null @@ -1,100 +0,0 @@ -

- Liveblocks - Liveblocks -

- -# `@liveblocks/zenrouter` - -

- NPM - Size - License -

- -Zen Router is an opinionated API router with batteries included, encouraging -patterns that remain maintainable as your application grows. - -## Installation - -``` -npm i @liveblocks/zenrouter -``` - -## Purpose - -The main purpose of this router is to implement an API backend. - -## Quick start - -```ts -import { object, string } from "decoders"; -import { Router } from "@liveblocks/zenrouter"; - -const zen = new Router(/* ... */); - -zen.route( - "GET /greet/", - - ({ p }) => ({ result: `Hi, ${p.name}!` }) -); - -zen.route( - "POST /greet", - - object({ name: string }), - - ({ body }) => ({ - result: `Hi, ${body.name}!`, - }) -); - -export default zen; -``` - -## The Zen Router pipeline - -![](./zen-router-diagram.png) - -## Principles - -### Pragmatic - -- Implementing real-world endpoints should be joyful, easy, and type-safe. -- All requests and responses are JSON by default. -- All error responses have at least an `{ error }` key with a human-readable - string. -- You can _throw_ any HTTP error to short-circuit a non-2xx response. -- JSON error responses for all known HTTP status codes, customizable per status - code. -- CORS support is built-in with a sane `{ cors: true }` default that applies to - all endpoints in the router. `OPTIONS` routes and responses are managed - automatically. - -### Secure by default - -- All requests must be authorized. Authorization is opt-out, not opt-in. -- All path params are verified and type-safe (`/foo//` available as - `p.bar` and `p.qux`), cannot be empty, and are URI-decoded automatically. -- Input JSON bodies of POST requests must be validated, and are made available - as a fully-type safe `body` in the handler. -- All query strings are type-safely accessible (`/foo?abc=hi` as `q.abc`). - -### Maintainable - -- All route patterns are static, fully qualified, and thus greppable. No "base" - prefix URL setup, which in practice makes codebases harder to navigate over - time. -- Routes include the method in the definition (`zen.route("POST /v2/foo/bar")` - instead of `zen.post("/v2/foo/bar")`). -- No complex middlewares. Only the request context and auth functions can carry - data alongside a request. No per-route middlewares, no monkey-patching of the - request object. -- Default error handling is configurable per status code; individual handlers - can always bypass it by throwing a custom Response. - -## License - -Licensed under the Apache License 2.0, Copyright © 2021-present -[Liveblocks](https://liveblocks.io). - -See [LICENSE](../../licenses/LICENSE-APACHE-2.0) for more information. diff --git a/packages/liveblocks-zenrouter/package.json b/packages/liveblocks-zenrouter/package.json deleted file mode 100644 index 86090c677d..0000000000 --- a/packages/liveblocks-zenrouter/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "@liveblocks/zenrouter", - "version": "1.0.12", - "description": "An opinionated router library for building APIs following best practices.", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "module": "./dist/index.js", - "default": "./dist/index.cjs" - } - } - }, - "files": [ - "dist/**", - "README.md" - ], - "bugs": { - "url": "https://github.com/liveblocks/zenrouter/issues" - }, - "scripts": { - "dev": "tsup --watch", - "build": "tsup", - "format": "eslint --color --fix src/ test/; prettier --write src/ test/", - "lint": "eslint --color src/ test/", - "lint:package": "publint --strict && attw --pack", - "test": "vitest run --color", - "test:watch": "vitest watch --color", - "test:types": "tsd" - }, - "license": "Apache-2.0", - "devDependencies": { - "@liveblocks/eslint-config": "*", - "decoders": "^2.8.0", - "hotscript": "^1.0.13", - "nanoid": "^3", - "zod": "^4.1.8" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/liveblocks/zenrouter.git" - }, - "sideEffects": false -} diff --git a/packages/liveblocks-zenrouter/src/ErrorHandler.ts b/packages/liveblocks-zenrouter/src/ErrorHandler.ts deleted file mode 100644 index 04774565da..0000000000 --- a/packages/liveblocks-zenrouter/src/ErrorHandler.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { raise } from "~/lib/utils.js"; -import { - HttpError, - isGenericAbort, - json, - ValidationError, -} from "~/responses/index.js"; - -export type ErrorContext = { - req: Request; - ctx?: RC; -}; - -export type ErrorHandlerFn = ( - error: E, - extra: ErrorContext -) => Response | Promise; - -// The default handler for HttpErrors, in case no custom handler is provided -const defaultHttpErrorHandler: ErrorHandlerFn = (e) => - json( - { - error: e.message, - reason: e instanceof ValidationError ? e.reason : undefined, - }, - e.status, - e.headers - ); - -// The default uncaught error handler, in case no custom handler is provided. -// It's the ultimate fallback if everything else has failed. -const defaultUncaughtErrorHandler: ErrorHandlerFn = () => - json({ error: "Internal Server Error" }, 500); - -/** - * Central registry instance for handling HTTP errors. Has configured defaults - * for every known HTTP error code. Allows you to override those defaults and - * provide your own error handling preferences. - */ -export class ErrorHandler { - // A registered error handler, if any error handlers, ordered from most-specific to - // least-specific - #_httpErrorHandler: ErrorHandlerFn | null = null; - - // A registered error handler to be called for any uncaught (non-HttpError) - // errors. They will typically be Error instances, but it cannot be - // guaranteed they are. - #_uncaughtErrorHandler: ErrorHandlerFn | null = null; - - /** - * Registers a custom HTTP error handler. - * - * This will get called whenever an `HttpError` is thrown (which also happens - * with `abort()`) from a route handler. - * - * It will *NOT* get called if a `Response` instance is thrown (or returned) - * from a handler directly! - */ - public onError(handler: ErrorHandlerFn): void { - if (this.#_httpErrorHandler !== null) { - raise("An error handler was already registered"); - } - this.#_httpErrorHandler = handler; - } - - /** - * Registers a custom uncaught error handler. - * - * This will only get called if there is an unexpected error thrown from - * a route handler, i.e. something that isn't a `Response` instance, or an - * `HttpError`. - */ - public onUncaughtError(handler: ErrorHandlerFn): void { - if (this.#_uncaughtErrorHandler !== null) { - raise("An uncaught error handler was already registered"); - } - this.#_uncaughtErrorHandler = handler; - } - - /** - * Given an error, will find the best (most-specific) error handler for it, - * and return its response. - */ - public async handle( - err: unknown, - extra: ErrorContext - ): Promise { - // If it's a Response, check if it's a generic abort or a custom response - if (err instanceof Response) { - if (isGenericAbort(err)) { - // Generic abort - convert to HttpError and run through normal error handling - const status = err.status; - const headers = Object.fromEntries(err.headers.entries()); - try { - err = new HttpError(status, undefined, headers); - // Fall through to HttpError handling below - } catch { - // Status code not supported by HttpError (5xx, 422, or unknown code) - return json({ error: "Unknown" }, status, headers); - } - } else { - // Custom response - return verbatim - return err; - } - } - - // If error is not an instance of HttpError, then it's an otherwise - // uncaught error that should lead to an 5xx response. We'll wrap it in an - // UncaughtError instance, so the custom handler will only ever have to - // deal with HttpErrors. - if (err instanceof HttpError) { - const httpErrorHandler = - this.#_httpErrorHandler ?? defaultHttpErrorHandler; - try { - return await httpErrorHandler(err, extra); - } catch (e) { - // Fall through, let the uncaught error handler handle it - err = e; - } - } - - // At this point, `err` can be anything - if (this.#_uncaughtErrorHandler) { - try { - return await this.#_uncaughtErrorHandler(err, extra); - } catch (e) { - // Fall through - // istanbul ignore next -- @preserve - err = e; - } - } else { - console.error(`Uncaught error: ${(err as Error)?.stack ?? String(err)}`); // prettier-ignore - console.error("...but no uncaught error handler was set up for this router."); // prettier-ignore - } - - // The default uncaught error handler cannot fail. It's the ultimate fallback. - return defaultUncaughtErrorHandler(err, extra); - } -} diff --git a/packages/liveblocks-zenrouter/src/Relay.ts b/packages/liveblocks-zenrouter/src/Relay.ts deleted file mode 100644 index 27b6e8f2ee..0000000000 --- a/packages/liveblocks-zenrouter/src/Relay.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ - -import { abort, HttpError } from "~/responses/index.js"; -import { ZenRouter } from "~/Router.js"; - -import { lookupContext } from "./contexts.js"; -import { ErrorHandler } from "./ErrorHandler.js"; -import type { PathPrefix } from "./lib/matchers.js"; -import { makePrefixPathMatcher } from "./lib/matchers.js"; - -type RequestHandler = ( - req: Request, - ...args: readonly any[] -) => Promise; - -type RelayOptions = { - errorHandler?: ErrorHandler; -}; - -/** - * Relay won't do any route handling itself. It will only hand-off any incoming - * request to one of the configured routers, based on the incoming request path - * (first matching prefix path wins). - * - * It does NOT check the HTTP verb (GET, POST, etc). - * It does NOT do any authentication. - * It does NOT look at any headers. - * - * Subrouters (typically Router instances) are responsible for all that - * themselves. - * - * If no matching route is found, it will return a generic 404 error response. - */ -export class ZenRelay { - readonly #_errorHandler: ErrorHandler; - readonly #_routers: [prefixMatcher: RegExp, handler: RequestHandler][] = []; - - constructor(options?: RelayOptions) { - this.#_errorHandler = options?.errorHandler ?? new ErrorHandler(); - } - - public get fetch(): ( - req: Request, - ...rest: readonly any[] - ) => Promise { - return this.#_tryDispatch.bind(this); - } - - /** - * If an incoming request matches the given prefix, forward the request as-is - * to the child router. Relaying happens strictly based on the request URL. - * It does not look at headers, or the HTTP method, or anything else to - * decide if it's a match. - */ - public relay( - prefix: PathPrefix, - router: - | ZenRouter - // - // NOTE: "RequestHandler" here is only allowed here to allow passing an - // IttyRouter.handle instance here directly. Itty router is not built with - // the same concepts as Zen Router in mind (for example, it can return - // `undefined` instead of a Response to trigger a fallthrough). Overall, - // it's better to remove this again once we're done refactoring away all - // instances of Itty router. - | RequestHandler - ): this { - const prefixMatcher = makePrefixPathMatcher(prefix); - this.#_routers.push([ - prefixMatcher, - router instanceof ZenRouter ? router.fetch : router, - ]); - return this; // Allow chaining - } - - async #_tryDispatch( - req: Request, - ...args: readonly any[] - ): Promise { - try { - return await this.#_dispatch(req, ...args); - } catch (err) { - if (!(err instanceof HttpError || err instanceof Response)) { - // This case is definitely unexpected, it should never happen when - // you're using only Relay or Router instances. However, it *can* - // happen if the handler is a custom function (e.g. you're deferring to - // itty-router), then this is not guaranteed. - console.error(`Relayer caught error in subrouter! This should never happen, as routers should never throw an unexpected error! ${String(err)}`); // prettier-ignore - } - return this.#_errorHandler.handle(err, { - req, - ctx: lookupContext(req), - }); - } - } - - #_dispatch(req: Request, ...args: readonly any[]): Promise { - const path = new URL(req.url).pathname; - for (const [matcher, handler] of this.#_routers) { - if (matcher.test(path)) { - return handler(req, ...args); - } - } - - // console.warn(`Relayer did not know how to handle requested path: ${path}`); - return abort(404); - } -} diff --git a/packages/liveblocks-zenrouter/src/Router.ts b/packages/liveblocks-zenrouter/src/Router.ts deleted file mode 100644 index 211517dfe4..0000000000 --- a/packages/liveblocks-zenrouter/src/Router.ts +++ /dev/null @@ -1,566 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - -// TODO: Make this a local definition? -import type { Json, JsonObject } from "@liveblocks/core"; - -import type { - ExtractParams, - HttpVerb, - MapSchemaOutput, - Pattern, - RouteMatcher, -} from "~/lib/matchers.js"; -import { routeMatcher, sortHttpVerbsInPlace } from "~/lib/matchers.js"; -import type { OtelConfig } from "~/lib/otel.js"; -import type { StandardSchemaV1 } from "~/lib/standard-schema.js"; -import { mapv, raise } from "~/lib/utils.js"; -import type { HttpError } from "~/responses/index.js"; -import { abort, json, ValidationError } from "~/responses/index.js"; - -import { attachContext, lookupContext } from "./contexts.js"; -import type { CorsOptions } from "./cors.js"; -import { AC_ORIGIN, getCorsHeaders } from "./cors.js"; -import type { ErrorHandlerFn } from "./ErrorHandler.js"; -import { ErrorHandler } from "./ErrorHandler.js"; - -/** - * An Incoming Request is what gets passed to every route handler. It includes - * the raw (unmodified) request, the derived context (user-defined), the parsed - * URL, the type-safe params `p`, the parsed query string `q`, and a verified - * JSON body (if a decoder is provided). - */ -type IncomingReq = { - /** - * The incoming request. - */ - readonly req: Request; - /** - * The incoming request parsed URL. - * This is equivalent to the result of `new URL(req.url)`. - */ - readonly url: URL; - /** - * The user-defined static context associated with this request. This is the - * best place to attach metadata you want to carry around along with the - * request, without having to monkey-patch the request instance. - * - * Use this context for static metadata. Do not use it for auth. - * - * Basically the result of calling the configured `getContext()` function on - * the request. - */ - readonly ctx: Readonly; - /** - * The result of the authorization check for this request. Basically the - * result of calling the configured `authorize()` function on the request. - */ - readonly auth: Readonly; - /** - * The type-safe params available for this request. Automatically derived - * from dynamic placeholders in the pattern. - */ - readonly p: TParams; - /** - * Convenience accessor for the parsed query string. - * Equivalent to `Object.entries(url.searchParams)`. - * - * Will only contain single strings, even if a query param occurs multiple - * times. If you need to read all of them, use the `url.searchParams` API - * instead. - */ - readonly q: Record; - /** - * Verified JSON body for this request, if a decoder instance was provided. - */ - readonly body: TBody; -}; - -/** - * Limited version of an Incoming Request. This incoming request data is - * deliberately limited until after a successful auth check. Only once the - * request has been authorized, further parsing will happen. - */ -type PreAuthIncomingReq = Omit< - IncomingReq, never, never, never>, - "auth" | "p" | "q" | "body" ->; - -/** - * Anything that can be returned from an endpoint implementation that would be - * considered a valid response. - */ -type ResponseLike = Promise | Response | JsonObject; - -// type AuthHandler = ( -// input: IncomingReq -// ) => boolean; - -type RouteHandler = ( - input: IncomingReq -) => ResponseLike; - -type RouteTuple = readonly [ - pattern: Pattern, - matcher: RouteMatcher, - auth: AuthFn, - bodySchema: StandardSchemaV1 | null, - handler: OpaqueRouteHandler, -]; - -type RouterOptions> = { - errorHandler?: ErrorHandler; - - // Mandatory config - /** - * Automatically handle CORS requests. Either set to `true` (to use all the - * default CORS options), or specify a CorsOptions object. - * - * When enabled, this will do two things: - * 1. It will respond to pre-flight requests (OPTIONS) automatically. - * 2. It will add the correct CORS headers to all returned responses. - * - * @default false - */ - cors?: Partial | boolean; - getContext?: (req: Request, ...args: readonly any[]) => RC; - authorize?: AuthFn; - - // Register any param decoders - params?: TParams; - - /** - * Optional OpenTelemetry integration. When provided, matched route patterns - * and decoded params will be set as span attributes. - */ - otel?: OtelConfig; - - // Optional config - debug?: boolean; -}; - -export type AuthFn = ( - input: PreAuthIncomingReq -) => AC | Promise; - -type OpaqueRouteHandler = ( - input: IncomingReq -) => Promise; - -type OpaqueParams = Record; - -export class ZenRouter< - RC, - AC, - TParams extends Record = {}, -> { - readonly #_debug: boolean; - readonly #_contextFn: (req: Request, ...args: readonly any[]) => RC; - readonly #_defaultAuthFn: AuthFn; - readonly #_routes: RouteTuple[]; - readonly #_paramDecoders: TParams; - readonly #_errorHandler: ErrorHandler; - readonly #_cors: Partial | null; - readonly #_otel: OtelConfig | undefined; - - constructor(options?: RouterOptions) { - this.#_errorHandler = options?.errorHandler ?? new ErrorHandler(); - this.#_debug = options?.debug ?? false; - this.#_contextFn = options?.getContext ?? (() => null as any as RC); - this.#_defaultAuthFn = - options?.authorize ?? - (() => { - // TODO Maybe make this fail as a 500 with info in the body? Since this is a setup error and should never be an issue in production. - console.error("This request was not checked for authorization. Please configure a generic `authorize` function in the ZenRouter constructor."); // prettier-ignore - return abort(403); - }); - this.#_routes = []; - this.#_paramDecoders = options?.params ?? ({} as TParams); - this.#_cors = (options?.cors === true ? {} : options?.cors) || null; - this.#_otel = options?.otel; - } - - // --- PUBLIC APIs ----------------------------------------------------------------- - - public get fetch(): ( - req: Request, - ...rest: readonly any[] - ) => Promise { - if (this.#_routes.length === 0) { - throw new Error("No routes configured yet. Try adding one?"); - } - - return async (req: Request, ...rest: readonly any[]): Promise => { - const resp = await this.#_tryDispatch(req, ...rest); - return this.#_addCorsIfNeeded(req, resp); - }; - } - - public route

( - pattern: P, - handler: RouteHandler< - RC, - AC, - ExtractParams>, - never - > - ): void; - public route

( - pattern: P, - bodySchema: StandardSchemaV1, - handler: RouteHandler< - RC, - AC, - ExtractParams>, - TBody - > - ): void; - /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - /* eslint-disable @typescript-eslint/no-unsafe-assignment */ - public route(first: any, second: any, third?: any): void { - /* eslint-enable @typescript-eslint/explicit-module-boundary-types */ - const pattern = first; - const bodySchema = arguments.length >= 3 ? second : null; - const handler = arguments.length >= 3 ? third : second; - /* eslint-enable @typescript-eslint/no-unsafe-assignment */ - this.#_register( - pattern, - bodySchema, - handler as RouteHandler - ); - } - - // TODO Maybe remove this on the Router class, since it's only a pass-through method - public onUncaughtError(handler: ErrorHandlerFn): this { - this.#_errorHandler.onUncaughtError(handler as ErrorHandlerFn); - return this; - } - - // TODO Maybe remove this on the Router class, since it's only a pass-through method - public onError( - handler: ErrorHandlerFn - // ^^^^^^^^^^^^^^^ - // Technically this isn't needed, because it is a subclass of - // HttpError already, but adding it here anyway for clarity. - ): this { - this.#_errorHandler.onError(handler as ErrorHandlerFn); - return this; - } - - // public get registerCannedResponse() { - // const eh = this.#_errorHandler; - // return eh.registerCannedResponse.bind(eh); - // } - - // --- PRIVATE APIs ---------------------------------------------------------------- - - #_getContext(req: Request, ...args: readonly any[]): RC { - return ( - lookupContext(req) ?? - attachContext(req, this.#_contextFn(req, ...args)) - ); - } - - #_register

( - pattern: P, - bodySchema: StandardSchemaV1 | null, - handler: RouteHandler - // authFn?: OpaqueAuthFn - ): void { - const matcher = routeMatcher(pattern); - - this.#_routes.push([ - pattern, - matcher, - /* authFn ?? */ this.#_defaultAuthFn, - bodySchema, - wrap(handler), - ]); - } - - /** - * Calls .#_dispatch(), but will catch any thrown error (which could be - * a known HTTP error) or an uncaught error, and makes sure to always return - * a Response. - */ - async #_tryDispatch( - req: Request, - ...args: readonly any[] - ): Promise { - try { - return await this.#_dispatch(req, ...args); // eslint-disable @typescript-eslint/no-unsafe-argument - } catch (err) { - return this.#_errorHandler.handle(err, { req, ctx: lookupContext(req) }); - } - } - - #_getAllowedVerbs(req: Request): string[] { - const url = new URL(req.url); - - const verbs: Set = new Set(); - verbs.add("OPTIONS"); // Always include OPTIONS - - // Collect HTTP verbs that are valid for this URL - for (const [_, matcher] of this.#_routes) { - // If we already collected this method, avoid the regex matching - if (verbs.has(matcher.method)) continue; - - const match = matcher.matchURL(url); - if (match) { - verbs.add(matcher.method); - } - } - - return sortHttpVerbsInPlace(Array.from(verbs)); - } - - #_dispatch_OPTIONS(req: Request): Response { - // All responses to OPTIONS requests must be 2xx - return new Response(null, { - status: 204, - headers: { - Allow: this.#_getAllowedVerbs(req).join(", "), - }, - }); - } - - /** - * Given an incoming request, starts matching its URL to one of the - * configured routes, and invoking it if a match is found. Will not (and - * should not) perform any error handling itself. - * - * Can throw: - * - HTTP 400, if a route matches, but its params are incorrectly encoded - * - HTTP 403, if a route matches, but the request isn't correctly authorized - * - HTTP 404, if none of the routes matches - * - HTTP 405, if a route path matches, but its method did not - * - HTTP 422, if a route matches, but its body could not be validated - */ - async #_dispatch(req: Request, ...args: readonly any[]): Promise { - if (req.method === "OPTIONS") { - return this.#_dispatch_OPTIONS(req); - } - - const url = new URL(req.url); - const log = this.#_debug - ? /* istanbul ignore next */ - console.log.bind(console) - : undefined; - log?.(`Trying to match ${req.method} ${url.pathname}`); - - // Match routes in the given order - let pathDidMatch = false; - for (const tup of this.#_routes) { - const [pattern, matcher, authorize, bodySchema, handler] = tup; - - const match = matcher.matchURL(url); - if (match === null) { - log?.(` ...against ${pattern}? ❌ No match`); - continue; - } else { - pathDidMatch = true; - if (!matcher.matchMethod(req)) { - log?.( - ` ...against ${pattern}? 🧐 Path matches, but method did not! ${JSON.stringify(match)}` - ); - continue; - } - - log?.(` ...against ${pattern}? ✅ Match! ${JSON.stringify(match)}`); - - // Add route pattern as span attribute - // This is done early so the route is recorded even for auth/validation errors - const span = this.#_otel?.getActiveSpan(); - span?.setAttribute("zen.route", pattern); - - const base = { - req, - url, - ctx: this.#_getContext(req, ...args), - }; - - // Perform auth - const auth = await authorize(base); - if (!auth) { - return abort(403); - } - - // Verify route params - let p; - try { - p = mapv(match, decodeURIComponent); - p = mapv(p, (value, key) => { - const schema = this.#_paramDecoders[key]; - if (!schema) return value; - const result = validateSync(schema, value); - if (result.issues) throw result.issues; - return result.value; - }); - } catch (err) { - // A malformed URI that cannot be decoded properly or a param that - // could not be decoded properly are both Bad Requests - return abort(400); - } - - // Add decoded route params as span attributes - for (const [key, value] of Object.entries(p)) { - span?.setAttribute(`zen.param.${key}`, String(value)); - } - - const bodyResult = bodySchema - ? // TODO: This can throw if the body does not contain a valid JSON - // request. If so, we should return a 400. - validateSync(bodySchema, await tryReadBodyAsJson(req)) - : null; - - if (bodyResult?.issues) { - const errmsg = bodyResult.issues.map(formatIssue).join("\n"); - throw new ValidationError(errmsg); - } - - // Decode the body - const input = { - ...base, - auth, - p, - q: Object.fromEntries(url.searchParams), - get body() { - if (bodyResult === null) { - raise("Cannot access body: this endpoint did not define a body schema"); // prettier-ignore - } - return bodyResult.value; - }, - }; - - return await handler(input); - } - } - - if (pathDidMatch) { - // If one of the paths did match, we can return a 405 error - return abort(405, { Allow: this.#_getAllowedVerbs(req).join(", ") }); - } - - return abort(404); - } - - #_addCorsIfNeeded(req: Request, resp: Response): Response { - if (!this.#_cors) { - // We don't want to handle CORS - return resp; - } - - // Never add CORS headers to the following response codes - if ( - // Never add to 101 (Switching Protocols) or 3xx (redirect) responses - resp.status === 101 || - (resp.status >= 300 && resp.status < 400) - ) { - return resp; - } - - // If this response already contains the main CORS header, don't touch it - // further - if (resp.headers.has(AC_ORIGIN)) { - // TODO Maybe throw if this happens? It definitely would be unexpected and - // undesired and it's better to let Zen Router be in control here. - return resp; - } - - // If we enabled automatic CORS handling, add necessary CORS headers to the - // response now - const corsHeadersToAdd = getCorsHeaders(req, this.#_cors); - if (corsHeadersToAdd === null) { - // Not a CORS request, or CORS not allowed - return resp; - } - - // This requires a CORS response, so let's add the headers to the returned output - const headers = new Headers(resp.headers); - for (const [k, v] of corsHeadersToAdd) { - if (k === "vary") { - // Important to not override any existing Vary headers - headers.append(k, v); - } else { - // Here, `k` is an `Access-Control-*` header - headers.set(k, v); - } - } - - // Unfortunately, if we're in a Cloudflare Workers runtime you cannot mutate - // headers on a Response instance directly (as you can in Node or Bun). - // So we'll have to reconstruct a new Response instance here :( - const { status, body } = resp; - return new Response(body, { status, headers }); - } -} - -function formatIssue(issue: StandardSchemaV1.Issue): string { - if (!issue.path?.length) return issue.message; - const keys = issue.path.map((p) => (typeof p === "object" ? p.key : p)); - let prefix: string; - if (keys.length === 1) { - prefix = - typeof keys[0] === "number" - ? `Value at index ${keys[0]}` - : `Value at key '${String(keys[0])}'`; - } else { - prefix = `Value at keypath '${keys.join(".")}'`; - } - return `${prefix}: ${issue.message}`; -} - -function isPromiseLike(value: any): value is Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - return typeof value?.then === "function"; -} - -/** - * Synchronously validates a value against a Standard Schema. Throws if the - * schema returns a Promise (async validation is not supported). - */ -function validateSync( - schema: StandardSchemaV1, - value: unknown -): StandardSchemaV1.Result { - const result = schema["~standard"].validate(value); - if (isPromiseLike(result)) - throw new Error("Async validation is not supported"); - return result; -} - -/** - * Helper to handle any endpoint handlers returning a JSON object, and turning - * that into a 200 response if so. - */ -function wrap( - handler: RouteHandler -): OpaqueRouteHandler { - return async (input) => { - const result = await handler(input); - if (result instanceof Response) { - return result; - } else { - return json(result, 200); - } - }; -} - -/** - * Attempts to reads the request body as JSON. Will return an empty request - * body as `undefined`. - */ -// TODO Currently, this helper will not look at or respect the Content-Type -// TODO header, and I think that is a bug. -// TODO Need to think about how to best handle this exactly without breaking -// TODO this API for the "lazy" that never set `content-type` to -// TODO "application/json" explicitly. -async function tryReadBodyAsJson(req: Request): Promise { - // Try reading JSON body - try { - const text = await req.text(); - return text === "" ? undefined : (JSON.parse(text) as Json); - } catch (e) { - // Invalid JSON body - abort(400); - } -} diff --git a/packages/liveblocks-zenrouter/src/contexts.ts b/packages/liveblocks-zenrouter/src/contexts.ts deleted file mode 100644 index 6b7f5494c1..0000000000 --- a/packages/liveblocks-zenrouter/src/contexts.ts +++ /dev/null @@ -1,10 +0,0 @@ -const ctxs = new WeakMap(); - -export function lookupContext(req: Request): C | undefined { - return (ctxs as WeakMap).get(req); -} - -export function attachContext(req: Request, ctx: C): C { - ctxs.set(req, ctx); - return ctx; -} diff --git a/packages/liveblocks-zenrouter/src/cors.ts b/packages/liveblocks-zenrouter/src/cors.ts deleted file mode 100644 index ffc958d749..0000000000 --- a/packages/liveblocks-zenrouter/src/cors.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { ALL_HTTP_VERBS } from "./lib/matchers.js"; - -export type CorsOptions = { - /** - * Send CORS headers only if the requested Origin is in this hardcoded list - * of origins. Note that the default is '*', but this still required all - * incoming requests to have an Origin header set. - * - * @default '*' (allow any Origin) - */ - allowedOrigins: "*" | string[]; - /** - * When sending back CORS headers, tell the browser which methods are - * allowed. - * - * @default All methods (you should likely not have to change this) - */ - allowedMethods: string[]; - /** - * Specify what headers are safe to allow on _incoming_ CORS requests. - * - * By default, all headers requested by the browser client will be allowed - * (as most headers will typically be ignored by the endpoint handlers), but - * you can specify a specific whitelist of allowed headers if you need to. - * - * Browsers will only ask for non-standard headers if those should be - * allowed, i.e. a browser can ask in a preflight (OPTIONS) request, if it's - * okay to send "X-Test", but won't ask if it's okay to send, say, - * "User-Agent". - * - * Note that this is different from the `exposeHeaders` config: - * - Allowed Headers: which headers a browser may include when - * _making_ the CORS request - * - Exposed Headers: which headers _returned_ in the CORS response the - * browser is allowed to safely expose to scripts - * - * @default '*' - */ - allowedHeaders: "*" | string[]; - /** - * The Access-Control-Allow-Credentials response header allows browsers - * to include include credentials in the next CORS request. - * - * Credentials are cookies, TLS client certificates, or WWW-Authentication - * headers containing a username and password. - * - * NOTE: The `Authorization` header is *NOT* considered a credential and as - * such you don’t need to enable this setting for sending such headers. - * - * NOTE: Allowing credentials alone doesn’t cause the browser to send those - * credentials automatically. For to to happen, make sure to also add `{ - * credentials: "include" }` on the fetch request. - * - * WARNING: By default, these credentials are not sent in cross-origin - * requests, and doing so can make a site vulnerable to CSRF attacks. - * - * @default false - */ - allowCredentials: boolean; - /** - * Specify what headers browsers *scripts* can access from the CORS response. - * This means when a client tries to programmatically read - * `resp.headers.get('...')`, this header determines which headers will be - * exposed to that client. - * - * Note that this is different from the `allowedHeaders` config: - * - Allowed Headers: which headers a browser may include when - * _making_ the CORS request - * - Exposed Headers: which headers _returned_ in the CORS response the - * browser is allowed to safely expose to scripts - * - * By default, browser scripts can only read the following headers from such - * responses: - * - Cache-Control - * - Content-Language - * - Content-Type - * - Expires - * - Last-Modified - * - Pragma - */ - exposeHeaders: string[]; - maxAge?: number; - /** - * When `allowedOrigins` isn't an explicit list of origins but '*' (= the - * default), normally the Origin will get allowed by echoing the Origin value - * back. When this option is set, it will instead allow '*'. - * - * Do not use this in combination with `allowCredentials` as this is not - * allowed by the spec. - * - * @default false - * - */ - sendWildcard: boolean; - /** - * Always send CORS headers on all responses, even if the request didn't - * contain an Origin header and thus isn't interested in CORS. - * - * @default true - */ - alwaysSend: boolean; - /** - * Normally, when returning a CORS response, it's a good idea to set the - * Vary header to include 'Origin', to behave better with caching. By default - * this will be done. If you don't want to auto-add the Vary header, set this - * to false. - * - * @default true - */ - varyHeader: boolean; -}; - -// Maybe make some of these overridable? But for now keep these the defaults -const DEFAULT_CORS_OPTIONS: CorsOptions = { - allowedOrigins: "*", - allowedMethods: ALL_HTTP_VERBS, - allowedHeaders: "*", // By default, allow all incoming headers (we'll ignore most of them anyway) - allowCredentials: false, - exposeHeaders: [], - maxAge: undefined, - sendWildcard: false, - alwaysSend: true, - varyHeader: true, -}; - -// Output Response Headers -export const AC_ORIGIN = "Access-Control-Allow-Origin"; -const AC_METHODS = "Access-Control-Allow-Methods"; -const AC_ALLOW_HEADERS = "Access-Control-Allow-Headers"; -const AC_EXPOSE_HEADERS = "Access-Control-Expose-Headers"; -const AC_CREDENTIALS = "Access-Control-Allow-Credentials"; -const AC_MAX_AGE = "Access-Control-Max-Age"; - -// Incoming Request Headers -const AC_REQUEST_METHOD = "Access-Control-Request-Method"; -const AC_REQUEST_HEADERS = "Access-Control-Request-Headers"; - -/** - * Computes the value of the Access-Control-Allow-Origin header. - * Either will be the Request's Origin header echoed back, or a "*". - * Returns `null` if the response should not include any CORS headers. - */ -function getCorsOrigin(options: CorsOptions, req: Request): string | null { - if (options.sendWildcard && options.allowCredentials) { - // This combination is not allowed by the spec - throw new Error("Invalid CORS configuration"); - } - - const allowAll = options.allowedOrigins === "*"; - const explicitOrigins = allowAll ? [] : (options.allowedOrigins as string[]); - - const origin = - req.headers.get("Origin") ?? - // -------------------------------------------------------------------------------- - // WARNING: Non-standard HTTP hack here! - // -------------------------------------------------------------------------------- - // Note that X-Relay-Origin is not an HTTP standard! This is done, because the - // default `fetch()` API will not allow you to manually set the Origin for - // a request, as it's considered a forbidden header :( - // - // This custom header gets set here: - // https://github.com/liveblocks/liveblocks.io/blob/862935833aa754cb419f2e5e8f7c32fb50e89de1/pages/api/public/authorize.ts#L69-L73 - // -------------------------------------------------------------------------------- - req.headers.get("X-Relay-Origin"); - - // If the Origin header is not present terminate this set of steps. - // The request is outside the scope of this specification.-- W3Spec - if (origin) { - // If the allowed origins is an asterisk or 'wildcard', always match - if (allowAll && options.sendWildcard) { - return "*"; - } else if (allowAll || explicitOrigins.includes(origin)) { - // Add a single Access-Control-Allow-Origin header, with either - // the value of the Origin header or the string "*" as value. - // -- W3Spec - return origin; - } else { - // The request's Origin header does not match any of allowed origins, so - // send no CORS-allowed headers back - return null; - } - } else if (options.alwaysSend) { - // Usually, if a request doesn’t include an Origin header, the client did - // not request CORS. This means we can ignore this request. However, if - // this is true, a most-likely-to-be-correct value is still set. - if (allowAll) { - // If wildcard is in the origins, even if `sendWildcard` is False, - // simply send the wildcard. Unless supportsCredentials is True, - // since that is forbidded by the spec.. - // It is the most-likely to be correct thing to do (the only other - // option is to return nothing, which almost certainly not what - // the developer wants if the '*' origin was specified. - if (options.allowCredentials) { - return null; - } else { - return "*"; - } - } else { - // Since there can be only one origin sent back, send back the first one - // as a best-effort - return explicitOrigins[0] ?? /* istanbul ignore next -- @preserve */ null; - } - } else { - // The request did not contain an 'Origin' header. This means the browser or client did not request CORS, ensure the Origin Header is set - return null; - } -} - -function getHeadersToAllow(allowed: "*" | string[], req: Request) { - const requested = (req.headers.get(AC_REQUEST_HEADERS) ?? "") - .toLowerCase() - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - - const result = - allowed === "*" ? requested : requested.filter((h) => allowed.includes(h)); - return result.length > 0 ? result : null; -} - -/** - * Returns CORS headers to attach to the Response for this request. - * - * For both preflight and non-preflight requests: - * - Will set the AC-Allow-Origin header, echoing back the Origin - * - Optionally, will set AC-Allow-Credentials and AC-Expose-Headers headers - * (depending on your config) - * - Set the Vary header accordingly - * - * For preflight-requests only: - * - Will additionally set AC-Allow-Method and/or AC-Allow-Headers headers - * (these don't have to be on the non-preflight requests) - * - * Returns `null` for non-CORS requests, or if CORS should not be allowed. - */ -export function getCorsHeaders( - req: Request, - opts: Partial -): Headers | null { - const options = { ...DEFAULT_CORS_OPTIONS, ...opts } as CorsOptions; - const originToSet = getCorsOrigin(options, req); - - if (originToSet === null) { - // CORS is not enabled for this route - return null; - } - - // Construct the CORS headers to put on the response - // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin#syntax - const headers: Headers = new Headers(); - headers.set(AC_ORIGIN, originToSet); - if (options.exposeHeaders.length > 0) { - headers.set(AC_EXPOSE_HEADERS, options.exposeHeaders.join(", ")); - } - if (options.allowCredentials) { - headers.set(AC_CREDENTIALS, "true"); // case-sensitive - } - - // This is a preflight request - // http://www.w3.org/TR/cors/#resource-preflight-requests - if (req.method === "OPTIONS") { - const requestedMethod = ( - req.headers.get(AC_REQUEST_METHOD) ?? "" - ).toUpperCase(); - - // If there is no Access-Control-Request-Method header or if parsing - // failed, do not set any additional headers - if (requestedMethod && options.allowedMethods.includes(requestedMethod)) { - const headersToAllow = getHeadersToAllow(options.allowedHeaders, req); - if (headersToAllow) { - headers.set(AC_ALLOW_HEADERS, headersToAllow.join(", ")); - } - if (options.maxAge) { - headers.set(AC_MAX_AGE, String(options.maxAge)); - } - // TODO Optionally, intersect resp.headers.get('Allow') with - // options.allowedMethods, but it won’t matter much - headers.set(AC_METHODS, options.allowedMethods.join(", ")); - } else { - console.log( - "The request's Access-Control-Request-Method header does not match allowed methods. CORS headers will not be applied." - ); - } - } - - // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin#cors_and_caching - if (options.varyHeader) { - if (headers.get(AC_ORIGIN) === "*") { - // Never set a Vary: Origin header if Origin is returned as "*" - } else { - headers.set("Vary", "Origin"); - } - } - - return headers; -} diff --git a/packages/liveblocks-zenrouter/src/index.ts b/packages/liveblocks-zenrouter/src/index.ts deleted file mode 100644 index 600ea93986..0000000000 --- a/packages/liveblocks-zenrouter/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "~/lib/otel.js"; - -// Simple response generators: json(), empty(), abort(), etc. -export * from "~/responses/index.js"; - -// Router + Relay -export * from "~/ErrorHandler.js"; -export * from "~/Relay.js"; -export * from "~/Router.js"; diff --git a/packages/liveblocks-zenrouter/src/lib/matchers.ts b/packages/liveblocks-zenrouter/src/lib/matchers.ts deleted file mode 100644 index fcb18927ce..0000000000 --- a/packages/liveblocks-zenrouter/src/lib/matchers.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { Resolve } from "@liveblocks/core"; -import type { - ComposeLeft, - Objects, - Pipe, - Strings, - Tuples, - Unions, -} from "hotscript"; - -import type { StandardSchemaV1 } from "./standard-schema.js"; -import { raise } from "./utils.js"; - -const cleanSegmentRe = /^[\w-]+$/; -const identifierRe = /^[a-z]\w*$/; -const pathPrefixRegex = /^\/(([\w-]+|<[\w-]+>)\/)*\*$/; - -export type Method = (typeof ALL_METHODS)[number]; -export type HttpVerb = (typeof ALL_HTTP_VERBS)[number]; - -// All supported HTTP verbs, in their most natural ordering -export const ALL_HTTP_VERBS = [ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE", - "OPTIONS", -]; - -export function sortHttpVerbsInPlace(verbs: HttpVerb[]): HttpVerb[] { - return verbs.sort( - (a, b) => ALL_HTTP_VERBS.indexOf(a) - ALL_HTTP_VERBS.indexOf(b) - ); -} - -// -// Subset of ALL_HTTP_VERBS, but OPTIONS is not included. This is because Zen -// Router will automatically allow OPTIONS for all registered routes, i.e. an -// explicit OPTIONS definition like this is (currently) not allowed: -// -// router.route('OPTIONS /my/path', ...) -// -export const ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; - -export type PathPattern = `/${string}`; -export type Pattern = `${Method} ${PathPattern}`; -export type PathPrefix = `/${string}/*` | "/*"; - -/** - * From a pattern like: - * - * 'GET /foo///baz' - * - * Extracts: - * - * { foo: string, bar: string } - */ -type ExtractParamsBasic

= Pipe< - P, // ....................................... 'GET /foo///baz' - [ - Strings.TrimLeft<`${Method} `>, // ........ '/foo///baz' - Strings.Split<"/">, // .................... ['', 'foo', '', '', 'baz'] - Tuples.Filter>, // ['', ''] - Tuples.Map< - ComposeLeft< - [ - Strings.Trim<"<" | ">">, // ......... ['bar', 'qux'] - Unions.ToTuple, // .................. [['bar'], ['qux']] - Tuples.Append, // ........... [['bar', string], ['qux', string]] - ] - > - >, - Tuples.ToUnion, // ........................ ['bar', string] | ['qux', string] - Objects.FromEntries, // ................... { bar: string; qux: string } - ] ->; - -/** - * For: - * - * { - * a: StandardSchemaV1, - * b: StandardSchemaV1, - * c: StandardSchemaV1, - * } - * - * Will return: - * - * { - * a: number, - * b: 'hi', - * c: boolean, - * } - * - */ -export type MapSchemaOutput = { - [K in keyof T]: T[K] extends StandardSchemaV1 - ? StandardSchemaV1.InferOutput - : never; -}; - -// export type WithDefaults = Pipe<>; - -/** - * From a pattern like: - * - * 'GET /foo///baz' - * - * Extracts: - * - * { foo: string, n: number } - */ -export type ExtractParams< - P extends Pattern, - TParamTypes extends Record, - 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"