Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/verify-interface/scenarios-first.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion docs/4.parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion skills/api-interface/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions src/parameter-decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,14 +322,29 @@ 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.
*
* Provides access to the WebSocket connection object for WebSocket handlers.
* 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
Expand All @@ -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),
);

/**
Expand Down
39 changes: 37 additions & 2 deletions src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -63,6 +68,26 @@ type RouteArguments = [
priority?: HandlerPriority,
];

const CONNECTION_MODES: ReadonlySet<RouteHandlerMode> = new Set([
"websocket",
"monitor",
]);

function refuseConnectionOutsideItsModes(
parameters: Array<ComputedParameter | null>,
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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
119 changes: 119 additions & 0 deletions src/tests/connection.test.ts
Original file line number Diff line number Diff line change
@@ -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)));
});
});
Loading