From c8c387ababa2e055ca79f76957bcda8f73e0a7ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 11:30:57 +0000 Subject: [PATCH 1/5] chore: align tooling with wolfstar-project/stars-components Bring plugins in line with the org's reference monorepo setup: - add .editorconfig (shared indent/EOL conventions) - add .npm-deprecaterc.yml for @favware/npm-deprecate, scoped to @wolfstar/plugin-api (this repo only ships one package) - add .coderabbit.yaml to match stars-components' review policy - bump mscharley/dependency-changesets-action to v1.2.4 and zizmorcore/zizmor-action to v0.5.7 in the reusable workflows - expand .gitignore with stars-components' extra ignore patterns while keeping this repo's existing .env/.vscode allowlist rules --- .coderabbit.yaml | 76 ++++++++++++++++++++++++ .editorconfig | 22 +++++++ .github/workflows/renovate-changeset.yml | 2 +- .github/workflows/zizmor.yml | 2 +- .gitignore | 48 +++++++++++++-- .npm-deprecaterc.yml | 3 + 6 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 .editorconfig create mode 100644 .npm-deprecaterc.yml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..899f75d --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,76 @@ +language: en-GB + +reviews: + profile: chill + high_level_summary_in_walkthrough: true + review_status: true + review_details: false + changed_files_summary: true + sequence_diagrams: true + estimate_code_review_effort: false + assess_linked_issues: true + related_issues: true + related_prs: true + suggested_labels: false + suggested_reviewers: true + in_progress_fortune: false + poem: false + + slop_detection: + enabled: true + label: "007" + + auto_review: + enabled: true + auto_pause_after_reviewed_commits: 5 + labels: + - "!release" + ignore_title_keywords: + - "WIP" + - "[skip-review]" + - "chore(release)" + - "Version Packages" + ignore_usernames: + - "renovate[bot]" + - "dependabot[bot]" + - "github-actions[bot]" + + pre_merge_checks: + docstrings: + mode: off + title: + mode: error + description: + mode: warning + issue_assessment: + mode: warning + + tools: + gitleaks: + enabled: true + osvScanner: + enabled: true + actionlint: + enabled: true + yamllint: + enabled: true + shellcheck: + enabled: true + dotenvLint: + enabled: true + eslint: + enabled: false + biome: + enabled: false + oxc: + enabled: false + markdownlint: + enabled: false + languagetool: + enabled: false + checkov: + enabled: false + trivy: + enabled: false + opengrep: + enabled: false diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a399bcd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{js,ts}] +indent_size = 4 +indent_style = tab +block_comment_start = /* +block_comment = * +block_comment_end = */ + +[*.{yml,yaml}] +indent_size = 2 +indent_style = space + +[*.{md,rmd,mkd,mkdn,mdwn,mdown,markdown,litcoffee}] +tab_width = 4 +trim_trailing_whitespace = false diff --git a/.github/workflows/renovate-changeset.yml b/.github/workflows/renovate-changeset.yml index d748dbc..bef542b 100644 --- a/.github/workflows/renovate-changeset.yml +++ b/.github/workflows/renovate-changeset.yml @@ -12,7 +12,7 @@ jobs: if: ${{ github.event.pull_request.user.login == 'renovate[bot]' && contains(github.event.pull_request.labels.*.name, 'dependencies') }} permissions: {} steps: - - uses: mscharley/dependency-changesets-action@f25d784f13eb09227429cebd880ade75d19d6283 # v1.1.9 + - uses: mscharley/dependency-changesets-action@f96232368519695969bea8a8cfff7cca32b1e56a # v1.2.4 with: token: ${{ secrets.WOLFSTAR_TOKEN }} changeset-folder: ".changeset" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index d657b7d..85c2434 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -28,6 +28,6 @@ jobs: with: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 with: advanced-security: false diff --git a/.gitignore b/.gitignore index 3d1037f..50cd037 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,50 @@ +# Ignore a blackhole and the folder for development node_modules/ -dist/ -.turbo/ -coverage/ -*.tsbuildinfo -*.log +.vs/ +.idea/ +*.iml + +# misc .DS_Store .env .env.* !.env.example -.idea/ +.env.local +.env.development.local +.env.test.local +.env.production.local + +# pnpm +.pnpm-store/ + +# Build Artifacts +dist/ +build/ +test_out/ +.swc/ +*.tsbuildinfo +tsup.config.bundled*.mjs +*.zip +*.tar +*.tar.xz +*.tar.gz +*.7z +*.rar + +# Ignore heapsnapshot and log files +*.heapsnapshot +*.log + +# Test artifacts +coverage/ +*.junit.xml + +# Ignore package locks +package-lock.json + +# Ignore Turbo caching +.turbo/ + .vscode/* !.vscode/extensions.json !.vscode/settings.json diff --git a/.npm-deprecaterc.yml b/.npm-deprecaterc.yml new file mode 100644 index 0000000..36a461b --- /dev/null +++ b/.npm-deprecaterc.yml @@ -0,0 +1,3 @@ +name: '*next*' +package: + - '@wolfstar/plugin-api' From aa768a56e821266ae6dee55b24d1b8b16535c803 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:18:34 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- packages/plugin-api/README.md | 38 ++-- packages/plugin-api/src/index.ts | 34 +-- .../plugin-api/src/lib/http/ApiRequest.ts | 90 ++++---- .../plugin-api/src/lib/http/ApiResponse.ts | 90 ++++---- packages/plugin-api/src/lib/http/ApiServer.ts | 214 +++++++++--------- .../src/lib/structures/Middleware.ts | 40 ++-- .../src/lib/structures/MiddlewareStore.ts | 80 +++---- .../plugin-api/src/lib/structures/Route.ts | 94 ++++---- .../src/lib/structures/RouteLoaderStrategy.ts | 16 +- .../src/lib/structures/RouteStore.ts | 14 +- .../src/lib/structures/router/RouterBranch.ts | 104 ++++----- .../src/lib/structures/router/RouterNode.ts | 40 ++-- .../src/lib/structures/router/RouterRoot.ts | 88 +++---- .../src/listeners/PluginRouteError.ts | 14 +- .../listeners/PluginServerMiddlewareError.ts | 14 +- .../src/listeners/PluginServerRequest.ts | 74 +++--- ...luginServerRouterBranchMethodNotAllowed.ts | 12 +- .../PluginServerRouterBranchNotFound.ts | 12 +- .../src/listeners/PluginServerRouterFound.ts | 20 +- packages/plugin-api/src/listeners/_load.ts | 64 +++--- packages/plugin-api/src/middlewares/_load.ts | 8 +- packages/plugin-api/src/middlewares/body.ts | 20 +- .../plugin-api/src/middlewares/headers.ts | 28 +-- packages/plugin-api/src/register.ts | 48 ++-- packages/plugin-api/tests/ApiServer.test.ts | 188 +++++++-------- .../plugin-api/tests/MiddlewareStore.test.ts | 132 +++++------ packages/plugin-api/tests/router.test.ts | 154 ++++++------- packages/plugin-api/tsdown.config.ts | 16 +- 28 files changed, 873 insertions(+), 873 deletions(-) diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md index 7e29cbd..a483d3d 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -24,10 +24,10 @@ import "@wolfstar/plugin-api/register"; import { Client } from "@wolfstar/http-framework"; const client = new Client({ - api: { - listenOptions: { port: 4000 }, - origin: "*", - }, + api: { + listenOptions: { port: 4000 }, + origin: "*", + }, }); await client.load(); @@ -44,9 +44,9 @@ import { HttpCodes } from "@wolfstar/http-framework"; import { Route } from "@wolfstar/plugin-api"; export class HealthRoute extends Route { - public run(_request: Route.Request, response: Route.Response) { - response.json({ status: "ok" }, HttpCodes.OK); - } + public run(_request: Route.Request, response: Route.Response) { + response.json({ status: "ok" }, HttpCodes.OK); + } } ``` @@ -56,13 +56,13 @@ The route's path and method are inferred from its location: `src/routes/health.g ```ts export class HealthRoute extends Route { - public constructor(context: Route.LoaderContext) { - super(context, { route: "/status", methods: ["GET", "HEAD"] }); - } + public constructor(context: Route.LoaderContext) { + super(context, { route: "/status", methods: ["GET", "HEAD"] }); + } - public run(request: Route.Request, response: Route.Response) { - response.json({ status: "ok", method: request.method }); - } + public run(request: Route.Request, response: Route.Response) { + response.json({ status: "ok", method: request.method }); + } } ``` @@ -77,13 +77,13 @@ import { Middleware } from "@wolfstar/plugin-api"; import { randomUUID } from "node:crypto"; export class RequestIdMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 15 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 15 }); + } - public run(request: Middleware.Request, response: Middleware.Response) { - response.setHeader("X-Request-Id", randomUUID()); - } + public run(request: Middleware.Request, response: Middleware.Response) { + response.setHeader("X-Request-Id", randomUUID()); + } } ``` diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index fec18a7..80ac221 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -18,25 +18,25 @@ export { loadListeners } from "./listeners/_load"; export { loadMiddlewares } from "./middlewares/_load"; declare module "@wolfstar/http-framework" { - interface ClientOptions { - /** - * Options for the auxiliary REST API server registered by `@wolfstar/plugin-api`. - */ - api?: ApiServerOptions; - } + interface ClientOptions { + /** + * Options for the auxiliary REST API server registered by `@wolfstar/plugin-api`. + */ + api?: ApiServerOptions; + } } declare module "@sapphire/pieces" { - interface StoreRegistryEntries { - routes: RouteStore; - middlewares: MiddlewareStore; - } + interface StoreRegistryEntries { + routes: RouteStore; + middlewares: MiddlewareStore; + } - interface Container { - /** - * The auxiliary REST API server registered by `@wolfstar/plugin-api`. Independent from the - * Discord interactions webhook server (`Client#server`). - */ - server: ApiServer; - } + interface Container { + /** + * The auxiliary REST API server registered by `@wolfstar/plugin-api`. Independent from the + * Discord interactions webhook server (`Client#server`). + */ + server: ApiServer; + } } diff --git a/packages/plugin-api/src/lib/http/ApiRequest.ts b/packages/plugin-api/src/lib/http/ApiRequest.ts index b2daaac..366cb24 100644 --- a/packages/plugin-api/src/lib/http/ApiRequest.ts +++ b/packages/plugin-api/src/lib/http/ApiRequest.ts @@ -3,49 +3,49 @@ import type { Route } from "../structures/Route"; import type { RouterBranch } from "../structures/router/RouterBranch"; export class ApiRequest extends IncomingMessage { - /** - * The parsed query string parameters. - */ - public query: URLSearchParams = new URLSearchParams(); - - /** - * The extracted dynamic (`[param]`) path segment values. - */ - public params: Record = {}; - - /** - * The matched router branch for this request's pathname, if any. - */ - public routerNode: RouterBranch | null = null; - - /** - * The matched route for this request's pathname and method, if any. - */ - public route: Route | null = null; - - /** - * Reads the full request body as a UTF-8 string. - * @param limit The maximum number of bytes to read before rejecting. Defaults to 1 MiB. - */ - public async readBodyText(limit = 1024 * 1024): Promise { - const chunks: Buffer[] = []; - let size = 0; - - for await (const chunk of this) { - size += (chunk as Buffer).length; - if (size > limit) throw new RangeError(`Request body exceeded the ${limit} byte limit`); - chunks.push(chunk as Buffer); - } - - return Buffer.concat(chunks).toString("utf8"); - } - - /** - * Reads and parses the request body as JSON. - * @param limit The maximum number of bytes to read before rejecting. Defaults to 1 MiB. - */ - public async readBodyJson(limit?: number): Promise { - const text = await this.readBodyText(limit); - return JSON.parse(text) as T; - } + /** + * The parsed query string parameters. + */ + public query: URLSearchParams = new URLSearchParams(); + + /** + * The extracted dynamic (`[param]`) path segment values. + */ + public params: Record = {}; + + /** + * The matched router branch for this request's pathname, if any. + */ + public routerNode: RouterBranch | null = null; + + /** + * The matched route for this request's pathname and method, if any. + */ + public route: Route | null = null; + + /** + * Reads the full request body as a UTF-8 string. + * @param limit The maximum number of bytes to read before rejecting. Defaults to 1 MiB. + */ + public async readBodyText(limit = 1024 * 1024): Promise { + const chunks: Buffer[] = []; + let size = 0; + + for await (const chunk of this) { + size += (chunk as Buffer).length; + if (size > limit) throw new RangeError(`Request body exceeded the ${limit} byte limit`); + chunks.push(chunk as Buffer); + } + + return Buffer.concat(chunks).toString("utf8"); + } + + /** + * Reads and parses the request body as JSON. + * @param limit The maximum number of bytes to read before rejecting. Defaults to 1 MiB. + */ + public async readBodyJson(limit?: number): Promise { + const text = await this.readBodyText(limit); + return JSON.parse(text) as T; + } } diff --git a/packages/plugin-api/src/lib/http/ApiResponse.ts b/packages/plugin-api/src/lib/http/ApiResponse.ts index f90d742..eeff049 100644 --- a/packages/plugin-api/src/lib/http/ApiResponse.ts +++ b/packages/plugin-api/src/lib/http/ApiResponse.ts @@ -3,49 +3,49 @@ import { HttpCodes } from "@wolfstar/http-framework"; import type { ApiRequest } from "./ApiRequest"; export class ApiResponse extends ServerResponse { - public status(statusCode: number): this { - this.statusCode = statusCode; - return this; - } - - public json(body: unknown, statusCode: number = HttpCodes.OK): this { - this.statusCode = statusCode; - if (!this.hasHeader("Content-Type")) - this.setHeader("Content-Type", "application/json; charset=utf-8"); - this.end(JSON.stringify(body)); - return this; - } - - public text(body: string, statusCode: number = HttpCodes.OK): this { - this.statusCode = statusCode; - if (!this.hasHeader("Content-Type")) - this.setHeader("Content-Type", "text/plain; charset=utf-8"); - this.end(body); - return this; - } - - public noContent(): this { - this.statusCode = HttpCodes.NoContent; - this.end(); - return this; - } - - public notFound(body: unknown = { error: "Not Found" }): this { - return this.json(body, HttpCodes.NotFound); - } - - public methodNotAllowed(body: unknown = { error: "Method Not Allowed" }): this { - return this.json(body, HttpCodes.MethodNotAllowed); - } - - public badRequest(body: unknown = { error: "Bad Request" }): this { - return this.json(body, HttpCodes.BadRequest); - } - - public error( - statusCode: number = HttpCodes.InternalServerError, - body: unknown = { error: "Internal Server Error" }, - ): this { - return this.json(body, statusCode); - } + public status(statusCode: number): this { + this.statusCode = statusCode; + return this; + } + + public json(body: unknown, statusCode: number = HttpCodes.OK): this { + this.statusCode = statusCode; + if (!this.hasHeader("Content-Type")) + this.setHeader("Content-Type", "application/json; charset=utf-8"); + this.end(JSON.stringify(body)); + return this; + } + + public text(body: string, statusCode: number = HttpCodes.OK): this { + this.statusCode = statusCode; + if (!this.hasHeader("Content-Type")) + this.setHeader("Content-Type", "text/plain; charset=utf-8"); + this.end(body); + return this; + } + + public noContent(): this { + this.statusCode = HttpCodes.NoContent; + this.end(); + return this; + } + + public notFound(body: unknown = { error: "Not Found" }): this { + return this.json(body, HttpCodes.NotFound); + } + + public methodNotAllowed(body: unknown = { error: "Method Not Allowed" }): this { + return this.json(body, HttpCodes.MethodNotAllowed); + } + + public badRequest(body: unknown = { error: "Bad Request" }): this { + return this.json(body, HttpCodes.BadRequest); + } + + public error( + statusCode: number = HttpCodes.InternalServerError, + body: unknown = { error: "Internal Server Error" }, + ): this { + return this.json(body, statusCode); + } } diff --git a/packages/plugin-api/src/lib/http/ApiServer.ts b/packages/plugin-api/src/lib/http/ApiServer.ts index 960ab80..1ef8241 100644 --- a/packages/plugin-api/src/lib/http/ApiServer.ts +++ b/packages/plugin-api/src/lib/http/ApiServer.ts @@ -1,9 +1,9 @@ import { AsyncEventEmitter } from "@vladfrangu/async_event_emitter"; import { container } from "@wolfstar/http-framework"; import { - createServer, - type Server as NodeHttpServer, - type ServerOptions as NodeHttpServerOptions, + createServer, + type Server as NodeHttpServer, + type ServerOptions as NodeHttpServerOptions, } from "node:http"; import type { ListenOptions } from "node:net"; import { MiddlewareStore } from "../structures/MiddlewareStore"; @@ -12,60 +12,60 @@ import { ApiRequest } from "./ApiRequest"; import { ApiResponse } from "./ApiResponse"; export enum ApiServerEvent { - Error = "error", - Request = "request", - RouterBranchNotFound = "routerBranchNotFound", - RouterBranchMethodNotAllowed = "routerBranchMethodNotAllowed", - RouterFound = "routerFound", - RouteError = "routeError", - MiddlewareError = "middlewareError", + Error = "error", + Request = "request", + RouterBranchNotFound = "routerBranchNotFound", + RouterBranchMethodNotAllowed = "routerBranchMethodNotAllowed", + RouterFound = "routerFound", + RouteError = "routeError", + MiddlewareError = "middlewareError", } export interface ApiServerEvents { - [ApiServerEvent.Error]: [error: Error]; - [ApiServerEvent.Request]: [request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.RouterBranchNotFound]: [request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.RouterBranchMethodNotAllowed]: [request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.RouterFound]: [request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.RouteError]: [error: unknown, request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.MiddlewareError]: [error: unknown, request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.Error]: [error: Error]; + [ApiServerEvent.Request]: [request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.RouterBranchNotFound]: [request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.RouterBranchMethodNotAllowed]: [request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.RouterFound]: [request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.RouteError]: [error: unknown, request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.MiddlewareError]: [error: unknown, request: ApiRequest, response: ApiResponse]; } export interface ApiServerOptions { - /** - * A path segment prefix applied to every route, e.g. `/api`. - */ - prefix?: string; - - /** - * The value of the `Access-Control-Allow-Origin` header set by the built-in `headers` middleware. - * @default '*' - */ - origin?: string; - - /** - * The maximum request body size in bytes, enforced by the built-in `body` middleware. - * @default 1024 * 1024 * 50 - */ - maximumBodyLength?: number; - - /** - * Raw options forwarded to `node:http`'s `createServer`. - */ - server?: NodeHttpServerOptions; - - /** - * Raw options forwarded to `Server#listen`. - * @default { port: 4000 } - */ - listenOptions?: ListenOptions; - - /** - * Whether to start listening automatically once the interaction webhook is up (during the - * `postListen` plugin hook). - * @default true - */ - automaticallyConnect?: boolean; + /** + * A path segment prefix applied to every route, e.g. `/api`. + */ + prefix?: string; + + /** + * The value of the `Access-Control-Allow-Origin` header set by the built-in `headers` middleware. + * @default '*' + */ + origin?: string; + + /** + * The maximum request body size in bytes, enforced by the built-in `body` middleware. + * @default 1024 * 1024 * 50 + */ + maximumBodyLength?: number; + + /** + * Raw options forwarded to `node:http`'s `createServer`. + */ + server?: NodeHttpServerOptions; + + /** + * Raw options forwarded to `Server#listen`. + * @default { port: 4000 } + */ + listenOptions?: ListenOptions; + + /** + * Whether to start listening automatically once the interaction webhook is up (during the + * `postListen` plugin hook). + * @default true + */ + automaticallyConnect?: boolean; } /** @@ -74,60 +74,60 @@ export interface ApiServerOptions { * reserved for the Discord interactions webhook. */ export class ApiServer extends AsyncEventEmitter { - public readonly routes: RouteStore; - public readonly middlewares: MiddlewareStore; - public readonly server: NodeHttpServer; - public readonly options: ApiServerOptions; - - public constructor(options: ApiServerOptions = {}) { - super(); - - container.server = this; - - this.options = options; - - const serverOptions: NodeHttpServerOptions = { - ...options.server, - IncomingMessage: ApiRequest, - ServerResponse: ApiResponse, - }; - this.server = createServer(serverOptions); - - this.routes = new RouteStore(); - this.middlewares = new MiddlewareStore(); - - this.server.on("error", (error) => this.emit(ApiServerEvent.Error, error)); - this.server.on("request", (request, response) => - this.emit(ApiServerEvent.Request, request, response), - ); - } - - /** - * Starts listening for requests. - */ - public connect(): Promise { - return new Promise((resolve, reject) => { - const onError = (error: Error) => { - this.server.off("listening", onListening); - reject(error); - }; - const onListening = () => { - this.server.off("error", onError); - resolve(); - }; - - this.server.once("error", onError); - this.server.once("listening", onListening); - this.server.listen({ port: 4000, ...this.options.listenOptions }); - }); - } - - /** - * Stops the server from accepting new connections. - */ - public disconnect(): Promise { - return new Promise((resolve, reject) => { - this.server.close((error) => (error ? reject(error) : resolve())); - }); - } + public readonly routes: RouteStore; + public readonly middlewares: MiddlewareStore; + public readonly server: NodeHttpServer; + public readonly options: ApiServerOptions; + + public constructor(options: ApiServerOptions = {}) { + super(); + + container.server = this; + + this.options = options; + + const serverOptions: NodeHttpServerOptions = { + ...options.server, + IncomingMessage: ApiRequest, + ServerResponse: ApiResponse, + }; + this.server = createServer(serverOptions); + + this.routes = new RouteStore(); + this.middlewares = new MiddlewareStore(); + + this.server.on("error", (error) => this.emit(ApiServerEvent.Error, error)); + this.server.on("request", (request, response) => + this.emit(ApiServerEvent.Request, request, response), + ); + } + + /** + * Starts listening for requests. + */ + public connect(): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + this.server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + this.server.off("error", onError); + resolve(); + }; + + this.server.once("error", onError); + this.server.once("listening", onListening); + this.server.listen({ port: 4000, ...this.options.listenOptions }); + }); + } + + /** + * Stops the server from accepting new connections. + */ + public disconnect(): Promise { + return new Promise((resolve, reject) => { + this.server.close((error) => (error ? reject(error) : resolve())); + }); + } } diff --git a/packages/plugin-api/src/lib/structures/Middleware.ts b/packages/plugin-api/src/lib/structures/Middleware.ts index 085a5aa..13bc448 100644 --- a/packages/plugin-api/src/lib/structures/Middleware.ts +++ b/packages/plugin-api/src/lib/structures/Middleware.ts @@ -9,31 +9,31 @@ import type { Awaitable } from "./Route"; * (`response.writableEnded`); there is no explicit `next()` callback. */ export abstract class Middleware< - Options extends Middleware.Options = Middleware.Options, + Options extends Middleware.Options = Middleware.Options, > extends Piece { - /** - * The built-in middlewares use the following positions: - * - `headers`: 10 (CORS headers, 404/405 short-circuit) - * - `body`: 20 (`Content-Length` validation) - */ - public readonly position: number; + /** + * The built-in middlewares use the following positions: + * - `headers`: 10 (CORS headers, 404/405 short-circuit) + * - `body`: 20 (`Content-Length` validation) + */ + public readonly position: number; - public constructor(context: Middleware.LoaderContext, options: Options) { - super(context, options); - this.position = options.position ?? 1000; - } + public constructor(context: Middleware.LoaderContext, options: Options) { + super(context, options); + this.position = options.position ?? 1000; + } - public abstract run(request: ApiRequest, response: ApiResponse): Awaitable; + public abstract run(request: ApiRequest, response: ApiResponse): Awaitable; } export namespace Middleware { - export type LoaderContext = Piece.LoaderContext<"middlewares">; + export type LoaderContext = Piece.LoaderContext<"middlewares">; - export interface Options extends PieceOptions { - /** - * Middlewares run in ascending order of this value. - * @default 1000 - */ - position?: number; - } + export interface Options extends PieceOptions { + /** + * Middlewares run in ascending order of this value. + * @default 1000 + */ + position?: number; + } } diff --git a/packages/plugin-api/src/lib/structures/MiddlewareStore.ts b/packages/plugin-api/src/lib/structures/MiddlewareStore.ts index d5cc2d4..d6b83e9 100644 --- a/packages/plugin-api/src/lib/structures/MiddlewareStore.ts +++ b/packages/plugin-api/src/lib/structures/MiddlewareStore.ts @@ -4,44 +4,44 @@ import type { ApiResponse } from "../http/ApiResponse"; import { Middleware } from "./Middleware"; export class MiddlewareStore extends Store { - /** - * Enabled middlewares, kept sorted in ascending {@link Middleware.position} order. - */ - public readonly sortedMiddlewares: Middleware[] = []; - - public constructor() { - super(Middleware, { name: "middlewares" }); - } - - /** - * Runs every enabled middleware in order, stopping early once the response has ended. - */ - public async run(request: ApiRequest, response: ApiResponse): Promise { - for (const middleware of this.sortedMiddlewares) { - if (response.writableEnded) return; - if (middleware.enabled) await middleware.run(request, response); - } - } - - public override set(key: string, value: Middleware): this { - const index = this.sortedMiddlewares.findIndex( - (middleware) => middleware.position >= value.position, - ); - if (index === -1) this.sortedMiddlewares.push(value); - else this.sortedMiddlewares.splice(index, 0, value); - - return super.set(key, value); - } - - public override delete(key: string): boolean { - const index = this.sortedMiddlewares.findIndex((middleware) => middleware.name === key); - if (index !== -1) this.sortedMiddlewares.splice(index, 1); - - return super.delete(key); - } - - public override clear(): void { - this.sortedMiddlewares.length = 0; - super.clear(); - } + /** + * Enabled middlewares, kept sorted in ascending {@link Middleware.position} order. + */ + public readonly sortedMiddlewares: Middleware[] = []; + + public constructor() { + super(Middleware, { name: "middlewares" }); + } + + /** + * Runs every enabled middleware in order, stopping early once the response has ended. + */ + public async run(request: ApiRequest, response: ApiResponse): Promise { + for (const middleware of this.sortedMiddlewares) { + if (response.writableEnded) return; + if (middleware.enabled) await middleware.run(request, response); + } + } + + public override set(key: string, value: Middleware): this { + const index = this.sortedMiddlewares.findIndex( + (middleware) => middleware.position >= value.position, + ); + if (index === -1) this.sortedMiddlewares.push(value); + else this.sortedMiddlewares.splice(index, 0, value); + + return super.set(key, value); + } + + public override delete(key: string): boolean { + const index = this.sortedMiddlewares.findIndex((middleware) => middleware.name === key); + if (index !== -1) this.sortedMiddlewares.splice(index, 1); + + return super.delete(key); + } + + public override clear(): void { + this.sortedMiddlewares.length = 0; + super.clear(); + } } diff --git a/packages/plugin-api/src/lib/structures/Route.ts b/packages/plugin-api/src/lib/structures/Route.ts index 5fc07bc..34a928c 100644 --- a/packages/plugin-api/src/lib/structures/Route.ts +++ b/packages/plugin-api/src/lib/structures/Route.ts @@ -14,65 +14,65 @@ export type Awaitable = T | PromiseLike; * filename suffix (e.g. `hello.post.ts`) implies that HTTP method. */ export abstract class Route extends Piece< - Options, - "routes" + Options, + "routes" > { - /** - * The normalized path segments this route is registered under, e.g. `['users', '[id]']`. - */ - public readonly path: readonly string[]; + /** + * The normalized path segments this route is registered under, e.g. `['users', '[id]']`. + */ + public readonly path: readonly string[]; - /** - * The HTTP methods this route responds to. - */ - public readonly methods: ReadonlySet; + /** + * The HTTP methods this route responds to. + */ + public readonly methods: ReadonlySet; - public constructor(context: Route.LoaderContext, options: Options) { - super(context, options); + public constructor(context: Route.LoaderContext, options: Options) { + super(context, options); - const methods = new Set(options.methods ?? []); + const methods = new Set(options.methods ?? []); - let path: string; - if (options.route) { - path = options.route; - } else { - let name = context.name; - const implied = RouterRoot.extractMethod(name); - if (implied) { - name = name.slice(0, name.length - implied.length - 1); - methods.add(implied); - } + let path: string; + if (options.route) { + path = options.route; + } else { + let name = context.name; + const implied = RouterRoot.extractMethod(name); + if (implied) { + name = name.slice(0, name.length - implied.length - 1); + methods.add(implied); + } - path = RouterRoot.makeRoutePathForPiece(this.location.directories, name); - } + path = RouterRoot.makeRoutePathForPiece(this.location.directories, name); + } - if (methods.size === 0) methods.add("GET"); + if (methods.size === 0) methods.add("GET"); - this.path = RouterRoot.normalize(path); - this.methods = methods; - } + this.path = RouterRoot.normalize(path); + this.methods = methods; + } - /** - * Handles every method this route was registered for. Branch on `request.method` when a route - * needs to support multiple methods. - */ - public abstract run(request: ApiRequest, response: ApiResponse): Awaitable; + /** + * Handles every method this route was registered for. Branch on `request.method` when a route + * needs to support multiple methods. + */ + public abstract run(request: ApiRequest, response: ApiResponse): Awaitable; } export namespace Route { - export type LoaderContext = Piece.LoaderContext<"routes">; + export type LoaderContext = Piece.LoaderContext<"routes">; - export interface Options extends PieceOptions { - /** - * An explicit route path, e.g. `/users/[id]`. If omitted, the path is inferred from the - * piece's directory structure and file name. - */ - route?: `/${string}`; + export interface Options extends PieceOptions { + /** + * An explicit route path, e.g. `/users/[id]`. If omitted, the path is inferred from the + * piece's directory structure and file name. + */ + route?: `/${string}`; - /** - * The HTTP methods this route responds to. If omitted, inferred from a `.` filename - * suffix (e.g. `hello.post.ts`), defaulting to `GET`. - */ - methods?: readonly HttpMethod[]; - } + /** + * The HTTP methods this route responds to. If omitted, inferred from a `.` filename + * suffix (e.g. `hello.post.ts`), defaulting to `GET`. + */ + methods?: readonly HttpMethod[]; + } } diff --git a/packages/plugin-api/src/lib/structures/RouteLoaderStrategy.ts b/packages/plugin-api/src/lib/structures/RouteLoaderStrategy.ts index 210af97..0f34233 100644 --- a/packages/plugin-api/src/lib/structures/RouteLoaderStrategy.ts +++ b/packages/plugin-api/src/lib/structures/RouteLoaderStrategy.ts @@ -6,13 +6,13 @@ import type { RouteStore } from "./RouteStore"; * Keeps {@link RouteStore.router} in sync as route pieces are loaded and unloaded. */ export class RouteLoaderStrategy extends LoaderStrategy { - public override onLoad(store: Store, piece: Route): void { - super.onLoad(store, piece); - (store as RouteStore).router.add(piece); - } + public override onLoad(store: Store, piece: Route): void { + super.onLoad(store, piece); + (store as RouteStore).router.add(piece); + } - public override onUnload(store: Store, piece: Route): void { - super.onUnload(store, piece); - (store as RouteStore).router.remove(piece); - } + public override onUnload(store: Store, piece: Route): void { + super.onUnload(store, piece); + (store as RouteStore).router.remove(piece); + } } diff --git a/packages/plugin-api/src/lib/structures/RouteStore.ts b/packages/plugin-api/src/lib/structures/RouteStore.ts index 00077c6..2f8c337 100644 --- a/packages/plugin-api/src/lib/structures/RouteStore.ts +++ b/packages/plugin-api/src/lib/structures/RouteStore.ts @@ -4,12 +4,12 @@ import { RouteLoaderStrategy } from "./RouteLoaderStrategy"; import { RouterRoot } from "./router/RouterRoot"; export class RouteStore extends Store { - /** - * The trie used to match an incoming request's pathname to a registered {@link Route}. - */ - public readonly router = new RouterRoot(); + /** + * The trie used to match an incoming request's pathname to a registered {@link Route}. + */ + public readonly router = new RouterRoot(); - public constructor() { - super(Route, { name: "routes", strategy: new RouteLoaderStrategy() }); - } + public constructor() { + super(Route, { name: "routes", strategy: new RouteLoaderStrategy() }); + } } diff --git a/packages/plugin-api/src/lib/structures/router/RouterBranch.ts b/packages/plugin-api/src/lib/structures/router/RouterBranch.ts index 2a1c23b..325bbca 100644 --- a/packages/plugin-api/src/lib/structures/router/RouterBranch.ts +++ b/packages/plugin-api/src/lib/structures/router/RouterBranch.ts @@ -7,70 +7,70 @@ import { RouterNode } from "./RouterNode"; * (`[param]`) child, which is only checked after every static child fails to match. */ export class RouterBranch { - public readonly name: string; - public readonly dynamic: boolean; - public readonly parent: RouterBranch | null; - public readonly node: RouterNode; + public readonly name: string; + public readonly dynamic: boolean; + public readonly parent: RouterBranch | null; + public readonly node: RouterNode; - private readonly staticChildren: RouterBranch[] = []; - private dynamicChild: RouterBranch | null = null; + private readonly staticChildren: RouterBranch[] = []; + private dynamicChild: RouterBranch | null = null; - public constructor(name: string, dynamic: boolean, parent: RouterBranch | null) { - this.name = name; - this.dynamic = dynamic; - this.parent = parent; - this.node = new RouterNode(this); - } + public constructor(name: string, dynamic: boolean, parent: RouterBranch | null) { + this.name = name; + this.dynamic = dynamic; + this.parent = parent; + this.node = new RouterNode(this); + } - public matches(part: string): boolean { - return this.dynamic || this.name === part; - } + public matches(part: string): boolean { + return this.dynamic || this.name === part; + } - protected insertAt(parts: readonly string[], index: number, route: Route): RouterNode { - if (index >= parts.length) { - for (const method of route.methods) this.node.set(method, route); - return this.node; - } + protected insertAt(parts: readonly string[], index: number, route: Route): RouterNode { + if (index >= parts.length) { + for (const method of route.methods) this.node.set(method, route); + return this.node; + } - const part = parts[index]; - const dynamic = part.startsWith("[") && part.endsWith("]"); + const part = parts[index]; + const dynamic = part.startsWith("[") && part.endsWith("]"); - if (dynamic) { - if (this.dynamicChild) return this.dynamicChild.insertAt(parts, index + 1, route); + if (dynamic) { + if (this.dynamicChild) return this.dynamicChild.insertAt(parts, index + 1, route); - const branch = new RouterBranch(part.slice(1, -1), true, this); - this.dynamicChild = branch; - return branch.insertAt(parts, index + 1, route); - } + const branch = new RouterBranch(part.slice(1, -1), true, this); + this.dynamicChild = branch; + return branch.insertAt(parts, index + 1, route); + } - const staticChild = this.staticChildren.find((branch) => branch.name === part); - if (staticChild) return staticChild.insertAt(parts, index + 1, route); + const staticChild = this.staticChildren.find((branch) => branch.name === part); + if (staticChild) return staticChild.insertAt(parts, index + 1, route); - const branch = new RouterBranch(part, false, this); - this.staticChildren.push(branch); - return branch.insertAt(parts, index + 1, route); - } + const branch = new RouterBranch(part, false, this); + this.staticChildren.push(branch); + return branch.insertAt(parts, index + 1, route); + } - protected removeAt(parts: readonly string[], index: number, route: Route): boolean { - if (index >= parts.length) { - let removed = false; - for (const method of route.methods) { - if (this.node.delete(method)) removed = true; - } - return removed; - } + protected removeAt(parts: readonly string[], index: number, route: Route): boolean { + if (index >= parts.length) { + let removed = false; + for (const method of route.methods) { + if (this.node.delete(method)) removed = true; + } + return removed; + } - const part = parts[index]; - const child = this.staticChildren.find((branch) => branch.matches(part)) ?? this.dynamicChild; - return child ? child.removeAt(parts, index + 1, route) : false; - } + const part = parts[index]; + const child = this.staticChildren.find((branch) => branch.matches(part)) ?? this.dynamicChild; + return child ? child.removeAt(parts, index + 1, route) : false; + } - protected findAt(parts: readonly string[], index: number): RouterBranch | null { - if (index >= parts.length) return this; + protected findAt(parts: readonly string[], index: number): RouterBranch | null { + if (index >= parts.length) return this; - const part = parts[index]; - const child = this.staticChildren.find((branch) => branch.matches(part)) ?? this.dynamicChild; + const part = parts[index]; + const child = this.staticChildren.find((branch) => branch.matches(part)) ?? this.dynamicChild; - return child ? child.findAt(parts, index + 1) : null; - } + return child ? child.findAt(parts, index + 1) : null; + } } diff --git a/packages/plugin-api/src/lib/structures/router/RouterNode.ts b/packages/plugin-api/src/lib/structures/router/RouterNode.ts index 82ad6f9..5fc7a2c 100644 --- a/packages/plugin-api/src/lib/structures/router/RouterNode.ts +++ b/packages/plugin-api/src/lib/structures/router/RouterNode.ts @@ -6,29 +6,29 @@ import type { RouterBranch } from "./RouterBranch"; * A single path segment's registered routes, keyed by HTTP method. */ export class RouterNode extends Map { - public readonly branch: RouterBranch; + public readonly branch: RouterBranch; - public constructor(branch: RouterBranch) { - super(); - this.branch = branch; - } + public constructor(branch: RouterBranch) { + super(); + this.branch = branch; + } - /** - * Walks up the branch chain collecting the values captured by dynamic (`[param]`) segments. - * @param parts The path segments of the matched request, in the same order used to reach this node. - */ - public extractParameters(parts: readonly string[]): Record { - const params: Record = {}; + /** + * Walks up the branch chain collecting the values captured by dynamic (`[param]`) segments. + * @param parts The path segments of the matched request, in the same order used to reach this node. + */ + public extractParameters(parts: readonly string[]): Record { + const params: Record = {}; - let branch: RouterBranch | null = this.branch; - let index = parts.length - 1; + let branch: RouterBranch | null = this.branch; + let index = parts.length - 1; - while (branch && branch.parent) { - if (branch.dynamic) params[branch.name] = decodeURIComponent(parts[index]); - branch = branch.parent; - index -= 1; - } + while (branch && branch.parent) { + if (branch.dynamic) params[branch.name] = decodeURIComponent(parts[index]); + branch = branch.parent; + index -= 1; + } - return params; - } + return params; + } } diff --git a/packages/plugin-api/src/lib/structures/router/RouterRoot.ts b/packages/plugin-api/src/lib/structures/router/RouterRoot.ts index d250c89..dda2a03 100644 --- a/packages/plugin-api/src/lib/structures/router/RouterRoot.ts +++ b/packages/plugin-api/src/lib/structures/router/RouterRoot.ts @@ -10,58 +10,58 @@ const ROOT_NAME = "::ROOT::"; * add/remove/find API plus the static path-parsing helpers used by {@link Route}'s constructor. */ export class RouterRoot extends RouterBranch { - public constructor() { - super(ROOT_NAME, false, null); - } + public constructor() { + super(ROOT_NAME, false, null); + } - public add(route: Route): RouterNode { - return this.insertAt(route.path, 0, route); - } + public add(route: Route): RouterNode { + return this.insertAt(route.path, 0, route); + } - public remove(route: Route): boolean { - return this.removeAt(route.path, 0, route); - } + public remove(route: Route): boolean { + return this.removeAt(route.path, 0, route); + } - public find(parts: readonly string[]): RouterBranch | null { - return this.findAt(parts, 0); - } + public find(parts: readonly string[]): RouterBranch | null { + return this.findAt(parts, 0); + } - /** - * Splits a `/`-delimited path into its non-empty segments. - */ - public static normalize(path?: string | null): string[] { - if (!path) return []; - return path.split("/").filter((part) => part.length > 0); - } + /** + * Splits a `/`-delimited path into its non-empty segments. + */ + public static normalize(path?: string | null): string[] { + if (!path) return []; + return path.split("/").filter((part) => part.length > 0); + } - /** - * Builds a route path from a piece's directory structure and file name: `(group)`-style - * directories are skipped, and a file named `index` collapses into its parent directory. - */ - public static makeRoutePathForPiece(directories: readonly string[], name: string): string { - const parts: string[] = []; + /** + * Builds a route path from a piece's directory structure and file name: `(group)`-style + * directories are skipped, and a file named `index` collapses into its parent directory. + */ + public static makeRoutePathForPiece(directories: readonly string[], name: string): string { + const parts: string[] = []; - for (const directory of directories) { - const trimmed = directory.trim(); - if (trimmed.length === 0) continue; - if (trimmed.startsWith("(") && trimmed.endsWith(")")) continue; - parts.push(trimmed); - } + for (const directory of directories) { + const trimmed = directory.trim(); + if (trimmed.length === 0) continue; + if (trimmed.startsWith("(") && trimmed.endsWith(")")) continue; + parts.push(trimmed); + } - const trimmedName = name.trim(); - if (trimmedName !== "index") parts.push(trimmedName); + const trimmedName = name.trim(); + if (trimmedName !== "index") parts.push(trimmedName); - return parts.join("/"); - } + return parts.join("/"); + } - /** - * Extracts a trailing `.` suffix from a piece name, e.g. `hello.post` → `POST`. - */ - public static extractMethod(name: string): HttpMethod | null { - const index = name.lastIndexOf("."); - if (index === -1) return null; + /** + * Extracts a trailing `.` suffix from a piece name, e.g. `hello.post` → `POST`. + */ + public static extractMethod(name: string): HttpMethod | null { + const index = name.lastIndexOf("."); + if (index === -1) return null; - const method = name.slice(index + 1).toUpperCase(); - return (HttpMethods as readonly string[]).includes(method) ? (method as HttpMethod) : null; - } + const method = name.slice(index + 1).toUpperCase(); + return (HttpMethods as readonly string[]).includes(method) ? (method as HttpMethod) : null; + } } diff --git a/packages/plugin-api/src/listeners/PluginRouteError.ts b/packages/plugin-api/src/listeners/PluginRouteError.ts index 7ecb9ae..a71a8ca 100644 --- a/packages/plugin-api/src/listeners/PluginRouteError.ts +++ b/packages/plugin-api/src/listeners/PluginRouteError.ts @@ -7,12 +7,12 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Logs and responds with a 500 when a route's `run` method throws. */ export class PluginRouteErrorListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.RouteError }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.RouteError }); + } - public override run(error: unknown, _request: ApiRequest, response: ApiResponse): void { - console.error("[plugin-api] Route error:", error); - if (!response.writableEnded) response.error(); - } + public override run(error: unknown, _request: ApiRequest, response: ApiResponse): void { + console.error("[plugin-api] Route error:", error); + if (!response.writableEnded) response.error(); + } } diff --git a/packages/plugin-api/src/listeners/PluginServerMiddlewareError.ts b/packages/plugin-api/src/listeners/PluginServerMiddlewareError.ts index a75360f..2ed15d6 100644 --- a/packages/plugin-api/src/listeners/PluginServerMiddlewareError.ts +++ b/packages/plugin-api/src/listeners/PluginServerMiddlewareError.ts @@ -7,12 +7,12 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Logs and responds with a 500 when a middleware throws. */ export class PluginServerMiddlewareErrorListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.MiddlewareError }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.MiddlewareError }); + } - public override run(error: unknown, _request: ApiRequest, response: ApiResponse): void { - console.error("[plugin-api] Middleware error:", error); - if (!response.writableEnded) response.error(); - } + public override run(error: unknown, _request: ApiRequest, response: ApiResponse): void { + console.error("[plugin-api] Middleware error:", error); + if (!response.writableEnded) response.error(); + } } diff --git a/packages/plugin-api/src/listeners/PluginServerRequest.ts b/packages/plugin-api/src/listeners/PluginServerRequest.ts index ad0d793..c413cb2 100644 --- a/packages/plugin-api/src/listeners/PluginServerRequest.ts +++ b/packages/plugin-api/src/listeners/PluginServerRequest.ts @@ -11,44 +11,44 @@ import { RouterRoot } from "../lib/structures/router/RouterRoot"; * downstream listeners to handle. */ export class PluginServerRequestListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.Request }); - } - - public override async run(request: ApiRequest, response: ApiResponse): Promise { - const [pathname, querystring] = splitUrl(request.url); - request.query = new URLSearchParams(querystring); - - const parts = RouterRoot.normalize(pathname); - const branch = container.server.routes.router.find(parts); - const node = branch?.node ?? null; - const route = node?.get((request.method ?? "GET") as HttpMethod) ?? null; - - if (node) request.params = node.extractParameters(parts); - request.routerNode = branch; - request.route = route; - - try { - // Middlewares always run, even without a match, since browsers send a pre-flight OPTIONS request. - await container.server.middlewares.run(request, response); - } catch (error) { - container.server.emit(ApiServerEvent.MiddlewareError, error, request, response); - return; - } - - if (response.writableEnded) return; - - if (branch === null) { - container.server.emit(ApiServerEvent.RouterBranchNotFound, request, response); - } else if (route === null) { - container.server.emit(ApiServerEvent.RouterBranchMethodNotAllowed, request, response); - } else { - container.server.emit(ApiServerEvent.RouterFound, request, response); - } - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.Request }); + } + + public override async run(request: ApiRequest, response: ApiResponse): Promise { + const [pathname, querystring] = splitUrl(request.url); + request.query = new URLSearchParams(querystring); + + const parts = RouterRoot.normalize(pathname); + const branch = container.server.routes.router.find(parts); + const node = branch?.node ?? null; + const route = node?.get((request.method ?? "GET") as HttpMethod) ?? null; + + if (node) request.params = node.extractParameters(parts); + request.routerNode = branch; + request.route = route; + + try { + // Middlewares always run, even without a match, since browsers send a pre-flight OPTIONS request. + await container.server.middlewares.run(request, response); + } catch (error) { + container.server.emit(ApiServerEvent.MiddlewareError, error, request, response); + return; + } + + if (response.writableEnded) return; + + if (branch === null) { + container.server.emit(ApiServerEvent.RouterBranchNotFound, request, response); + } else if (route === null) { + container.server.emit(ApiServerEvent.RouterBranchMethodNotAllowed, request, response); + } else { + container.server.emit(ApiServerEvent.RouterFound, request, response); + } + } } function splitUrl(url = "/"): [pathname: string, querystring: string] { - const index = url.indexOf("?"); - return index === -1 ? [url, ""] : [url.slice(0, index), url.slice(index + 1)]; + const index = url.indexOf("?"); + return index === -1 ? [url, ""] : [url.slice(0, index), url.slice(index + 1)]; } diff --git a/packages/plugin-api/src/listeners/PluginServerRouterBranchMethodNotAllowed.ts b/packages/plugin-api/src/listeners/PluginServerRouterBranchMethodNotAllowed.ts index abed865..1ef383a 100644 --- a/packages/plugin-api/src/listeners/PluginServerRouterBranchMethodNotAllowed.ts +++ b/packages/plugin-api/src/listeners/PluginServerRouterBranchMethodNotAllowed.ts @@ -7,11 +7,11 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Responds with a 405 when the pathname matched but no route handles this HTTP method. */ export class PluginServerRouterBranchMethodNotAllowedListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.RouterBranchMethodNotAllowed }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.RouterBranchMethodNotAllowed }); + } - public override run(_request: ApiRequest, response: ApiResponse): void { - if (!response.writableEnded) response.methodNotAllowed(); - } + public override run(_request: ApiRequest, response: ApiResponse): void { + if (!response.writableEnded) response.methodNotAllowed(); + } } diff --git a/packages/plugin-api/src/listeners/PluginServerRouterBranchNotFound.ts b/packages/plugin-api/src/listeners/PluginServerRouterBranchNotFound.ts index d1016dc..9f015ca 100644 --- a/packages/plugin-api/src/listeners/PluginServerRouterBranchNotFound.ts +++ b/packages/plugin-api/src/listeners/PluginServerRouterBranchNotFound.ts @@ -7,11 +7,11 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Responds with a 404 when no route matched the request's pathname. */ export class PluginServerRouterBranchNotFoundListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.RouterBranchNotFound }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.RouterBranchNotFound }); + } - public override run(_request: ApiRequest, response: ApiResponse): void { - if (!response.writableEnded) response.notFound(); - } + public override run(_request: ApiRequest, response: ApiResponse): void { + if (!response.writableEnded) response.notFound(); + } } diff --git a/packages/plugin-api/src/listeners/PluginServerRouterFound.ts b/packages/plugin-api/src/listeners/PluginServerRouterFound.ts index 83a1d10..dccc125 100644 --- a/packages/plugin-api/src/listeners/PluginServerRouterFound.ts +++ b/packages/plugin-api/src/listeners/PluginServerRouterFound.ts @@ -7,15 +7,15 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Invokes the matched route's `run` method, emitting `routeError` if it throws. */ export class PluginServerRouterFoundListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.RouterFound }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.RouterFound }); + } - public override async run(request: ApiRequest, response: ApiResponse): Promise { - try { - await request.route!.run(request, response); - } catch (error) { - container.server.emit(ApiServerEvent.RouteError, error, request, response); - } - } + public override async run(request: ApiRequest, response: ApiResponse): Promise { + try { + await request.route!.run(request, response); + } catch (error) { + container.server.emit(ApiServerEvent.RouteError, error, request, response); + } + } } diff --git a/packages/plugin-api/src/listeners/_load.ts b/packages/plugin-api/src/listeners/_load.ts index a08a59a..1ae05d2 100644 --- a/packages/plugin-api/src/listeners/_load.ts +++ b/packages/plugin-api/src/listeners/_load.ts @@ -11,36 +11,36 @@ import { PluginServerRouterFoundListener } from "./PluginServerRouterFound"; * store, targeting the `server` container entry (see {@link ApiServer}). */ export async function loadListeners(): Promise { - await Promise.all([ - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerRequest", - piece: PluginServerRequestListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerRouterFound", - piece: PluginServerRouterFoundListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerRouterBranchNotFound", - piece: PluginServerRouterBranchNotFoundListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerRouterBranchMethodNotAllowed", - piece: PluginServerRouterBranchMethodNotAllowedListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerMiddlewareError", - piece: PluginServerMiddlewareErrorListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginRouteError", - piece: PluginRouteErrorListener, - }), - ]); + await Promise.all([ + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerRequest", + piece: PluginServerRequestListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerRouterFound", + piece: PluginServerRouterFoundListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerRouterBranchNotFound", + piece: PluginServerRouterBranchNotFoundListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerRouterBranchMethodNotAllowed", + piece: PluginServerRouterBranchMethodNotAllowedListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerMiddlewareError", + piece: PluginServerMiddlewareErrorListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginRouteError", + piece: PluginRouteErrorListener, + }), + ]); } diff --git a/packages/plugin-api/src/middlewares/_load.ts b/packages/plugin-api/src/middlewares/_load.ts index c2e3745..4fc060b 100644 --- a/packages/plugin-api/src/middlewares/_load.ts +++ b/packages/plugin-api/src/middlewares/_load.ts @@ -6,8 +6,8 @@ import { HeadersMiddleware } from "./headers"; * Registers the built-in middlewares (`headers`, `body`) into {@link ApiServer.middlewares}. */ export async function loadMiddlewares(): Promise { - await Promise.all([ - container.stores.loadPiece({ store: "middlewares", name: "headers", piece: HeadersMiddleware }), - container.stores.loadPiece({ store: "middlewares", name: "body", piece: BodyMiddleware }), - ]); + await Promise.all([ + container.stores.loadPiece({ store: "middlewares", name: "headers", piece: HeadersMiddleware }), + container.stores.loadPiece({ store: "middlewares", name: "body", piece: BodyMiddleware }), + ]); } diff --git a/packages/plugin-api/src/middlewares/body.ts b/packages/plugin-api/src/middlewares/body.ts index 142054f..af0db77 100644 --- a/packages/plugin-api/src/middlewares/body.ts +++ b/packages/plugin-api/src/middlewares/body.ts @@ -8,16 +8,16 @@ import { Middleware } from "../lib/structures/Middleware"; * Runs second (position 20), after CORS headers have been set. */ export class BodyMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 20 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 20 }); + } - public override run(request: ApiRequest, response: ApiResponse): void { - const limit = container.server.options.maximumBodyLength ?? 1024 * 1024 * 50; - const contentLength = Number(request.headers["content-length"] ?? 0); + public override run(request: ApiRequest, response: ApiResponse): void { + const limit = container.server.options.maximumBodyLength ?? 1024 * 1024 * 50; + const contentLength = Number(request.headers["content-length"] ?? 0); - if (contentLength > limit) { - response.json({ error: "Payload Too Large" }, HttpCodes.PayloadTooLarge); - } - } + if (contentLength > limit) { + response.json({ error: "Payload Too Large" }, HttpCodes.PayloadTooLarge); + } + } } diff --git a/packages/plugin-api/src/middlewares/headers.ts b/packages/plugin-api/src/middlewares/headers.ts index e2f5f36..03ad495 100644 --- a/packages/plugin-api/src/middlewares/headers.ts +++ b/packages/plugin-api/src/middlewares/headers.ts @@ -9,21 +9,21 @@ import { Middleware } from "../lib/structures/Middleware"; * listeners, so it is not duplicated here. */ export class HeadersMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 10 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 10 }); + } - public override run(request: ApiRequest, response: ApiResponse): void { - const origin = container.server.options.origin ?? "*"; + public override run(request: ApiRequest, response: ApiResponse): void { + const origin = container.server.options.origin ?? "*"; - response.setHeader("Access-Control-Allow-Origin", origin); - response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); - response.setHeader( - "Access-Control-Allow-Methods", - "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS", - ); - response.setHeader("Date", new Date().toUTCString()); + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.setHeader( + "Access-Control-Allow-Methods", + "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS", + ); + response.setHeader("Date", new Date().toUTCString()); - if (request.method === "OPTIONS") response.noContent(); - } + if (request.method === "OPTIONS") response.noContent(); + } } diff --git a/packages/plugin-api/src/register.ts b/packages/plugin-api/src/register.ts index 6dc6718..068eda9 100644 --- a/packages/plugin-api/src/register.ts +++ b/packages/plugin-api/src/register.ts @@ -1,10 +1,10 @@ import { - Client, - container, - Plugin, - postInitialization, - postListen, - type ClientOptions, + Client, + container, + Plugin, + postInitialization, + postListen, + type ClientOptions, } from "@wolfstar/http-framework"; import "./index"; import { ApiServer } from "./lib/http/ApiServer"; @@ -22,29 +22,29 @@ import { loadMiddlewares } from "./middlewares/_load"; * ``` */ export class ApiPlugin extends Plugin { - public static [postInitialization](this: Client, options: ClientOptions): void { - const server = new ApiServer(options.api); + public static [postInitialization](this: Client, options: ClientOptions): void { + const server = new ApiServer(options.api); - container.stores // - .register(server.routes) - .register(server.middlewares); + container.stores // + .register(server.routes) + .register(server.middlewares); - loadListeners().catch((error: unknown) => - console.error("[plugin-api] Failed to load listeners:", error), - ); - loadMiddlewares().catch((error: unknown) => - console.error("[plugin-api] Failed to load middlewares:", error), - ); - } + loadListeners().catch((error: unknown) => + console.error("[plugin-api] Failed to load listeners:", error), + ); + loadMiddlewares().catch((error: unknown) => + console.error("[plugin-api] Failed to load middlewares:", error), + ); + } - public static async [postListen](this: Client, options: ClientOptions): Promise { - if ((options.api?.automaticallyConnect ?? true) === false) return; - await container.server.connect(); - } + public static async [postListen](this: Client, options: ClientOptions): Promise { + if ((options.api?.automaticallyConnect ?? true) === false) return; + await container.server.connect(); + } } Client.plugins.registerPostInitializationHook( - ApiPlugin[postInitialization], - "WolfStar-Api-PostInitialization", + ApiPlugin[postInitialization], + "WolfStar-Api-PostInitialization", ); Client.plugins.registerPostListenHook(ApiPlugin[postListen], "WolfStar-Api-PostListen"); diff --git a/packages/plugin-api/tests/ApiServer.test.ts b/packages/plugin-api/tests/ApiServer.test.ts index 260ebb9..7a4bc97 100644 --- a/packages/plugin-api/tests/ApiServer.test.ts +++ b/packages/plugin-api/tests/ApiServer.test.ts @@ -6,109 +6,109 @@ import { loadListeners } from "../src/listeners/_load"; import { loadMiddlewares } from "../src/middlewares/_load"; class GreetRoute extends Route { - public constructor(context: Route.LoaderContext) { - super(context, { route: "/greet/[name]", methods: ["GET"] }); - } + public constructor(context: Route.LoaderContext) { + super(context, { route: "/greet/[name]", methods: ["GET"] }); + } - public override run(request: Route.Request, response: Route.Response): void { - response.json({ message: `Hello, ${request.params.name}!` }); - } + public override run(request: Route.Request, response: Route.Response): void { + response.json({ message: `Hello, ${request.params.name}!` }); + } } class EchoRoute extends Route { - public constructor(context: Route.LoaderContext) { - super(context, { route: "/echo", methods: ["POST"] }); - } - - public override async run(request: Route.Request, response: Route.Response): Promise { - const body = await request.readBodyJson<{ text: string }>(); - response.json({ echo: body.text }); - } + public constructor(context: Route.LoaderContext) { + super(context, { route: "/echo", methods: ["POST"] }); + } + + public override async run(request: Route.Request, response: Route.Response): Promise { + const body = await request.readBodyJson<{ text: string }>(); + response.json({ echo: body.text }); + } } function rawRequest( - url: string, - options: { method: string; headers?: Record }, + url: string, + options: { method: string; headers?: Record }, ): Promise { - return new Promise((resolve, reject) => { - const req = httpRequest(url, { method: options.method, headers: options.headers }, (res) => { - res.resume(); - res.on("end", () => resolve(res.statusCode ?? 0)); - }); - req.on("error", reject); - req.end(); - }); + return new Promise((resolve, reject) => { + const req = httpRequest(url, { method: options.method, headers: options.headers }, (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode ?? 0)); + }); + req.on("error", reject); + req.end(); + }); } describe("ApiServer (integration)", () => { - let server: ApiServer; - let baseUrl: string; - - beforeAll(async () => { - server = new ApiServer({ listenOptions: { port: 0 } }); - - container.stores // - .register(server.routes) - .register(server.middlewares); - - await loadMiddlewares(); - await loadListeners(); - - await server.routes.loadPiece({ name: "greet.get", piece: GreetRoute }); - await server.routes.loadPiece({ name: "echo.post", piece: EchoRoute }); - - // `loadPiece` only queues pieces; flushing them is normally `client.load()`'s job. - await container.stores.load(); - - await server.connect(); - - const address = server.server.address(); - if (address === null || typeof address === "string") throw new Error("Expected an AddressInfo"); - baseUrl = `http://127.0.0.1:${address.port}`; - }); - - afterAll(async () => { - await server.disconnect(); - }); - - it("given a dynamic route then extracts the param and responds with json", async () => { - const response = await fetch(`${baseUrl}/greet/world`); - expect(response.status).toBe(200); - expect(response.headers.get("access-control-allow-origin")).toBe("*"); - await expect(response.json()).resolves.toStrictEqual({ message: "Hello, world!" }); - }); - - it("given a POST route then reads the request body", async () => { - const response = await fetch(`${baseUrl}/echo`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ text: "hi" }), - }); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual({ echo: "hi" }); - }); - - it("given an unregistered path then responds with 404", async () => { - const response = await fetch(`${baseUrl}/missing`); - expect(response.status).toBe(404); - }); - - it("given a registered path with the wrong method then responds with 405", async () => { - const response = await fetch(`${baseUrl}/greet/world`, { method: "POST" }); - expect(response.status).toBe(405); - }); - - it("given an OPTIONS preflight request then responds with 204 and CORS headers", async () => { - const response = await fetch(`${baseUrl}/greet/world`, { method: "OPTIONS" }); - expect(response.status).toBe(204); - expect(response.headers.get("access-control-allow-methods")).toContain("GET"); - }); - - it("given a request declaring an oversized body then responds with 413", async () => { - const statusCode = await rawRequest(`${baseUrl}/echo`, { - method: "POST", - headers: { "content-type": "application/json", "content-length": String(1024 * 1024 * 100) }, - }); - expect(statusCode).toBe(413); - }); + let server: ApiServer; + let baseUrl: string; + + beforeAll(async () => { + server = new ApiServer({ listenOptions: { port: 0 } }); + + container.stores // + .register(server.routes) + .register(server.middlewares); + + await loadMiddlewares(); + await loadListeners(); + + await server.routes.loadPiece({ name: "greet.get", piece: GreetRoute }); + await server.routes.loadPiece({ name: "echo.post", piece: EchoRoute }); + + // `loadPiece` only queues pieces; flushing them is normally `client.load()`'s job. + await container.stores.load(); + + await server.connect(); + + const address = server.server.address(); + if (address === null || typeof address === "string") throw new Error("Expected an AddressInfo"); + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await server.disconnect(); + }); + + it("given a dynamic route then extracts the param and responds with json", async () => { + const response = await fetch(`${baseUrl}/greet/world`); + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + await expect(response.json()).resolves.toStrictEqual({ message: "Hello, world!" }); + }); + + it("given a POST route then reads the request body", async () => { + const response = await fetch(`${baseUrl}/echo`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ echo: "hi" }); + }); + + it("given an unregistered path then responds with 404", async () => { + const response = await fetch(`${baseUrl}/missing`); + expect(response.status).toBe(404); + }); + + it("given a registered path with the wrong method then responds with 405", async () => { + const response = await fetch(`${baseUrl}/greet/world`, { method: "POST" }); + expect(response.status).toBe(405); + }); + + it("given an OPTIONS preflight request then responds with 204 and CORS headers", async () => { + const response = await fetch(`${baseUrl}/greet/world`, { method: "OPTIONS" }); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-methods")).toContain("GET"); + }); + + it("given a request declaring an oversized body then responds with 413", async () => { + const statusCode = await rawRequest(`${baseUrl}/echo`, { + method: "POST", + headers: { "content-type": "application/json", "content-length": String(1024 * 1024 * 100) }, + }); + expect(statusCode).toBe(413); + }); }); diff --git a/packages/plugin-api/tests/MiddlewareStore.test.ts b/packages/plugin-api/tests/MiddlewareStore.test.ts index 000a260..4c6bccc 100644 --- a/packages/plugin-api/tests/MiddlewareStore.test.ts +++ b/packages/plugin-api/tests/MiddlewareStore.test.ts @@ -6,81 +6,81 @@ import { MiddlewareStore } from "../src/lib/structures/MiddlewareStore"; const calls: string[] = []; class FirstMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 10 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 10 }); + } - public override run(): void { - calls.push("first"); - } + public override run(): void { + calls.push("first"); + } } class SecondMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 20 }); - } - - public override run(_request: ApiRequest, response: ApiResponse): void { - calls.push("second"); - response.end(); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 20 }); + } + + public override run(_request: ApiRequest, response: ApiResponse): void { + calls.push("second"); + response.end(); + } } class ThirdMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 30 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 30 }); + } - public override run(): void { - calls.push("third"); - } + public override run(): void { + calls.push("third"); + } } describe("MiddlewareStore", () => { - beforeEach(() => { - calls.length = 0; - }); - - it("given middlewares registered out of order then sorts them ascending by position", async () => { - const store = new MiddlewareStore(); - await store.loadPiece({ name: "third", piece: ThirdMiddleware }); - await store.loadPiece({ name: "first", piece: FirstMiddleware }); - await store.loadPiece({ name: "second", piece: SecondMiddleware }); - await store.loadAll(); - - expect(store.sortedMiddlewares.map((middleware) => middleware.position)).toStrictEqual([ - 10, 20, 30, - ]); - }); - - it("given a middleware that ends the response then stops running the remaining chain", async () => { - const store = new MiddlewareStore(); - await store.loadPiece({ name: "first", piece: FirstMiddleware }); - await store.loadPiece({ name: "second", piece: SecondMiddleware }); - await store.loadPiece({ name: "third", piece: ThirdMiddleware }); - await store.loadAll(); - - const response = { - writableEnded: false, - end: vi.fn(function (this: { writableEnded: boolean }) { - this.writableEnded = true; - }), - } as unknown as ApiResponse; - - await store.run({} as ApiRequest, response); - - // `second` ends the response, so `third` (checked before it would run) never fires. - expect(calls).toStrictEqual(["first", "second"]); - }); - - it("given a removed middleware then no longer runs it", async () => { - const store = new MiddlewareStore(); - await store.loadPiece({ name: "first", piece: FirstMiddleware }); - await store.loadPiece({ name: "second", piece: SecondMiddleware }); - await store.loadAll(); - store.delete("second"); - - expect(store.sortedMiddlewares).toHaveLength(1); - expect(store.sortedMiddlewares[0]!.name).toBe("first"); - }); + beforeEach(() => { + calls.length = 0; + }); + + it("given middlewares registered out of order then sorts them ascending by position", async () => { + const store = new MiddlewareStore(); + await store.loadPiece({ name: "third", piece: ThirdMiddleware }); + await store.loadPiece({ name: "first", piece: FirstMiddleware }); + await store.loadPiece({ name: "second", piece: SecondMiddleware }); + await store.loadAll(); + + expect(store.sortedMiddlewares.map((middleware) => middleware.position)).toStrictEqual([ + 10, 20, 30, + ]); + }); + + it("given a middleware that ends the response then stops running the remaining chain", async () => { + const store = new MiddlewareStore(); + await store.loadPiece({ name: "first", piece: FirstMiddleware }); + await store.loadPiece({ name: "second", piece: SecondMiddleware }); + await store.loadPiece({ name: "third", piece: ThirdMiddleware }); + await store.loadAll(); + + const response = { + writableEnded: false, + end: vi.fn(function (this: { writableEnded: boolean }) { + this.writableEnded = true; + }), + } as unknown as ApiResponse; + + await store.run({} as ApiRequest, response); + + // `second` ends the response, so `third` (checked before it would run) never fires. + expect(calls).toStrictEqual(["first", "second"]); + }); + + it("given a removed middleware then no longer runs it", async () => { + const store = new MiddlewareStore(); + await store.loadPiece({ name: "first", piece: FirstMiddleware }); + await store.loadPiece({ name: "second", piece: SecondMiddleware }); + await store.loadAll(); + store.delete("second"); + + expect(store.sortedMiddlewares).toHaveLength(1); + expect(store.sortedMiddlewares[0]!.name).toBe("first"); + }); }); diff --git a/packages/plugin-api/tests/router.test.ts b/packages/plugin-api/tests/router.test.ts index ad25477..43c8693 100644 --- a/packages/plugin-api/tests/router.test.ts +++ b/packages/plugin-api/tests/router.test.ts @@ -2,87 +2,87 @@ import type { Route } from "../src/lib/structures/Route"; import { RouterRoot } from "../src/lib/structures/router/RouterRoot"; function fakeRoute(path: readonly string[], methods: readonly string[]): Route { - return { path, methods: new Set(methods) } as unknown as Route; + return { path, methods: new Set(methods) } as unknown as Route; } describe("RouterRoot static helpers", () => { - it("given a path with slashes then normalizes to its non-empty segments", () => { - expect(RouterRoot.normalize("/users/[id]/posts/")).toStrictEqual(["users", "[id]", "posts"]); - }); - - it("given a nullish path then normalizes to an empty array", () => { - expect(RouterRoot.normalize(undefined)).toStrictEqual([]); - expect(RouterRoot.normalize(null)).toStrictEqual([]); - expect(RouterRoot.normalize("")).toStrictEqual([]); - }); - - it("given directories with a (group) segment then skips it", () => { - expect(RouterRoot.makeRoutePathForPiece(["(v1)", "users"], "profile")).toBe("users/profile"); - }); - - it("given a piece named index then collapses it into its parent", () => { - expect(RouterRoot.makeRoutePathForPiece(["users"], "index")).toBe("users"); - }); - - it("given a name with a method suffix then extracts the uppercased method", () => { - expect(RouterRoot.extractMethod("hello.post")).toBe("POST"); - expect(RouterRoot.extractMethod("hello")).toBeNull(); - expect(RouterRoot.extractMethod("hello.notamethod")).toBeNull(); - }); + it("given a path with slashes then normalizes to its non-empty segments", () => { + expect(RouterRoot.normalize("/users/[id]/posts/")).toStrictEqual(["users", "[id]", "posts"]); + }); + + it("given a nullish path then normalizes to an empty array", () => { + expect(RouterRoot.normalize(undefined)).toStrictEqual([]); + expect(RouterRoot.normalize(null)).toStrictEqual([]); + expect(RouterRoot.normalize("")).toStrictEqual([]); + }); + + it("given directories with a (group) segment then skips it", () => { + expect(RouterRoot.makeRoutePathForPiece(["(v1)", "users"], "profile")).toBe("users/profile"); + }); + + it("given a piece named index then collapses it into its parent", () => { + expect(RouterRoot.makeRoutePathForPiece(["users"], "index")).toBe("users"); + }); + + it("given a name with a method suffix then extracts the uppercased method", () => { + expect(RouterRoot.extractMethod("hello.post")).toBe("POST"); + expect(RouterRoot.extractMethod("hello")).toBeNull(); + expect(RouterRoot.extractMethod("hello.notamethod")).toBeNull(); + }); }); describe("RouterRoot trie", () => { - it("given a static route then matches its exact path", () => { - const root = new RouterRoot(); - const route = fakeRoute(["health"], ["GET"]); - root.add(route); - - const branch = root.find(["health"]); - expect(branch?.node.get("GET")).toBe(route); - }); - - it("given no matching path then returns null", () => { - const root = new RouterRoot(); - root.add(fakeRoute(["health"], ["GET"])); - - expect(root.find(["missing"])).toBeNull(); - }); - - it("given a dynamic segment then matches and exposes the branch for param extraction", () => { - const root = new RouterRoot(); - const route = fakeRoute(["users", "[id]"], ["GET"]); - root.add(route); - - const branch = root.find(["users", "42"]); - expect(branch?.node.get("GET")).toBe(route); - expect(branch?.node.extractParameters(["users", "42"])).toStrictEqual({ id: "42" }); - }); - - it("given a static child and a dynamic child then prefers the static match", () => { - const root = new RouterRoot(); - const staticRoute = fakeRoute(["users", "me"], ["GET"]); - const dynamicRoute = fakeRoute(["users", "[id]"], ["GET"]); - root.add(dynamicRoute); - root.add(staticRoute); - - expect(root.find(["users", "me"])?.node.get("GET")).toBe(staticRoute); - expect(root.find(["users", "42"])?.node.get("GET")).toBe(dynamicRoute); - }); - - it("given a matched path but an unregistered method then the node has no entry for it", () => { - const root = new RouterRoot(); - root.add(fakeRoute(["health"], ["GET"])); - - const branch = root.find(["health"]); - expect(branch?.node.get("POST")).toBeUndefined(); - }); - - it("given a removed route then it no longer matches", () => { - const root = new RouterRoot(); - const route = fakeRoute(["health"], ["GET"]); - root.add(route); - root.remove(route); - - expect(root.find(["health"])?.node.get("GET")).toBeUndefined(); - }); + it("given a static route then matches its exact path", () => { + const root = new RouterRoot(); + const route = fakeRoute(["health"], ["GET"]); + root.add(route); + + const branch = root.find(["health"]); + expect(branch?.node.get("GET")).toBe(route); + }); + + it("given no matching path then returns null", () => { + const root = new RouterRoot(); + root.add(fakeRoute(["health"], ["GET"])); + + expect(root.find(["missing"])).toBeNull(); + }); + + it("given a dynamic segment then matches and exposes the branch for param extraction", () => { + const root = new RouterRoot(); + const route = fakeRoute(["users", "[id]"], ["GET"]); + root.add(route); + + const branch = root.find(["users", "42"]); + expect(branch?.node.get("GET")).toBe(route); + expect(branch?.node.extractParameters(["users", "42"])).toStrictEqual({ id: "42" }); + }); + + it("given a static child and a dynamic child then prefers the static match", () => { + const root = new RouterRoot(); + const staticRoute = fakeRoute(["users", "me"], ["GET"]); + const dynamicRoute = fakeRoute(["users", "[id]"], ["GET"]); + root.add(dynamicRoute); + root.add(staticRoute); + + expect(root.find(["users", "me"])?.node.get("GET")).toBe(staticRoute); + expect(root.find(["users", "42"])?.node.get("GET")).toBe(dynamicRoute); + }); + + it("given a matched path but an unregistered method then the node has no entry for it", () => { + const root = new RouterRoot(); + root.add(fakeRoute(["health"], ["GET"])); + + const branch = root.find(["health"]); + expect(branch?.node.get("POST")).toBeUndefined(); + }); + + it("given a removed route then it no longer matches", () => { + const root = new RouterRoot(); + const route = fakeRoute(["health"], ["GET"]); + root.add(route); + root.remove(route); + + expect(root.find(["health"])?.node.get("GET")).toBeUndefined(); + }); }); diff --git a/packages/plugin-api/tsdown.config.ts b/packages/plugin-api/tsdown.config.ts index 9e8b6c5..83b7128 100644 --- a/packages/plugin-api/tsdown.config.ts +++ b/packages/plugin-api/tsdown.config.ts @@ -1,12 +1,12 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/register.ts"], - format: "esm", - target: "es2022", - dts: true, - clean: true, - sourcemap: true, - fixedExtension: false, - outDir: "dist", + entry: ["src/index.ts", "src/register.ts"], + format: "esm", + target: "es2022", + dts: true, + clean: true, + sourcemap: true, + fixedExtension: false, + outDir: "dist", }); From 85005bd23e58b39b9cc2c3540314c7f50cddd9ae Mon Sep 17 00:00:00 2001 From: RedStar071 Date: Fri, 10 Jul 2026 15:18:59 +0000 Subject: [PATCH 3/5] ci: fix zizmor ref-version-mismatch in labeler workflow Co-authored-by: Codesmith --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index a60f052..8f9bc14 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - name: Label pull request - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6 + uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: repo-token: "${{ secrets.WOLFSTAR_TOKEN }}" sync-labels: true From b786c497c19f8699d5fcba4e104adb6eccffbe77 Mon Sep 17 00:00:00 2001 From: RedStar071 Date: Fri, 10 Jul 2026 15:19:52 +0000 Subject: [PATCH 4/5] chore: add empty changeset for tooling-only changes Co-authored-by: Codesmith --- .changeset/tooling-setup.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/tooling-setup.md diff --git a/.changeset/tooling-setup.md b/.changeset/tooling-setup.md new file mode 100644 index 0000000..a845151 --- /dev/null +++ b/.changeset/tooling-setup.md @@ -0,0 +1,2 @@ +--- +--- From 8d99a5c90cc3dbc4522f642714ea6b6d890b746e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 15:22:41 +0000 Subject: [PATCH 5/5] fix: revert unintended reformat caused by .editorconfig mismatch The .editorconfig added for stars-components alignment used tabs for *.{js,ts}, which doesn't match this repo's actual style (2-space indent). That mismatch caused oxfmt to reformat all of packages/plugin-api to tabs via the autofix-ci bot, and also broke oxfmt's markdown check (tab_width in the markdown section was leaking into embedded ts code-block formatting). Fix .editorconfig to match this repo's real conventions and restore the original formatting of packages/plugin-api. --- .editorconfig | 5 +- packages/plugin-api/README.md | 38 ++-- packages/plugin-api/src/index.ts | 34 +-- .../plugin-api/src/lib/http/ApiRequest.ts | 90 ++++---- .../plugin-api/src/lib/http/ApiResponse.ts | 90 ++++---- packages/plugin-api/src/lib/http/ApiServer.ts | 214 +++++++++--------- .../src/lib/structures/Middleware.ts | 40 ++-- .../src/lib/structures/MiddlewareStore.ts | 80 +++---- .../plugin-api/src/lib/structures/Route.ts | 94 ++++---- .../src/lib/structures/RouteLoaderStrategy.ts | 16 +- .../src/lib/structures/RouteStore.ts | 14 +- .../src/lib/structures/router/RouterBranch.ts | 104 ++++----- .../src/lib/structures/router/RouterNode.ts | 40 ++-- .../src/lib/structures/router/RouterRoot.ts | 88 +++---- .../src/listeners/PluginRouteError.ts | 14 +- .../listeners/PluginServerMiddlewareError.ts | 14 +- .../src/listeners/PluginServerRequest.ts | 74 +++--- ...luginServerRouterBranchMethodNotAllowed.ts | 12 +- .../PluginServerRouterBranchNotFound.ts | 12 +- .../src/listeners/PluginServerRouterFound.ts | 20 +- packages/plugin-api/src/listeners/_load.ts | 64 +++--- packages/plugin-api/src/middlewares/_load.ts | 8 +- packages/plugin-api/src/middlewares/body.ts | 20 +- .../plugin-api/src/middlewares/headers.ts | 28 +-- packages/plugin-api/src/register.ts | 48 ++-- packages/plugin-api/tests/ApiServer.test.ts | 188 +++++++-------- .../plugin-api/tests/MiddlewareStore.test.ts | 132 +++++------ packages/plugin-api/tests/router.test.ts | 154 ++++++------- packages/plugin-api/tsdown.config.ts | 16 +- 29 files changed, 875 insertions(+), 876 deletions(-) diff --git a/.editorconfig b/.editorconfig index a399bcd..9687260 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,8 +7,8 @@ insert_final_newline = true trim_trailing_whitespace = true [*.{js,ts}] -indent_size = 4 -indent_style = tab +indent_size = 2 +indent_style = space block_comment_start = /* block_comment = * block_comment_end = */ @@ -18,5 +18,4 @@ indent_size = 2 indent_style = space [*.{md,rmd,mkd,mkdn,mdwn,mdown,markdown,litcoffee}] -tab_width = 4 trim_trailing_whitespace = false diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md index a483d3d..7e29cbd 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -24,10 +24,10 @@ import "@wolfstar/plugin-api/register"; import { Client } from "@wolfstar/http-framework"; const client = new Client({ - api: { - listenOptions: { port: 4000 }, - origin: "*", - }, + api: { + listenOptions: { port: 4000 }, + origin: "*", + }, }); await client.load(); @@ -44,9 +44,9 @@ import { HttpCodes } from "@wolfstar/http-framework"; import { Route } from "@wolfstar/plugin-api"; export class HealthRoute extends Route { - public run(_request: Route.Request, response: Route.Response) { - response.json({ status: "ok" }, HttpCodes.OK); - } + public run(_request: Route.Request, response: Route.Response) { + response.json({ status: "ok" }, HttpCodes.OK); + } } ``` @@ -56,13 +56,13 @@ The route's path and method are inferred from its location: `src/routes/health.g ```ts export class HealthRoute extends Route { - public constructor(context: Route.LoaderContext) { - super(context, { route: "/status", methods: ["GET", "HEAD"] }); - } + public constructor(context: Route.LoaderContext) { + super(context, { route: "/status", methods: ["GET", "HEAD"] }); + } - public run(request: Route.Request, response: Route.Response) { - response.json({ status: "ok", method: request.method }); - } + public run(request: Route.Request, response: Route.Response) { + response.json({ status: "ok", method: request.method }); + } } ``` @@ -77,13 +77,13 @@ import { Middleware } from "@wolfstar/plugin-api"; import { randomUUID } from "node:crypto"; export class RequestIdMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 15 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 15 }); + } - public run(request: Middleware.Request, response: Middleware.Response) { - response.setHeader("X-Request-Id", randomUUID()); - } + public run(request: Middleware.Request, response: Middleware.Response) { + response.setHeader("X-Request-Id", randomUUID()); + } } ``` diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 80ac221..fec18a7 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -18,25 +18,25 @@ export { loadListeners } from "./listeners/_load"; export { loadMiddlewares } from "./middlewares/_load"; declare module "@wolfstar/http-framework" { - interface ClientOptions { - /** - * Options for the auxiliary REST API server registered by `@wolfstar/plugin-api`. - */ - api?: ApiServerOptions; - } + interface ClientOptions { + /** + * Options for the auxiliary REST API server registered by `@wolfstar/plugin-api`. + */ + api?: ApiServerOptions; + } } declare module "@sapphire/pieces" { - interface StoreRegistryEntries { - routes: RouteStore; - middlewares: MiddlewareStore; - } + interface StoreRegistryEntries { + routes: RouteStore; + middlewares: MiddlewareStore; + } - interface Container { - /** - * The auxiliary REST API server registered by `@wolfstar/plugin-api`. Independent from the - * Discord interactions webhook server (`Client#server`). - */ - server: ApiServer; - } + interface Container { + /** + * The auxiliary REST API server registered by `@wolfstar/plugin-api`. Independent from the + * Discord interactions webhook server (`Client#server`). + */ + server: ApiServer; + } } diff --git a/packages/plugin-api/src/lib/http/ApiRequest.ts b/packages/plugin-api/src/lib/http/ApiRequest.ts index 366cb24..b2daaac 100644 --- a/packages/plugin-api/src/lib/http/ApiRequest.ts +++ b/packages/plugin-api/src/lib/http/ApiRequest.ts @@ -3,49 +3,49 @@ import type { Route } from "../structures/Route"; import type { RouterBranch } from "../structures/router/RouterBranch"; export class ApiRequest extends IncomingMessage { - /** - * The parsed query string parameters. - */ - public query: URLSearchParams = new URLSearchParams(); - - /** - * The extracted dynamic (`[param]`) path segment values. - */ - public params: Record = {}; - - /** - * The matched router branch for this request's pathname, if any. - */ - public routerNode: RouterBranch | null = null; - - /** - * The matched route for this request's pathname and method, if any. - */ - public route: Route | null = null; - - /** - * Reads the full request body as a UTF-8 string. - * @param limit The maximum number of bytes to read before rejecting. Defaults to 1 MiB. - */ - public async readBodyText(limit = 1024 * 1024): Promise { - const chunks: Buffer[] = []; - let size = 0; - - for await (const chunk of this) { - size += (chunk as Buffer).length; - if (size > limit) throw new RangeError(`Request body exceeded the ${limit} byte limit`); - chunks.push(chunk as Buffer); - } - - return Buffer.concat(chunks).toString("utf8"); - } - - /** - * Reads and parses the request body as JSON. - * @param limit The maximum number of bytes to read before rejecting. Defaults to 1 MiB. - */ - public async readBodyJson(limit?: number): Promise { - const text = await this.readBodyText(limit); - return JSON.parse(text) as T; - } + /** + * The parsed query string parameters. + */ + public query: URLSearchParams = new URLSearchParams(); + + /** + * The extracted dynamic (`[param]`) path segment values. + */ + public params: Record = {}; + + /** + * The matched router branch for this request's pathname, if any. + */ + public routerNode: RouterBranch | null = null; + + /** + * The matched route for this request's pathname and method, if any. + */ + public route: Route | null = null; + + /** + * Reads the full request body as a UTF-8 string. + * @param limit The maximum number of bytes to read before rejecting. Defaults to 1 MiB. + */ + public async readBodyText(limit = 1024 * 1024): Promise { + const chunks: Buffer[] = []; + let size = 0; + + for await (const chunk of this) { + size += (chunk as Buffer).length; + if (size > limit) throw new RangeError(`Request body exceeded the ${limit} byte limit`); + chunks.push(chunk as Buffer); + } + + return Buffer.concat(chunks).toString("utf8"); + } + + /** + * Reads and parses the request body as JSON. + * @param limit The maximum number of bytes to read before rejecting. Defaults to 1 MiB. + */ + public async readBodyJson(limit?: number): Promise { + const text = await this.readBodyText(limit); + return JSON.parse(text) as T; + } } diff --git a/packages/plugin-api/src/lib/http/ApiResponse.ts b/packages/plugin-api/src/lib/http/ApiResponse.ts index eeff049..f90d742 100644 --- a/packages/plugin-api/src/lib/http/ApiResponse.ts +++ b/packages/plugin-api/src/lib/http/ApiResponse.ts @@ -3,49 +3,49 @@ import { HttpCodes } from "@wolfstar/http-framework"; import type { ApiRequest } from "./ApiRequest"; export class ApiResponse extends ServerResponse { - public status(statusCode: number): this { - this.statusCode = statusCode; - return this; - } - - public json(body: unknown, statusCode: number = HttpCodes.OK): this { - this.statusCode = statusCode; - if (!this.hasHeader("Content-Type")) - this.setHeader("Content-Type", "application/json; charset=utf-8"); - this.end(JSON.stringify(body)); - return this; - } - - public text(body: string, statusCode: number = HttpCodes.OK): this { - this.statusCode = statusCode; - if (!this.hasHeader("Content-Type")) - this.setHeader("Content-Type", "text/plain; charset=utf-8"); - this.end(body); - return this; - } - - public noContent(): this { - this.statusCode = HttpCodes.NoContent; - this.end(); - return this; - } - - public notFound(body: unknown = { error: "Not Found" }): this { - return this.json(body, HttpCodes.NotFound); - } - - public methodNotAllowed(body: unknown = { error: "Method Not Allowed" }): this { - return this.json(body, HttpCodes.MethodNotAllowed); - } - - public badRequest(body: unknown = { error: "Bad Request" }): this { - return this.json(body, HttpCodes.BadRequest); - } - - public error( - statusCode: number = HttpCodes.InternalServerError, - body: unknown = { error: "Internal Server Error" }, - ): this { - return this.json(body, statusCode); - } + public status(statusCode: number): this { + this.statusCode = statusCode; + return this; + } + + public json(body: unknown, statusCode: number = HttpCodes.OK): this { + this.statusCode = statusCode; + if (!this.hasHeader("Content-Type")) + this.setHeader("Content-Type", "application/json; charset=utf-8"); + this.end(JSON.stringify(body)); + return this; + } + + public text(body: string, statusCode: number = HttpCodes.OK): this { + this.statusCode = statusCode; + if (!this.hasHeader("Content-Type")) + this.setHeader("Content-Type", "text/plain; charset=utf-8"); + this.end(body); + return this; + } + + public noContent(): this { + this.statusCode = HttpCodes.NoContent; + this.end(); + return this; + } + + public notFound(body: unknown = { error: "Not Found" }): this { + return this.json(body, HttpCodes.NotFound); + } + + public methodNotAllowed(body: unknown = { error: "Method Not Allowed" }): this { + return this.json(body, HttpCodes.MethodNotAllowed); + } + + public badRequest(body: unknown = { error: "Bad Request" }): this { + return this.json(body, HttpCodes.BadRequest); + } + + public error( + statusCode: number = HttpCodes.InternalServerError, + body: unknown = { error: "Internal Server Error" }, + ): this { + return this.json(body, statusCode); + } } diff --git a/packages/plugin-api/src/lib/http/ApiServer.ts b/packages/plugin-api/src/lib/http/ApiServer.ts index 1ef8241..960ab80 100644 --- a/packages/plugin-api/src/lib/http/ApiServer.ts +++ b/packages/plugin-api/src/lib/http/ApiServer.ts @@ -1,9 +1,9 @@ import { AsyncEventEmitter } from "@vladfrangu/async_event_emitter"; import { container } from "@wolfstar/http-framework"; import { - createServer, - type Server as NodeHttpServer, - type ServerOptions as NodeHttpServerOptions, + createServer, + type Server as NodeHttpServer, + type ServerOptions as NodeHttpServerOptions, } from "node:http"; import type { ListenOptions } from "node:net"; import { MiddlewareStore } from "../structures/MiddlewareStore"; @@ -12,60 +12,60 @@ import { ApiRequest } from "./ApiRequest"; import { ApiResponse } from "./ApiResponse"; export enum ApiServerEvent { - Error = "error", - Request = "request", - RouterBranchNotFound = "routerBranchNotFound", - RouterBranchMethodNotAllowed = "routerBranchMethodNotAllowed", - RouterFound = "routerFound", - RouteError = "routeError", - MiddlewareError = "middlewareError", + Error = "error", + Request = "request", + RouterBranchNotFound = "routerBranchNotFound", + RouterBranchMethodNotAllowed = "routerBranchMethodNotAllowed", + RouterFound = "routerFound", + RouteError = "routeError", + MiddlewareError = "middlewareError", } export interface ApiServerEvents { - [ApiServerEvent.Error]: [error: Error]; - [ApiServerEvent.Request]: [request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.RouterBranchNotFound]: [request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.RouterBranchMethodNotAllowed]: [request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.RouterFound]: [request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.RouteError]: [error: unknown, request: ApiRequest, response: ApiResponse]; - [ApiServerEvent.MiddlewareError]: [error: unknown, request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.Error]: [error: Error]; + [ApiServerEvent.Request]: [request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.RouterBranchNotFound]: [request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.RouterBranchMethodNotAllowed]: [request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.RouterFound]: [request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.RouteError]: [error: unknown, request: ApiRequest, response: ApiResponse]; + [ApiServerEvent.MiddlewareError]: [error: unknown, request: ApiRequest, response: ApiResponse]; } export interface ApiServerOptions { - /** - * A path segment prefix applied to every route, e.g. `/api`. - */ - prefix?: string; - - /** - * The value of the `Access-Control-Allow-Origin` header set by the built-in `headers` middleware. - * @default '*' - */ - origin?: string; - - /** - * The maximum request body size in bytes, enforced by the built-in `body` middleware. - * @default 1024 * 1024 * 50 - */ - maximumBodyLength?: number; - - /** - * Raw options forwarded to `node:http`'s `createServer`. - */ - server?: NodeHttpServerOptions; - - /** - * Raw options forwarded to `Server#listen`. - * @default { port: 4000 } - */ - listenOptions?: ListenOptions; - - /** - * Whether to start listening automatically once the interaction webhook is up (during the - * `postListen` plugin hook). - * @default true - */ - automaticallyConnect?: boolean; + /** + * A path segment prefix applied to every route, e.g. `/api`. + */ + prefix?: string; + + /** + * The value of the `Access-Control-Allow-Origin` header set by the built-in `headers` middleware. + * @default '*' + */ + origin?: string; + + /** + * The maximum request body size in bytes, enforced by the built-in `body` middleware. + * @default 1024 * 1024 * 50 + */ + maximumBodyLength?: number; + + /** + * Raw options forwarded to `node:http`'s `createServer`. + */ + server?: NodeHttpServerOptions; + + /** + * Raw options forwarded to `Server#listen`. + * @default { port: 4000 } + */ + listenOptions?: ListenOptions; + + /** + * Whether to start listening automatically once the interaction webhook is up (during the + * `postListen` plugin hook). + * @default true + */ + automaticallyConnect?: boolean; } /** @@ -74,60 +74,60 @@ export interface ApiServerOptions { * reserved for the Discord interactions webhook. */ export class ApiServer extends AsyncEventEmitter { - public readonly routes: RouteStore; - public readonly middlewares: MiddlewareStore; - public readonly server: NodeHttpServer; - public readonly options: ApiServerOptions; - - public constructor(options: ApiServerOptions = {}) { - super(); - - container.server = this; - - this.options = options; - - const serverOptions: NodeHttpServerOptions = { - ...options.server, - IncomingMessage: ApiRequest, - ServerResponse: ApiResponse, - }; - this.server = createServer(serverOptions); - - this.routes = new RouteStore(); - this.middlewares = new MiddlewareStore(); - - this.server.on("error", (error) => this.emit(ApiServerEvent.Error, error)); - this.server.on("request", (request, response) => - this.emit(ApiServerEvent.Request, request, response), - ); - } - - /** - * Starts listening for requests. - */ - public connect(): Promise { - return new Promise((resolve, reject) => { - const onError = (error: Error) => { - this.server.off("listening", onListening); - reject(error); - }; - const onListening = () => { - this.server.off("error", onError); - resolve(); - }; - - this.server.once("error", onError); - this.server.once("listening", onListening); - this.server.listen({ port: 4000, ...this.options.listenOptions }); - }); - } - - /** - * Stops the server from accepting new connections. - */ - public disconnect(): Promise { - return new Promise((resolve, reject) => { - this.server.close((error) => (error ? reject(error) : resolve())); - }); - } + public readonly routes: RouteStore; + public readonly middlewares: MiddlewareStore; + public readonly server: NodeHttpServer; + public readonly options: ApiServerOptions; + + public constructor(options: ApiServerOptions = {}) { + super(); + + container.server = this; + + this.options = options; + + const serverOptions: NodeHttpServerOptions = { + ...options.server, + IncomingMessage: ApiRequest, + ServerResponse: ApiResponse, + }; + this.server = createServer(serverOptions); + + this.routes = new RouteStore(); + this.middlewares = new MiddlewareStore(); + + this.server.on("error", (error) => this.emit(ApiServerEvent.Error, error)); + this.server.on("request", (request, response) => + this.emit(ApiServerEvent.Request, request, response), + ); + } + + /** + * Starts listening for requests. + */ + public connect(): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => { + this.server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + this.server.off("error", onError); + resolve(); + }; + + this.server.once("error", onError); + this.server.once("listening", onListening); + this.server.listen({ port: 4000, ...this.options.listenOptions }); + }); + } + + /** + * Stops the server from accepting new connections. + */ + public disconnect(): Promise { + return new Promise((resolve, reject) => { + this.server.close((error) => (error ? reject(error) : resolve())); + }); + } } diff --git a/packages/plugin-api/src/lib/structures/Middleware.ts b/packages/plugin-api/src/lib/structures/Middleware.ts index 13bc448..085a5aa 100644 --- a/packages/plugin-api/src/lib/structures/Middleware.ts +++ b/packages/plugin-api/src/lib/structures/Middleware.ts @@ -9,31 +9,31 @@ import type { Awaitable } from "./Route"; * (`response.writableEnded`); there is no explicit `next()` callback. */ export abstract class Middleware< - Options extends Middleware.Options = Middleware.Options, + Options extends Middleware.Options = Middleware.Options, > extends Piece { - /** - * The built-in middlewares use the following positions: - * - `headers`: 10 (CORS headers, 404/405 short-circuit) - * - `body`: 20 (`Content-Length` validation) - */ - public readonly position: number; + /** + * The built-in middlewares use the following positions: + * - `headers`: 10 (CORS headers, 404/405 short-circuit) + * - `body`: 20 (`Content-Length` validation) + */ + public readonly position: number; - public constructor(context: Middleware.LoaderContext, options: Options) { - super(context, options); - this.position = options.position ?? 1000; - } + public constructor(context: Middleware.LoaderContext, options: Options) { + super(context, options); + this.position = options.position ?? 1000; + } - public abstract run(request: ApiRequest, response: ApiResponse): Awaitable; + public abstract run(request: ApiRequest, response: ApiResponse): Awaitable; } export namespace Middleware { - export type LoaderContext = Piece.LoaderContext<"middlewares">; + export type LoaderContext = Piece.LoaderContext<"middlewares">; - export interface Options extends PieceOptions { - /** - * Middlewares run in ascending order of this value. - * @default 1000 - */ - position?: number; - } + export interface Options extends PieceOptions { + /** + * Middlewares run in ascending order of this value. + * @default 1000 + */ + position?: number; + } } diff --git a/packages/plugin-api/src/lib/structures/MiddlewareStore.ts b/packages/plugin-api/src/lib/structures/MiddlewareStore.ts index d6b83e9..d5cc2d4 100644 --- a/packages/plugin-api/src/lib/structures/MiddlewareStore.ts +++ b/packages/plugin-api/src/lib/structures/MiddlewareStore.ts @@ -4,44 +4,44 @@ import type { ApiResponse } from "../http/ApiResponse"; import { Middleware } from "./Middleware"; export class MiddlewareStore extends Store { - /** - * Enabled middlewares, kept sorted in ascending {@link Middleware.position} order. - */ - public readonly sortedMiddlewares: Middleware[] = []; - - public constructor() { - super(Middleware, { name: "middlewares" }); - } - - /** - * Runs every enabled middleware in order, stopping early once the response has ended. - */ - public async run(request: ApiRequest, response: ApiResponse): Promise { - for (const middleware of this.sortedMiddlewares) { - if (response.writableEnded) return; - if (middleware.enabled) await middleware.run(request, response); - } - } - - public override set(key: string, value: Middleware): this { - const index = this.sortedMiddlewares.findIndex( - (middleware) => middleware.position >= value.position, - ); - if (index === -1) this.sortedMiddlewares.push(value); - else this.sortedMiddlewares.splice(index, 0, value); - - return super.set(key, value); - } - - public override delete(key: string): boolean { - const index = this.sortedMiddlewares.findIndex((middleware) => middleware.name === key); - if (index !== -1) this.sortedMiddlewares.splice(index, 1); - - return super.delete(key); - } - - public override clear(): void { - this.sortedMiddlewares.length = 0; - super.clear(); - } + /** + * Enabled middlewares, kept sorted in ascending {@link Middleware.position} order. + */ + public readonly sortedMiddlewares: Middleware[] = []; + + public constructor() { + super(Middleware, { name: "middlewares" }); + } + + /** + * Runs every enabled middleware in order, stopping early once the response has ended. + */ + public async run(request: ApiRequest, response: ApiResponse): Promise { + for (const middleware of this.sortedMiddlewares) { + if (response.writableEnded) return; + if (middleware.enabled) await middleware.run(request, response); + } + } + + public override set(key: string, value: Middleware): this { + const index = this.sortedMiddlewares.findIndex( + (middleware) => middleware.position >= value.position, + ); + if (index === -1) this.sortedMiddlewares.push(value); + else this.sortedMiddlewares.splice(index, 0, value); + + return super.set(key, value); + } + + public override delete(key: string): boolean { + const index = this.sortedMiddlewares.findIndex((middleware) => middleware.name === key); + if (index !== -1) this.sortedMiddlewares.splice(index, 1); + + return super.delete(key); + } + + public override clear(): void { + this.sortedMiddlewares.length = 0; + super.clear(); + } } diff --git a/packages/plugin-api/src/lib/structures/Route.ts b/packages/plugin-api/src/lib/structures/Route.ts index 34a928c..5fc07bc 100644 --- a/packages/plugin-api/src/lib/structures/Route.ts +++ b/packages/plugin-api/src/lib/structures/Route.ts @@ -14,65 +14,65 @@ export type Awaitable = T | PromiseLike; * filename suffix (e.g. `hello.post.ts`) implies that HTTP method. */ export abstract class Route extends Piece< - Options, - "routes" + Options, + "routes" > { - /** - * The normalized path segments this route is registered under, e.g. `['users', '[id]']`. - */ - public readonly path: readonly string[]; + /** + * The normalized path segments this route is registered under, e.g. `['users', '[id]']`. + */ + public readonly path: readonly string[]; - /** - * The HTTP methods this route responds to. - */ - public readonly methods: ReadonlySet; + /** + * The HTTP methods this route responds to. + */ + public readonly methods: ReadonlySet; - public constructor(context: Route.LoaderContext, options: Options) { - super(context, options); + public constructor(context: Route.LoaderContext, options: Options) { + super(context, options); - const methods = new Set(options.methods ?? []); + const methods = new Set(options.methods ?? []); - let path: string; - if (options.route) { - path = options.route; - } else { - let name = context.name; - const implied = RouterRoot.extractMethod(name); - if (implied) { - name = name.slice(0, name.length - implied.length - 1); - methods.add(implied); - } + let path: string; + if (options.route) { + path = options.route; + } else { + let name = context.name; + const implied = RouterRoot.extractMethod(name); + if (implied) { + name = name.slice(0, name.length - implied.length - 1); + methods.add(implied); + } - path = RouterRoot.makeRoutePathForPiece(this.location.directories, name); - } + path = RouterRoot.makeRoutePathForPiece(this.location.directories, name); + } - if (methods.size === 0) methods.add("GET"); + if (methods.size === 0) methods.add("GET"); - this.path = RouterRoot.normalize(path); - this.methods = methods; - } + this.path = RouterRoot.normalize(path); + this.methods = methods; + } - /** - * Handles every method this route was registered for. Branch on `request.method` when a route - * needs to support multiple methods. - */ - public abstract run(request: ApiRequest, response: ApiResponse): Awaitable; + /** + * Handles every method this route was registered for. Branch on `request.method` when a route + * needs to support multiple methods. + */ + public abstract run(request: ApiRequest, response: ApiResponse): Awaitable; } export namespace Route { - export type LoaderContext = Piece.LoaderContext<"routes">; + export type LoaderContext = Piece.LoaderContext<"routes">; - export interface Options extends PieceOptions { - /** - * An explicit route path, e.g. `/users/[id]`. If omitted, the path is inferred from the - * piece's directory structure and file name. - */ - route?: `/${string}`; + export interface Options extends PieceOptions { + /** + * An explicit route path, e.g. `/users/[id]`. If omitted, the path is inferred from the + * piece's directory structure and file name. + */ + route?: `/${string}`; - /** - * The HTTP methods this route responds to. If omitted, inferred from a `.` filename - * suffix (e.g. `hello.post.ts`), defaulting to `GET`. - */ - methods?: readonly HttpMethod[]; - } + /** + * The HTTP methods this route responds to. If omitted, inferred from a `.` filename + * suffix (e.g. `hello.post.ts`), defaulting to `GET`. + */ + methods?: readonly HttpMethod[]; + } } diff --git a/packages/plugin-api/src/lib/structures/RouteLoaderStrategy.ts b/packages/plugin-api/src/lib/structures/RouteLoaderStrategy.ts index 0f34233..210af97 100644 --- a/packages/plugin-api/src/lib/structures/RouteLoaderStrategy.ts +++ b/packages/plugin-api/src/lib/structures/RouteLoaderStrategy.ts @@ -6,13 +6,13 @@ import type { RouteStore } from "./RouteStore"; * Keeps {@link RouteStore.router} in sync as route pieces are loaded and unloaded. */ export class RouteLoaderStrategy extends LoaderStrategy { - public override onLoad(store: Store, piece: Route): void { - super.onLoad(store, piece); - (store as RouteStore).router.add(piece); - } + public override onLoad(store: Store, piece: Route): void { + super.onLoad(store, piece); + (store as RouteStore).router.add(piece); + } - public override onUnload(store: Store, piece: Route): void { - super.onUnload(store, piece); - (store as RouteStore).router.remove(piece); - } + public override onUnload(store: Store, piece: Route): void { + super.onUnload(store, piece); + (store as RouteStore).router.remove(piece); + } } diff --git a/packages/plugin-api/src/lib/structures/RouteStore.ts b/packages/plugin-api/src/lib/structures/RouteStore.ts index 2f8c337..00077c6 100644 --- a/packages/plugin-api/src/lib/structures/RouteStore.ts +++ b/packages/plugin-api/src/lib/structures/RouteStore.ts @@ -4,12 +4,12 @@ import { RouteLoaderStrategy } from "./RouteLoaderStrategy"; import { RouterRoot } from "./router/RouterRoot"; export class RouteStore extends Store { - /** - * The trie used to match an incoming request's pathname to a registered {@link Route}. - */ - public readonly router = new RouterRoot(); + /** + * The trie used to match an incoming request's pathname to a registered {@link Route}. + */ + public readonly router = new RouterRoot(); - public constructor() { - super(Route, { name: "routes", strategy: new RouteLoaderStrategy() }); - } + public constructor() { + super(Route, { name: "routes", strategy: new RouteLoaderStrategy() }); + } } diff --git a/packages/plugin-api/src/lib/structures/router/RouterBranch.ts b/packages/plugin-api/src/lib/structures/router/RouterBranch.ts index 325bbca..2a1c23b 100644 --- a/packages/plugin-api/src/lib/structures/router/RouterBranch.ts +++ b/packages/plugin-api/src/lib/structures/router/RouterBranch.ts @@ -7,70 +7,70 @@ import { RouterNode } from "./RouterNode"; * (`[param]`) child, which is only checked after every static child fails to match. */ export class RouterBranch { - public readonly name: string; - public readonly dynamic: boolean; - public readonly parent: RouterBranch | null; - public readonly node: RouterNode; + public readonly name: string; + public readonly dynamic: boolean; + public readonly parent: RouterBranch | null; + public readonly node: RouterNode; - private readonly staticChildren: RouterBranch[] = []; - private dynamicChild: RouterBranch | null = null; + private readonly staticChildren: RouterBranch[] = []; + private dynamicChild: RouterBranch | null = null; - public constructor(name: string, dynamic: boolean, parent: RouterBranch | null) { - this.name = name; - this.dynamic = dynamic; - this.parent = parent; - this.node = new RouterNode(this); - } + public constructor(name: string, dynamic: boolean, parent: RouterBranch | null) { + this.name = name; + this.dynamic = dynamic; + this.parent = parent; + this.node = new RouterNode(this); + } - public matches(part: string): boolean { - return this.dynamic || this.name === part; - } + public matches(part: string): boolean { + return this.dynamic || this.name === part; + } - protected insertAt(parts: readonly string[], index: number, route: Route): RouterNode { - if (index >= parts.length) { - for (const method of route.methods) this.node.set(method, route); - return this.node; - } + protected insertAt(parts: readonly string[], index: number, route: Route): RouterNode { + if (index >= parts.length) { + for (const method of route.methods) this.node.set(method, route); + return this.node; + } - const part = parts[index]; - const dynamic = part.startsWith("[") && part.endsWith("]"); + const part = parts[index]; + const dynamic = part.startsWith("[") && part.endsWith("]"); - if (dynamic) { - if (this.dynamicChild) return this.dynamicChild.insertAt(parts, index + 1, route); + if (dynamic) { + if (this.dynamicChild) return this.dynamicChild.insertAt(parts, index + 1, route); - const branch = new RouterBranch(part.slice(1, -1), true, this); - this.dynamicChild = branch; - return branch.insertAt(parts, index + 1, route); - } + const branch = new RouterBranch(part.slice(1, -1), true, this); + this.dynamicChild = branch; + return branch.insertAt(parts, index + 1, route); + } - const staticChild = this.staticChildren.find((branch) => branch.name === part); - if (staticChild) return staticChild.insertAt(parts, index + 1, route); + const staticChild = this.staticChildren.find((branch) => branch.name === part); + if (staticChild) return staticChild.insertAt(parts, index + 1, route); - const branch = new RouterBranch(part, false, this); - this.staticChildren.push(branch); - return branch.insertAt(parts, index + 1, route); - } + const branch = new RouterBranch(part, false, this); + this.staticChildren.push(branch); + return branch.insertAt(parts, index + 1, route); + } - protected removeAt(parts: readonly string[], index: number, route: Route): boolean { - if (index >= parts.length) { - let removed = false; - for (const method of route.methods) { - if (this.node.delete(method)) removed = true; - } - return removed; - } + protected removeAt(parts: readonly string[], index: number, route: Route): boolean { + if (index >= parts.length) { + let removed = false; + for (const method of route.methods) { + if (this.node.delete(method)) removed = true; + } + return removed; + } - const part = parts[index]; - const child = this.staticChildren.find((branch) => branch.matches(part)) ?? this.dynamicChild; - return child ? child.removeAt(parts, index + 1, route) : false; - } + const part = parts[index]; + const child = this.staticChildren.find((branch) => branch.matches(part)) ?? this.dynamicChild; + return child ? child.removeAt(parts, index + 1, route) : false; + } - protected findAt(parts: readonly string[], index: number): RouterBranch | null { - if (index >= parts.length) return this; + protected findAt(parts: readonly string[], index: number): RouterBranch | null { + if (index >= parts.length) return this; - const part = parts[index]; - const child = this.staticChildren.find((branch) => branch.matches(part)) ?? this.dynamicChild; + const part = parts[index]; + const child = this.staticChildren.find((branch) => branch.matches(part)) ?? this.dynamicChild; - return child ? child.findAt(parts, index + 1) : null; - } + return child ? child.findAt(parts, index + 1) : null; + } } diff --git a/packages/plugin-api/src/lib/structures/router/RouterNode.ts b/packages/plugin-api/src/lib/structures/router/RouterNode.ts index 5fc7a2c..82ad6f9 100644 --- a/packages/plugin-api/src/lib/structures/router/RouterNode.ts +++ b/packages/plugin-api/src/lib/structures/router/RouterNode.ts @@ -6,29 +6,29 @@ import type { RouterBranch } from "./RouterBranch"; * A single path segment's registered routes, keyed by HTTP method. */ export class RouterNode extends Map { - public readonly branch: RouterBranch; + public readonly branch: RouterBranch; - public constructor(branch: RouterBranch) { - super(); - this.branch = branch; - } + public constructor(branch: RouterBranch) { + super(); + this.branch = branch; + } - /** - * Walks up the branch chain collecting the values captured by dynamic (`[param]`) segments. - * @param parts The path segments of the matched request, in the same order used to reach this node. - */ - public extractParameters(parts: readonly string[]): Record { - const params: Record = {}; + /** + * Walks up the branch chain collecting the values captured by dynamic (`[param]`) segments. + * @param parts The path segments of the matched request, in the same order used to reach this node. + */ + public extractParameters(parts: readonly string[]): Record { + const params: Record = {}; - let branch: RouterBranch | null = this.branch; - let index = parts.length - 1; + let branch: RouterBranch | null = this.branch; + let index = parts.length - 1; - while (branch && branch.parent) { - if (branch.dynamic) params[branch.name] = decodeURIComponent(parts[index]); - branch = branch.parent; - index -= 1; - } + while (branch && branch.parent) { + if (branch.dynamic) params[branch.name] = decodeURIComponent(parts[index]); + branch = branch.parent; + index -= 1; + } - return params; - } + return params; + } } diff --git a/packages/plugin-api/src/lib/structures/router/RouterRoot.ts b/packages/plugin-api/src/lib/structures/router/RouterRoot.ts index dda2a03..d250c89 100644 --- a/packages/plugin-api/src/lib/structures/router/RouterRoot.ts +++ b/packages/plugin-api/src/lib/structures/router/RouterRoot.ts @@ -10,58 +10,58 @@ const ROOT_NAME = "::ROOT::"; * add/remove/find API plus the static path-parsing helpers used by {@link Route}'s constructor. */ export class RouterRoot extends RouterBranch { - public constructor() { - super(ROOT_NAME, false, null); - } + public constructor() { + super(ROOT_NAME, false, null); + } - public add(route: Route): RouterNode { - return this.insertAt(route.path, 0, route); - } + public add(route: Route): RouterNode { + return this.insertAt(route.path, 0, route); + } - public remove(route: Route): boolean { - return this.removeAt(route.path, 0, route); - } + public remove(route: Route): boolean { + return this.removeAt(route.path, 0, route); + } - public find(parts: readonly string[]): RouterBranch | null { - return this.findAt(parts, 0); - } + public find(parts: readonly string[]): RouterBranch | null { + return this.findAt(parts, 0); + } - /** - * Splits a `/`-delimited path into its non-empty segments. - */ - public static normalize(path?: string | null): string[] { - if (!path) return []; - return path.split("/").filter((part) => part.length > 0); - } + /** + * Splits a `/`-delimited path into its non-empty segments. + */ + public static normalize(path?: string | null): string[] { + if (!path) return []; + return path.split("/").filter((part) => part.length > 0); + } - /** - * Builds a route path from a piece's directory structure and file name: `(group)`-style - * directories are skipped, and a file named `index` collapses into its parent directory. - */ - public static makeRoutePathForPiece(directories: readonly string[], name: string): string { - const parts: string[] = []; + /** + * Builds a route path from a piece's directory structure and file name: `(group)`-style + * directories are skipped, and a file named `index` collapses into its parent directory. + */ + public static makeRoutePathForPiece(directories: readonly string[], name: string): string { + const parts: string[] = []; - for (const directory of directories) { - const trimmed = directory.trim(); - if (trimmed.length === 0) continue; - if (trimmed.startsWith("(") && trimmed.endsWith(")")) continue; - parts.push(trimmed); - } + for (const directory of directories) { + const trimmed = directory.trim(); + if (trimmed.length === 0) continue; + if (trimmed.startsWith("(") && trimmed.endsWith(")")) continue; + parts.push(trimmed); + } - const trimmedName = name.trim(); - if (trimmedName !== "index") parts.push(trimmedName); + const trimmedName = name.trim(); + if (trimmedName !== "index") parts.push(trimmedName); - return parts.join("/"); - } + return parts.join("/"); + } - /** - * Extracts a trailing `.` suffix from a piece name, e.g. `hello.post` → `POST`. - */ - public static extractMethod(name: string): HttpMethod | null { - const index = name.lastIndexOf("."); - if (index === -1) return null; + /** + * Extracts a trailing `.` suffix from a piece name, e.g. `hello.post` → `POST`. + */ + public static extractMethod(name: string): HttpMethod | null { + const index = name.lastIndexOf("."); + if (index === -1) return null; - const method = name.slice(index + 1).toUpperCase(); - return (HttpMethods as readonly string[]).includes(method) ? (method as HttpMethod) : null; - } + const method = name.slice(index + 1).toUpperCase(); + return (HttpMethods as readonly string[]).includes(method) ? (method as HttpMethod) : null; + } } diff --git a/packages/plugin-api/src/listeners/PluginRouteError.ts b/packages/plugin-api/src/listeners/PluginRouteError.ts index a71a8ca..7ecb9ae 100644 --- a/packages/plugin-api/src/listeners/PluginRouteError.ts +++ b/packages/plugin-api/src/listeners/PluginRouteError.ts @@ -7,12 +7,12 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Logs and responds with a 500 when a route's `run` method throws. */ export class PluginRouteErrorListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.RouteError }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.RouteError }); + } - public override run(error: unknown, _request: ApiRequest, response: ApiResponse): void { - console.error("[plugin-api] Route error:", error); - if (!response.writableEnded) response.error(); - } + public override run(error: unknown, _request: ApiRequest, response: ApiResponse): void { + console.error("[plugin-api] Route error:", error); + if (!response.writableEnded) response.error(); + } } diff --git a/packages/plugin-api/src/listeners/PluginServerMiddlewareError.ts b/packages/plugin-api/src/listeners/PluginServerMiddlewareError.ts index 2ed15d6..a75360f 100644 --- a/packages/plugin-api/src/listeners/PluginServerMiddlewareError.ts +++ b/packages/plugin-api/src/listeners/PluginServerMiddlewareError.ts @@ -7,12 +7,12 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Logs and responds with a 500 when a middleware throws. */ export class PluginServerMiddlewareErrorListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.MiddlewareError }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.MiddlewareError }); + } - public override run(error: unknown, _request: ApiRequest, response: ApiResponse): void { - console.error("[plugin-api] Middleware error:", error); - if (!response.writableEnded) response.error(); - } + public override run(error: unknown, _request: ApiRequest, response: ApiResponse): void { + console.error("[plugin-api] Middleware error:", error); + if (!response.writableEnded) response.error(); + } } diff --git a/packages/plugin-api/src/listeners/PluginServerRequest.ts b/packages/plugin-api/src/listeners/PluginServerRequest.ts index c413cb2..ad0d793 100644 --- a/packages/plugin-api/src/listeners/PluginServerRequest.ts +++ b/packages/plugin-api/src/listeners/PluginServerRequest.ts @@ -11,44 +11,44 @@ import { RouterRoot } from "../lib/structures/router/RouterRoot"; * downstream listeners to handle. */ export class PluginServerRequestListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.Request }); - } - - public override async run(request: ApiRequest, response: ApiResponse): Promise { - const [pathname, querystring] = splitUrl(request.url); - request.query = new URLSearchParams(querystring); - - const parts = RouterRoot.normalize(pathname); - const branch = container.server.routes.router.find(parts); - const node = branch?.node ?? null; - const route = node?.get((request.method ?? "GET") as HttpMethod) ?? null; - - if (node) request.params = node.extractParameters(parts); - request.routerNode = branch; - request.route = route; - - try { - // Middlewares always run, even without a match, since browsers send a pre-flight OPTIONS request. - await container.server.middlewares.run(request, response); - } catch (error) { - container.server.emit(ApiServerEvent.MiddlewareError, error, request, response); - return; - } - - if (response.writableEnded) return; - - if (branch === null) { - container.server.emit(ApiServerEvent.RouterBranchNotFound, request, response); - } else if (route === null) { - container.server.emit(ApiServerEvent.RouterBranchMethodNotAllowed, request, response); - } else { - container.server.emit(ApiServerEvent.RouterFound, request, response); - } - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.Request }); + } + + public override async run(request: ApiRequest, response: ApiResponse): Promise { + const [pathname, querystring] = splitUrl(request.url); + request.query = new URLSearchParams(querystring); + + const parts = RouterRoot.normalize(pathname); + const branch = container.server.routes.router.find(parts); + const node = branch?.node ?? null; + const route = node?.get((request.method ?? "GET") as HttpMethod) ?? null; + + if (node) request.params = node.extractParameters(parts); + request.routerNode = branch; + request.route = route; + + try { + // Middlewares always run, even without a match, since browsers send a pre-flight OPTIONS request. + await container.server.middlewares.run(request, response); + } catch (error) { + container.server.emit(ApiServerEvent.MiddlewareError, error, request, response); + return; + } + + if (response.writableEnded) return; + + if (branch === null) { + container.server.emit(ApiServerEvent.RouterBranchNotFound, request, response); + } else if (route === null) { + container.server.emit(ApiServerEvent.RouterBranchMethodNotAllowed, request, response); + } else { + container.server.emit(ApiServerEvent.RouterFound, request, response); + } + } } function splitUrl(url = "/"): [pathname: string, querystring: string] { - const index = url.indexOf("?"); - return index === -1 ? [url, ""] : [url.slice(0, index), url.slice(index + 1)]; + const index = url.indexOf("?"); + return index === -1 ? [url, ""] : [url.slice(0, index), url.slice(index + 1)]; } diff --git a/packages/plugin-api/src/listeners/PluginServerRouterBranchMethodNotAllowed.ts b/packages/plugin-api/src/listeners/PluginServerRouterBranchMethodNotAllowed.ts index 1ef383a..abed865 100644 --- a/packages/plugin-api/src/listeners/PluginServerRouterBranchMethodNotAllowed.ts +++ b/packages/plugin-api/src/listeners/PluginServerRouterBranchMethodNotAllowed.ts @@ -7,11 +7,11 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Responds with a 405 when the pathname matched but no route handles this HTTP method. */ export class PluginServerRouterBranchMethodNotAllowedListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.RouterBranchMethodNotAllowed }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.RouterBranchMethodNotAllowed }); + } - public override run(_request: ApiRequest, response: ApiResponse): void { - if (!response.writableEnded) response.methodNotAllowed(); - } + public override run(_request: ApiRequest, response: ApiResponse): void { + if (!response.writableEnded) response.methodNotAllowed(); + } } diff --git a/packages/plugin-api/src/listeners/PluginServerRouterBranchNotFound.ts b/packages/plugin-api/src/listeners/PluginServerRouterBranchNotFound.ts index 9f015ca..d1016dc 100644 --- a/packages/plugin-api/src/listeners/PluginServerRouterBranchNotFound.ts +++ b/packages/plugin-api/src/listeners/PluginServerRouterBranchNotFound.ts @@ -7,11 +7,11 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Responds with a 404 when no route matched the request's pathname. */ export class PluginServerRouterBranchNotFoundListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.RouterBranchNotFound }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.RouterBranchNotFound }); + } - public override run(_request: ApiRequest, response: ApiResponse): void { - if (!response.writableEnded) response.notFound(); - } + public override run(_request: ApiRequest, response: ApiResponse): void { + if (!response.writableEnded) response.notFound(); + } } diff --git a/packages/plugin-api/src/listeners/PluginServerRouterFound.ts b/packages/plugin-api/src/listeners/PluginServerRouterFound.ts index dccc125..83a1d10 100644 --- a/packages/plugin-api/src/listeners/PluginServerRouterFound.ts +++ b/packages/plugin-api/src/listeners/PluginServerRouterFound.ts @@ -7,15 +7,15 @@ import { ApiServerEvent } from "../lib/http/ApiServer"; * Invokes the matched route's `run` method, emitting `routeError` if it throws. */ export class PluginServerRouterFoundListener extends Listener { - public constructor(context: Listener.LoaderContext) { - super(context, { emitter: "server", event: ApiServerEvent.RouterFound }); - } + public constructor(context: Listener.LoaderContext) { + super(context, { emitter: "server", event: ApiServerEvent.RouterFound }); + } - public override async run(request: ApiRequest, response: ApiResponse): Promise { - try { - await request.route!.run(request, response); - } catch (error) { - container.server.emit(ApiServerEvent.RouteError, error, request, response); - } - } + public override async run(request: ApiRequest, response: ApiResponse): Promise { + try { + await request.route!.run(request, response); + } catch (error) { + container.server.emit(ApiServerEvent.RouteError, error, request, response); + } + } } diff --git a/packages/plugin-api/src/listeners/_load.ts b/packages/plugin-api/src/listeners/_load.ts index 1ae05d2..a08a59a 100644 --- a/packages/plugin-api/src/listeners/_load.ts +++ b/packages/plugin-api/src/listeners/_load.ts @@ -11,36 +11,36 @@ import { PluginServerRouterFoundListener } from "./PluginServerRouterFound"; * store, targeting the `server` container entry (see {@link ApiServer}). */ export async function loadListeners(): Promise { - await Promise.all([ - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerRequest", - piece: PluginServerRequestListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerRouterFound", - piece: PluginServerRouterFoundListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerRouterBranchNotFound", - piece: PluginServerRouterBranchNotFoundListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerRouterBranchMethodNotAllowed", - piece: PluginServerRouterBranchMethodNotAllowedListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginServerMiddlewareError", - piece: PluginServerMiddlewareErrorListener, - }), - container.stores.loadPiece({ - store: "listeners", - name: "pluginRouteError", - piece: PluginRouteErrorListener, - }), - ]); + await Promise.all([ + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerRequest", + piece: PluginServerRequestListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerRouterFound", + piece: PluginServerRouterFoundListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerRouterBranchNotFound", + piece: PluginServerRouterBranchNotFoundListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerRouterBranchMethodNotAllowed", + piece: PluginServerRouterBranchMethodNotAllowedListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginServerMiddlewareError", + piece: PluginServerMiddlewareErrorListener, + }), + container.stores.loadPiece({ + store: "listeners", + name: "pluginRouteError", + piece: PluginRouteErrorListener, + }), + ]); } diff --git a/packages/plugin-api/src/middlewares/_load.ts b/packages/plugin-api/src/middlewares/_load.ts index 4fc060b..c2e3745 100644 --- a/packages/plugin-api/src/middlewares/_load.ts +++ b/packages/plugin-api/src/middlewares/_load.ts @@ -6,8 +6,8 @@ import { HeadersMiddleware } from "./headers"; * Registers the built-in middlewares (`headers`, `body`) into {@link ApiServer.middlewares}. */ export async function loadMiddlewares(): Promise { - await Promise.all([ - container.stores.loadPiece({ store: "middlewares", name: "headers", piece: HeadersMiddleware }), - container.stores.loadPiece({ store: "middlewares", name: "body", piece: BodyMiddleware }), - ]); + await Promise.all([ + container.stores.loadPiece({ store: "middlewares", name: "headers", piece: HeadersMiddleware }), + container.stores.loadPiece({ store: "middlewares", name: "body", piece: BodyMiddleware }), + ]); } diff --git a/packages/plugin-api/src/middlewares/body.ts b/packages/plugin-api/src/middlewares/body.ts index af0db77..142054f 100644 --- a/packages/plugin-api/src/middlewares/body.ts +++ b/packages/plugin-api/src/middlewares/body.ts @@ -8,16 +8,16 @@ import { Middleware } from "../lib/structures/Middleware"; * Runs second (position 20), after CORS headers have been set. */ export class BodyMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 20 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 20 }); + } - public override run(request: ApiRequest, response: ApiResponse): void { - const limit = container.server.options.maximumBodyLength ?? 1024 * 1024 * 50; - const contentLength = Number(request.headers["content-length"] ?? 0); + public override run(request: ApiRequest, response: ApiResponse): void { + const limit = container.server.options.maximumBodyLength ?? 1024 * 1024 * 50; + const contentLength = Number(request.headers["content-length"] ?? 0); - if (contentLength > limit) { - response.json({ error: "Payload Too Large" }, HttpCodes.PayloadTooLarge); - } - } + if (contentLength > limit) { + response.json({ error: "Payload Too Large" }, HttpCodes.PayloadTooLarge); + } + } } diff --git a/packages/plugin-api/src/middlewares/headers.ts b/packages/plugin-api/src/middlewares/headers.ts index 03ad495..e2f5f36 100644 --- a/packages/plugin-api/src/middlewares/headers.ts +++ b/packages/plugin-api/src/middlewares/headers.ts @@ -9,21 +9,21 @@ import { Middleware } from "../lib/structures/Middleware"; * listeners, so it is not duplicated here. */ export class HeadersMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 10 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 10 }); + } - public override run(request: ApiRequest, response: ApiResponse): void { - const origin = container.server.options.origin ?? "*"; + public override run(request: ApiRequest, response: ApiResponse): void { + const origin = container.server.options.origin ?? "*"; - response.setHeader("Access-Control-Allow-Origin", origin); - response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); - response.setHeader( - "Access-Control-Allow-Methods", - "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS", - ); - response.setHeader("Date", new Date().toUTCString()); + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.setHeader( + "Access-Control-Allow-Methods", + "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS", + ); + response.setHeader("Date", new Date().toUTCString()); - if (request.method === "OPTIONS") response.noContent(); - } + if (request.method === "OPTIONS") response.noContent(); + } } diff --git a/packages/plugin-api/src/register.ts b/packages/plugin-api/src/register.ts index 068eda9..6dc6718 100644 --- a/packages/plugin-api/src/register.ts +++ b/packages/plugin-api/src/register.ts @@ -1,10 +1,10 @@ import { - Client, - container, - Plugin, - postInitialization, - postListen, - type ClientOptions, + Client, + container, + Plugin, + postInitialization, + postListen, + type ClientOptions, } from "@wolfstar/http-framework"; import "./index"; import { ApiServer } from "./lib/http/ApiServer"; @@ -22,29 +22,29 @@ import { loadMiddlewares } from "./middlewares/_load"; * ``` */ export class ApiPlugin extends Plugin { - public static [postInitialization](this: Client, options: ClientOptions): void { - const server = new ApiServer(options.api); + public static [postInitialization](this: Client, options: ClientOptions): void { + const server = new ApiServer(options.api); - container.stores // - .register(server.routes) - .register(server.middlewares); + container.stores // + .register(server.routes) + .register(server.middlewares); - loadListeners().catch((error: unknown) => - console.error("[plugin-api] Failed to load listeners:", error), - ); - loadMiddlewares().catch((error: unknown) => - console.error("[plugin-api] Failed to load middlewares:", error), - ); - } + loadListeners().catch((error: unknown) => + console.error("[plugin-api] Failed to load listeners:", error), + ); + loadMiddlewares().catch((error: unknown) => + console.error("[plugin-api] Failed to load middlewares:", error), + ); + } - public static async [postListen](this: Client, options: ClientOptions): Promise { - if ((options.api?.automaticallyConnect ?? true) === false) return; - await container.server.connect(); - } + public static async [postListen](this: Client, options: ClientOptions): Promise { + if ((options.api?.automaticallyConnect ?? true) === false) return; + await container.server.connect(); + } } Client.plugins.registerPostInitializationHook( - ApiPlugin[postInitialization], - "WolfStar-Api-PostInitialization", + ApiPlugin[postInitialization], + "WolfStar-Api-PostInitialization", ); Client.plugins.registerPostListenHook(ApiPlugin[postListen], "WolfStar-Api-PostListen"); diff --git a/packages/plugin-api/tests/ApiServer.test.ts b/packages/plugin-api/tests/ApiServer.test.ts index 7a4bc97..260ebb9 100644 --- a/packages/plugin-api/tests/ApiServer.test.ts +++ b/packages/plugin-api/tests/ApiServer.test.ts @@ -6,109 +6,109 @@ import { loadListeners } from "../src/listeners/_load"; import { loadMiddlewares } from "../src/middlewares/_load"; class GreetRoute extends Route { - public constructor(context: Route.LoaderContext) { - super(context, { route: "/greet/[name]", methods: ["GET"] }); - } + public constructor(context: Route.LoaderContext) { + super(context, { route: "/greet/[name]", methods: ["GET"] }); + } - public override run(request: Route.Request, response: Route.Response): void { - response.json({ message: `Hello, ${request.params.name}!` }); - } + public override run(request: Route.Request, response: Route.Response): void { + response.json({ message: `Hello, ${request.params.name}!` }); + } } class EchoRoute extends Route { - public constructor(context: Route.LoaderContext) { - super(context, { route: "/echo", methods: ["POST"] }); - } - - public override async run(request: Route.Request, response: Route.Response): Promise { - const body = await request.readBodyJson<{ text: string }>(); - response.json({ echo: body.text }); - } + public constructor(context: Route.LoaderContext) { + super(context, { route: "/echo", methods: ["POST"] }); + } + + public override async run(request: Route.Request, response: Route.Response): Promise { + const body = await request.readBodyJson<{ text: string }>(); + response.json({ echo: body.text }); + } } function rawRequest( - url: string, - options: { method: string; headers?: Record }, + url: string, + options: { method: string; headers?: Record }, ): Promise { - return new Promise((resolve, reject) => { - const req = httpRequest(url, { method: options.method, headers: options.headers }, (res) => { - res.resume(); - res.on("end", () => resolve(res.statusCode ?? 0)); - }); - req.on("error", reject); - req.end(); - }); + return new Promise((resolve, reject) => { + const req = httpRequest(url, { method: options.method, headers: options.headers }, (res) => { + res.resume(); + res.on("end", () => resolve(res.statusCode ?? 0)); + }); + req.on("error", reject); + req.end(); + }); } describe("ApiServer (integration)", () => { - let server: ApiServer; - let baseUrl: string; - - beforeAll(async () => { - server = new ApiServer({ listenOptions: { port: 0 } }); - - container.stores // - .register(server.routes) - .register(server.middlewares); - - await loadMiddlewares(); - await loadListeners(); - - await server.routes.loadPiece({ name: "greet.get", piece: GreetRoute }); - await server.routes.loadPiece({ name: "echo.post", piece: EchoRoute }); - - // `loadPiece` only queues pieces; flushing them is normally `client.load()`'s job. - await container.stores.load(); - - await server.connect(); - - const address = server.server.address(); - if (address === null || typeof address === "string") throw new Error("Expected an AddressInfo"); - baseUrl = `http://127.0.0.1:${address.port}`; - }); - - afterAll(async () => { - await server.disconnect(); - }); - - it("given a dynamic route then extracts the param and responds with json", async () => { - const response = await fetch(`${baseUrl}/greet/world`); - expect(response.status).toBe(200); - expect(response.headers.get("access-control-allow-origin")).toBe("*"); - await expect(response.json()).resolves.toStrictEqual({ message: "Hello, world!" }); - }); - - it("given a POST route then reads the request body", async () => { - const response = await fetch(`${baseUrl}/echo`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ text: "hi" }), - }); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toStrictEqual({ echo: "hi" }); - }); - - it("given an unregistered path then responds with 404", async () => { - const response = await fetch(`${baseUrl}/missing`); - expect(response.status).toBe(404); - }); - - it("given a registered path with the wrong method then responds with 405", async () => { - const response = await fetch(`${baseUrl}/greet/world`, { method: "POST" }); - expect(response.status).toBe(405); - }); - - it("given an OPTIONS preflight request then responds with 204 and CORS headers", async () => { - const response = await fetch(`${baseUrl}/greet/world`, { method: "OPTIONS" }); - expect(response.status).toBe(204); - expect(response.headers.get("access-control-allow-methods")).toContain("GET"); - }); - - it("given a request declaring an oversized body then responds with 413", async () => { - const statusCode = await rawRequest(`${baseUrl}/echo`, { - method: "POST", - headers: { "content-type": "application/json", "content-length": String(1024 * 1024 * 100) }, - }); - expect(statusCode).toBe(413); - }); + let server: ApiServer; + let baseUrl: string; + + beforeAll(async () => { + server = new ApiServer({ listenOptions: { port: 0 } }); + + container.stores // + .register(server.routes) + .register(server.middlewares); + + await loadMiddlewares(); + await loadListeners(); + + await server.routes.loadPiece({ name: "greet.get", piece: GreetRoute }); + await server.routes.loadPiece({ name: "echo.post", piece: EchoRoute }); + + // `loadPiece` only queues pieces; flushing them is normally `client.load()`'s job. + await container.stores.load(); + + await server.connect(); + + const address = server.server.address(); + if (address === null || typeof address === "string") throw new Error("Expected an AddressInfo"); + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await server.disconnect(); + }); + + it("given a dynamic route then extracts the param and responds with json", async () => { + const response = await fetch(`${baseUrl}/greet/world`); + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + await expect(response.json()).resolves.toStrictEqual({ message: "Hello, world!" }); + }); + + it("given a POST route then reads the request body", async () => { + const response = await fetch(`${baseUrl}/echo`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: "hi" }), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ echo: "hi" }); + }); + + it("given an unregistered path then responds with 404", async () => { + const response = await fetch(`${baseUrl}/missing`); + expect(response.status).toBe(404); + }); + + it("given a registered path with the wrong method then responds with 405", async () => { + const response = await fetch(`${baseUrl}/greet/world`, { method: "POST" }); + expect(response.status).toBe(405); + }); + + it("given an OPTIONS preflight request then responds with 204 and CORS headers", async () => { + const response = await fetch(`${baseUrl}/greet/world`, { method: "OPTIONS" }); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-methods")).toContain("GET"); + }); + + it("given a request declaring an oversized body then responds with 413", async () => { + const statusCode = await rawRequest(`${baseUrl}/echo`, { + method: "POST", + headers: { "content-type": "application/json", "content-length": String(1024 * 1024 * 100) }, + }); + expect(statusCode).toBe(413); + }); }); diff --git a/packages/plugin-api/tests/MiddlewareStore.test.ts b/packages/plugin-api/tests/MiddlewareStore.test.ts index 4c6bccc..000a260 100644 --- a/packages/plugin-api/tests/MiddlewareStore.test.ts +++ b/packages/plugin-api/tests/MiddlewareStore.test.ts @@ -6,81 +6,81 @@ import { MiddlewareStore } from "../src/lib/structures/MiddlewareStore"; const calls: string[] = []; class FirstMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 10 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 10 }); + } - public override run(): void { - calls.push("first"); - } + public override run(): void { + calls.push("first"); + } } class SecondMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 20 }); - } - - public override run(_request: ApiRequest, response: ApiResponse): void { - calls.push("second"); - response.end(); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 20 }); + } + + public override run(_request: ApiRequest, response: ApiResponse): void { + calls.push("second"); + response.end(); + } } class ThirdMiddleware extends Middleware { - public constructor(context: Middleware.LoaderContext) { - super(context, { position: 30 }); - } + public constructor(context: Middleware.LoaderContext) { + super(context, { position: 30 }); + } - public override run(): void { - calls.push("third"); - } + public override run(): void { + calls.push("third"); + } } describe("MiddlewareStore", () => { - beforeEach(() => { - calls.length = 0; - }); - - it("given middlewares registered out of order then sorts them ascending by position", async () => { - const store = new MiddlewareStore(); - await store.loadPiece({ name: "third", piece: ThirdMiddleware }); - await store.loadPiece({ name: "first", piece: FirstMiddleware }); - await store.loadPiece({ name: "second", piece: SecondMiddleware }); - await store.loadAll(); - - expect(store.sortedMiddlewares.map((middleware) => middleware.position)).toStrictEqual([ - 10, 20, 30, - ]); - }); - - it("given a middleware that ends the response then stops running the remaining chain", async () => { - const store = new MiddlewareStore(); - await store.loadPiece({ name: "first", piece: FirstMiddleware }); - await store.loadPiece({ name: "second", piece: SecondMiddleware }); - await store.loadPiece({ name: "third", piece: ThirdMiddleware }); - await store.loadAll(); - - const response = { - writableEnded: false, - end: vi.fn(function (this: { writableEnded: boolean }) { - this.writableEnded = true; - }), - } as unknown as ApiResponse; - - await store.run({} as ApiRequest, response); - - // `second` ends the response, so `third` (checked before it would run) never fires. - expect(calls).toStrictEqual(["first", "second"]); - }); - - it("given a removed middleware then no longer runs it", async () => { - const store = new MiddlewareStore(); - await store.loadPiece({ name: "first", piece: FirstMiddleware }); - await store.loadPiece({ name: "second", piece: SecondMiddleware }); - await store.loadAll(); - store.delete("second"); - - expect(store.sortedMiddlewares).toHaveLength(1); - expect(store.sortedMiddlewares[0]!.name).toBe("first"); - }); + beforeEach(() => { + calls.length = 0; + }); + + it("given middlewares registered out of order then sorts them ascending by position", async () => { + const store = new MiddlewareStore(); + await store.loadPiece({ name: "third", piece: ThirdMiddleware }); + await store.loadPiece({ name: "first", piece: FirstMiddleware }); + await store.loadPiece({ name: "second", piece: SecondMiddleware }); + await store.loadAll(); + + expect(store.sortedMiddlewares.map((middleware) => middleware.position)).toStrictEqual([ + 10, 20, 30, + ]); + }); + + it("given a middleware that ends the response then stops running the remaining chain", async () => { + const store = new MiddlewareStore(); + await store.loadPiece({ name: "first", piece: FirstMiddleware }); + await store.loadPiece({ name: "second", piece: SecondMiddleware }); + await store.loadPiece({ name: "third", piece: ThirdMiddleware }); + await store.loadAll(); + + const response = { + writableEnded: false, + end: vi.fn(function (this: { writableEnded: boolean }) { + this.writableEnded = true; + }), + } as unknown as ApiResponse; + + await store.run({} as ApiRequest, response); + + // `second` ends the response, so `third` (checked before it would run) never fires. + expect(calls).toStrictEqual(["first", "second"]); + }); + + it("given a removed middleware then no longer runs it", async () => { + const store = new MiddlewareStore(); + await store.loadPiece({ name: "first", piece: FirstMiddleware }); + await store.loadPiece({ name: "second", piece: SecondMiddleware }); + await store.loadAll(); + store.delete("second"); + + expect(store.sortedMiddlewares).toHaveLength(1); + expect(store.sortedMiddlewares[0]!.name).toBe("first"); + }); }); diff --git a/packages/plugin-api/tests/router.test.ts b/packages/plugin-api/tests/router.test.ts index 43c8693..ad25477 100644 --- a/packages/plugin-api/tests/router.test.ts +++ b/packages/plugin-api/tests/router.test.ts @@ -2,87 +2,87 @@ import type { Route } from "../src/lib/structures/Route"; import { RouterRoot } from "../src/lib/structures/router/RouterRoot"; function fakeRoute(path: readonly string[], methods: readonly string[]): Route { - return { path, methods: new Set(methods) } as unknown as Route; + return { path, methods: new Set(methods) } as unknown as Route; } describe("RouterRoot static helpers", () => { - it("given a path with slashes then normalizes to its non-empty segments", () => { - expect(RouterRoot.normalize("/users/[id]/posts/")).toStrictEqual(["users", "[id]", "posts"]); - }); - - it("given a nullish path then normalizes to an empty array", () => { - expect(RouterRoot.normalize(undefined)).toStrictEqual([]); - expect(RouterRoot.normalize(null)).toStrictEqual([]); - expect(RouterRoot.normalize("")).toStrictEqual([]); - }); - - it("given directories with a (group) segment then skips it", () => { - expect(RouterRoot.makeRoutePathForPiece(["(v1)", "users"], "profile")).toBe("users/profile"); - }); - - it("given a piece named index then collapses it into its parent", () => { - expect(RouterRoot.makeRoutePathForPiece(["users"], "index")).toBe("users"); - }); - - it("given a name with a method suffix then extracts the uppercased method", () => { - expect(RouterRoot.extractMethod("hello.post")).toBe("POST"); - expect(RouterRoot.extractMethod("hello")).toBeNull(); - expect(RouterRoot.extractMethod("hello.notamethod")).toBeNull(); - }); + it("given a path with slashes then normalizes to its non-empty segments", () => { + expect(RouterRoot.normalize("/users/[id]/posts/")).toStrictEqual(["users", "[id]", "posts"]); + }); + + it("given a nullish path then normalizes to an empty array", () => { + expect(RouterRoot.normalize(undefined)).toStrictEqual([]); + expect(RouterRoot.normalize(null)).toStrictEqual([]); + expect(RouterRoot.normalize("")).toStrictEqual([]); + }); + + it("given directories with a (group) segment then skips it", () => { + expect(RouterRoot.makeRoutePathForPiece(["(v1)", "users"], "profile")).toBe("users/profile"); + }); + + it("given a piece named index then collapses it into its parent", () => { + expect(RouterRoot.makeRoutePathForPiece(["users"], "index")).toBe("users"); + }); + + it("given a name with a method suffix then extracts the uppercased method", () => { + expect(RouterRoot.extractMethod("hello.post")).toBe("POST"); + expect(RouterRoot.extractMethod("hello")).toBeNull(); + expect(RouterRoot.extractMethod("hello.notamethod")).toBeNull(); + }); }); describe("RouterRoot trie", () => { - it("given a static route then matches its exact path", () => { - const root = new RouterRoot(); - const route = fakeRoute(["health"], ["GET"]); - root.add(route); - - const branch = root.find(["health"]); - expect(branch?.node.get("GET")).toBe(route); - }); - - it("given no matching path then returns null", () => { - const root = new RouterRoot(); - root.add(fakeRoute(["health"], ["GET"])); - - expect(root.find(["missing"])).toBeNull(); - }); - - it("given a dynamic segment then matches and exposes the branch for param extraction", () => { - const root = new RouterRoot(); - const route = fakeRoute(["users", "[id]"], ["GET"]); - root.add(route); - - const branch = root.find(["users", "42"]); - expect(branch?.node.get("GET")).toBe(route); - expect(branch?.node.extractParameters(["users", "42"])).toStrictEqual({ id: "42" }); - }); - - it("given a static child and a dynamic child then prefers the static match", () => { - const root = new RouterRoot(); - const staticRoute = fakeRoute(["users", "me"], ["GET"]); - const dynamicRoute = fakeRoute(["users", "[id]"], ["GET"]); - root.add(dynamicRoute); - root.add(staticRoute); - - expect(root.find(["users", "me"])?.node.get("GET")).toBe(staticRoute); - expect(root.find(["users", "42"])?.node.get("GET")).toBe(dynamicRoute); - }); - - it("given a matched path but an unregistered method then the node has no entry for it", () => { - const root = new RouterRoot(); - root.add(fakeRoute(["health"], ["GET"])); - - const branch = root.find(["health"]); - expect(branch?.node.get("POST")).toBeUndefined(); - }); - - it("given a removed route then it no longer matches", () => { - const root = new RouterRoot(); - const route = fakeRoute(["health"], ["GET"]); - root.add(route); - root.remove(route); - - expect(root.find(["health"])?.node.get("GET")).toBeUndefined(); - }); + it("given a static route then matches its exact path", () => { + const root = new RouterRoot(); + const route = fakeRoute(["health"], ["GET"]); + root.add(route); + + const branch = root.find(["health"]); + expect(branch?.node.get("GET")).toBe(route); + }); + + it("given no matching path then returns null", () => { + const root = new RouterRoot(); + root.add(fakeRoute(["health"], ["GET"])); + + expect(root.find(["missing"])).toBeNull(); + }); + + it("given a dynamic segment then matches and exposes the branch for param extraction", () => { + const root = new RouterRoot(); + const route = fakeRoute(["users", "[id]"], ["GET"]); + root.add(route); + + const branch = root.find(["users", "42"]); + expect(branch?.node.get("GET")).toBe(route); + expect(branch?.node.extractParameters(["users", "42"])).toStrictEqual({ id: "42" }); + }); + + it("given a static child and a dynamic child then prefers the static match", () => { + const root = new RouterRoot(); + const staticRoute = fakeRoute(["users", "me"], ["GET"]); + const dynamicRoute = fakeRoute(["users", "[id]"], ["GET"]); + root.add(dynamicRoute); + root.add(staticRoute); + + expect(root.find(["users", "me"])?.node.get("GET")).toBe(staticRoute); + expect(root.find(["users", "42"])?.node.get("GET")).toBe(dynamicRoute); + }); + + it("given a matched path but an unregistered method then the node has no entry for it", () => { + const root = new RouterRoot(); + root.add(fakeRoute(["health"], ["GET"])); + + const branch = root.find(["health"]); + expect(branch?.node.get("POST")).toBeUndefined(); + }); + + it("given a removed route then it no longer matches", () => { + const root = new RouterRoot(); + const route = fakeRoute(["health"], ["GET"]); + root.add(route); + root.remove(route); + + expect(root.find(["health"])?.node.get("GET")).toBeUndefined(); + }); }); diff --git a/packages/plugin-api/tsdown.config.ts b/packages/plugin-api/tsdown.config.ts index 83b7128..9e8b6c5 100644 --- a/packages/plugin-api/tsdown.config.ts +++ b/packages/plugin-api/tsdown.config.ts @@ -1,12 +1,12 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/register.ts"], - format: "esm", - target: "es2022", - dts: true, - clean: true, - sourcemap: true, - fixedExtension: false, - outDir: "dist", + entry: ["src/index.ts", "src/register.ts"], + format: "esm", + target: "es2022", + dts: true, + clean: true, + sourcemap: true, + fixedExtension: false, + outDir: "dist", });