The class was already implemented in "https://deno.land/std@0.144.0/streams/conversion.ts". So, I litterally lost 1 day of work! I hate myself.
377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
|
|
|
|
import type { Application, State } from "./application.ts";
|
|
import {
|
|
createHttpError,
|
|
type ErrorStatus,
|
|
type HttpErrorOptions,
|
|
KeyStack,
|
|
SecureCookieMap,
|
|
ServerSentEventStreamTarget,
|
|
type ServerSentEventTarget,
|
|
type ServerSentEventTargetOptions,
|
|
} from "./deps.ts";
|
|
import { Request } from "./request.ts";
|
|
import { Response } from "./response.ts";
|
|
import { send, SendOptions } from "./send.ts";
|
|
import type { ServerRequest, UpgradeWebSocketOptions } from "./types.d.ts";
|
|
import { assert } from "./util.ts";
|
|
|
|
export interface ContextOptions<
|
|
S extends AS = State,
|
|
// deno-lint-ignore no-explicit-any
|
|
AS extends State = Record<string, any>,
|
|
> {
|
|
jsonBodyReplacer?: (
|
|
key: string,
|
|
value: unknown,
|
|
context: Context<S>,
|
|
) => unknown;
|
|
jsonBodyReviver?: (
|
|
key: string,
|
|
value: unknown,
|
|
context: Context<S>,
|
|
) => unknown;
|
|
secure?: boolean;
|
|
}
|
|
|
|
export interface ContextSendOptions extends SendOptions {
|
|
/** The filename to send, which will be resolved based on the other options.
|
|
* If this property is omitted, the current context's `.request.url.pathname`
|
|
* will be used. */
|
|
path?: string;
|
|
}
|
|
|
|
/** Provides context about the current request and response to middleware
|
|
* functions, and the current instance being processed is the first argument
|
|
* provided a {@linkcode Middleware} function.
|
|
*
|
|
* _Typically this is only used as a type annotation and shouldn't be
|
|
* constructed directly._
|
|
*
|
|
* ### Example
|
|
*
|
|
* ```ts
|
|
* import { Application, Context } from "https://deno.land/x/oak/mod.ts";
|
|
*
|
|
* const app = new Application();
|
|
*
|
|
* app.use((ctx) => {
|
|
* // information about the request is here:
|
|
* ctx.request;
|
|
* // information about the response is here:
|
|
* ctx.response;
|
|
* // the cookie store is here:
|
|
* ctx.cookies;
|
|
* });
|
|
*
|
|
* // Needs a type annotation because it cannot be inferred.
|
|
* function mw(ctx: Context) {
|
|
* // process here...
|
|
* }
|
|
*
|
|
* app.use(mw);
|
|
* ```
|
|
*
|
|
* @template S the state which extends the application state (`AS`)
|
|
* @template AS the type of the state derived from the application
|
|
*/
|
|
export class Context<
|
|
S extends AS = State,
|
|
// deno-lint-ignore no-explicit-any
|
|
AS extends State = Record<string, any>,
|
|
> {
|
|
#socket?: WebSocket;
|
|
#sse?: ServerSentEventTarget;
|
|
|
|
#wrapReviverReplacer(
|
|
reviver?: (key: string, value: unknown, context: this) => unknown,
|
|
): undefined | ((key: string, value: unknown) => unknown) {
|
|
return reviver
|
|
? (key: string, value: unknown) => reviver(key, value, this)
|
|
: undefined;
|
|
}
|
|
|
|
/** A reference to the current application. */
|
|
app: Application<AS>;
|
|
|
|
/** An object which allows access to cookies, mediating both the request and
|
|
* response. */
|
|
cookies: SecureCookieMap;
|
|
|
|
/** Is `true` if the current connection is upgradeable to a web socket.
|
|
* Otherwise the value is `false`. Use `.upgrade()` to upgrade the connection
|
|
* and return the web socket. */
|
|
get isUpgradable(): boolean {
|
|
const upgrade = this.request.headers.get("upgrade");
|
|
if (!upgrade || upgrade.toLowerCase() !== "websocket") {
|
|
return false;
|
|
}
|
|
const secKey = this.request.headers.get("sec-websocket-key");
|
|
return typeof secKey === "string" && secKey != "";
|
|
}
|
|
|
|
/** Determines if the request should be responded to. If `false` when the
|
|
* middleware completes processing, the response will not be sent back to the
|
|
* requestor. Typically this is used if the middleware will take over low
|
|
* level processing of requests and responses, for example if using web
|
|
* sockets. This automatically gets set to `false` when the context is
|
|
* upgraded to a web socket via the `.upgrade()` method.
|
|
*
|
|
* The default is `true`. */
|
|
respond: boolean;
|
|
|
|
/** An object which contains information about the current request. */
|
|
request: Request;
|
|
|
|
/** An object which contains information about the response that will be sent
|
|
* when the middleware finishes processing. */
|
|
response: Response;
|
|
|
|
/** If the the current context has been upgraded, then this will be set to
|
|
* with the current web socket, otherwise it is `undefined`. */
|
|
get socket(): WebSocket | undefined {
|
|
return this.#socket;
|
|
}
|
|
|
|
/** The object to pass state to front-end views. This can be typed by
|
|
* supplying the generic state argument when creating a new app. For
|
|
* example:
|
|
*
|
|
* ```ts
|
|
* const app = new Application<{ foo: string }>();
|
|
* ```
|
|
*
|
|
* Or can be contextually inferred based on setting an initial state object:
|
|
*
|
|
* ```ts
|
|
* const app = new Application({ state: { foo: "bar" } });
|
|
* ```
|
|
*
|
|
* On each request/response cycle, the context's state is cloned from the
|
|
* application state. This means changes to the context's `.state` will be
|
|
* dropped when the request drops, but "defaults" can be applied to the
|
|
* application's state. Changes to the application's state though won't be
|
|
* reflected until the next request in the context's state.
|
|
*/
|
|
state: S;
|
|
|
|
constructor(
|
|
app: Application<AS>,
|
|
serverRequest: ServerRequest,
|
|
state: S,
|
|
{
|
|
secure = false,
|
|
jsonBodyReplacer,
|
|
jsonBodyReviver,
|
|
}: ContextOptions<S, AS> = {},
|
|
) {
|
|
this.app = app;
|
|
this.state = state;
|
|
const { proxy } = app;
|
|
this.request = new Request(
|
|
serverRequest,
|
|
{
|
|
proxy,
|
|
secure,
|
|
jsonBodyReviver: this.#wrapReviverReplacer(jsonBodyReviver),
|
|
},
|
|
);
|
|
this.respond = true;
|
|
this.response = new Response(
|
|
this.request,
|
|
this.#wrapReviverReplacer(jsonBodyReplacer),
|
|
);
|
|
this.cookies = new SecureCookieMap(serverRequest, {
|
|
keys: this.app.keys as KeyStack | undefined,
|
|
response: this.response,
|
|
secure: this.request.secure,
|
|
});
|
|
}
|
|
|
|
/** Asserts the condition and if the condition fails, creates an HTTP error
|
|
* with the provided status (which defaults to `500`). The error status by
|
|
* default will be set on the `.response.status`.
|
|
*
|
|
* Because of limitation of TypeScript, any assertion type function requires
|
|
* specific type annotations, so the {@linkcode Context} type should be used
|
|
* even if it can be inferred from the context.
|
|
*
|
|
* ### Example
|
|
*
|
|
* ```ts
|
|
* import { Context, Status } from "https://deno.land/x/oak/mod.ts";
|
|
*
|
|
* export function mw(ctx: Context) {
|
|
* const body = ctx.request.body();
|
|
* ctx.assert(body.type === "json", Status.NotAcceptable);
|
|
* // process the body and send a response...
|
|
* }
|
|
* ```
|
|
*/
|
|
assert(
|
|
// deno-lint-ignore no-explicit-any
|
|
condition: any,
|
|
errorStatus: ErrorStatus = 500,
|
|
message?: string,
|
|
props?: Record<string, unknown> & Omit<HttpErrorOptions, "status">,
|
|
): asserts condition {
|
|
if (condition) {
|
|
return;
|
|
}
|
|
const httpErrorOptions: HttpErrorOptions = {};
|
|
if (typeof props === "object") {
|
|
if ("headers" in props) {
|
|
httpErrorOptions.headers = props.headers;
|
|
delete props.headers;
|
|
}
|
|
if ("expose" in props) {
|
|
httpErrorOptions.expose = props.expose;
|
|
delete props.expose;
|
|
}
|
|
}
|
|
const err = createHttpError(errorStatus, message, httpErrorOptions);
|
|
if (props) {
|
|
Object.assign(err, props);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
/** Asynchronously fulfill a response with a file from the local file
|
|
* system.
|
|
*
|
|
* If the `options.path` is not supplied, the file to be sent will default
|
|
* to this `.request.url.pathname`.
|
|
*
|
|
* Requires Deno read permission. */
|
|
send(options: ContextSendOptions): Promise<string | undefined> {
|
|
const { path = this.request.url.pathname, ...sendOptions } = options;
|
|
return send(this, path, sendOptions);
|
|
}
|
|
|
|
/** Convert the connection to stream events, returning an event target for
|
|
* sending server sent events. Events dispatched on the returned target will
|
|
* be sent to the client and be available in the client's `EventSource` that
|
|
* initiated the connection.
|
|
*
|
|
* **Note** the body needs to be returned to the client to be able to
|
|
* dispatch events, so dispatching events within the middleware will delay
|
|
* sending the body back to the client.
|
|
*
|
|
* This will set the response body and update response headers to support
|
|
* sending SSE events. Additional middleware should not modify the body.
|
|
*/
|
|
sendEvents(options?: ServerSentEventTargetOptions): ServerSentEventTarget {
|
|
if (!this.#sse) {
|
|
assert(this.response.writable, "The response is not writable.");
|
|
const sse = this.#sse = new ServerSentEventStreamTarget(options);
|
|
this.app.addEventListener("close", () => sse.close());
|
|
const [bodyInit, { headers }] = sse.asResponseInit({
|
|
headers: this.response.headers,
|
|
});
|
|
this.response.body = bodyInit;
|
|
if (headers instanceof Headers) {
|
|
this.response.headers = headers;
|
|
}
|
|
}
|
|
return this.#sse;
|
|
}
|
|
|
|
/** Create and throw an HTTP Error, which can be used to pass status
|
|
* information which can be caught by other middleware to send more
|
|
* meaningful error messages back to the client. The passed error status will
|
|
* be set on the `.response.status` by default as well.
|
|
*/
|
|
throw(
|
|
errorStatus: ErrorStatus,
|
|
message?: string,
|
|
props?: Record<string, unknown>,
|
|
): never {
|
|
const err = createHttpError(errorStatus, message);
|
|
if (props) {
|
|
Object.assign(err, props);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
/** Take the current request and upgrade it to a web socket, resolving with
|
|
* the a web standard `WebSocket` object. This will set `.respond` to
|
|
* `false`. If the socket cannot be upgraded, this method will throw. */
|
|
upgrade(options?: UpgradeWebSocketOptions): WebSocket {
|
|
if (this.#socket) {
|
|
return this.#socket;
|
|
}
|
|
if (!this.request.originalRequest.upgrade) {
|
|
throw new TypeError(
|
|
"Web socket upgrades not currently supported for this type of server.",
|
|
);
|
|
}
|
|
this.#socket = this.request.originalRequest.upgrade(options);
|
|
this.app.addEventListener("close", () => this.#socket?.close());
|
|
this.respond = false;
|
|
return this.#socket;
|
|
}
|
|
|
|
[Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) {
|
|
const {
|
|
app,
|
|
cookies,
|
|
isUpgradable,
|
|
respond,
|
|
request,
|
|
response,
|
|
socket,
|
|
state,
|
|
} = this;
|
|
return `${this.constructor.name} ${
|
|
inspect({
|
|
app,
|
|
cookies,
|
|
isUpgradable,
|
|
respond,
|
|
request,
|
|
response,
|
|
socket,
|
|
state,
|
|
})
|
|
}`;
|
|
}
|
|
|
|
[Symbol.for("nodejs.util.inspect.custom")](
|
|
depth: number,
|
|
// deno-lint-ignore no-explicit-any
|
|
options: any,
|
|
inspect: (value: unknown, options?: unknown) => string,
|
|
) {
|
|
if (depth < 0) {
|
|
return options.stylize(`[${this.constructor.name}]`, "special");
|
|
}
|
|
|
|
const newOptions = Object.assign({}, options, {
|
|
depth: options.depth === null ? null : options.depth - 1,
|
|
});
|
|
const {
|
|
app,
|
|
cookies,
|
|
isUpgradable,
|
|
respond,
|
|
request,
|
|
response,
|
|
socket,
|
|
state,
|
|
} = this;
|
|
return `${options.stylize(this.constructor.name, "special")} ${
|
|
inspect({
|
|
app,
|
|
cookies,
|
|
isUpgradable,
|
|
respond,
|
|
request,
|
|
response,
|
|
socket,
|
|
state,
|
|
}, newOptions)
|
|
}`;
|
|
}
|
|
}
|