From cde88e4f3115c51582d068caa87c63052207344a Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 8 Nov 2026 07:51:32 +0000 Subject: [PATCH 1/3] test(parameters): refuse @Connection outside WebSocket and monitor handlers --- src/tests/connection.test.ts | 119 +++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/tests/connection.test.ts diff --git a/src/tests/connection.test.ts b/src/tests/connection.test.ts new file mode 100644 index 0000000..3801c8a --- /dev/null +++ b/src/tests/connection.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert"; +import type WebSocket from "ws"; + +import { + Connection, + Controller, + Get, + getRegisteredRouteHandlers, + Monitor, + Postfix, + Prefix, + UnregisterRoute, + WebsocketHandler, +} from "../index"; + +const ROOT = "/connection-mode"; + +function handlersUnder(location: string) { + return getRegisteredRouteHandlers().filter(({ handler }) => + handler.location.startsWith(location), + ); +} + +describe("@Connection handler modes", () => { + it("refuses a @Get handler and registers nothing", () => { + assert.throws( + () => { + class Chat extends Controller(`${ROOT}/get`) { + @Get("") + plain(@Connection() connection: WebSocket) { + return { connection }; + } + } + return Chat; + }, + { + name: "TypeError", + message: + "@Connection applies in websocket and monitor mode; parameter 0 of Chat.plain, declared on it or on a parent controller's method of that name, registers in handler mode", + }, + ); + assert.deepEqual(handlersUnder(`${ROOT}/get`), []); + }); + + it("refuses @Prefix and @Postfix handlers, which never see a connection", () => { + assert.throws(() => { + class Before extends Controller(`${ROOT}/prefix`) { + @Prefix("get", "") + check(@Connection() connection: WebSocket) { + return connection; + } + } + return Before; + }, /of Before\.check, .* registers in prefix mode/); + assert.throws(() => { + class After extends Controller(`${ROOT}/postfix`) { + @Postfix("get", "") + check(@Connection() connection: WebSocket) { + return connection; + } + } + return After; + }, /of After\.check, .* registers in postfix mode/); + assert.deepEqual(handlersUnder(`${ROOT}/prefix`), []); + assert.deepEqual(handlersUnder(`${ROOT}/postfix`), []); + }); + + it("accepts a @Monitor handler, which runs after a WebSocket upgrade", () => { + class Watch extends Controller(`${ROOT}/monitor`) { + @Monitor("get", "") + observe(@Connection() connection: WebSocket) { + return connection; + } + } + const registered = handlersUnder(`${ROOT}/monitor`); + assert.deepEqual( + registered.map(({ handler }) => [handler.mode, handler.controller.name]), + [["monitor", Watch.name]], + ); + registered.forEach(({ id }) => UnregisterRoute(Number(id))); + }); + + it("refuses @Connection after a default-valued parameter of a @Get handler", () => { + assert.throws(() => { + class Flagged extends Controller(`${ROOT}/flagged`) { + @Get("") + open(flag = false, @Connection() connection?: WebSocket) { + return [flag, connection]; + } + } + return Flagged; + }, /parameter 1 of Flagged\.open, .* registers in handler mode/); + assert.deepEqual(handlersUnder(`${ROOT}/flagged`), []); + }); + + it("refuses a child @Get override that inherits the parent's @Connection", () => { + class Room extends Controller(`${ROOT}/room`) { + @WebsocketHandler("") + open(@Connection() connection: WebSocket): number { + return connection.readyState; + } + } + assert.throws(() => { + class Lobby extends Room.extend("lobby") { + @Get("") + override open() { + return 0; + } + } + return Lobby; + }, /parameter 0 of Lobby\.open, declared on it or on a parent controller's method of that name, registers in handler mode/); + const registered = handlersUnder(`${ROOT}/room`); + assert.deepEqual( + registered.map(({ handler }) => [handler.mode, handler.controller.name]), + [["websocket", Room.name]], + ); + registered.forEach(({ id }) => UnregisterRoute(Number(id))); + }); +}); From 477d5fedf8285f8f64495fc6b73402297927b1e0 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 8 Nov 2026 07:51:32 +0000 Subject: [PATCH 2/3] fix(parameters): refuse @Connection on handlers that never receive a connection --- docs/4.parameters.md | 2 +- skills/api-interface/SKILL.md | 5 ++++- src/parameter-decorators.ts | 19 +++++++++++++++-- src/routes.ts | 39 +++++++++++++++++++++++++++++++++-- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/docs/4.parameters.md b/docs/4.parameters.md index 0982cce..94186ec 100644 --- a/docs/4.parameters.md +++ b/docs/4.parameters.md @@ -209,7 +209,7 @@ class StreamController extends Controller("/stream") { ### `@Connection` -`@Connection()` provides the WebSocket connection object of the implementation. Use it only on a `@WebsocketHandler` handler. +`@Connection()` provides the WebSocket connection object of the implementation. On a handler parameter it applies in `websocket` mode, set by `@WebsocketHandler`, and in `monitor` mode, set by `@Monitor`. A monitor receives the connection for a WebSocket request and `undefined` for an HTTP request. In `handler` mode, which `@Get`, `@Post`, `@Put` and `@Delete` set unless given another mode, and in the `prefix` and `postfix` modes of `@Prefix` and `@Postfix`, the route decorator throws a `TypeError` when the class is defined and registers no route for that handler. A child controller method inherits the parameter decorators of the parent method with the same name, so a `@Get` override of a parent `@WebsocketHandler` method that uses `@Connection` throws too: give the HTTP handler another name. On a class property, the value is `undefined` outside a WebSocket request. ```typescript import { diff --git a/skills/api-interface/SKILL.md b/skills/api-interface/SKILL.md index 32a66ba..3ca5992 100644 --- a/skills/api-interface/SKILL.md +++ b/skills/api-interface/SKILL.md @@ -107,7 +107,10 @@ Sub-paths: `class Sub extends BookController.extend("archive") {}` mounts at Nothing is sent until the handler returns `context.response`: write the stream from a background task and return at once, never await the writes. - WebSocket routes use `@WebsocketHandler(location)` with `@Connection()` to - get the socket; they are matched as GET upgrades. + get the socket; they are matched as GET upgrades. `@Connection()` on a + handler parameter applies in `websocket` and `monitor` mode; in `handler`, + `prefix` or `postfix` mode the route decorator throws a `TypeError` when the + class is defined. A monitor gets `undefined` for an HTTP request. - Custom injection: build your own decorators with `SetParameterProvider` / `AddParameterModifier` (provider -> modifiers chain, each may be async), wrapped with `MakeParameterDecorator` from diff --git a/src/parameter-decorators.ts b/src/parameter-decorators.ts index 05a2436..19477d0 100644 --- a/src/parameter-decorators.ts +++ b/src/parameter-decorators.ts @@ -322,6 +322,17 @@ export const Transform = MakeParameterAndPropertyDecorator( AddParameterModifier(target, key, param, transformer), ); +/** + * Provider that {@link Connection} sets, compared by `ProcessCallback` + * to refuse the decorator on handlers that never receive a connection. + * + * @param context Request context + * @returns The connection the implementation stored on the context + */ +export function connectionProvider(context: RequestContext) { + return context.connection; +} + /** * Parameter Provider: WebSocket Connection. * @@ -329,7 +340,11 @@ export const Transform = MakeParameterAndPropertyDecorator( * This allows you to interact with the WebSocket connection, such as sending * messages to the client or handling connection events. * - * This provider can only be used with handlers decorated with @WebsocketHandler. + * On a handler parameter, it applies in `websocket` mode (`@WebsocketHandler`) + * and `monitor` mode (`@Monitor`); a monitor receives `undefined` for an HTTP + * request. In `handler`, `prefix` or `postfix` mode, the route decorator throws + * a `TypeError` at class definition and registers no route for that handler. + * On a class property, it is `undefined` outside a WebSocket request. * * Example: * ```ts @@ -348,7 +363,7 @@ export const Transform = MakeParameterAndPropertyDecorator( */ export const Connection = MakeParameterAndPropertyDecorator( (target, key, param) => - SetParameterProvider(target, key, param, (context) => context.connection), + SetParameterProvider(target, key, param, connectionProvider), ); /** diff --git a/src/routes.ts b/src/routes.ts index 2c351d6..265984a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -4,7 +4,12 @@ import type { DecoratorTarget } from "@antelopejs/interface-core/decorators"; import { isSymbol } from "./guards"; import { routesProxy } from "./proxies"; -import { ControllerMeta, isControllerClass } from "./controller"; +import { + type ComputedParameter, + ControllerMeta, + isControllerClass, +} from "./controller"; +import { connectionProvider } from "./parameter-decorators"; import { type HandlerPriority, type RouteHandler, @@ -63,6 +68,26 @@ type RouteArguments = [ priority?: HandlerPriority, ]; +const CONNECTION_MODES: ReadonlySet = new Set([ + "websocket", + "monitor", +]); + +function refuseConnectionOutsideItsModes( + parameters: Array, + mode: RouteHandlerMode, + handlerName: string, +) { + const index = parameters.findIndex( + (parameter) => parameter?.provider === connectionProvider, + ); + if (!CONNECTION_MODES.has(mode) && index !== -1) { + throw new TypeError( + `@Connection applies in websocket and monitor mode; parameter ${index} of ${handlerName}, declared on it or on a parent controller's method of that name, registers in ${mode} mode`, + ); + } +} + function handlerSegment( controllerName: string, key: PropertyKey, @@ -86,6 +111,9 @@ function handlerSegment( * priority are optional * @returns `{ id }`, an object because {@link Route} returns it as a method * decorator's property descriptor + * @throws {TypeError} When a parameter of the handler receives `@Connection`, + * set on it or inherited from a parent controller's method of the same name, + * and the mode is neither `websocket` nor `monitor` */ export function ProcessCallback( target: DecoratorTarget, @@ -113,13 +141,20 @@ export function ProcessCallback( "/", ); + const parameters = meta.getParameterArray(key); + refuseConnectionOutsideItsModes( + parameters, + mode, + `${controller.name}.${String(key)}`, + ); + return { id: RegisterRoute({ mode, method, location: fullLocation, callback, - parameters: meta.getParameterArray(key), + parameters, properties: meta.computed_props, controller, priority, From 19dc6bd49b2a87e5c53028d3606088dd10aa62d4 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Sun, 8 Nov 2026 07:51:32 +0000 Subject: [PATCH 3/3] test(verify): inject @Connection through a WebSocket handler --- .agents/skills/verify-interface/features/parameter-sources.md | 2 +- .agents/skills/verify-interface/scenarios-first.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/verify-interface/features/parameter-sources.md b/.agents/skills/verify-interface/features/parameter-sources.md index 6bb961f..f1c1dd8 100644 --- a/.agents/skills/verify-interface/features/parameter-sources.md +++ b/.agents/skills/verify-interface/features/parameter-sources.md @@ -19,7 +19,7 @@ A module author injects request data into handler arguments and class properties ## Sub-features - `parameter-sources`: a missing value is undefined (`[]` for `@MultiParameter`), `?page` and `?page=` give `true`, a repeated query parameter gives its first value, header names are case-insensitive, and the same decorators fill class properties. -- `injected-values`: `@Context` is the request context, `@Result` its response, `@Connection` its connection, `@WriteStream` puts the response in stream mode with the given content type, `@BodyStream` yields the body chunks. +- `injected-values`: `@Context` is the request context, `@Result` its response, `@Connection` on a `@WebsocketHandler` its connection, `@Connection` on a `@Get` handler throws a `TypeError`, `@WriteStream` puts the response in stream mode with the given content type, `@BodyStream` yields the body chunks. ## How to get to it (user POV) diff --git a/.agents/skills/verify-interface/scenarios-first.ts b/.agents/skills/verify-interface/scenarios-first.ts index 607b1eb..0f49936 100644 --- a/.agents/skills/verify-interface/scenarios-first.ts +++ b/.agents/skills/verify-interface/scenarios-first.ts @@ -17,8 +17,8 @@ export const FIRST_SCENARIOS = { printed: "{\"keys\":[\"internal\",\"DEFAULT_REQUEST_BODY_LIMIT\",\"HTTPResult\",\"HandlerPriority\",\"ControllerMeta\",\"GetControllerInstance\",\"Listen\",\"GetCorsConfig\",\"SetCorsConfig\",\"routesProxy\",\"Route\",\"Delete\",\"Get\",\"Post\",\"Put\",\"Prefix\",\"Postfix\",\"Monitor\",\"WebsocketHandler\",\"RawBody\",\"JSONBody\",\"BodyStream\",\"Parameter\",\"Context\",\"Result\",\"WriteStream\",\"Transform\",\"Connection\",\"MultiParameter\",\"computeParameter\",\"Controller\",\"PartialController\",\"getRegisteredRouteHandlers\",\"RegisterRoute\",\"UnregisterRoute\",\"getRegisteredRoutes\",\"ProcessCallback\",\"ReadBody\",\"SetParameterProvider\",\"AddParameterModifier\",\"StreamBody\"],\"kinds\":[\"function\",\"function\",\"object\",1048576,\"object\"],\"descriptor\":[true,true]}", }, "injected": { - body: "const c1 = ctx();\nR.context = [(await compute([a.Context()], c1)) === c1, (await compute([a.Result()], c1)) === c1.response, (await compute([a.Connection()], c1))];\nconst c2 = ctx(); const ws = await compute([a.WriteStream(\"text/event-stream\")], c2);\nR.writeStream = [ws instanceof PassThrough, c2.response.isStream(), c2.response.getContentType(), c2.response.getStatus()];\nconst c3 = ctx(); await compute([a.WriteStream()], c3); R.writeStreamDefault = [c3.response.getContentType(), c3.response.getStatus()];\nconst bs = await compute([a.BodyStream()], ctx({ rawRequest: mk([Buffer.from(\"q\"), Buffer.from(\"r\")]) })); const got = []; for await (const chunk of bs) got.push(Buffer.from(chunk).toString()); R.bodyStream = got;\n", - printed: "{\"context\":[true,true,{\"c\":1}],\"writeStream\":[true,true,\"text/event-stream\",200],\"writeStreamDefault\":[\"text/plain\",200],\"bodyStream\":[\"q\",\"r\"]}", + body: "const c1 = ctx();\na.Connection()(C.prototype, \"w0\", 0); deco(a.WebsocketHandler(\"w0\"), C, \"w0\");\nR.context = [(await compute([a.Context()], c1)) === c1, (await compute([a.Result()], c1)) === c1.response, (await a.computeParameter(c1, routeOf(\"w0\").parameters[0], new C()))];\nR.connectionOnGet = await settle(compute([a.Connection()]), (e) => e.name);\nconst c2 = ctx(); const ws = await compute([a.WriteStream(\"text/event-stream\")], c2);\nR.writeStream = [ws instanceof PassThrough, c2.response.isStream(), c2.response.getContentType(), c2.response.getStatus()];\nconst c3 = ctx(); await compute([a.WriteStream()], c3); R.writeStreamDefault = [c3.response.getContentType(), c3.response.getStatus()];\nconst bs = await compute([a.BodyStream()], ctx({ rawRequest: mk([Buffer.from(\"q\"), Buffer.from(\"r\")]) })); const got = []; for await (const chunk of bs) got.push(Buffer.from(chunk).toString()); R.bodyStream = got;\n", + printed: "{\"context\":[true,true,{\"c\":1}],\"connectionOnGet\":[\"rejected\",\"TypeError\"],\"writeStream\":[true,true,\"text/event-stream\",200],\"writeStreamDefault\":[\"text/plain\",200],\"bodyStream\":[\"q\",\"r\"]}", }, "json-body": { body: "const jb = async (chunks, dec = a.JSONBody()) => settle(compute([dec], ctx({ rawRequest: mk(chunks) })), (e) => e instanceof a.HTTPResult ? status(e) : e.name);\nR.json = [await jb([Buffer.from('{\"a\":[1,null]}')]), await jb([Buffer.from(\"[1]\")]), await jb([Buffer.from(\"null\")]), await jb([]), await jb([Buffer.from(\"{bad\")]), await jb([Buffer.alloc(20, 32)], a.JSONBody(5))];\nconst rb = async (chunks, dec) => settle(compute([dec], ctx({ rawRequest: mk(chunks) })), status);\nR.raw = [await rb([Buffer.from(\"xy\")], a.RawBody()), await rb([Buffer.from(\"xyz\")], a.RawBody(2))];\nR.raw = R.raw.map((x) => x[0] === \"ok\" ? [\"ok\", x[1].toString()] : x);\nconst quoted = (bytes) => Buffer.from('\"' + \"a\".repeat(bytes - 2) + '\"');\nconst MIB = 1024 * 1024;\nconst attempt = async (dec, bytes) => { const r = await settle(compute([dec], ctx({ rawRequest: mk([bytes === \"huge\" ? Buffer.alloc(MIB + 1, 32) : quoted(bytes)]) })), (e) => e instanceof a.HTTPResult ? status(e)[1] : e.name); return r[0] === \"ok\" ? [\"ok\", Buffer.isBuffer(r[1]) || typeof r[1] === \"string\" ? r[1].length : r[1]] : r; };\nR.defaults = [await attempt(a.RawBody(), 2000), await attempt(a.RawBody(), \"huge\"), await attempt(a.JSONBody(), 2000), await attempt(a.JSONBody(), \"huge\")];\nR.widened = [await attempt(a.JSONBody(2 * MIB), MIB + 500), await attempt(a.RawBody(2 * MIB), MIB + 500), await attempt(a.JSONBody(2 * MIB), 2 * MIB + 10)];\nconst exactly = async (dec, bytes) => { const r = await settle(compute([dec], ctx({ rawRequest: mk([bytes]) })), (e) => e instanceof a.HTTPResult ? status(e)[1] : e.name); return r[0] === \"ok\" ? [\"ok\", Buffer.isBuffer(r[1]) || typeof r[1] === \"string\" ? r[1].length : r[1]] : r; };\nR.exactLimit = [await exactly(a.JSONBody(), quoted(MIB)), await exactly(a.RawBody(), Buffer.alloc(MIB, 97)), await exactly(a.JSONBody(), quoted(MIB + 1)), await exactly(a.RawBody(), Buffer.alloc(MIB + 1, 97)), await exactly(a.RawBody(7), Buffer.alloc(7, 97)), await exactly(a.RawBody(7), Buffer.alloc(8, 97))];\n",