Added a very complicated AsyncIterableReader class.

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.
This commit is contained in:
didier amyot committed 2023-06-12 12:02:15 -04:00
1 parent 17a1b4bed3
commit 0d627c9051
125 files changed
+26943 -83

No files matched your search

+732
View File
@@ -0,0 +1,732 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import { Context } from "./context.ts";
import { KeyStack, Status, STATUS_TEXT } from "./deps.ts";
import { HttpServer } from "./http_server_native.ts";
import { NativeRequest } from "./http_server_native_request.ts";
import {
compose,
isMiddlewareObject,
type MiddlewareOrMiddlewareObject,
} from "./middleware.ts";
import { cloneState } from "./structured_clone.ts";
import {
Key,
Listener,
Server,
ServerConstructor,
ServerRequest,
} from "./types.d.ts";
import { assert, isConn } from "./util.ts";
export interface ListenOptionsBase {
/** The port to listen on. If not specified, defaults to `0`, which allows the
* operating system to determine the value. */
port?: number;
/** A literal IP address or host name that can be resolved to an IP address.
* If not specified, defaults to `0.0.0.0`.
*
* __Note about `0.0.0.0`__ While listening `0.0.0.0` works on all platforms,
* the browsers on Windows don't work with the address `0.0.0.0`.
* You should show the message like `server running on localhost:8080` instead of
* `server running on 0.0.0.0:8080` if your program supports Windows. */
hostname?: string;
secure?: false;
/** An optional abort signal which can be used to close the listener. */
signal?: AbortSignal;
}
export interface ListenOptionsTls extends Deno.ListenTlsOptions {
/** Application-Layer Protocol Negotiation (ALPN) protocols to announce to
* the client. If not specified, no ALPN extension will be included in the
* TLS handshake.
*
* **NOTE** this is part of the native HTTP server in Deno 1.9 or later,
* which requires the `--unstable` flag to be available.
*/
alpnProtocols?: string[];
secure: true;
/** An optional abort signal which can be used to close the listener. */
signal?: AbortSignal;
}
export interface HandleMethod {
/** Handle an individual server request, returning the server response. This
* is similar to `.listen()`, but opening the connection and retrieving
* requests are not the responsibility of the application. If the generated
* context gets set to not to respond, then the method resolves with
* `undefined`, otherwise it resolves with a DOM `Response` object. */
(
request: Request,
conn?: Deno.Conn,
secure?: boolean,
): Promise<Response | undefined>;
}
export type ListenOptions = ListenOptionsTls | ListenOptionsBase;
interface ApplicationCloseEventListener {
(evt: ApplicationCloseEvent): void | Promise<void>;
}
interface ApplicationCloseEventListenerObject {
handleEvent(evt: ApplicationCloseEvent): void | Promise<void>;
}
type ApplicationCloseEventListenerOrEventListenerObject =
| ApplicationCloseEventListener
| ApplicationCloseEventListenerObject;
interface ApplicationErrorEventListener<S extends AS, AS extends State> {
(evt: ApplicationErrorEvent<S, AS>): void | Promise<void>;
}
interface ApplicationErrorEventListenerObject<S extends AS, AS extends State> {
handleEvent(evt: ApplicationErrorEvent<S, AS>): void | Promise<void>;
}
interface ApplicationErrorEventInit<S extends AS, AS extends State>
extends ErrorEventInit {
context?: Context<S, AS>;
}
type ApplicationErrorEventListenerOrEventListenerObject<
S extends AS,
AS extends State,
> =
| ApplicationErrorEventListener<S, AS>
| ApplicationErrorEventListenerObject<S, AS>;
interface ApplicationListenEventListener {
(evt: ApplicationListenEvent): void | Promise<void>;
}
interface ApplicationListenEventListenerObject {
handleEvent(evt: ApplicationListenEvent): void | Promise<void>;
}
interface ApplicationListenEventInit extends EventInit {
hostname: string;
listener: Listener;
port: number;
secure: boolean;
serverType: "native" | "custom";
}
type ApplicationListenEventListenerOrEventListenerObject =
| ApplicationListenEventListener
| ApplicationListenEventListenerObject;
/** Available options that are used when creating a new instance of
* {@linkcode Application}. */
export interface ApplicationOptions<S extends State, R extends ServerRequest> {
/** Determine how when creating a new context, the state from the application
* should be applied. A value of `"clone"` will set the state as a clone of
* the app state. Any non-cloneable or non-enumerable properties will not be
* copied. A value of `"prototype"` means that the application's state will be
* used as the prototype of the the context's state, meaning shallow
* properties on the context's state will not be reflected in the
* application's state. A value of `"alias"` means that application's `.state`
* and the context's `.state` will be a reference to the same object. A value
* of `"empty"` will initialize the context's `.state` with an empty object.
*
* The default value is `"clone"`.
*/
contextState?: "clone" | "prototype" | "alias" | "empty";
/** An optional replacer function to be used when serializing a JSON
* response. The replacer will be used with `JSON.stringify()` to encode any
* response bodies that need to be converted before sending the response.
*
* This is intended to allow responses to contain bigints and circular
* references and encoding other values which JSON does not support directly.
*
* This can be used in conjunction with `jsonBodyReviver` to handle decoding
* of request bodies if the same semantics are used for client requests.
*
* If more detailed or conditional usage is required, then serialization
* should be implemented directly in middleware. */
jsonBodyReplacer?: (
key: string,
value: unknown,
context: Context<S>,
) => unknown;
/** An optional reviver function to be used when parsing a JSON request. The
* reviver will be used with `JSON.parse()` to decode any response bodies that
* are being converted as JSON.
*
* This is intended to allow requests to deserialize to bigints, circular
* references, or other values which JSON does not support directly.
*
* This can be used in conjunction with `jsonBodyReplacer` to handle decoding
* of response bodies if the same semantics are used for responses.
*
* If more detailed or conditional usage is required, then deserialization
* should be implemented directly in the middleware.
*/
jsonBodyReviver?: (
key: string,
value: unknown,
context: Context<S>,
) => unknown;
/** An initial set of keys (or instance of {@linkcode KeyStack}) to be used for signing
* cookies produced by the application. */
keys?: KeyStack | Key[];
/** If `true`, any errors handled by the application will be logged to the
* stderr. If `false` nothing will be logged. The default is `true`.
*
* All errors are available as events on the application of type `"error"` and
* can be accessed for custom logging/application management via adding an
* event listener to the application:
*
* ```ts
* const app = new Application({ logErrors: false });
* app.addEventListener("error", (evt) => {
* // evt.error will contain what error was thrown
* });
* ```
*/
logErrors?: boolean;
/** If set to `true`, proxy headers will be trusted when processing requests.
* This defaults to `false`. */
proxy?: boolean;
/** A server constructor to use instead of the default server for receiving
* requests.
*
* Generally this is only used for testing. */
serverConstructor?: ServerConstructor<R>;
/** The initial state object for the application, of which the type can be
* used to infer the type of the state for both the application and any of the
* application's context. */
state?: S;
}
interface RequestState {
handling: Set<Promise<void>>;
closing: boolean;
closed: boolean;
server: Server<ServerRequest>;
}
// deno-lint-ignore no-explicit-any
export type State = Record<string | number | symbol, any>;
const ADDR_REGEXP = /^\[?([^\]]*)\]?:([0-9]{1,5})$/;
const DEFAULT_SERVER: ServerConstructor<ServerRequest> = HttpServer;
export class ApplicationCloseEvent extends Event {
constructor(eventInitDict: EventInit) {
super("close", eventInitDict);
}
}
export class ApplicationErrorEvent<S extends AS, AS extends State>
extends ErrorEvent {
context?: Context<S, AS>;
constructor(eventInitDict: ApplicationErrorEventInit<S, AS>) {
super("error", eventInitDict);
this.context = eventInitDict.context;
}
}
function logErrorListener<S extends AS, AS extends State>(
{ error, context }: ApplicationErrorEvent<S, AS>,
) {
if (error instanceof Error) {
console.error(
`[uncaught application error]: ${error.name} - ${error.message}`,
);
} else {
console.error(`[uncaught application error]\n`, error);
}
if (context) {
let url: string;
try {
url = context.request.url.toString();
} catch {
url = "[malformed url]";
}
console.error(`\nrequest:`, {
url,
method: context.request.method,
hasBody: context.request.hasBody,
});
console.error(`response:`, {
status: context.response.status,
type: context.response.type,
hasBody: !!context.response.body,
writable: context.response.writable,
});
}
if (error instanceof Error && error.stack) {
console.error(`\n${error.stack.split("\n").slice(1).join("\n")}`);
}
}
export class ApplicationListenEvent extends Event {
hostname: string;
listener: Listener;
port: number;
secure: boolean;
serverType: "native" | "custom";
constructor(eventInitDict: ApplicationListenEventInit) {
super("listen", eventInitDict);
this.hostname = eventInitDict.hostname;
this.listener = eventInitDict.listener;
this.port = eventInitDict.port;
this.secure = eventInitDict.secure;
this.serverType = eventInitDict.serverType;
}
}
/** A class which registers middleware (via `.use()`) and then processes
* inbound requests against that middleware (via `.listen()`).
*
* The `context.state` can be typed via passing a generic argument when
* constructing an instance of `Application`. It can also be inferred by setting
* the {@linkcode ApplicationOptions.state} option when constructing the
* application.
*
* ### Basic example
*
* ```ts
* import { Application } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
*
* app.use((ctx, next) => {
* // called on each request with the context (`ctx`) of the request,
* // response, and other data.
* // `next()` is use to modify the flow control of the middleware stack.
* });
*
* app.listen({ port: 8080 });
* ```
*
* @template AS the type of the application state which extends
* {@linkcode State} and defaults to a simple string record.
*/
// deno-lint-ignore no-explicit-any
export class Application<AS extends State = Record<string, any>>
extends EventTarget {
#composedMiddleware?: (context: Context<AS, AS>) => Promise<unknown>;
#contextOptions: Pick<
ApplicationOptions<AS, ServerRequest>,
"jsonBodyReplacer" | "jsonBodyReviver"
>;
#contextState: "clone" | "prototype" | "alias" | "empty";
#keys?: KeyStack;
#middleware: MiddlewareOrMiddlewareObject<State, Context<State, AS>>[] = [];
#serverConstructor: ServerConstructor<ServerRequest>;
/** A set of keys, or an instance of `KeyStack` which will be used to sign
* cookies read and set by the application to avoid tampering with the
* cookies. */
get keys(): KeyStack | Key[] | undefined {
return this.#keys;
}
set keys(keys: KeyStack | Key[] | undefined) {
if (!keys) {
this.#keys = undefined;
return;
} else if (Array.isArray(keys)) {
this.#keys = new KeyStack(keys);
} else {
this.#keys = keys;
}
}
/** If `true`, proxy headers will be trusted when processing requests. This
* defaults to `false`. */
proxy: boolean;
/** Generic state of the application, which can be specified by passing the
* generic argument when constructing:
*
* const app = new Application<{ foo: string }>();
*
* Or can be contextually inferred based on setting an initial state object:
*
* const app = new Application({ state: { foo: "bar" } });
*
* When a new context is created, the application's state is cloned and the
* state is unique to that request/response. Changes can be made to the
* application state that will be shared with all contexts.
*/
state: AS;
constructor(options: ApplicationOptions<AS, ServerRequest> = {}) {
super();
const {
state,
keys,
proxy,
serverConstructor = DEFAULT_SERVER,
contextState = "clone",
logErrors = true,
...contextOptions
} = options;
this.proxy = proxy ?? false;
this.keys = keys;
this.state = state ?? {} as AS;
this.#serverConstructor = serverConstructor;
this.#contextOptions = contextOptions;
this.#contextState = contextState;
if (logErrors) {
this.addEventListener("error", logErrorListener);
}
}
#getComposed(): (context: Context<AS, AS>) => Promise<unknown> {
if (!this.#composedMiddleware) {
this.#composedMiddleware = compose(this.#middleware);
}
return this.#composedMiddleware;
}
#getContextState(): AS {
switch (this.#contextState) {
case "alias":
return this.state;
case "clone":
return cloneState(this.state);
case "empty":
return {} as AS;
case "prototype":
return Object.create(this.state);
}
}
/** Deal with uncaught errors in either the middleware or sending the
* response. */
// deno-lint-ignore no-explicit-any
#handleError(context: Context<AS>, error: any): void {
if (!(error instanceof Error)) {
error = new Error(`non-error thrown: ${JSON.stringify(error)}`);
}
const { message } = error;
this.dispatchEvent(new ApplicationErrorEvent({ context, message, error }));
if (!context.response.writable) {
return;
}
for (const key of [...context.response.headers.keys()]) {
context.response.headers.delete(key);
}
if (error.headers && error.headers instanceof Headers) {
for (const [key, value] of error.headers) {
context.response.headers.set(key, value);
}
}
context.response.type = "text";
const status: Status = context.response.status =
Deno.errors && error instanceof Deno.errors.NotFound
? 404
: error.status && typeof error.status === "number"
? error.status
: 500;
context.response.body = error.expose ? error.message : STATUS_TEXT[status];
}
/** Processing registered middleware on each request. */
async #handleRequest(
request: ServerRequest,
secure: boolean,
state: RequestState,
): Promise<void> {
let context: Context<AS, AS> | undefined;
try {
context = new Context(
this,
request,
this.#getContextState(),
{ secure, ...this.#contextOptions },
);
} catch (e) {
const error = e instanceof Error
? e
: new Error(`non-error thrown: ${JSON.stringify(e)}`);
const { message } = error;
this.dispatchEvent(new ApplicationErrorEvent({ message, error }));
return;
}
assert(context, "Context was not created.");
let resolve: () => void;
const handlingPromise = new Promise<void>((res) => resolve = res);
state.handling.add(handlingPromise);
if (!state.closing && !state.closed) {
try {
await this.#getComposed()(context);
} catch (err) {
this.#handleError(context, err);
}
}
if (context.respond === false) {
context.response.destroy();
resolve!();
state.handling.delete(handlingPromise);
return;
}
let closeResources = true;
let response: Response;
try {
closeResources = false;
response = await context.response.toDomResponse();
} catch (err) {
this.#handleError(context, err);
response = await context.response.toDomResponse();
}
assert(response);
try {
await request.respond(response);
} catch (err) {
this.#handleError(context, err);
} finally {
context.response.destroy(closeResources);
resolve!();
state.handling.delete(handlingPromise);
if (state.closing) {
await state.server.close();
if (!state.closed) {
this.dispatchEvent(new ApplicationCloseEvent({}));
}
state.closed = true;
}
}
}
/** Add an event listener for a `"close"` event which occurs when the
* application is closed and no longer listening or handling requests. */
addEventListener<S extends AS>(
type: "close",
listener: ApplicationCloseEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
/** Add an event listener for an `"error"` event which occurs when an
* un-caught error occurs when processing the middleware or during processing
* of the response. */
addEventListener<S extends AS>(
type: "error",
listener: ApplicationErrorEventListenerOrEventListenerObject<S, AS> | null,
options?: boolean | AddEventListenerOptions,
): void;
/** Add an event listener for a `"listen"` event which occurs when the server
* has successfully opened but before any requests start being processed. */
addEventListener(
type: "listen",
listener: ApplicationListenEventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void;
/** Add an event listener for an event. Currently valid event types are
* `"error"` and `"listen"`. */
addEventListener(
type: "close" | "error" | "listen",
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener(type, listener, options);
}
/** Handle an individual server request, returning the server response. This
* is similar to `.listen()`, but opening the connection and retrieving
* requests are not the responsibility of the application. If the generated
* context gets set to not to respond, then the method resolves with
* `undefined`, otherwise it resolves with a request that is compatible with
* `std/http/server`. */
handle = (async (
request: Request,
secureOrConn: Deno.Conn | boolean | undefined,
secure: boolean | undefined = false,
): Promise<Response | undefined> => {
if (!this.#middleware.length) {
throw new TypeError("There is no middleware to process requests.");
}
assert(isConn(secureOrConn) || typeof secureOrConn === "undefined");
const contextRequest = new NativeRequest({
request,
respondWith() {
return Promise.resolve(undefined);
},
}, { conn: secureOrConn });
const context = new Context(
this,
contextRequest,
this.#getContextState(),
{ secure, ...this.#contextOptions },
);
try {
await this.#getComposed()(context);
} catch (err) {
this.#handleError(context, err);
}
if (context.respond === false) {
context.response.destroy();
return;
}
try {
const response = await context.response.toDomResponse();
context.response.destroy(false);
return response;
} catch (err) {
this.#handleError(context, err);
throw err;
}
}) as HandleMethod;
/** Start listening for requests, processing registered middleware on each
* request. If the options `.secure` is undefined or `false`, the listening
* will be over HTTP. If the options `.secure` property is `true`, a
* `.certFile` and a `.keyFile` property need to be supplied and requests
* will be processed over HTTPS. */
async listen(addr: string): Promise<void>;
/** Start listening for requests, processing registered middleware on each
* request. If the options `.secure` is undefined or `false`, the listening
* will be over HTTP. If the options `.secure` property is `true`, a
* `.certFile` and a `.keyFile` property need to be supplied and requests
* will be processed over HTTPS.
*
* Omitting options will default to `{ port: 0 }` which allows the operating
* system to select the port. */
async listen(options?: ListenOptions): Promise<void>;
async listen(options: string | ListenOptions = { port: 0 }): Promise<void> {
if (!this.#middleware.length) {
throw new TypeError("There is no middleware to process requests.");
}
for (const middleware of this.#middleware) {
if (isMiddlewareObject(middleware) && middleware.init) {
await middleware.init();
}
}
if (typeof options === "string") {
const match = ADDR_REGEXP.exec(options);
if (!match) {
throw TypeError(`Invalid address passed: "${options}"`);
}
const [, hostname, portStr] = match;
options = { hostname, port: parseInt(portStr, 10) };
}
options = Object.assign({ port: 0 }, options);
const server = new this.#serverConstructor(
this,
options as Deno.ListenOptions,
);
const { signal } = options;
const state = {
closed: false,
closing: false,
handling: new Set<Promise<void>>(),
server,
};
if (signal) {
signal.addEventListener("abort", () => {
if (!state.handling.size) {
server.close();
state.closed = true;
this.dispatchEvent(new ApplicationCloseEvent({}));
}
state.closing = true;
});
}
const { secure = false } = options;
const serverType = server instanceof HttpServer ? "native" : "custom";
const listener = await server.listen();
const { hostname, port } = listener.addr as Deno.NetAddr;
this.dispatchEvent(
new ApplicationListenEvent({
hostname,
listener,
port,
secure,
serverType,
}),
);
try {
for await (const request of server) {
this.#handleRequest(request, secure, state);
}
await Promise.all(state.handling);
} catch (error) {
const message = error instanceof Error
? error.message
: "Application Error";
this.dispatchEvent(
new ApplicationErrorEvent({ message, error }),
);
}
}
/** Register middleware to be used with the application. Middleware will
* be processed in the order it is added, but middleware can control the flow
* of execution via the use of the `next()` function that the middleware
* function will be called with. The `context` object provides information
* about the current state of the application.
*
* Basic usage:
*
* ```ts
* const import { Application } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
*
* app.use((ctx, next) => {
* ctx.request; // contains request information
* ctx.response; // setups up information to use in the response;
* await next(); // manages the flow control of the middleware execution
* });
*
* await app.listen({ port: 80 });
* ```
*/
use<S extends State = AS>(
middleware: MiddlewareOrMiddlewareObject<S, Context<S, AS>>,
...middlewares: MiddlewareOrMiddlewareObject<S, Context<S, AS>>[]
): Application<S extends AS ? S : (S & AS)>;
use<S extends State = AS>(
...middleware: MiddlewareOrMiddlewareObject<S, Context<S, AS>>[]
): Application<S extends AS ? S : (S & AS)> {
this.#middleware.push(...middleware);
this.#composedMiddleware = undefined;
// deno-lint-ignore no-explicit-any
return this as Application<any>;
}
[Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) {
const { keys, proxy, state } = this;
return `${this.constructor.name} ${
inspect({ "#middleware": this.#middleware, keys, proxy, 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 { keys, proxy, state } = this;
return `${options.stylize(this.constructor.name, "special")} ${
inspect(
{ "#middleware": this.#middleware, keys, proxy, state },
newOptions,
)
}`;
}
}
+471
View File
@@ -0,0 +1,471 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import {
createHttpError,
errors,
readerFromStreamReader,
Status,
} from "./deps.ts";
import { isMediaType } from "./isMediaType.ts";
import { FormDataReader } from "./multipart.ts";
import type { ServerRequestBody } from "./types.d.ts";
import { assert } from "./util.ts";
/** The type of the body, where:
*
* - `"bytes"` - the body is provided as a promise which resolves to an
* {@linkcode Uint8Array}. This is essentially a "raw" body type.
* - `"form"` - the body was decoded as a form with the contents provided as a
* promise which resolves with a {@linkcode URLSearchParams}.
* - `"form-data"` - the body was decoded as a multi-part form data and the
* contents are provided as a promise which resolves with a
* {@linkcode FormDataReader}.
* - `"json"` - the body was decoded as JSON, where the contents are provided as
* the result of using `JSON.parse()` on the string contents of the body.
* - `"text"` - the body was decoded as text, where the contents are provided as
* a string.
* - `"reader"` - the body is provided as {@linkcode Deno.Reader} interface for
* reading the "raw" body.
* - `"stream"` - the body is provided as a
* {@linkcode ReadableStream<Uint8Array>} for reading the "raw" body.
* - `"undefined"` - there is no request body or it could not be decoded.
*/
export type BodyType =
| "bytes"
| "form"
| "form-data"
| "json"
| "text"
| "reader"
| "stream"
| "undefined";
/** The tagged type for `"bytes"` bodies. */
export type BodyBytes = {
readonly type: "bytes";
readonly value: Promise<Uint8Array>;
};
/** The tagged type for `"json"` bodies. */
export type BodyJson = {
readonly type: "json";
// deno-lint-ignore no-explicit-any
readonly value: Promise<any>;
};
/** The tagged type for `"form"` bodies. */
export type BodyForm = {
readonly type: "form";
readonly value: Promise<URLSearchParams>;
};
/** The tagged type for `"form-data"` bodies. */
export type BodyFormData = {
readonly type: "form-data";
readonly value: FormDataReader;
};
/** The tagged type for `"text"` bodies. */
export type BodyText = {
readonly type: "text";
readonly value: Promise<string>;
};
/** The tagged type for `"undefined"` bodies. */
export type BodyUndefined = {
readonly type: "undefined";
readonly value: undefined;
};
/** The tagged type for `"reader"` bodies. */
export type BodyReader = {
readonly type: "reader";
readonly value: Deno.Reader;
};
/** The tagged type for `"stream"` bodies. */
export type BodyStream = {
readonly type: "stream";
readonly value: ReadableStream<Uint8Array>;
};
/** The type returned from the `.body()` function, which is a tagged union type
* of all the different types of bodies which can be identified by the `.type`
* property which will be of type {@linkcode BodyType} and the `.value`
* property being a `Promise` which resolves with the appropriate value, or
* `undefined` if there is no body. */
export type Body =
| BodyBytes
| BodyJson
| BodyForm
| BodyFormData
| BodyText
| BodyUndefined;
type BodyValueGetter = () => Body["value"];
/** When setting the `contentTypes` property of {@linkcode BodyOptions}, provide
* additional content types which can influence how the body is decoded. This
* is specifically designed to allow a server to support custom or specialized
* media types that are not part of the public database. */
export interface BodyOptionsContentTypes {
/** Content types listed here will always return an Uint8Array. */
bytes?: string[];
/** Content types listed here will be parsed as a JSON string. */
json?: string[];
/** Content types listed here will be parsed as form data and return
* `URLSearchParameters` as the value of the body. */
form?: string[];
/** Content types listed here will be parsed as from data and return a
* `FormDataBody` interface as the value of the body. */
formData?: string[];
/** Content types listed here will be parsed as text. */
text?: string[];
}
/** Options which can be used when accessing the `.body()` of a request.
*
* @template T the {@linkcode BodyType} to attempt to use when decoding the
* request body.
*/
export interface BodyOptions<T extends BodyType = BodyType> {
/** When reading a non-streaming body, set a limit whereby if the content
* length is greater then the limit or not set, reading the body will throw.
*
* This is to prevent malicious requests where the body exceeds the capacity
* of the server. Set the limit to 0 to allow unbounded reads. The default
* is 10 Mib. */
limit?: number;
/** Instead of utilizing the content type of the request, attempt to parse the
* body as the type specified. The value has to be of {@linkcode BodyType}. */
type?: T;
/** A map of extra content types to determine how to parse the body. */
contentTypes?: BodyOptionsContentTypes;
}
export interface BodyContentTypes {
json?: string[];
form?: string[];
text?: string[];
}
const DEFAULT_LIMIT = 10_485_760; // 10mb
const defaultBodyContentTypes = {
json: ["json", "application/*+json", "application/csp-report"],
form: ["urlencoded"],
formData: ["multipart"],
text: ["text"],
};
function resolveType(
contentType: string,
contentTypes: BodyOptionsContentTypes,
): BodyType {
const contentTypesJson = [
...defaultBodyContentTypes.json,
...(contentTypes.json ?? []),
];
const contentTypesForm = [
...defaultBodyContentTypes.form,
...(contentTypes.form ?? []),
];
const contentTypesFormData = [
...defaultBodyContentTypes.formData,
...(contentTypes.formData ?? []),
];
const contentTypesText = [
...defaultBodyContentTypes.text,
...(contentTypes.text ?? []),
];
if (contentTypes.bytes && isMediaType(contentType, contentTypes.bytes)) {
return "bytes";
} else if (isMediaType(contentType, contentTypesJson)) {
return "json";
} else if (isMediaType(contentType, contentTypesForm)) {
return "form";
} else if (isMediaType(contentType, contentTypesFormData)) {
return "form-data";
} else if (isMediaType(contentType, contentTypesText)) {
return "text";
}
return "bytes";
}
const decoder = new TextDecoder(undefined, { fatal: true });
export class RequestBody {
#body: ReadableStream<Uint8Array> | null;
#formDataReader?: FormDataReader;
#headers: Headers;
#jsonBodyReviver?: (key: string, value: unknown) => unknown;
#stream?: ReadableStream<Uint8Array>;
#readAllBody?: Promise<Uint8Array>;
#readBody: () => Promise<Uint8Array>;
#type?: "bytes" | "form-data" | "reader" | "stream" | "undefined";
#exceedsLimit(limit: number): boolean {
if (!limit || limit === Infinity) {
return false;
}
if (!this.#body) {
return false;
}
const contentLength = this.#headers.get("content-length") ?? "0";
const parsed = parseInt(contentLength, 10);
if (isNaN(parsed)) {
return true;
}
return parsed > limit;
}
#parse(type: BodyType, limit: number): BodyValueGetter {
switch (type) {
case "form":
this.#type = "bytes";
if (this.#exceedsLimit(limit)) {
return () =>
Promise.reject(
createHttpError(
Status.BadRequest,
`Body exceeds a limit of ${limit}.`,
{ expose: false },
),
);
}
return async () =>
new URLSearchParams(
decoder.decode(await this.#valuePromise()).replace(/\+/g, " "),
);
case "form-data":
this.#type = "form-data";
return () => {
const contentType = this.#headers.get("content-type");
assert(contentType);
const readableStream = this.#body ?? new ReadableStream();
try {
return this.#formDataReader ??
(this.#formDataReader = new FormDataReader(
contentType,
readerFromStreamReader(
(readableStream as ReadableStream<Uint8Array>).getReader(),
),
));
} catch (err) {
const message = err instanceof Error
? err.message
: "Malformed request body.";
throw createHttpError(
Status.BadRequest,
message,
{ expose: false },
);
}
};
case "json":
this.#type = "bytes";
if (this.#exceedsLimit(limit)) {
return () =>
Promise.reject(createHttpError(
Status.BadRequest,
`Body exceeds a limit of ${limit}.`,
{ expose: false },
));
}
return async () => {
const value = await this.#valuePromise();
try {
return value.length
? JSON.parse(
decoder.decode(await this.#valuePromise()),
this.#jsonBodyReviver,
)
: null;
} catch (err) {
const message = err instanceof Error
? err.message
: "Malformed request body.";
throw createHttpError(
Status.BadRequest,
message,
{ expose: false },
);
}
};
case "bytes":
this.#type = "bytes";
if (this.#exceedsLimit(limit)) {
return () =>
Promise.reject(createHttpError(
Status.BadRequest,
`Body exceeds a limit of ${limit}.`,
{ expose: false },
));
}
return () => this.#valuePromise();
case "text":
this.#type = "bytes";
if (this.#exceedsLimit(limit)) {
return () =>
Promise.reject(createHttpError(
Status.BadRequest,
`Body exceeds a limit of ${limit}.`,
{ expose: false },
));
}
return async () => {
try {
return decoder.decode(await this.#valuePromise());
} catch (err) {
const message = err instanceof Error
? err.message
: "Malformed request body.";
throw createHttpError(
Status.BadRequest,
message,
{ expose: false },
);
}
};
default:
throw createHttpError(
Status.InternalServerError,
`Invalid body type: "${type}"`,
{ expose: true },
);
}
}
#validateGetArgs(
type: BodyType | undefined,
contentTypes: BodyOptionsContentTypes,
) {
if (type === "reader" && this.#type && this.#type !== "reader") {
throw new TypeError(
`Body already consumed as "${this.#type}" and cannot be returned as a reader.`,
);
}
if (type === "stream" && this.#type && this.#type !== "stream") {
throw new TypeError(
`Body already consumed as "${this.#type}" and cannot be returned as a stream.`,
);
}
if (type === "form-data" && this.#type && this.#type !== "form-data") {
throw new TypeError(
`Body already consumed as "${this.#type}" and cannot be returned as a stream.`,
);
}
if (this.#type === "reader" && type !== "reader") {
throw new TypeError(
"Body already consumed as a reader and can only be returned as a reader.",
);
}
if (this.#type === "stream" && type !== "stream") {
throw new TypeError(
"Body already consumed as a stream and can only be returned as a stream.",
);
}
if (this.#type === "form-data" && type !== "form-data") {
throw new TypeError(
"Body already consumed as form data and can only be returned as form data.",
);
}
if (type && Object.keys(contentTypes).length) {
throw new TypeError(
`"type" and "contentTypes" cannot be specified at the same time`,
);
}
}
#valuePromise() {
return this.#readAllBody ?? (this.#readAllBody = this.#readBody());
}
constructor(
{ body, readBody }: ServerRequestBody,
headers: Headers,
jsonBodyReviver?: (key: string, value: unknown) => unknown,
) {
this.#body = body;
this.#headers = headers;
this.#jsonBodyReviver = jsonBodyReviver;
this.#readBody = readBody;
}
get(
{ limit = DEFAULT_LIMIT, type, contentTypes = {} }: BodyOptions = {},
): Body | BodyReader | BodyStream {
this.#validateGetArgs(type, contentTypes);
if (type === "reader") {
if (!this.#body) {
this.#type = "undefined";
throw new TypeError(
`Body is undefined and cannot be returned as "reader".`,
);
}
this.#type = "reader";
return {
type,
value: readerFromStreamReader(this.#body.getReader()),
};
}
if (type === "stream") {
if (!this.#body) {
this.#type = "undefined";
throw new TypeError(
`Body is undefined and cannot be returned as "stream".`,
);
}
this.#type = "stream";
const streams =
((this.#stream ?? this.#body) as ReadableStream<Uint8Array>)
.tee();
this.#stream = streams[1];
return { type, value: streams[0] };
}
if (!this.has()) {
this.#type = "undefined";
} else if (!this.#type) {
const encoding = this.#headers.get("content-encoding") ??
"identity";
if (encoding !== "identity") {
throw new errors.UnsupportedMediaType(
`Unsupported content-encoding: ${encoding}`,
);
}
}
if (this.#type === "undefined" && (!type || type === "undefined")) {
return { type: "undefined", value: undefined };
}
if (!type) {
const contentType = this.#headers.get("content-type");
if (!contentType) {
throw createHttpError(
Status.BadRequest,
"The Content-Type header is missing from the request",
);
}
type = resolveType(contentType, contentTypes);
}
assert(type);
const body: Body = Object.create(null);
Object.defineProperties(body, {
type: {
value: type,
configurable: true,
enumerable: true,
},
value: {
get: this.#parse(type, limit),
configurable: true,
enumerable: true,
},
});
return body;
}
/** Returns if the request might have a body or not, without attempting to
* consume it.
*
* **WARNING** This is an unreliable API. In HTTP/2 it is not possible to
* determine if certain HTTP methods have a body or not without attempting to
* read the body. As of Deno 1.16.1 and later, for HTTP/1.1 aligns to the
* HTTP/2 behaviour.
*/
has(): boolean {
return this.#body != null;
}
}
+183
View File
@@ -0,0 +1,183 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import { assert, stripEol } from "./util.ts";
export interface ReadLineResult {
bytes: Uint8Array;
eol: boolean;
}
const DEFAULT_BUF_SIZE = 4096;
const MIN_BUF_SIZE = 16;
const MAX_CONSECUTIVE_EMPTY_READS = 100;
const CR = "\r".charCodeAt(0);
const LF = "\n".charCodeAt(0);
export class BufferFullError extends Error {
name = "BufferFullError";
constructor(public partial: Uint8Array) {
super("Buffer full");
}
}
/** BufReader implements buffering for a Reader object. */
export class BufReader {
#buffer!: Uint8Array;
#reader!: Deno.Reader;
#posRead = 0;
#posWrite = 0;
#eof = false;
// Reads a new chunk into the buffer.
async #fill(): Promise<void> {
// Slide existing data to beginning.
if (this.#posRead > 0) {
this.#buffer.copyWithin(0, this.#posRead, this.#posWrite);
this.#posWrite -= this.#posRead;
this.#posRead = 0;
}
if (this.#posWrite >= this.#buffer.byteLength) {
throw Error("bufio: tried to fill full buffer");
}
// Read new data: try a limited number of times.
for (let i = MAX_CONSECUTIVE_EMPTY_READS; i > 0; i--) {
const rr = await this.#reader.read(this.#buffer.subarray(this.#posWrite));
if (rr === null) {
this.#eof = true;
return;
}
assert(rr >= 0, "negative read");
this.#posWrite += rr;
if (rr > 0) {
return;
}
}
throw new Error(
`No progress after ${MAX_CONSECUTIVE_EMPTY_READS} read() calls`,
);
}
#reset(buffer: Uint8Array, reader: Deno.Reader): void {
this.#buffer = buffer;
this.#reader = reader;
this.#eof = false;
}
constructor(rd: Deno.Reader, size: number = DEFAULT_BUF_SIZE) {
if (size < MIN_BUF_SIZE) {
size = MIN_BUF_SIZE;
}
this.#reset(new Uint8Array(size), rd);
}
buffered(): number {
return this.#posWrite - this.#posRead;
}
async readLine(
strip = true,
): Promise<{ bytes: Uint8Array; eol: boolean } | null> {
let line: Uint8Array | null;
try {
line = await this.readSlice(LF);
} catch (err) {
assert(err instanceof Error);
let { partial } = err as Error & { partial?: Uint8Array };
assert(
partial instanceof Uint8Array,
"Caught error from `readSlice()` without `partial` property",
);
// Don't throw if `readSlice()` failed with `BufferFullError`, instead we
// just return whatever is available and set the `more` flag.
if (!(err instanceof BufferFullError)) {
throw err;
}
// Handle the case where "\r\n" straddles the buffer.
if (
!this.#eof &&
partial.byteLength > 0 &&
partial[partial.byteLength - 1] === CR
) {
// Put the '\r' back on buf and drop it from line.
// Let the next call to ReadLine check for "\r\n".
assert(
this.#posRead > 0,
"Tried to rewind past start of buffer",
);
this.#posRead--;
partial = partial.subarray(0, partial.byteLength - 1);
}
return { bytes: partial, eol: this.#eof };
}
if (line === null) {
return null;
}
if (line.byteLength === 0) {
return { bytes: line, eol: true };
}
if (strip) {
line = stripEol(line);
}
return { bytes: line, eol: true };
}
async readSlice(delim: number): Promise<Uint8Array | null> {
let s = 0; // search start index
let slice: Uint8Array | undefined;
while (true) {
// Search buffer.
let i = this.#buffer.subarray(this.#posRead + s, this.#posWrite).indexOf(
delim,
);
if (i >= 0) {
i += s;
slice = this.#buffer.subarray(this.#posRead, this.#posRead + i + 1);
this.#posRead += i + 1;
break;
}
// EOF?
if (this.#eof) {
if (this.#posRead === this.#posWrite) {
return null;
}
slice = this.#buffer.subarray(this.#posRead, this.#posWrite);
this.#posRead = this.#posWrite;
break;
}
// Buffer full?
if (this.buffered() >= this.#buffer.byteLength) {
this.#posRead = this.#posWrite;
// #4521 The internal buffer should not be reused across reads because it causes corruption of data.
const oldbuf = this.#buffer;
const newbuf = this.#buffer.slice(0);
this.#buffer = newbuf;
throw new BufferFullError(oldbuf);
}
s = this.#posWrite - this.#posRead; // do not rescan area we scanned before
// Buffer is not full.
try {
await this.#fill();
} catch (err) {
const e = err instanceof Error ? err : new Error("[non-object thrown]");
(e as Error & { partial: unknown }).partial = slice;
throw err;
}
}
return slice;
}
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Adapted directly from content-disposition.js at
* https://github.com/Rob--W/open-in-browser/blob/master/extension/content-disposition.js
* which is licensed as:
*
* (c) 2017 Rob Wu <rob@robwu.nl> (https://robwu.nl)
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { toParamRegExp, unquote } from "./headers.ts";
let needsEncodingFixup = false;
function fixupEncoding(value: string): string {
if (needsEncodingFixup && /[\x80-\xff]/.test(value)) {
value = textDecode("utf-8", value);
if (needsEncodingFixup) {
value = textDecode("iso-8859-1", value);
}
}
return value;
}
const FILENAME_STAR_REGEX = toParamRegExp("filename\\*", "i");
const FILENAME_START_ITER_REGEX = toParamRegExp(
"filename\\*((?!0\\d)\\d+)(\\*?)",
"ig",
);
const FILENAME_REGEX = toParamRegExp("filename", "i");
function rfc2047decode(value: string): string {
// deno-lint-ignore no-control-regex
if (!value.startsWith("=?") || /[\x00-\x19\x80-\xff]/.test(value)) {
return value;
}
return value.replace(
/=\?([\w-]*)\?([QqBb])\?((?:[^?]|\?(?!=))*)\?=/g,
(_: string, charset: string, encoding: string, text: string) => {
if (encoding === "q" || encoding === "Q") {
text = text.replace(/_/g, " ");
text = text.replace(
/=([0-9a-fA-F]{2})/g,
(_, hex) => String.fromCharCode(parseInt(hex, 16)),
);
return textDecode(charset, text);
}
try {
text = atob(text);
// deno-lint-ignore no-empty
} catch {}
return textDecode(charset, text);
},
);
}
function rfc2231getParam(header: string): string {
const matches: [string, string][] = [];
let match: RegExpExecArray | null;
while ((match = FILENAME_START_ITER_REGEX.exec(header))) {
const [, ns, quote, part] = match;
const n = parseInt(ns, 10);
if (n in matches) {
if (n === 0) {
break;
}
continue;
}
matches[n] = [quote, part];
}
const parts: string[] = [];
for (let n = 0; n < matches.length; ++n) {
if (!(n in matches)) {
break;
}
let [quote, part] = matches[n];
part = unquote(part);
if (quote) {
part = unescape(part);
if (n === 0) {
part = rfc5987decode(part);
}
}
parts.push(part);
}
return parts.join("");
}
function rfc5987decode(value: string): string {
const encodingEnd = value.indexOf(`'`);
if (encodingEnd === -1) {
return value;
}
const encoding = value.slice(0, encodingEnd);
const langValue = value.slice(encodingEnd + 1);
return textDecode(encoding, langValue.replace(/^[^']*'/, ""));
}
function textDecode(encoding: string, value: string): string {
if (encoding) {
try {
const decoder = new TextDecoder(encoding, { fatal: true });
const bytes = Array.from(value, (c) => c.charCodeAt(0));
if (bytes.every((code) => code <= 0xFF)) {
value = decoder.decode(new Uint8Array(bytes));
needsEncodingFixup = false;
}
// deno-lint-ignore no-empty
} catch {}
}
return value;
}
export function getFilename(header: string): string {
needsEncodingFixup = true;
// filename*=ext-value ("ext-value" from RFC 5987, referenced by RFC 6266).
let matches = FILENAME_STAR_REGEX.exec(header);
if (matches) {
const [, filename] = matches;
return fixupEncoding(
rfc2047decode(rfc5987decode(unescape(unquote(filename)))),
);
}
// Continuations (RFC 2231 section 3, referenced by RFC 5987 section 3.1).
// filename*n*=part
// filename*n=part
const filename = rfc2231getParam(header);
if (filename) {
return fixupEncoding(rfc2047decode(filename));
}
// filename=value (RFC 5987, section 4.1).
matches = FILENAME_REGEX.exec(header);
if (matches) {
const [, filename] = matches;
return fixupEncoding(rfc2047decode(unquote(filename)));
}
return "";
}
+376
View File
@@ -0,0 +1,376 @@
// 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)
}`;
}
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
// This file contains the external dependencies that oak depends upon
// `std` dependencies
export {
type Deferred,
deferred,
} from "https://deno.land/std@0.188.0/async/deferred.ts";
export {
concat,
copy as copyBytes,
equals,
} from "https://deno.land/std@0.188.0/bytes/mod.ts";
export { timingSafeEqual } from "https://deno.land/std@0.188.0/crypto/timing_safe_equal.ts";
export { KeyStack } from "https://deno.land/std@0.188.0/crypto/keystack.ts";
export * as base64 from "https://deno.land/std@0.188.0/encoding/base64.ts";
export {
mergeHeaders,
SecureCookieMap,
type SecureCookieMapGetOptions,
type SecureCookieMapSetDeleteOptions,
} from "https://deno.land/std@0.188.0/http/cookie_map.ts";
export {
createHttpError,
errors,
HttpError,
type HttpErrorOptions,
isHttpError,
} from "https://deno.land/std@0.188.0/http/http_errors.ts";
export {
type ErrorStatus,
isErrorStatus,
isRedirectStatus,
type RedirectStatus,
Status,
STATUS_TEXT,
} from "https://deno.land/std@0.188.0/http/http_status.ts";
export {
calculate,
type ETagOptions,
type FileInfo,
ifMatch,
ifNoneMatch,
} from "https://deno.land/std@0.188.0/http/etag.ts";
export { type HttpMethod as HTTPMethods } from "https://deno.land/std@0.188.0/http/method.ts";
export {
accepts,
acceptsEncodings,
acceptsLanguages,
} from "https://deno.land/std@0.188.0/http/negotiation.ts";
export {
ServerSentEvent,
type ServerSentEventInit,
ServerSentEventStreamTarget,
type ServerSentEventTarget,
type ServerSentEventTargetOptions,
} from "https://deno.land/std@0.188.0/http/server_sent_event.ts";
export { LimitedReader } from "https://deno.land/std@0.188.0/io/mod.ts";
export {
contentType,
extension,
typeByExtension,
} from "https://deno.land/std@0.188.0/media_types/mod.ts";
export {
readAll,
readerFromStreamReader,
writeAll,
} from "https://deno.land/std@0.188.0/streams/mod.ts";
export {
basename,
extname,
isAbsolute,
join,
normalize,
parse,
SEP,
} from "https://deno.land/std@0.188.0/path/mod.ts";
// 3rd party dependencies
export {
compile,
type Key,
match as pathMatch,
parse as pathParse,
type ParseOptions,
pathToRegexp,
type TokensToRegexpOptions,
} from "https://deno.land/x/path_to_regexp@v6.2.1/index.ts";
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
/**
* A collection of oak specific APIs for management of ETags.
*
* @module
*/
import type { State } from "./application.ts";
import type { Context } from "./context.ts";
import { calculate, type ETagOptions } from "./deps.ts";
import type { Middleware } from "./middleware.ts";
import { BODY_TYPES, isAsyncIterable, isReader } from "./util.ts";
// re-exports to maintain backwards compatibility
export {
calculate,
type ETagOptions,
type FileInfo,
ifMatch,
ifNoneMatch,
} from "./deps.ts";
function fstat(file: Deno.FsFile): Promise<Deno.FileInfo | undefined> {
if ("fstat" in Deno) {
// deno-lint-ignore no-explicit-any
return (Deno as any).fstat(file.rid);
}
return Promise.resolve(undefined);
}
/** For a given Context, try to determine the response body entity that an ETag
* can be calculated from. */
// deno-lint-ignore no-explicit-any
export function getEntity<S extends State = Record<string, any>>(
context: Context<S>,
): Promise<string | Uint8Array | Deno.FileInfo | undefined> {
const { body } = context.response;
if (body instanceof Deno.FsFile) {
return fstat(body);
}
if (body instanceof Uint8Array) {
return Promise.resolve(body);
}
if (BODY_TYPES.includes(typeof body)) {
return Promise.resolve(String(body));
}
if (isAsyncIterable(body) || isReader(body)) {
return Promise.resolve(undefined);
}
if (typeof body === "object" && body !== null) {
try {
const bodyText = JSON.stringify(body);
return Promise.resolve(bodyText);
} catch {
// We don't really care about errors here
}
}
return Promise.resolve(undefined);
}
/**
* Create middleware that will attempt to decode the response.body into
* something that can be used to generate an `ETag` and add the `ETag` header to
* the response.
*/
// deno-lint-ignore no-explicit-any
export function factory<S extends State = Record<string, any>>(
options?: ETagOptions,
): Middleware<S> {
return async function etag(context: Context<S>, next) {
await next();
if (!context.response.headers.has("ETag")) {
const entity = await getEntity(context);
if (entity) {
const etag = await calculate(entity, options);
if (etag) {
context.response.headers.set("ETag", etag);
}
}
}
};
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import type { BufReader } from "./buf_reader.ts";
import { errors } from "./deps.ts";
const COLON = ":".charCodeAt(0);
const HTAB = "\t".charCodeAt(0);
const SPACE = " ".charCodeAt(0);
const decoder = new TextDecoder();
/** With a provided attribute pattern, return a RegExp which will match and
* capture in the first group the value of the attribute from a header value. */
export function toParamRegExp(
attributePattern: string,
flags?: string,
): RegExp {
// deno-fmt-ignore
return new RegExp(
`(?:^|;)\\s*${attributePattern}\\s*=\\s*` +
`(` +
`[^";\\s][^;\\s]*` +
`|` +
`"(?:[^"\\\\]|\\\\"?)+"?` +
`)`,
flags
);
}
/** Asynchronously read the headers out of body request and resolve with them as
* a `Headers` object. */
export async function readHeaders(
body: BufReader,
): Promise<Record<string, string>> {
const headers: Record<string, string> = {};
let readResult = await body.readLine();
while (readResult) {
const { bytes } = readResult;
if (!bytes.length) {
return headers;
}
let i = bytes.indexOf(COLON);
if (i === -1) {
throw new errors.BadRequest(
`Malformed header: ${decoder.decode(bytes)}`,
);
}
const key = decoder.decode(bytes.subarray(0, i)).trim().toLowerCase();
if (key === "") {
throw new errors.BadRequest("Invalid header key.");
}
i++;
while (i < bytes.byteLength && (bytes[i] === SPACE || bytes[i] === HTAB)) {
i++;
}
const value = decoder.decode(bytes.subarray(i)).trim();
headers[key] = value;
readResult = await body.readLine();
}
throw new errors.BadRequest("Unexpected end of body reached.");
}
/** Unquotes attribute values that might be pass as part of a header. */
export function unquote(value: string): string {
if (value.startsWith(`"`)) {
const parts = value.slice(1).split(`\\"`);
for (let i = 0; i < parts.length; ++i) {
const quoteIndex = parts[i].indexOf(`"`);
if (quoteIndex !== -1) {
parts[i] = parts[i].slice(0, quoteIndex);
parts.length = i + 1; // Truncates and stops the loop
}
parts[i] = parts[i].replace(/\\(.)/g, "$1");
}
value = parts.join(`"`);
}
return value;
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import type { Application, State } from "./application.ts";
import { NativeRequest } from "./http_server_native_request.ts";
import type { HttpConn, Listener, Server } from "./types.d.ts";
import { assert, isListenTlsOptions } from "./util.ts";
// this is included so when down-emitting to npm/Node.js, ReadableStream has
// async iterators
declare global {
// deno-lint-ignore no-explicit-any
interface ReadableStream<R = any> {
[Symbol.asyncIterator](options?: {
preventCancel?: boolean;
}): AsyncIterableIterator<R>;
}
}
export type Respond = (r: Response | Promise<Response>) => void;
// This type is part of Deno, but not part of lib.dom.d.ts, therefore add it here
// so that type checking can occur properly under `lib.dom.d.ts`.
interface ReadableStreamDefaultControllerCallback<R> {
(controller: ReadableStreamDefaultController<R>): void | PromiseLike<void>;
}
const serveHttp: (conn: Deno.Conn) => HttpConn = "serveHttp" in Deno
// deno-lint-ignore no-explicit-any
? (Deno as any).serveHttp.bind(Deno)
: undefined;
/** The oak abstraction of the Deno native HTTP server which is used internally
* for handling native HTTP requests. Generally users of oak do not need to
* worry about this class. */
// deno-lint-ignore no-explicit-any
export class HttpServer<AS extends State = Record<string, any>>
implements Server<NativeRequest> {
#app: Application<AS>;
#closed = false;
#listener?: Deno.Listener;
#httpConnections: Set<HttpConn> = new Set();
#options: Deno.ListenOptions | Deno.ListenTlsOptions;
constructor(
app: Application<AS>,
options: Deno.ListenOptions | Deno.ListenTlsOptions,
) {
if (!("serveHttp" in Deno)) {
throw new Error(
"The native bindings for serving HTTP are not available.",
);
}
this.#app = app;
this.#options = options;
}
get app(): Application<AS> {
return this.#app;
}
get closed(): boolean {
return this.#closed;
}
close(): void {
this.#closed = true;
if (this.#listener) {
this.#listener.close();
this.#listener = undefined;
}
for (const httpConn of this.#httpConnections) {
try {
httpConn.close();
} catch (error) {
if (!(error instanceof Deno.errors.BadResource)) {
throw error;
}
}
}
this.#httpConnections.clear();
}
listen(): Listener {
return (this.#listener = isListenTlsOptions(this.#options)
? Deno.listenTls(this.#options)
: Deno.listen(this.#options)) as Listener;
}
#trackHttpConnection(httpConn: HttpConn): void {
this.#httpConnections.add(httpConn);
}
#untrackHttpConnection(httpConn: HttpConn): void {
this.#httpConnections.delete(httpConn);
}
[Symbol.asyncIterator](): AsyncIterableIterator<NativeRequest> {
const start: ReadableStreamDefaultControllerCallback<NativeRequest> = (
controller,
) => {
// deno-lint-ignore no-this-alias
const server = this;
async function serve(conn: Deno.Conn) {
const httpConn = serveHttp(conn);
server.#trackHttpConnection(httpConn);
while (true) {
try {
const requestEvent = await httpConn.nextRequest();
if (requestEvent === null) {
return;
}
const nativeRequest = new NativeRequest(requestEvent, { conn });
controller.enqueue(nativeRequest);
// if we await here, this becomes blocking, and really all we want
// it to dispatch any errors that occur on the promise
nativeRequest.donePromise.catch((error) => {
server.app.dispatchEvent(new ErrorEvent("error", { error }));
});
} catch (error) {
server.app.dispatchEvent(new ErrorEvent("error", { error }));
}
if (server.closed) {
server.#untrackHttpConnection(httpConn);
httpConn.close();
controller.close();
}
}
}
const listener = this.#listener;
assert(listener);
async function accept() {
while (true) {
try {
const conn = await listener!.accept();
serve(conn);
} catch (error) {
if (!server.closed) {
server.app.dispatchEvent(new ErrorEvent("error", { error }));
}
}
if (server.closed) {
controller.close();
return;
}
}
}
accept();
};
const stream = new ReadableStream<NativeRequest>({ start });
return stream[Symbol.asyncIterator]();
}
}
@@ -0,0 +1,147 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import type {
RequestEvent,
ServerRequest,
ServerRequestBody,
UpgradeWebSocketFn,
UpgradeWebSocketOptions,
} from "./types.d.ts";
// deno-lint-ignore no-explicit-any
export const DomResponse: typeof Response = (globalThis as any).Response ??
class MockResponse {};
const maybeUpgradeWebSocket: UpgradeWebSocketFn | undefined =
"upgradeWebSocket" in Deno
// deno-lint-ignore no-explicit-any
? (Deno as any).upgradeWebSocket.bind(Deno)
: undefined;
export function isNativeRequest(r: ServerRequest): r is NativeRequest {
return r instanceof NativeRequest;
}
export interface NativeRequestOptions {
conn?: Deno.Conn;
upgradeWebSocket?: UpgradeWebSocketFn;
}
/** An internal oak abstraction for handling a Deno native request. Most users
* of oak do not need to worry about this abstraction. */
export class NativeRequest implements ServerRequest {
#conn?: Deno.Conn;
// deno-lint-ignore no-explicit-any
#reject!: (reason?: any) => void;
#request: Request;
#requestPromise: Promise<void>;
#resolve!: (value: Response) => void;
#resolved = false;
#upgradeWebSocket?: UpgradeWebSocketFn;
constructor(
requestEvent: RequestEvent,
options: NativeRequestOptions = {},
) {
const { conn } = options;
this.#conn = conn;
// this allows for the value to be explicitly undefined in the options
this.#upgradeWebSocket = "upgradeWebSocket" in options
? options["upgradeWebSocket"]
: maybeUpgradeWebSocket;
this.#request = requestEvent.request;
const p = new Promise<Response>((resolve, reject) => {
this.#resolve = resolve;
this.#reject = reject;
});
this.#requestPromise = requestEvent.respondWith(p);
}
get body(): ReadableStream<Uint8Array> | null {
// when shimming with undici under Node.js, this is a
// `ControlledAsyncIterable`
// deno-lint-ignore no-explicit-any
return this.#request.body as any;
}
get donePromise(): Promise<void> {
return this.#requestPromise;
}
get headers(): Headers {
return this.#request.headers;
}
get method(): string {
return this.#request.method;
}
get remoteAddr(): string | undefined {
return (this.#conn?.remoteAddr as Deno.NetAddr)?.hostname;
}
get request(): Request {
return this.#request;
}
get url(): string {
try {
const url = new URL(this.#request.url);
return this.#request.url.replace(url.origin, "");
} catch {
// we don't care about errors, we just want to fall back
}
return this.#request.url;
}
get rawUrl(): string {
return this.#request.url;
}
// deno-lint-ignore no-explicit-any
error(reason?: any): void {
if (this.#resolved) {
throw new Error("Request already responded to.");
}
this.#reject(reason);
this.#resolved = true;
}
getBody(): ServerRequestBody {
return {
// when emitting to Node.js, the body is not compatible, and thought it
// doesn't run at runtime, it still gets type checked.
// deno-lint-ignore no-explicit-any
body: this.#request.body as any,
readBody: async () => {
const ab = await this.#request.arrayBuffer();
return new Uint8Array(ab);
},
};
}
respond(response: Response): Promise<void> {
if (this.#resolved) {
throw new Error("Request already responded to.");
}
this.#resolve(response);
this.#resolved = true;
return this.#requestPromise;
}
upgrade(options?: UpgradeWebSocketOptions): WebSocket {
if (this.#resolved) {
throw new Error("Request already responded to.");
}
if (!this.#upgradeWebSocket) {
throw new TypeError("Upgrading web sockets not supported.");
}
const { response, socket } = this.#upgradeWebSocket(
this.#request,
options,
);
this.#resolve(response);
this.#resolved = true;
return socket;
}
}
+89
View File
@@ -0,0 +1,89 @@
/*!
* Adapted directly from type-is at https://github.com/jshttp/type-is/
* which is licensed as follows:
*
* Copyright(c) 2014 Jonathan Ong
* Copyright(c) 2014-2015 Douglas Christopher Wilson
* MIT Licensed
*/
import { typeByExtension } from "./deps.ts";
import { format, parse } from "./mediaTyper.ts";
function mimeMatch(expected: string | undefined, actual: string): boolean {
if (expected === undefined) {
return false;
}
const actualParts = actual.split("/");
const expectedParts = expected.split("/");
if (actualParts.length !== 2 || expectedParts.length !== 2) {
return false;
}
const [actualType, actualSubtype] = actualParts;
const [expectedType, expectedSubtype] = expectedParts;
if (expectedType !== "*" && expectedType !== actualType) {
return false;
}
if (expectedSubtype.substr(0, 2) === "*+") {
return (
expectedSubtype.length <= actualSubtype.length + 1 &&
expectedSubtype.substr(1) ===
actualSubtype.substr(1 - expectedSubtype.length)
);
}
if (expectedSubtype !== "*" && expectedSubtype !== actualSubtype) {
return false;
}
return true;
}
function normalize(type: string): string | undefined {
if (type === "urlencoded") {
return "application/x-www-form-urlencoded";
} else if (type === "multipart") {
return "multipart/*";
} else if (type[0] === "+") {
return `*/*${type}`;
}
return type.includes("/") ? type : typeByExtension(type);
}
function normalizeType(value: string): string | undefined {
try {
const val = value.split(";");
const type = parse(val[0]);
return format(type);
} catch {
return;
}
}
/** Given a value of the content type of a request and an array of types,
* provide the matching type or `false` if no types are matched.
*/
export function isMediaType(value: string, types: string[]): string | false {
const val = normalizeType(value);
if (!val) {
return false;
}
if (!types.length) {
return val;
}
for (const type of types) {
if (mimeMatch(normalize(type), val)) {
return type[0] === "+" || type.includes("*") ? val : type;
}
}
return false;
}
+82
View File
@@ -0,0 +1,82 @@
/*!
* Adapted directly from media-typer at https://github.com/jshttp/media-typer/
* which is licensed as follows:
*
* media-typer
* Copyright(c) 2014-2017 Douglas Christopher Wilson
* MIT Licensed
*/
const SUBTYPE_NAME_REGEXP = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.-]{0,126}$/;
const TYPE_NAME_REGEXP = /^[A-Za-z0-9][A-Za-z0-9!#$&^_-]{0,126}$/;
const TYPE_REGEXP =
/^ *([A-Za-z0-9][A-Za-z0-9!#$&^_-]{0,126})\/([A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}) *$/;
class MediaType {
constructor(
/** The type of the media type. */
public type: string,
/** The subtype of the media type. */
public subtype: string,
/** The optional suffix of the media type. */
public suffix?: string,
) {}
}
/** Given a media type object, return a media type string.
*
* format({
* type: "text",
* subtype: "html"
* }); // returns "text/html"
*/
export function format(obj: MediaType): string {
const { subtype, suffix, type } = obj;
if (!TYPE_NAME_REGEXP.test(type)) {
throw new TypeError("Invalid type.");
}
if (!SUBTYPE_NAME_REGEXP.test(subtype)) {
throw new TypeError("Invalid subtype.");
}
let str = `${type}/${subtype}`;
if (suffix) {
if (!TYPE_NAME_REGEXP.test(suffix)) {
throw new TypeError("Invalid suffix.");
}
str += `+${suffix}`;
}
return str;
}
/** Given a media type string, return a media type object.
*
* parse("application/json-patch+json");
* // returns {
* // type: "application",
* // subtype: "json-patch",
* // suffix: "json"
* // }
*/
export function parse(str: string): MediaType {
const match = TYPE_REGEXP.exec(str.toLowerCase());
if (!match) {
throw new TypeError("Invalid media type.");
}
let [, type, subtype] = match;
let suffix: string | undefined;
const idx = subtype.lastIndexOf("+");
if (idx !== -1) {
suffix = subtype.substr(idx + 1);
subtype = subtype.substr(0, idx);
}
return new MediaType(type, subtype, suffix);
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
// deno-lint-ignore-file
import type { State } from "./application.ts";
import type { Context } from "./context.ts";
/** A function for chaining middleware. */
export type Next = () => Promise<unknown>;
/** Middleware are functions which are chained together to deal with
* requests. */
export interface Middleware<
S extends State = Record<string, any>,
T extends Context = Context<S>,
> {
(context: T, next: Next): Promise<unknown> | unknown;
}
/** Middleware objects allow encapsulation of middleware along with the ability
* to initialize the middleware upon listen. */
export interface MiddlewareObject<
S extends State = Record<string, any>,
T extends Context<S> = Context<S>,
> {
/** Optional function for delayed initialization which will be called when
* the application starts listening. */
init?: () => Promise<unknown> | unknown;
/** The method to be called to handle the request. */
handleRequest(context: T, next: Next): Promise<unknown> | unknown;
}
/** Type that represents {@linkcode Middleware} or
* {@linkcode MiddlewareObject}. */
export type MiddlewareOrMiddlewareObject<
S extends State = Record<string, any>,
T extends Context = Context<S>,
> = Middleware<S, T> | MiddlewareObject<S, T>;
/** A type guard that returns true if the value is
* {@linkcode MiddlewareObject}. */
export function isMiddlewareObject<
S extends State = Record<string, any>,
T extends Context = Context<S>,
>(value: MiddlewareOrMiddlewareObject<S, T>): value is MiddlewareObject<S, T> {
return value && typeof value === "object" && "handleRequest" in value;
}
/** Compose multiple middleware functions into a single middleware function. */
export function compose<
S extends State = Record<string, any>,
T extends Context = Context<S>,
>(
middleware: MiddlewareOrMiddlewareObject<S, T>[],
): (context: T, next?: Next) => Promise<unknown> {
return function composedMiddleware(
context: T,
next?: Next,
): Promise<unknown> {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) {
throw new Error("next() called multiple times.");
}
index = i;
let m: MiddlewareOrMiddlewareObject<S, T> | undefined = middleware[i];
let fn: Middleware<S, T> | undefined;
if (typeof m === "function") {
fn = m;
} else if (m && typeof m.handleRequest === "function") {
fn = (m as MiddlewareObject).handleRequest.bind(m);
}
if (i === middleware.length) {
fn = next;
}
if (!fn) {
return;
}
await fn(context, dispatch.bind(null, i + 1));
}
return dispatch(0);
};
}
+512
View File
@@ -0,0 +1,512 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import { BufReader, ReadLineResult } from "./buf_reader.ts";
import { getFilename } from "./content_disposition.ts";
import { equals, errors, extension, writeAll } from "./deps.ts";
import { readHeaders, toParamRegExp, unquote } from "./headers.ts";
import { getRandomFilename, skipLWSPChar, stripEol } from "./util.ts";
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const BOUNDARY_PARAM_REGEX = toParamRegExp("boundary", "i");
const DEFAULT_BUFFER_SIZE = 1_048_576; // 1mb
const DEFAULT_MAX_FILE_SIZE = 10_485_760; // 10mb
const DEFAULT_MAX_SIZE = 0; // all files written to disc
const NAME_PARAM_REGEX = toParamRegExp("name", "i");
/** When reading a body in full via `.read()` from a {@linkcode FormDataReader}
* this is what is what the value is resolved, providing a split between any
* fields, and multi-part files that were provided. */
export interface FormDataBody {
/** A record of form parts where the key was the `name` of the part and the
* value was the value of the part. This record does not include any files
* that were part of the form data.
*
* *Note*: Duplicate names are not included in this record, if there are
* duplicates, the last value will be the value that is set here. If there
* is a possibility of duplicate values, use the `.stream()` method on
* {@linkcode FormDataReader} to iterate over the values. */
fields: Record<string, string>;
/** An array of any files that were part of the form data. */
files?: FormDataFile[];
}
/** A representation of a file that has been read from a form data body. Based
* on the {@linkcode FormDataReadOptions} that were passed when reading will
* determine if files are written to disk or not and how they are written to
* disk. When written to disk, the extension of the file will be determined by
* the content type, with the `.filename` property containing the full path to
* the file.
*
* The original filename as part of the form data is available in
* `originalName`, but for security and stability reasons, it is not used to
* determine the name of the file on disk. If further processing or renaming
* is required, the implementor should do that processing. */
export interface FormDataFile {
/** When the file has not been written out to disc, the contents of the file
* as a {@linkcode Uint8Array}. */
content?: Uint8Array;
/** The content type of the form data file. */
contentType: string;
/** When the file has been written out to disc, the full path to the file. */
filename?: string;
/** The `name` that was assigned to the form data file. */
name: string;
/** The `filename` that was provided in the form data file. */
originalName: string;
}
/** Options which impact how the form data is decoded for a
* {@linkcode FormDataReader}. All these options have sensible defaults for
* most applications, but can be modified for different use cases. Many of these
* options can have an impact on the stability of a server, especially if there
* is someone attempting a denial of service attack on your server, so be
* careful when changing the defaults. */
export interface FormDataReadOptions {
/** The size of the buffer to read from the request body at a single time.
* This defaults to 1mb. */
bufferSize?: number;
/** A mapping of custom media types that are supported, mapped to their
* extension when determining the extension for a file. The key should be an
* all lowercase media type with the value being the extension (without an
* initial period), to be used when decoding the file.
*
* ### Example
*
* Form data that is sent with content having a type of `text/vdn.custom` will
* be decoded and assigned a filename ending with `.txt`:
*
* ```ts
* import { Application } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
* app.use(async (ctx) => {
* const body = ctx.request.body();
* if (body.type === "form-data") {
* const formatData = await body.value.read({
* customContentTypes: {
* "text/vnd.custom": "txt"
* }
* });
* console.log(formData);
* }
* });
* ```
*/
customContentTypes?: Record<string, string>;
/** The maximum file size (in bytes) that can be handled. This defaults to
* 10MB when not specified. This is to try to avoid DOS attacks where
* someone would continue to try to send a "file" continuously until a host
* limit was reached crashing the server or the host. Also see `maxSize`. */
maxFileSize?: number;
/** The maximum size (in bytes) of a file to hold in memory, and not write
* to disk. This defaults to `0`, so that all multipart form files are
* written to disk. When set to a positive integer, if the form data file is
* smaller, it will be retained in memory and available in the `.content`
* property of the `FormDataFile` object. If the file exceeds the `maxSize`
* it will be written to disk and the `.filename` property will contain the
* full path to the output file. */
maxSize?: number;
/** When writing form data files to disk, the output path. This will default
* to a temporary path generated by `Deno.makeTempDir()`. */
outPath?: string;
/** When a form data file is written to disk, it will be generated with a
* random filename and have an extension based off the content type for the
* file. `prefix` can be specified though to prepend to the file name. */
prefix?: string;
}
interface PartsOptions {
body: BufReader;
customContentTypes?: Record<string, string>;
final: Uint8Array;
maxFileSize: number;
maxSize: number;
outPath?: string;
part: Uint8Array;
prefix?: string;
}
function append(a: Uint8Array, b: Uint8Array): Uint8Array {
const ab = new Uint8Array(a.length + b.length);
ab.set(a, 0);
ab.set(b, a.length);
return ab;
}
function isEqual(a: Uint8Array, b: Uint8Array): boolean {
return equals(skipLWSPChar(a), b);
}
async function readToStartOrEnd(
body: BufReader,
start: Uint8Array,
end: Uint8Array,
): Promise<boolean> {
let lineResult: ReadLineResult | null;
while ((lineResult = await body.readLine())) {
if (isEqual(lineResult.bytes, start)) {
return true;
}
if (isEqual(lineResult.bytes, end)) {
return false;
}
}
throw new errors.BadRequest(
"Unable to find multi-part boundary.",
);
}
/** Yield up individual parts by reading the body and parsing out the ford
* data values. */
async function* parts(
{
body,
customContentTypes = {},
final,
part,
maxFileSize,
maxSize,
outPath,
prefix,
}: PartsOptions,
): AsyncIterableIterator<[string, string | FormDataFile]> {
async function getFile(contentType: string): Promise<[string, Deno.FsFile]> {
const ext = customContentTypes[contentType.toLowerCase()] ??
extension(contentType);
if (!ext) {
throw new errors.BadRequest(
`The form contained content type "${contentType}" which is not supported by the server.`,
);
}
if (!outPath) {
outPath = await Deno.makeTempDir();
}
const filename = `${outPath}/${await getRandomFilename(prefix, ext)}`;
const file = await Deno.open(filename, { write: true, createNew: true });
return [filename, file];
}
while (true) {
const headers = await readHeaders(body);
const contentType = headers["content-type"];
const contentDisposition = headers["content-disposition"];
if (!contentDisposition) {
throw new errors.BadRequest(
"Form data part missing content-disposition header",
);
}
if (!contentDisposition.match(/^form-data;/i)) {
throw new errors.BadRequest(
`Unexpected content-disposition header: "${contentDisposition}"`,
);
}
const matches = NAME_PARAM_REGEX.exec(contentDisposition);
if (!matches) {
throw new errors.BadRequest(
`Unable to determine name of form body part`,
);
}
let [, name] = matches;
name = unquote(name);
if (contentType) {
const originalName = getFilename(contentDisposition);
let byteLength = 0;
let file: Deno.FsFile | undefined;
let filename: string | undefined;
let buf: Uint8Array | undefined;
if (maxSize) {
buf = new Uint8Array();
} else {
const result = await getFile(contentType);
filename = result[0];
file = result[1];
}
while (true) {
const readResult = await body.readLine(false);
if (!readResult) {
throw new errors.BadRequest("Unexpected EOF reached");
}
const { bytes } = readResult;
const strippedBytes = stripEol(bytes);
if (isEqual(strippedBytes, part) || isEqual(strippedBytes, final)) {
if (file) {
// remove extra 2 bytes ([CR, LF]) from result file
const bytesDiff = bytes.length - strippedBytes.length;
if (bytesDiff) {
const originalBytesSize = await file.seek(
-bytesDiff,
Deno.SeekMode.Current,
);
await file.truncate(originalBytesSize);
}
file.close();
}
yield [
name,
{
content: buf,
contentType,
name,
filename,
originalName,
} as FormDataFile,
];
if (isEqual(strippedBytes, final)) {
return;
}
break;
}
byteLength += bytes.byteLength;
if (byteLength > maxFileSize) {
if (file) {
file.close();
}
throw new errors.RequestEntityTooLarge(
`File size exceeds limit of ${maxFileSize} bytes.`,
);
}
if (buf) {
if (byteLength > maxSize) {
const result = await getFile(contentType);
filename = result[0];
file = result[1];
await writeAll(file, buf);
buf = undefined;
} else {
buf = append(buf, bytes);
}
}
if (file) {
await writeAll(file, bytes);
}
}
} else {
const lines: string[] = [];
while (true) {
const readResult = await body.readLine();
if (!readResult) {
throw new errors.BadRequest("Unexpected EOF reached");
}
const { bytes } = readResult;
if (isEqual(bytes, part) || isEqual(bytes, final)) {
yield [name, lines.join("\n")];
if (isEqual(bytes, final)) {
return;
}
break;
}
lines.push(decoder.decode(bytes));
}
}
}
}
/** An interface which provides an interface to access the fields of a
* `multipart/form-data` body.
*
* Normally an instance of this is accessed when handling a request body, and
* dealing with decoding it. There are options that can be set when attempting
* to read a multi-part body (see: {@linkcode FormDataReadOptions}).
*
* If you `.read()` the value, then a promise is provided of type
* {@linkcode FormDataBody}. If you use the `.stream()` property, it is an async
* iterator which yields up a tuple of with the first element being a
*
* ### Examples
*
* Using `.read()`:
*
* ```ts
* import { Application } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
*
* app.use(async (ctx) => {
* const body = ctx.request.body();
* if (body.type === "form-data") {
* const value = body.value;
* const formData = await value.read();
* // the form data is fully available
* }
* });
* ```
*
* Using `.stream()`:
*
* ```ts
* import { Application } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
*
* app.use(async (ctx) => {
* const body = ctx.request.body();
* if (body.type === "form-data") {
* const value = body.value;
* for await (const [name, value] of value.stream()) {
* // asynchronously iterate each part of the body
* }
* }
* });
* ```
*/
export class FormDataReader {
#body: Deno.Reader;
#boundaryFinal: Uint8Array;
#boundaryPart: Uint8Array;
#reading = false;
constructor(contentType: string, body: Deno.Reader) {
const matches = contentType.match(BOUNDARY_PARAM_REGEX);
if (!matches) {
throw new errors.BadRequest(
`Content type "${contentType}" does not contain a valid boundary.`,
);
}
let [, boundary] = matches;
boundary = unquote(boundary);
this.#boundaryPart = encoder.encode(`--${boundary}`);
this.#boundaryFinal = encoder.encode(`--${boundary}--`);
this.#body = body;
}
/** Reads the multipart body of the response and resolves with an object which
* contains fields and files that were part of the response.
*
* *Note*: this method handles multiple files with the same `name` attribute
* in the request, but by design it does not handle multiple fields that share
* the same `name`. If you expect the request body to contain multiple form
* data fields with the same name, it is better to use the `.stream()` method
* which will iterate over each form data field individually. */
async read(options: FormDataReadOptions = {}): Promise<FormDataBody> {
if (this.#reading) {
throw new Error("Body is already being read.");
}
this.#reading = true;
const {
outPath,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
maxSize = DEFAULT_MAX_SIZE,
bufferSize = DEFAULT_BUFFER_SIZE,
customContentTypes,
} = options;
const body = new BufReader(this.#body, bufferSize);
const result: FormDataBody = { fields: {} };
if (
!(await readToStartOrEnd(body, this.#boundaryPart, this.#boundaryFinal))
) {
return result;
}
try {
for await (
const part of parts({
body,
customContentTypes,
part: this.#boundaryPart,
final: this.#boundaryFinal,
maxFileSize,
maxSize,
outPath,
})
) {
const [key, value] = part;
if (typeof value === "string") {
result.fields[key] = value;
} else {
if (!result.files) {
result.files = [];
}
result.files.push(value);
}
}
} catch (err) {
if (err instanceof Deno.errors.PermissionDenied) {
console.error(err.stack ? err.stack : `${err.name}: ${err.message}`);
} else {
throw err;
}
}
return result;
}
/** Returns an iterator which will asynchronously yield each part of the form
* data. The yielded value is a tuple, where the first element is the name
* of the part and the second element is a `string` or a `FormDataFile`
* object. */
async *stream(
options: FormDataReadOptions = {},
): AsyncIterableIterator<[name: string, value: string | FormDataFile]> {
if (this.#reading) {
throw new Error("Body is already being read.");
}
this.#reading = true;
const {
outPath,
customContentTypes,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
maxSize = DEFAULT_MAX_SIZE,
bufferSize = 32000,
} = options;
const body = new BufReader(this.#body, bufferSize);
if (
!(await readToStartOrEnd(body, this.#boundaryPart, this.#boundaryFinal))
) {
return;
}
try {
for await (
const part of parts({
body,
customContentTypes,
part: this.#boundaryPart,
final: this.#boundaryFinal,
maxFileSize,
maxSize,
outPath,
})
) {
yield part;
}
} catch (err) {
if (err instanceof Deno.errors.PermissionDenied) {
console.error(err.stack ? err.stack : `${err.name}: ${err.message}`);
} else {
throw err;
}
}
}
[Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) {
return `${this.constructor.name} ${inspect({})}`;
}
[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,
});
return `${options.stylize(this.constructor.name, "special")} ${
inspect({}, newOptions)
}`;
}
}
+169
View File
@@ -0,0 +1,169 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import {
concat,
contentType,
copyBytes,
createHttpError,
Status,
} from "./deps.ts";
import { calculate } from "./etag.ts";
import type { FileInfo } from "./etag.ts";
import { assert, DEFAULT_CHUNK_SIZE } from "./util.ts";
const ETAG_RE = /(?:W\/)?"[ !#-\x7E\x80-\xFF]+"/;
export interface ByteRange {
start: number;
end: number;
}
/** Determine, by the value of an `If-Range` header, if a `Range` header should
* be applied to a request, returning `true` if it should or otherwise
* `false`. */
export async function ifRange(
value: string,
mtime: number,
entity: Uint8Array | FileInfo,
): Promise<boolean> {
if (value) {
const matches = value.match(ETAG_RE);
if (matches) {
const [match] = matches;
if (await calculate(entity) === match) {
return true;
}
} else {
return new Date(value).getTime() >= mtime;
}
}
return false;
}
export function parseRange(value: string, size: number): ByteRange[] {
const ranges: ByteRange[] = [];
const [unit, rangesStr] = value.split("=");
if (unit !== "bytes") {
throw createHttpError(Status.RequestedRangeNotSatisfiable);
}
for (const range of rangesStr.split(/\s*,\s+/)) {
const item = range.split("-");
if (item.length !== 2) {
throw createHttpError(Status.RequestedRangeNotSatisfiable);
}
const [startStr, endStr] = item;
let start: number;
let end: number;
try {
if (startStr === "") {
start = size - parseInt(endStr, 10) - 1;
end = size - 1;
} else if (endStr === "") {
start = parseInt(startStr, 10);
end = size - 1;
} else {
start = parseInt(startStr, 10);
end = parseInt(endStr, 10);
}
} catch {
throw createHttpError();
}
if (start < 0 || start >= size || end < 0 || end >= size || start > end) {
throw createHttpError(Status.RequestedRangeNotSatisfiable);
}
ranges.push({ start, end });
}
return ranges;
}
/** A reader */
async function readRange(
file: Deno.Reader & Deno.Seeker,
range: ByteRange,
): Promise<Uint8Array> {
let length = range.end - range.start + 1;
assert(length);
await file.seek(range.start, Deno.SeekMode.Start);
const result = new Uint8Array(length);
let off = 0;
while (length) {
const p = new Uint8Array(Math.min(length, DEFAULT_CHUNK_SIZE));
const nread = await file.read(p);
assert(nread !== null, "Unexpected EOF encountered when reading a range.");
assert(nread > 0, "Unexpected read of 0 bytes while reading a range.");
copyBytes(p, result, off);
off += nread;
length -= nread;
assert(length >= 0, "Unexpected length remaining.");
}
return result;
}
const encoder = new TextEncoder();
/** A class that takes a file (either a Deno.FsFile or Uint8Array) and bytes
* and streams the ranges as a multi-part encoded HTTP body.
*
* This is specifically used by the `.send()` functionality to fulfill range
* requests it receives, and could be used by others when trying to deal with
* range requests, but is generally a low level API that most users of oak
* would not need to worry about. */
export class MultiPartStream extends ReadableStream<Uint8Array> {
#contentLength: number;
#postscript: Uint8Array;
#preamble: Uint8Array;
constructor(
file: (Deno.Reader & Deno.Seeker & Deno.Closer) | Uint8Array,
type: string,
ranges: ByteRange[],
size: number,
boundary: string,
) {
super({
pull: async (controller) => {
const range = ranges.shift();
if (!range) {
controller.enqueue(this.#postscript);
controller.close();
if (!(file instanceof Uint8Array)) {
file.close();
}
return;
}
let bytes: Uint8Array;
if (file instanceof Uint8Array) {
bytes = file.subarray(range.start, range.end + 1);
} else {
bytes = await readRange(file, range);
}
const rangeHeader = encoder.encode(
`Content-Range: ${range.start}-${range.end}/${size}\n\n`,
);
controller.enqueue(concat(this.#preamble, rangeHeader, bytes));
},
});
const resolvedType = contentType(type);
if (!resolvedType) {
throw new TypeError(`Could not resolve media type for "${type}"`);
}
this.#preamble = encoder.encode(
`\n--${boundary}\nContent-Type: ${resolvedType}\n`,
);
this.#postscript = encoder.encode(`\n--${boundary}--\n`);
this.#contentLength = ranges.reduce(
(prev, { start, end }): number => {
return prev + this.#preamble.length + String(start).length +
String(end).length + String(size).length + 20 + (end - start);
},
this.#postscript.length,
);
}
/** The content length of the entire streamed body. */
contentLength() {
return this.#contentLength;
}
}
+269
View File
@@ -0,0 +1,269 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import type {
Body,
BodyBytes,
BodyForm,
BodyFormData,
BodyJson,
BodyOptions,
BodyReader,
BodyStream,
BodyText,
} from "./body.ts";
import { RequestBody } from "./body.ts";
import {
accepts,
acceptsEncodings,
acceptsLanguages,
type HTTPMethods,
} from "./deps.ts";
import type { ServerRequest } from "./types.d.ts";
export interface OakRequestOptions {
jsonBodyReviver?: (key: string, value: unknown) => unknown;
proxy?: boolean;
secure?: boolean;
}
/** An interface which provides information about the current request. The
* instance related to the current request is available on the
* {@linkcode Context}'s `.request` property.
*
* The interface contains several properties to get information about the
* request as well as several methods, which include content negotiation and
* the ability to decode a request body.
*/
export class Request {
#body: RequestBody;
#proxy: boolean;
#secure: boolean;
#serverRequest: ServerRequest;
#url?: URL;
#getRemoteAddr(): string {
return this.#serverRequest.remoteAddr ?? "";
}
/** Is `true` if the request might have a body, otherwise `false`.
*
* **WARNING** this is an unreliable API. In HTTP/2 in many situations you
* cannot determine if a request has a body or not unless you attempt to read
* the body, due to the streaming nature of HTTP/2. As of Deno 1.16.1, for
* HTTP/1.1, Deno also reflects that behaviour. The only reliable way to
* determine if a request has a body or not is to attempt to read the body.
*/
get hasBody(): boolean {
return this.#body.has();
}
/** The `Headers` supplied in the request. */
get headers(): Headers {
return this.#serverRequest.headers;
}
/** Request remote address. When the application's `.proxy` is true, the
* `X-Forwarded-For` will be used to determine the requesting remote address.
*/
get ip(): string {
return (this.#proxy ? this.ips[0] : this.#getRemoteAddr()) ?? "";
}
/** When the application's `.proxy` is `true`, this will be set to an array of
* IPs, ordered from upstream to downstream, based on the value of the header
* `X-Forwarded-For`. When `false` an empty array is returned. */
get ips(): string[] {
return this.#proxy
? (this.#serverRequest.headers.get("x-forwarded-for") ??
this.#getRemoteAddr()).split(/\s*,\s*/)
: [];
}
/** The HTTP Method used by the request. */
get method(): HTTPMethods {
return this.#serverRequest.method as HTTPMethods;
}
/** Shortcut to `request.url.protocol === "https:"`. */
get secure(): boolean {
return this.#secure;
}
/** Set to the value of the _original_ Deno server request. */
get originalRequest(): ServerRequest {
return this.#serverRequest;
}
/** A parsed URL for the request which complies with the browser standards.
* When the application's `.proxy` is `true`, this value will be based off of
* the `X-Forwarded-Proto` and `X-Forwarded-Host` header values if present in
* the request. */
get url(): URL {
if (!this.#url) {
const serverRequest = this.#serverRequest;
if (!this.#proxy) {
// between 1.9.0 and 1.9.1 the request.url of the native HTTP started
// returning the full URL, where previously it only returned the path
// so we will try to use that URL here, but default back to old logic
// if the URL isn't valid.
try {
if (serverRequest.rawUrl) {
this.#url = new URL(serverRequest.rawUrl);
return this.#url;
}
} catch {
// we don't care about errors here
}
}
let proto: string;
let host: string;
if (this.#proxy) {
proto = serverRequest
.headers.get("x-forwarded-proto")?.split(/\s*,\s*/, 1)[0] ??
"http";
host = serverRequest.headers.get("x-forwarded-host") ??
serverRequest.headers.get("host") ?? "";
} else {
proto = this.#secure ? "https" : "http";
host = serverRequest.headers.get("host") ?? "";
}
try {
this.#url = new URL(`${proto}://${host}${serverRequest.url}`);
} catch {
throw new TypeError(
`The server request URL of "${proto}://${host}${serverRequest.url}" is invalid.`,
);
}
}
return this.#url;
}
constructor(
serverRequest: ServerRequest,
{ proxy = false, secure = false, jsonBodyReviver }: OakRequestOptions = {},
) {
this.#proxy = proxy;
this.#secure = secure;
this.#serverRequest = serverRequest;
this.#body = new RequestBody(
serverRequest.getBody(),
serverRequest.headers,
jsonBodyReviver,
);
}
/** Returns an array of media types, accepted by the requestor, in order of
* preference. If there are no encodings supplied by the requestor,
* then accepting any is implied is returned.
*/
accepts(): string[] | undefined;
/** For a given set of media types, return the best match accepted by the
* requestor. If there are no encoding that match, then the method returns
* `undefined`.
*/
accepts(...types: string[]): string | undefined;
accepts(...types: string[]): string | string[] | undefined {
if (!this.#serverRequest.headers.has("Accept")) {
return types.length ? types[0] : ["*/*"];
}
if (types.length) {
return accepts(this.#serverRequest, ...types);
}
return accepts(this.#serverRequest);
}
/** Returns an array of encodings, accepted by the requestor, in order of
* preference. If there are no encodings supplied by the requestor,
* then `["*"]` is returned, matching any.
*/
acceptsEncodings(): string[] | undefined;
/** For a given set of encodings, return the best match accepted by the
* requestor. If there are no encodings that match, then the method returns
* `undefined`.
*
* **NOTE:** You should always supply `identity` as one of the encodings
* to ensure that there is a match when the `Accept-Encoding` header is part
* of the request.
*/
acceptsEncodings(...encodings: string[]): string | undefined;
acceptsEncodings(...encodings: string[]): string[] | string | undefined {
if (!this.#serverRequest.headers.has("Accept-Encoding")) {
return encodings.length ? encodings[0] : ["*"];
}
if (encodings.length) {
return acceptsEncodings(this.#serverRequest, ...encodings);
}
return acceptsEncodings(this.#serverRequest);
}
/** Returns an array of languages, accepted by the requestor, in order of
* preference. If there are no languages supplied by the requestor,
* `["*"]` is returned, indicating any language is accepted.
*/
acceptsLanguages(): string[] | undefined;
/** For a given set of languages, return the best match accepted by the
* requestor. If there are no languages that match, then the method returns
* `undefined`. */
acceptsLanguages(...langs: string[]): string | undefined;
acceptsLanguages(...langs: string[]): string[] | string | undefined {
if (!this.#serverRequest.headers.get("Accept-Language")) {
return langs.length ? langs[0] : ["*"];
}
if (langs.length) {
return acceptsLanguages(this.#serverRequest, ...langs);
}
return acceptsLanguages(this.#serverRequest);
}
body(options: BodyOptions<"bytes">): BodyBytes;
body(options: BodyOptions<"form">): BodyForm;
body(options: BodyOptions<"form-data">): BodyFormData;
body(options: BodyOptions<"json">): BodyJson;
body(options: BodyOptions<"reader">): BodyReader;
body(options: BodyOptions<"stream">): BodyStream;
body(options: BodyOptions<"text">): BodyText;
body(options?: BodyOptions): Body;
/** Access the body of the request. This is a method, because there are
* several options which can be provided which can influence how the body is
* handled. */
body(options: BodyOptions = {}): Body | BodyReader | BodyStream {
return this.#body.get(options);
}
[Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) {
const { hasBody, headers, ip, ips, method, secure, url } = this;
return `${this.constructor.name} ${
inspect({
hasBody,
headers,
ip,
ips,
method,
secure,
url: url.toString(),
})
}`;
}
[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 { hasBody, headers, ip, ips, method, secure, url } = this;
return `${options.stylize(this.constructor.name, "special")} ${
inspect(
{ hasBody, headers, ip, ips, method, secure, url },
newOptions,
)
}`;
}
}
+355
View File
@@ -0,0 +1,355 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import { contentType, isRedirectStatus, Status, STATUS_TEXT } from "./deps.ts";
import { DomResponse } from "./http_server_native_request.ts";
import type { Request } from "./request.ts";
import {
BODY_TYPES,
encodeUrl,
isAsyncIterable,
isHtml,
isReader,
readableStreamFromAsyncIterable,
readableStreamFromReader,
Uint8ArrayTransformStream,
} from "./util.ts";
export type ResponseBody =
| string
| number
| bigint
| boolean
| symbol
// deno-lint-ignore ban-types
| object
| undefined
| null;
export type ResponseBodyFunction = () => ResponseBody | Promise<ResponseBody>;
/** A symbol that indicates to `response.redirect()` to attempt to redirect
* back to the request referrer. For example:
*
* ```ts
* import { Application, REDIRECT_BACK } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
*
* app.use((ctx) => {
* if (ctx.request.url.pathName === "/back") {
* ctx.response.redirect(REDIRECT_BACK, "/");
* }
* });
*
* await app.listen({ port: 80 });
* ```
*/
export const REDIRECT_BACK = Symbol("redirect backwards");
export async function convertBodyToBodyInit(
body: ResponseBody | ResponseBodyFunction,
type?: string,
jsonBodyReplacer?: (key: string, value: unknown) => unknown,
): Promise<[globalThis.BodyInit | undefined, string | undefined]> {
let result: globalThis.BodyInit | undefined;
if (BODY_TYPES.includes(typeof body)) {
result = String(body);
type = type ?? (isHtml(result) ? "html" : "text/plain");
} else if (isReader(body)) {
result = readableStreamFromReader(body);
} else if (
ArrayBuffer.isView(body) || body instanceof ArrayBuffer ||
body instanceof Blob || body instanceof URLSearchParams
) {
// deno-lint-ignore no-explicit-any
result = body as any;
} else if (body instanceof ReadableStream) {
result = body.pipeThrough(new Uint8ArrayTransformStream());
} else if (body instanceof FormData) {
result = body;
type = "multipart/form-data";
} else if (isAsyncIterable(body)) {
result = readableStreamFromAsyncIterable(body);
} else if (body && typeof body === "object") {
result = JSON.stringify(body, jsonBodyReplacer);
type = type ?? "json";
} else if (typeof body === "function") {
const result = body.call(null);
return convertBodyToBodyInit(await result, type, jsonBodyReplacer);
} else if (body) {
throw new TypeError("Response body was set but could not be converted.");
}
return [result, type];
}
/** An interface to control what response will be sent when the middleware
* finishes processing the request.
*
* The response is usually accessed via the context's `.response` property.
*
* ### Example
*
* ```ts
* import { Application, Status } from "https://deno.land/x/oak/mod.ts";
*
* const app = new Application();
*
* app.use((ctx) => {
* ctx.response.body = { hello: "oak" };
* ctx.response.type = "json";
* ctx.response.status = Status.OK;
* });
* ```
*/
export class Response {
#body?: ResponseBody | ResponseBodyFunction;
#bodySet = false;
#domResponse?: globalThis.Response;
#headers = new Headers();
#jsonBodyReplacer?: (key: string, value: unknown) => unknown;
#request: Request;
#resources: number[] = [];
#status?: Status;
#type?: string;
#writable = true;
async #getBodyInit(): Promise<globalThis.BodyInit | undefined> {
const [body, type] = await convertBodyToBodyInit(
this.body,
this.type,
this.#jsonBodyReplacer,
);
this.type = type;
return body;
}
#setContentType(): void {
if (this.type) {
const contentTypeString = contentType(this.type);
if (contentTypeString && !this.headers.has("Content-Type")) {
this.headers.append("Content-Type", contentTypeString);
}
}
}
/** The body of the response. The body will be automatically processed when
* the response is being sent and converted to a `Uint8Array` or a
* `Deno.Reader`.
*
* Automatic conversion to a `Deno.Reader` occurs for async iterables. */
get body(): ResponseBody | ResponseBodyFunction {
return this.#body;
}
/** The body of the response. The body will be automatically processed when
* the response is being sent and converted to a `Uint8Array` or a
* `Deno.Reader`.
*
* Automatic conversion to a `Deno.Reader` occurs for async iterables. */
set body(value: ResponseBody | ResponseBodyFunction) {
if (!this.#writable) {
throw new Error("The response is not writable.");
}
this.#bodySet = true;
this.#body = value;
}
/** Headers that will be returned in the response. */
get headers(): Headers {
return this.#headers;
}
/** Headers that will be returned in the response. */
set headers(value: Headers) {
if (!this.#writable) {
throw new Error("The response is not writable.");
}
this.#headers = value;
}
/** The HTTP status of the response. If this has not been explicitly set,
* reading the value will return what would be the value of status if the
* response were sent at this point in processing the middleware. If the body
* has been set, the status will be `200 OK`. If a value for the body has
* not been set yet, the status will be `404 Not Found`. */
get status(): Status {
if (this.#status) {
return this.#status;
}
return this.body != null
? Status.OK
: this.#bodySet
? Status.NoContent
: Status.NotFound;
}
/** The HTTP status of the response. If this has not been explicitly set,
* reading the value will return what would be the value of status if the
* response were sent at this point in processing the middleware. If the body
* has been set, the status will be `200 OK`. If a value for the body has
* not been set yet, the status will be `404 Not Found`. */
set status(value: Status) {
if (!this.#writable) {
throw new Error("The response is not writable.");
}
this.#status = value;
}
/** The media type, or extension of the response. Setting this value will
* ensure an appropriate `Content-Type` header is added to the response. */
get type(): string | undefined {
return this.#type;
}
/** The media type, or extension of the response. Setting this value will
* ensure an appropriate `Content-Type` header is added to the response. */
set type(value: string | undefined) {
if (!this.#writable) {
throw new Error("The response is not writable.");
}
this.#type = value;
}
/** A read-only property which determines if the response is writable or not.
* Once the response has been processed, this value is set to `false`. */
get writable(): boolean {
return this.#writable;
}
constructor(
request: Request,
jsonBodyReplacer?: (key: string, value: unknown) => unknown,
) {
this.#request = request;
this.#jsonBodyReplacer = jsonBodyReplacer;
}
/** Add a resource to the list of resources that will be closed when the
* request is destroyed. */
addResource(rid: number): void {
this.#resources.push(rid);
}
/** Release any resources that are being tracked by the response.
*
* @param closeResources close any resource IDs registered with the response
*/
destroy(closeResources = true): void {
this.#writable = false;
this.#body = undefined;
this.#domResponse = undefined;
if (closeResources) {
for (const rid of this.#resources) {
try {
Deno.close(rid);
} catch {
// we don't care about errors here
}
}
}
}
/** Sets the response to redirect to the supplied `url`.
*
* If the `.status` is not currently a redirect status, the status will be set
* to `302 Found`.
*
* The body will be set to a message indicating the redirection is occurring.
*/
redirect(url: string | URL): void;
/** Sets the response to redirect back to the referrer if available, with an
* optional `alt` URL if there is no referrer header on the request. If there
* is no referrer header, nor an `alt` parameter, the redirect is set to `/`.
*
* If the `.status` is not currently a redirect status, the status will be set
* to `302 Found`.
*
* The body will be set to a message indicating the redirection is occurring.
*/
redirect(url: typeof REDIRECT_BACK, alt?: string | URL): void;
redirect(
url: string | URL | typeof REDIRECT_BACK,
alt: string | URL = "/",
): void {
if (url === REDIRECT_BACK) {
url = this.#request.headers.get("Referer") ?? String(alt);
} else if (typeof url === "object") {
url = String(url);
}
this.headers.set("Location", encodeUrl(url));
if (!this.status || !isRedirectStatus(this.status)) {
this.status = Status.Found;
}
if (this.#request.accepts("html")) {
url = encodeURI(url);
this.type = "text/html; charset=UTF-8";
this.body = `Redirecting to <a href="${url}">${url}</a>.`;
return;
}
this.type = "text/plain; charset=UTF-8";
this.body = `Redirecting to ${url}.`;
}
async toDomResponse(): Promise<globalThis.Response> {
if (this.#domResponse) {
return this.#domResponse;
}
const bodyInit = await this.#getBodyInit();
this.#setContentType();
const { headers } = this;
// If there is no body and no content type and no set length, then set the
// content length to 0
if (
!(
bodyInit ||
headers.has("Content-Type") ||
headers.has("Content-Length")
)
) {
headers.append("Content-Length", "0");
}
this.#writable = false;
const status = this.status;
const responseInit: ResponseInit = {
headers,
status,
statusText: STATUS_TEXT[status],
};
return this.#domResponse = new DomResponse(bodyInit, responseInit);
}
[Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) {
const { body, headers, status, type, writable } = this;
return `${this.constructor.name} ${
inspect({ body, headers, status, type, writable })
}`;
}
[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 { body, headers, status, type, writable } = this;
return `${options.stylize(this.constructor.name, "special")} ${
inspect(
{ body, headers, status, type, writable },
newOptions,
)
}`;
}
}
File diff suppressed because it is too large. Load diff
+367
View File
@@ -0,0 +1,367 @@
/*!
* Adapted from koa-send at https://github.com/koajs/send and which is licensed
* with the MIT license.
*/
import type { Context } from "./context.ts";
import { calculate, FileInfo, ifNoneMatch } from "./etag.ts";
import {
basename,
createHttpError,
extname,
LimitedReader,
parse,
readAll,
Status,
} from "./deps.ts";
import { ifRange, MultiPartStream, parseRange } from "./range.ts";
import type { Response } from "./response.ts";
import { assert, decodeComponent, getBoundary, resolvePath } from "./util.ts";
const MAXBUFFER_DEFAULT = 1_048_576; // 1MiB;
// this will be lazily set as it needs to be done asynchronously and we want to
// avoid top level await
let boundary: string | undefined;
export interface SendOptions {
/** Try to serve the brotli version of a file automatically when brotli is
* supported by a client and if the requested file with `.br` extension
* exists. (defaults to `true`) */
brotli?: boolean;
/** A record of extensions and content types that should be used when
* determining the content of a file being served. By default, the
* [`media_type`](https://github.com/oakserver/media_types/) database is used
* to map an extension to the served content-type. The keys of the map are
* extensions, and values are the content types to use. The content type can
* be a partial content type, which will be resolved to a full content type
* header.
*
* Any extensions matched will override the default behavior. Key should
* include the leading dot (e.g. `.ext` instead of just `ext`).
*
* ### Example
*
* ```ts
* app.use((ctx) => {
* return send(ctx, ctx.request.url.pathname, {
* contentTypes: {
* ".importmap": "application/importmap+json"
* },
* root: ".",
* })
* });
* ```
*/
contentTypes?: Record<string, string>;
/** Try to match extensions from passed array to search for file when no
* extension is sufficed in URL. First found is served. (defaults to
* `undefined`) */
extensions?: string[];
/** If `true`, format the path to serve static file servers and not require a
* trailing slash for directories, so that you can do both `/directory` and
* `/directory/`. (defaults to `true`) */
format?: boolean;
/** Try to serve the gzipped version of a file automatically when gzip is
* supported by a client and if the requested file with `.gz` extension
* exists. (defaults to `true`). */
gzip?: boolean;
/** Allow transfer of hidden files. (defaults to `false`) */
hidden?: boolean;
/** Tell the browser the resource is immutable and can be cached
* indefinitely. (defaults to `false`) */
immutable?: boolean;
/** Name of the index file to serve automatically when visiting the root
* location. (defaults to none) */
index?: string;
/** Browser cache max-age in milliseconds. (defaults to `0`) */
maxage?: number;
/** A size in bytes where if the file is less than this size, the file will
* be read into memory by send instead of returning a file handle. Files less
* than the byte size will send an "strong" `ETag` header while those larger
* than the bytes size will only be able to send a "weak" `ETag` header (as
* they cannot hash the contents of the file). (defaults to 1MiB)
*/
maxbuffer?: number;
/** Root directory to restrict file access. */
root: string;
}
function isHidden(path: string) {
const pathArr = path.split("/");
for (const segment of pathArr) {
if (segment[0] === "." && segment !== "." && segment !== "..") {
return true;
}
return false;
}
}
async function exists(path: string): Promise<boolean> {
try {
return (await Deno.stat(path)).isFile;
} catch {
return false;
}
}
async function getEntity(
path: string,
mtime: number,
stats: Deno.FileInfo,
maxbuffer: number,
response: Response,
): Promise<[Uint8Array | Deno.FsFile, Uint8Array | FileInfo]> {
let body: Uint8Array | Deno.FsFile;
let entity: Uint8Array | FileInfo;
const file = await Deno.open(path, { read: true });
if (stats.size < maxbuffer) {
const buffer = await readAll(file);
file.close();
body = entity = buffer;
} else {
response.addResource(file.rid);
body = file;
entity = {
mtime: new Date(mtime!),
size: stats.size,
};
}
return [body, entity];
}
async function sendRange(
response: Response,
body: Uint8Array | Deno.FsFile,
range: string,
size: number,
) {
const ranges = parseRange(range, size);
if (ranges.length === 0) {
throw createHttpError(Status.RequestedRangeNotSatisfiable);
}
response.status = Status.PartialContent;
if (ranges.length === 1) {
const [byteRange] = ranges;
response.headers.set(
"Content-Range",
`bytes ${byteRange.start}-${byteRange.end}/${size}`,
);
if (body instanceof Uint8Array) {
response.body = body.slice(byteRange.start, byteRange.end + 1);
} else {
await body.seek(byteRange.start, Deno.SeekMode.Start);
response.body = new LimitedReader(
body,
byteRange.end - byteRange.start + 1,
);
}
} else {
assert(response.type);
if (!boundary) {
boundary = await getBoundary();
}
response.headers.set(
"content-type",
`multipart/byteranges; boundary=${boundary}`,
);
const multipartBody = new MultiPartStream(
body,
response.type,
ranges,
size,
boundary,
);
response.body = multipartBody;
}
}
/** Asynchronously fulfill a response with a file from the local file
* system.
*
* Requires Deno read permission for the `root` directory. */
export async function send(
// deno-lint-ignore no-explicit-any
{ request, response }: Context<any>,
path: string,
options: SendOptions = { root: "" },
): Promise<string | undefined> {
const {
brotli = true,
contentTypes = {},
extensions,
format = true,
gzip = true,
hidden = false,
immutable = false,
index,
maxbuffer = MAXBUFFER_DEFAULT,
maxage = 0,
root,
} = options;
const trailingSlash = path[path.length - 1] === "/";
path = decodeComponent(path.substr(parse(path).root.length));
if (index && trailingSlash) {
path += index;
}
if (!hidden && isHidden(path)) {
throw createHttpError(403);
}
path = resolvePath(root, path);
let encodingExt = "";
if (
brotli &&
request.acceptsEncodings("br", "identity") === "br" &&
(await exists(`${path}.br`))
) {
path = `${path}.br`;
response.headers.set("Content-Encoding", "br");
response.headers.delete("Content-Length");
encodingExt = ".br";
} else if (
gzip &&
request.acceptsEncodings("gzip", "identity") === "gzip" &&
(await exists(`${path}.gz`))
) {
path = `${path}.gz`;
response.headers.set("Content-Encoding", "gzip");
response.headers.delete("Content-Length");
encodingExt = ".gz";
}
if (extensions && !/\.[^/]*$/.exec(path)) {
for (let ext of extensions) {
if (!/^\./.exec(ext)) {
ext = `.${ext}`;
}
if (await exists(`${path}${ext}`)) {
path += ext;
break;
}
}
}
let stats: Deno.FileInfo;
try {
stats = await Deno.stat(path);
if (stats.isDirectory) {
if (format && index) {
path += `/${index}`;
stats = await Deno.stat(path);
} else {
return;
}
}
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
throw createHttpError(404, err.message);
}
// TODO(@kitsonk) remove when https://github.com/denoland/node_deno_shims/issues/87 resolved
if (err instanceof Error && err.message.startsWith("ENOENT:")) {
throw createHttpError(404, err.message);
}
throw createHttpError(
500,
err instanceof Error ? err.message : "[non-error thrown]",
);
}
let mtime: number | null = null;
if (response.headers.has("Last-Modified")) {
mtime = new Date(response.headers.get("Last-Modified")!).getTime();
} else if (stats.mtime) {
// Round down to second because it's the precision of the UTC string.
mtime = stats.mtime.getTime();
mtime -= mtime % 1000;
response.headers.set("Last-Modified", new Date(mtime).toUTCString());
}
if (!response.headers.has("Cache-Control")) {
const directives = [`max-age=${(maxage / 1000) | 0}`];
if (immutable) {
directives.push("immutable");
}
response.headers.set("Cache-Control", directives.join(","));
}
if (!response.type) {
response.type = encodingExt !== ""
? extname(basename(path, encodingExt))
: contentTypes[extname(path)] ?? extname(path);
}
let entity: Uint8Array | FileInfo | null = null;
let body: Uint8Array | Deno.FsFile | null = null;
if (request.headers.has("If-None-Match") && mtime) {
[body, entity] = await getEntity(path, mtime, stats, maxbuffer, response);
const etag = await calculate(entity);
if (
etag && (!await ifNoneMatch(request.headers.get("If-None-Match")!, etag))
) {
response.headers.set("ETag", etag);
response.status = 304;
return path;
}
}
if (request.headers.has("If-Modified-Since") && mtime) {
const ifModifiedSince = new Date(request.headers.get("If-Modified-Since")!);
if (ifModifiedSince.getTime() >= mtime) {
response.status = 304;
return path;
}
}
if (!body || !entity) {
[body, entity] = await getEntity(
path,
mtime ?? 0,
stats,
maxbuffer,
response,
);
}
if (
request.headers.has("If-Range") && mtime &&
await ifRange(request.headers.get("If-Range")!, mtime, entity) &&
request.headers.has("Range")
) {
await sendRange(response, body, request.headers.get("Range")!, stats.size);
return path;
}
if (request.headers.has("Range")) {
await sendRange(response, body, request.headers.get("Range")!, stats.size);
return path;
}
response.body = body;
if (!response.headers.has("ETag")) {
const etag = await calculate(entity);
if (etag) {
response.headers.set("ETag", etag);
}
}
if (!response.headers.has("Accept-Ranges")) {
response.headers.set("Accept-Ranges", "bytes");
}
return path;
}
+177
View File
@@ -0,0 +1,177 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
export type StructuredClonable =
| { [key: string]: StructuredClonable }
| Array<StructuredClonable>
| ArrayBuffer
| ArrayBufferView
// deno-lint-ignore ban-types
| BigInt
| bigint
| Blob
// deno-lint-ignore ban-types
| Boolean
| boolean
| Date
| Error
| EvalError
| Map<StructuredClonable, StructuredClonable>
// deno-lint-ignore ban-types
| Number
| number
| RangeError
| ReferenceError
| RegExp
| Set<StructuredClonable>
// deno-lint-ignore ban-types
| String
| string
| SyntaxError
| TypeError
| URIError;
/** Internal functions on the `Deno.core` namespace */
interface DenoCore {
deserialize(value: unknown): StructuredClonable;
serialize(value: StructuredClonable): unknown;
}
const objectCloneMemo = new WeakMap();
function cloneArrayBuffer(
srcBuffer: ArrayBuffer,
srcByteOffset: number,
srcLength: number,
// deno-lint-ignore no-explicit-any
_cloneConstructor: any,
) {
// this function fudges the return type but SharedArrayBuffer is disabled for a while anyway
return srcBuffer.slice(
srcByteOffset,
srcByteOffset + srcLength,
);
}
/** A loose approximation for structured cloning, used when the `Deno.core`
* APIs are not available. */
// deno-lint-ignore no-explicit-any
function cloneValue(value: any): any {
switch (typeof value) {
case "number":
case "string":
case "boolean":
case "undefined":
case "bigint":
return value;
case "object": {
if (objectCloneMemo.has(value)) {
return objectCloneMemo.get(value);
}
if (value === null) {
return value;
}
if (value instanceof Date) {
return new Date(value.valueOf());
}
if (value instanceof RegExp) {
return new RegExp(value);
}
if (value instanceof SharedArrayBuffer) {
return value;
}
if (value instanceof ArrayBuffer) {
const cloned = cloneArrayBuffer(
value,
0,
value.byteLength,
ArrayBuffer,
);
objectCloneMemo.set(value, cloned);
return cloned;
}
if (ArrayBuffer.isView(value)) {
const clonedBuffer = cloneValue(value.buffer);
// Use DataViewConstructor type purely for type-checking, can be a
// DataView or TypedArray. They use the same constructor signature,
// only DataView has a length in bytes and TypedArrays use a length in
// terms of elements, so we adjust for that.
let length;
if (value instanceof DataView) {
length = value.byteLength;
} else {
// deno-lint-ignore no-explicit-any
length = (value as any).length;
}
// deno-lint-ignore no-explicit-any
return new (value.constructor as any)(
clonedBuffer,
value.byteOffset,
length,
);
}
if (value instanceof Map) {
const clonedMap = new Map();
objectCloneMemo.set(value, clonedMap);
value.forEach((v, k) => {
clonedMap.set(cloneValue(k), cloneValue(v));
});
return clonedMap;
}
if (value instanceof Set) {
// assumes that cloneValue still takes only one argument
const clonedSet = new Set([...value].map(cloneValue));
objectCloneMemo.set(value, clonedSet);
return clonedSet;
}
// default for objects
// deno-lint-ignore no-explicit-any
const clonedObj: Record<any, any> = {};
objectCloneMemo.set(value, clonedObj);
const sourceKeys = Object.getOwnPropertyNames(value);
for (const key of sourceKeys) {
clonedObj[key] = cloneValue(value[key]);
}
Reflect.setPrototypeOf(clonedObj, Reflect.getPrototypeOf(value));
return clonedObj;
}
case "symbol":
case "function":
default:
throw new DOMException("Uncloneable value in stream", "DataCloneError");
}
}
// deno-lint-ignore no-explicit-any
const core = (Deno as any)?.core as DenoCore | undefined;
const structuredClone: ((value: unknown) => unknown) | undefined =
// deno-lint-ignore no-explicit-any
(globalThis as any).structuredClone;
/**
* Provides structured cloning
* @param value
* @returns
*/
function sc<T extends StructuredClonable>(value: T): T {
return structuredClone
? structuredClone(value)
: core
? core.deserialize(core.serialize(value))
: cloneValue(value);
}
/** Clones a state object, skipping any values that cannot be cloned. */
// deno-lint-ignore no-explicit-any
export function cloneState<S extends Record<string, any>>(state: S): S {
const clone = {} as S;
for (const [key, value] of Object.entries(state)) {
try {
const clonedValue = sc(value);
clone[key as keyof S] = clonedValue;
} catch {
// we just no-op values that cannot be cloned
}
}
return clone;
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import type { Application, State } from "./application.ts";
export interface Listener {
addr: { hostname: string; port: number };
}
export interface Server<T> extends AsyncIterable<T> {
close(): void | Promise<void>;
listen(): Listener | Promise<Listener>;
[Symbol.asyncIterator](): AsyncIterableIterator<T>;
}
export interface ServerRequestBody {
body: ReadableStream<Uint8Array> | null;
readBody: () => Promise<Uint8Array>;
}
export interface ServerRequest {
readonly remoteAddr: string | undefined;
readonly headers: Headers;
readonly method: string;
readonly rawUrl?: string;
readonly url: string;
// deno-lint-ignore no-explicit-any
error(reason?: any): void;
getBody(): ServerRequestBody;
respond(response: Response): Promise<void>;
upgrade?(options?: UpgradeWebSocketOptions): WebSocket;
}
export interface ServerConstructor<T extends ServerRequest> {
// deno-lint-ignore no-explicit-any
new <AS extends State = Record<string, any>>(
app: Application<AS>,
options: Deno.ListenOptions | Deno.ListenTlsOptions,
): Server<T>;
prototype: Server<T>;
}
export type Data = string | number[] | ArrayBuffer | Uint8Array;
export type Key = string | number[] | ArrayBuffer | Uint8Array;
export interface UpgradeWebSocketOptions {
protocol?: string;
}
export type UpgradeWebSocketFn = (
request: Request,
options?: UpgradeWebSocketOptions,
) => WebSocketUpgrade;
interface WebSocketUpgrade {
response: Response;
socket: WebSocket;
}
// Since the native bindings are currently unstable in Deno, we will add the
// interfaces here, so that we can type check oak without requiring the
// `--unstable` flag to be used.
export interface RequestEvent {
readonly request: Request;
respondWith(r: Response | Promise<Response>): Promise<void>;
}
export interface HttpConn extends AsyncIterable<RequestEvent> {
readonly rid: number;
nextRequest(): Promise<RequestEvent | null>;
close(): void;
}
+413
View File
@@ -0,0 +1,413 @@
// Copyright 2018-2023 the oak authors. All rights reserved. MIT license.
import type { State } from "./application.ts";
import type { Context } from "./context.ts";
import {
base64,
createHttpError,
isAbsolute,
join,
normalize,
SEP,
} from "./deps.ts";
import type { RouteParams, RouterContext } from "./router.ts";
import type { Data, Key } from "./types.d.ts";
const ENCODE_CHARS_REGEXP =
/(?:[^\x21\x25\x26-\x3B\x3D\x3F-\x5B\x5D\x5F\x61-\x7A\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g;
const HTAB = "\t".charCodeAt(0);
const SPACE = " ".charCodeAt(0);
const CR = "\r".charCodeAt(0);
const LF = "\n".charCodeAt(0);
const UNMATCHED_SURROGATE_PAIR_REGEXP =
/(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g;
const UNMATCHED_SURROGATE_PAIR_REPLACE = "$1\uFFFD$2";
export const DEFAULT_CHUNK_SIZE = 16_640; // 17 Kib
/** Body types which will be coerced into strings before being sent. */
export const BODY_TYPES = ["string", "number", "bigint", "boolean", "symbol"];
export function assert(cond: unknown, msg = "Assertion failed"): asserts cond {
if (!cond) {
throw new Error(msg);
}
}
/** Safely decode a URI component, where if it fails, instead of throwing,
* just returns the original string
*/
export function decodeComponent(text: string) {
try {
return decodeURIComponent(text);
} catch {
return text;
}
}
/** Encodes the url preventing double enconding */
export function encodeUrl(url: string) {
return String(url)
.replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE)
.replace(ENCODE_CHARS_REGEXP, encodeURI);
}
function bufferToHex(buffer: ArrayBuffer): string {
const arr = Array.from(new Uint8Array(buffer));
return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function getRandomFilename(
prefix = "",
extension = "",
): Promise<string> {
const buffer = await crypto.subtle.digest(
"SHA-1",
crypto.getRandomValues(new Uint8Array(256)),
);
return `${prefix}${bufferToHex(buffer)}${extension ? `.${extension}` : ""}`;
}
export async function getBoundary(): Promise<string> {
const buffer = await crypto.subtle.digest(
"SHA-1",
crypto.getRandomValues(new Uint8Array(256)),
);
return `oak_${bufferToHex(buffer)}`;
}
/** Guard for Async Iterables */
export function isAsyncIterable(
value: unknown,
): value is AsyncIterable<unknown> {
return typeof value === "object" && value !== null &&
Symbol.asyncIterator in value &&
// deno-lint-ignore no-explicit-any
typeof (value as any)[Symbol.asyncIterator] === "function";
}
export function isRouterContext<
R extends string,
P extends RouteParams<R>,
S extends State,
>(
value: Context<S>,
): value is RouterContext<R, P, S> {
return "params" in value;
}
/** Guard for `Deno.Reader`. */
export function isReader(value: unknown): value is Deno.Reader {
return typeof value === "object" && value !== null && "read" in value &&
typeof (value as Record<string, unknown>).read === "function";
}
function isCloser(value: unknown): value is Deno.Closer {
return typeof value === "object" && value != null && "close" in value &&
// deno-lint-ignore no-explicit-any
typeof (value as Record<string, any>)["close"] === "function";
}
export function isConn(value: unknown): value is Deno.Conn {
return typeof value === "object" && value != null && "rid" in value &&
// deno-lint-ignore no-explicit-any
typeof (value as any).rid === "number" && "localAddr" in value &&
"remoteAddr" in value;
}
export function isListenTlsOptions(
value: unknown,
): value is Deno.ListenTlsOptions {
return typeof value === "object" && value !== null &&
("cert" in value || "certFile" in value) &&
("key" in value || "keyFile" in value) && "port" in value;
}
export interface ReadableStreamFromReaderOptions {
/** If the `reader` is also a `Deno.Closer`, automatically close the `reader`
* when `EOF` is encountered, or a read error occurs.
*
* Defaults to `true`. */
autoClose?: boolean;
/** The size of chunks to allocate to read, the default is ~16KiB, which is
* the maximum size that Deno operations can currently support. */
chunkSize?: number;
/** The queuing strategy to create the `ReadableStream` with. */
strategy?: { highWaterMark?: number | undefined; size?: undefined };
}
/**
* Create a `ReadableStream<Uint8Array>` from an `AsyncIterable`.
*/
export function readableStreamFromAsyncIterable(
source: AsyncIterable<unknown>,
): ReadableStream<Uint8Array> {
return new ReadableStream({
async start(controller) {
for await (const chunk of source) {
if (BODY_TYPES.includes(typeof chunk)) {
controller.enqueue(encoder.encode(String(chunk)));
} else if (chunk instanceof Uint8Array) {
controller.enqueue(chunk);
} else if (ArrayBuffer.isView(chunk)) {
controller.enqueue(new Uint8Array(chunk.buffer));
} else if (chunk instanceof ArrayBuffer) {
controller.enqueue(new Uint8Array(chunk));
} else {
try {
controller.enqueue(encoder.encode(JSON.stringify(chunk)));
} catch {
// we just swallow errors here
}
}
}
controller.close();
},
});
}
/**
* Create a `ReadableStream<Uint8Array>` from a `Deno.Reader`.
*
* When the pull algorithm is called on the stream, a chunk from the reader
* will be read. When `null` is returned from the reader, the stream will be
* closed along with the reader (if it is also a `Deno.Closer`).
*
* An example converting a `Deno.FsFile` into a readable stream:
*
* ```ts
* import { readableStreamFromReader } from "https://deno.land/std/io/mod.ts";
*
* const file = await Deno.open("./file.txt", { read: true });
* const fileStream = readableStreamFromReader(file);
* ```
*/
export function readableStreamFromReader(
reader: Deno.Reader | (Deno.Reader & Deno.Closer),
options: ReadableStreamFromReaderOptions = {},
): ReadableStream<Uint8Array> {
const {
autoClose = true,
chunkSize = DEFAULT_CHUNK_SIZE,
strategy,
} = options;
return new ReadableStream({
async pull(controller) {
const chunk = new Uint8Array(chunkSize);
try {
const read = await reader.read(chunk);
if (read === null) {
if (isCloser(reader) && autoClose) {
reader.close();
}
controller.close();
return;
}
controller.enqueue(chunk.subarray(0, read));
} catch (e) {
controller.error(e);
if (isCloser(reader)) {
reader.close();
}
}
},
cancel() {
if (isCloser(reader) && autoClose) {
reader.close();
}
},
}, strategy);
}
/** Determines if a string "looks" like HTML */
export function isHtml(value: string): boolean {
return /^\s*<(?:!DOCTYPE|html|body)/i.test(value);
}
/** Returns `u8` with leading white space removed. */
export function skipLWSPChar(u8: Uint8Array): Uint8Array {
const result = new Uint8Array(u8.length);
let j = 0;
for (let i = 0; i < u8.length; i++) {
if (u8[i] === SPACE || u8[i] === HTAB) continue;
result[j++] = u8[i];
}
return result.slice(0, j);
}
export function stripEol(value: Uint8Array): Uint8Array {
if (value[value.byteLength - 1] == LF) {
let drop = 1;
if (value.byteLength > 1 && value[value.byteLength - 2] === CR) {
drop = 2;
}
return value.subarray(0, value.byteLength - drop);
}
return value;
}
/*!
* Adapted directly from https://github.com/pillarjs/resolve-path
* which is licensed as follows:
*
* The MIT License (MIT)
*
* Copyright (c) 2014 Jonathan Ong <me@jongleberry.com>
* Copyright (c) 2015-2018 Douglas Christopher Wilson <doug@somethingdoug.com>
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* 'Software'), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
export function resolvePath(relativePath: string): string;
export function resolvePath(rootPath: string, relativePath: string): string;
export function resolvePath(rootPath: string, relativePath?: string): string {
let path = relativePath;
let root = rootPath;
// root is optional, similar to root.resolve
if (relativePath === undefined) {
path = rootPath;
root = ".";
}
if (path == null) {
throw new TypeError("Argument relativePath is required.");
}
// containing NULL bytes is malicious
if (path.includes("\0")) {
throw createHttpError(400, "Malicious Path");
}
// path should never be absolute
if (isAbsolute(path)) {
throw createHttpError(400, "Malicious Path");
}
// path outside root
if (UP_PATH_REGEXP.test(normalize(`.${SEP}${path}`))) {
throw createHttpError(403);
}
// join the relative path
return normalize(join(root, path));
}
/** A utility class that transforms "any" chunk into an `Uint8Array`. */
export class Uint8ArrayTransformStream
extends TransformStream<unknown, Uint8Array> {
constructor() {
const init = {
async transform(
chunk: unknown,
controller: TransformStreamDefaultController<Uint8Array>,
) {
chunk = await chunk;
switch (typeof chunk) {
case "object":
if (chunk === null) {
controller.terminate();
} else if (ArrayBuffer.isView(chunk)) {
controller.enqueue(
new Uint8Array(
chunk.buffer,
chunk.byteOffset,
chunk.byteLength,
),
);
} else if (
Array.isArray(chunk) &&
chunk.every((value) => typeof value === "number")
) {
controller.enqueue(new Uint8Array(chunk));
} else if (
typeof chunk.valueOf === "function" && chunk.valueOf() !== chunk
) {
this.transform(chunk.valueOf(), controller);
} else if ("toJSON" in chunk) {
this.transform(JSON.stringify(chunk), controller);
}
break;
case "symbol":
controller.error(
new TypeError("Cannot transform a symbol to a Uint8Array"),
);
break;
case "undefined":
controller.error(
new TypeError("Cannot transform undefined to a Uint8Array"),
);
break;
default:
controller.enqueue(this.encoder.encode(String(chunk)));
}
},
encoder: new TextEncoder(),
};
super(init);
}
}
const replacements: Record<string, string> = {
"/": "_",
"+": "-",
"=": "",
};
const encoder = new TextEncoder();
export function encodeBase64Safe(data: string | ArrayBuffer): string {
return base64.encode(data).replace(/\/|\+|=/g, (c) => replacements[c]);
}
export function isNode(): boolean {
return "process" in globalThis && "global" in globalThis;
}
export function importKey(key: Key): Promise<CryptoKey> {
if (typeof key === "string") {
key = encoder.encode(key);
} else if (Array.isArray(key)) {
key = new Uint8Array(key);
}
return crypto.subtle.importKey(
"raw",
key,
{
name: "HMAC",
hash: { name: "SHA-256" },
},
true,
["sign", "verify"],
);
}
export function sign(data: Data, key: CryptoKey): Promise<ArrayBuffer> {
if (typeof data === "string") {
data = encoder.encode(data);
} else if (Array.isArray(data)) {
data = Uint8Array.from(data);
}
return crypto.subtle.sign("HMAC", key, data);
}