Started to implement server
This commit is contained in:
1 parent
1f2ea8b7e5
commit
17a1b4bed3
177 files changed
+29894
-74
No files matched your search
+699
@@ -0,0 +1,699 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
import { Context } from "./context.ts";
|
||||
import { KeyStack, Status, STATUS_TEXT } from "./deps.ts";
|
||||
import { FlashServer } from "./http_server_flash.ts";
|
||||
import { HttpServer } from "./http_server_native.ts";
|
||||
import { NativeRequest } from "./http_server_native_request.ts";
|
||||
import { compose, Middleware } 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 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" | "flash" | "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 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" | "flash" | "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: Middleware<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();
|
||||
state.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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: "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.");
|
||||
}
|
||||
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;
|
||||
}
|
||||
state.closing = true;
|
||||
});
|
||||
}
|
||||
const { secure = false } = options;
|
||||
const serverType = server instanceof HttpServer
|
||||
? "native"
|
||||
: server instanceof FlashServer
|
||||
? "flash"
|
||||
: "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: Middleware<S, Context<S, AS>>,
|
||||
...middlewares: Middleware<S, Context<S, AS>>[]
|
||||
): Application<S extends AS ? S : (S & AS)>;
|
||||
use<S extends State = AS>(
|
||||
...middleware: Middleware<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,
|
||||
)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
import { errors, readerFromStreamReader } 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();
|
||||
|
||||
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(new RangeError(`Body exceeds a limit of ${limit}.`));
|
||||
}
|
||||
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();
|
||||
return this.#formDataReader ??
|
||||
(this.#formDataReader = new FormDataReader(
|
||||
contentType,
|
||||
readerFromStreamReader(
|
||||
(readableStream as ReadableStream<Uint8Array>).getReader(),
|
||||
),
|
||||
));
|
||||
};
|
||||
case "json":
|
||||
this.#type = "bytes";
|
||||
if (this.#exceedsLimit(limit)) {
|
||||
return () =>
|
||||
Promise.reject(new RangeError(`Body exceeds a limit of ${limit}.`));
|
||||
}
|
||||
return async () => {
|
||||
const value = await this.#valuePromise();
|
||||
return value.length
|
||||
? JSON.parse(
|
||||
decoder.decode(await this.#valuePromise()),
|
||||
this.#jsonBodyReviver,
|
||||
)
|
||||
: null;
|
||||
};
|
||||
case "bytes":
|
||||
this.#type = "bytes";
|
||||
if (this.#exceedsLimit(limit)) {
|
||||
return () =>
|
||||
Promise.reject(new RangeError(`Body exceeds a limit of ${limit}.`));
|
||||
}
|
||||
return () => this.#valuePromise();
|
||||
case "text":
|
||||
this.#type = "bytes";
|
||||
if (this.#exceedsLimit(limit)) {
|
||||
return () =>
|
||||
Promise.reject(new RangeError(`Body exceeds a limit of ${limit}.`));
|
||||
}
|
||||
return async () => decoder.decode(await this.#valuePromise());
|
||||
default:
|
||||
throw new TypeError(`Invalid body type: "${type}"`);
|
||||
}
|
||||
}
|
||||
|
||||
#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");
|
||||
assert(
|
||||
contentType,
|
||||
"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
@@ -0,0 +1,183 @@
|
||||
// Copyright 2018-2022 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;
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
import type { Application, State } from "./application.ts";
|
||||
import { createHttpError, KeyStack, SecureCookieMap } from "./deps.ts";
|
||||
import { Request } from "./request.ts";
|
||||
import { Response } from "./response.ts";
|
||||
import { send, SendOptions } from "./send.ts";
|
||||
import {
|
||||
ServerSentEventTargetOptions,
|
||||
SSEStreamTarget,
|
||||
} from "./server_sent_event.ts";
|
||||
import type { ServerSentEventTarget } from "./server_sent_event.ts";
|
||||
import type {
|
||||
ErrorStatus,
|
||||
ServerRequest,
|
||||
UpgradeWebSocketOptions,
|
||||
} from "./types.d.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>,
|
||||
): asserts condition {
|
||||
if (condition) {
|
||||
return;
|
||||
}
|
||||
const err = createHttpError(errorStatus, message);
|
||||
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.
|
||||
*
|
||||
* This will set `.respond` to `false`. */
|
||||
sendEvents(options?: ServerSentEventTargetOptions): ServerSentEventTarget {
|
||||
if (!this.#sse) {
|
||||
this.#sse = new SSEStreamTarget(this, options);
|
||||
}
|
||||
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.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)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Copyright 2018-2022 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.178.0/async/deferred.ts";
|
||||
export {
|
||||
concat,
|
||||
copy as copyBytes,
|
||||
equals,
|
||||
} from "https://deno.land/std@0.178.0/bytes/mod.ts";
|
||||
export { timingSafeEqual } from "https://deno.land/std@0.178.0/crypto/timing_safe_equal.ts";
|
||||
export { KeyStack } from "https://deno.land/std@0.178.0/crypto/keystack.ts";
|
||||
export * as base64 from "https://deno.land/std@0.178.0/encoding/base64.ts";
|
||||
export {
|
||||
mergeHeaders,
|
||||
SecureCookieMap,
|
||||
type SecureCookieMapGetOptions,
|
||||
type SecureCookieMapSetDeleteOptions,
|
||||
} from "https://deno.land/std@0.178.0/http/cookie_map.ts";
|
||||
export {
|
||||
createHttpError,
|
||||
errors,
|
||||
HttpError,
|
||||
isHttpError,
|
||||
} from "https://deno.land/std@0.178.0/http/http_errors.ts";
|
||||
export {
|
||||
Status,
|
||||
STATUS_TEXT,
|
||||
} from "https://deno.land/std@0.178.0/http/http_status.ts";
|
||||
export {
|
||||
accepts,
|
||||
acceptsEncodings,
|
||||
acceptsLanguages,
|
||||
} from "https://deno.land/std@0.178.0/http/negotiation.ts";
|
||||
export { LimitedReader } from "https://deno.land/std@0.178.0/io/mod.ts";
|
||||
export {
|
||||
contentType,
|
||||
extension,
|
||||
typeByExtension,
|
||||
} from "https://deno.land/std@0.178.0/media_types/mod.ts";
|
||||
export {
|
||||
readAll,
|
||||
readerFromStreamReader,
|
||||
writeAll,
|
||||
} from "https://deno.land/std@0.178.0/streams/mod.ts";
|
||||
export {
|
||||
basename,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
normalize,
|
||||
parse,
|
||||
sep,
|
||||
} from "https://deno.land/std@0.178.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";
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
/**
|
||||
* A collection of APIs for dealing with ETags in requests and responses.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { State } from "./application.ts";
|
||||
import type { Context } from "./context.ts";
|
||||
import { base64 } from "./deps.ts";
|
||||
import type { Middleware } from "./middleware.ts";
|
||||
import { BODY_TYPES, isAsyncIterable, isReader } from "./util.ts";
|
||||
|
||||
export interface ETagOptions {
|
||||
/** Override the default behavior of calculating the `ETag`, either forcing
|
||||
* a tag to be labelled weak or not. */
|
||||
weak?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Just the part of `Deno.FileInfo` that is required to calculate an `ETag`,
|
||||
* so partial or user generated file information can be passed.
|
||||
*/
|
||||
export interface FileInfo {
|
||||
mtime: Date | null;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function isFileInfo(value: unknown): value is FileInfo {
|
||||
return Boolean(
|
||||
value && typeof value === "object" && "mtime" in value && "size" in value,
|
||||
);
|
||||
}
|
||||
|
||||
function calcStatTag(entity: FileInfo): string {
|
||||
const mtime = entity.mtime?.getTime().toString(16) ?? "0";
|
||||
const size = entity.size.toString(16);
|
||||
|
||||
return `"${size}-${mtime}"`;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
async function calcEntityTag(entity: string | Uint8Array): Promise<string> {
|
||||
if (entity.length === 0) {
|
||||
return `"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk="`;
|
||||
}
|
||||
|
||||
if (typeof entity === "string") {
|
||||
entity = encoder.encode(entity);
|
||||
}
|
||||
|
||||
const hash = base64.encode(await crypto.subtle.digest("SHA-1", entity))
|
||||
.substring(0, 27);
|
||||
|
||||
return `"${entity.length.toString(16)}-${hash}"`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate an ETag value for an entity. If the entity is `FileInfo`, then the
|
||||
* tag will default to a _weak_ ETag. `options.weak` overrides any default
|
||||
* behavior in generating the tag.
|
||||
*
|
||||
* @param entity A string, Uint8Array, or file info to use to generate the ETag
|
||||
* @param options
|
||||
*/
|
||||
export async function calculate(
|
||||
entity: string | Uint8Array | FileInfo,
|
||||
options: ETagOptions = {},
|
||||
): Promise<string> {
|
||||
const weak = options.weak ?? isFileInfo(entity);
|
||||
const tag = isFileInfo(entity)
|
||||
? calcStatTag(entity)
|
||||
: await calcEntityTag(entity);
|
||||
|
||||
return weak ? `W/${tag}` : tag;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
context.response.headers.set("ETag", await calculate(entity, options));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function that takes the value from the `If-Match` header and an
|
||||
* entity and returns `true` if the `ETag` for the entity matches the supplied
|
||||
* value, otherwise `false`.
|
||||
*
|
||||
* See MDN's [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match)
|
||||
* article for more information on how to use this function.
|
||||
*/
|
||||
export async function ifMatch(
|
||||
value: string,
|
||||
entity: string | Uint8Array | FileInfo,
|
||||
options: ETagOptions = {},
|
||||
): Promise<boolean> {
|
||||
const etag = await calculate(entity, options);
|
||||
// Weak tags cannot be matched and return false.
|
||||
if (etag.startsWith("W/")) {
|
||||
return false;
|
||||
}
|
||||
if (value.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
const tags = value.split(/\s*,\s*/);
|
||||
return tags.includes(etag);
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function that takes the value from the `If-No-Match` header and
|
||||
* an entity and returns `false` if the `ETag` for the entity matches the
|
||||
* supplied value, otherwise `false`.
|
||||
*
|
||||
* See MDN's [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match)
|
||||
* article for more information on how to use this function.
|
||||
*/
|
||||
export async function ifNoneMatch(
|
||||
value: string,
|
||||
entity: string | Uint8Array | FileInfo,
|
||||
options: ETagOptions = {},
|
||||
): Promise<boolean> {
|
||||
if (value.trim() === "*") {
|
||||
return false;
|
||||
}
|
||||
const etag = await calculate(entity, options);
|
||||
const tags = value.split(/\s*,\s*/);
|
||||
return !tags.includes(etag);
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
// Copyright 2018-2022 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;
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
/**
|
||||
* A collection of APIs to help assist in creating middleware.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Context } from "./context.ts";
|
||||
import type { RouterContext } from "./router.ts";
|
||||
import { isRouterContext } from "./util.ts";
|
||||
|
||||
interface GetQueryOptionsBase {
|
||||
/** The return value should be a `Map` instead of a record object. */
|
||||
asMap?: boolean;
|
||||
|
||||
/** Merge in the context's `.params`. This only works when a `RouterContext`
|
||||
* is passed. */
|
||||
mergeParams?: boolean;
|
||||
}
|
||||
|
||||
interface GetQueryOptionsAsMap extends GetQueryOptionsBase {
|
||||
/** The return value should be a `Map` instead of a record object. */
|
||||
asMap: true;
|
||||
}
|
||||
|
||||
export type GetParamsOptions = GetQueryOptionsBase | GetQueryOptionsAsMap;
|
||||
|
||||
/** Given a context, return the `.request.url.searchParams` as a `Map` of keys
|
||||
* and values of the params. */
|
||||
export function getQuery(
|
||||
ctx: Context | RouterContext<string>,
|
||||
options: GetQueryOptionsAsMap,
|
||||
): Map<string, string>;
|
||||
/** Given a context, return the `.request.url.searchParams` as a record object
|
||||
* of keys and values of the params. */
|
||||
export function getQuery(
|
||||
ctx: Context | RouterContext<string>,
|
||||
options?: GetQueryOptionsBase,
|
||||
): Record<string, string>;
|
||||
export function getQuery(
|
||||
ctx: Context | RouterContext<string>,
|
||||
{ mergeParams, asMap }: GetParamsOptions = {},
|
||||
): Map<string, string> | Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
if (mergeParams && isRouterContext(ctx)) {
|
||||
Object.assign(result, ctx.params);
|
||||
}
|
||||
for (const [key, value] of ctx.request.url.searchParams) {
|
||||
result[key] = value;
|
||||
}
|
||||
return asMap ? new Map(Object.entries(result)) : result;
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
import { type Deferred } from "./deps.ts";
|
||||
import type {
|
||||
ServerRequest,
|
||||
ServerRequestBody,
|
||||
UpgradeWebSocketFn,
|
||||
UpgradeWebSocketOptions,
|
||||
} from "./types.d.ts";
|
||||
|
||||
const maybeUpgradeWebSocket: UpgradeWebSocketFn | undefined =
|
||||
"upgradeWebSocket" in Deno
|
||||
// deno-lint-ignore no-explicit-any
|
||||
? (Deno as any).upgradeWebSocket.bind(Deno)
|
||||
: undefined;
|
||||
|
||||
/** An abstraction which wraps a {@linkcode Request} from Deno's flash server.
|
||||
* The constructor takes a {@linkcode Deferred} which it will resolve when the
|
||||
* response is ready.
|
||||
*
|
||||
* This request can be used in situations where there isn't a specific method
|
||||
* to respond with, but where a `Promise<Response>` is accepted as a value. It
|
||||
* is specifically designed to work with Deno's flash server.
|
||||
*/
|
||||
export class HttpRequest implements ServerRequest {
|
||||
#deferred: Deferred<Response>;
|
||||
#request: Request;
|
||||
#resolved = false;
|
||||
#upgradeWebSocket?: UpgradeWebSocketFn;
|
||||
|
||||
get remoteAddr(): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get headers(): Headers {
|
||||
return this.#request.headers;
|
||||
}
|
||||
|
||||
get method(): string {
|
||||
return this.#request.method;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
constructor(
|
||||
request: Request,
|
||||
deferred: Deferred<Response>,
|
||||
upgradeWebSocket?: UpgradeWebSocketFn,
|
||||
) {
|
||||
this.#deferred = deferred;
|
||||
this.#request = request;
|
||||
this.#upgradeWebSocket = upgradeWebSocket ?? maybeUpgradeWebSocket;
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
error(reason?: any): void {
|
||||
if (this.#resolved) {
|
||||
throw new Error("Request already responded to.");
|
||||
}
|
||||
this.#deferred.reject(reason);
|
||||
this.#resolved = true;
|
||||
}
|
||||
|
||||
getBody(): ServerRequestBody {
|
||||
return {
|
||||
body: this.#request.body,
|
||||
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.#deferred.resolve(response);
|
||||
this.#resolved = true;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
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.#deferred.resolve(response);
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
import { type Application } from "./application.ts";
|
||||
import { deferred, Status, STATUS_TEXT } from "./deps.ts";
|
||||
import { HttpRequest } from "./http_request.ts";
|
||||
import type { Listener, Server } from "./types.d.ts";
|
||||
import { assert } from "./util.ts";
|
||||
|
||||
type ServeHandler = (
|
||||
request: Request,
|
||||
) => Response | Promise<Response> | void | Promise<void>;
|
||||
|
||||
interface ServeOptions {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
signal?: AbortSignal;
|
||||
onError?: (error: unknown) => Response | Promise<Response>;
|
||||
onListen?: (params: { hostname: string; port: number }) => void;
|
||||
}
|
||||
|
||||
interface ServeTlsOptions extends ServeOptions {
|
||||
cert: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
type FlashServerOptions = Omit<Partial<ServeTlsOptions>, "onListen" | "signal">;
|
||||
|
||||
const serve: (
|
||||
handler: ServeHandler,
|
||||
options?: ServeOptions | ServeTlsOptions,
|
||||
) => Promise<void> = "serve" in Deno
|
||||
// deno-lint-ignore no-explicit-any
|
||||
? (Deno as any).serve.bind(Deno)
|
||||
: undefined;
|
||||
|
||||
/** A function that determines if the current environment supports Deno flash.*/
|
||||
export function hasFlash(): boolean {
|
||||
return Boolean(serve);
|
||||
}
|
||||
|
||||
/** A server abstraction which manages requests from Deno's flash server.
|
||||
*
|
||||
* You can pass the class as the `server` property when constructing a new
|
||||
* application to force the application to use Deno's flash server.
|
||||
*/
|
||||
export class FlashServer implements Server<HttpRequest> {
|
||||
// deno-lint-ignore no-explicit-any
|
||||
#app: Application<any>;
|
||||
#closed = false;
|
||||
#controller?: ReadableStreamDefaultController<HttpRequest>;
|
||||
#abortController = new AbortController();
|
||||
#options: FlashServerOptions;
|
||||
#servePromise?: Promise<void>;
|
||||
#stream?: ReadableStream<HttpRequest>;
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
constructor(app: Application<any>, options: FlashServerOptions) {
|
||||
if (!serve) {
|
||||
throw new Error("The flash bindings for serving HTTP are not available.");
|
||||
}
|
||||
this.#app = app;
|
||||
this.#options = options;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
this.#closed = true;
|
||||
try {
|
||||
this.#controller?.close();
|
||||
this.#controller = undefined;
|
||||
this.#stream = undefined;
|
||||
this.#abortController.abort();
|
||||
if (this.#servePromise) {
|
||||
await this.#servePromise;
|
||||
this.#servePromise = undefined;
|
||||
}
|
||||
} catch {
|
||||
// just swallow here
|
||||
}
|
||||
}
|
||||
|
||||
listen(): Promise<Listener> {
|
||||
const p = deferred<Listener>();
|
||||
const start: ReadableStreamDefaultControllerCallback<HttpRequest> = (
|
||||
controller,
|
||||
) => {
|
||||
this.#controller = controller;
|
||||
const options: ServeOptions | ServeTlsOptions = {
|
||||
...this.#options,
|
||||
signal: this.#abortController.signal,
|
||||
onListen: (addr) => p.resolve({ addr }),
|
||||
onError: (error) => {
|
||||
this.#app.dispatchEvent(new ErrorEvent("error", { error }));
|
||||
return new Response("Internal server error", {
|
||||
status: Status.InternalServerError,
|
||||
statusText: STATUS_TEXT[Status.InternalServerError],
|
||||
});
|
||||
},
|
||||
};
|
||||
const handler: ServeHandler = (request) => {
|
||||
const resolve = deferred<Response>();
|
||||
const flashRequest = new HttpRequest(request, resolve);
|
||||
controller.enqueue(flashRequest);
|
||||
return resolve;
|
||||
};
|
||||
this.#servePromise = serve(handler, options);
|
||||
};
|
||||
this.#stream = new ReadableStream({ start });
|
||||
return p;
|
||||
}
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterableIterator<HttpRequest> {
|
||||
assert(
|
||||
this.#stream,
|
||||
".listen() was not called before iterating or server is closed.",
|
||||
);
|
||||
return this.#stream[Symbol.asyncIterator]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright 2018-2022 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,143 @@
|
||||
// Copyright 2018-2022 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 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
@@ -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
@@ -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);
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
// deno-lint-ignore-file
|
||||
|
||||
import type { State } from "./application.ts";
|
||||
import type { Context } from "./context.ts";
|
||||
|
||||
/** 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: () => Promise<unknown>): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
/** Compose multiple middleware functions into a single middleware function. */
|
||||
export function compose<
|
||||
S extends State = Record<string, any>,
|
||||
T extends Context = Context<S>,
|
||||
>(
|
||||
middleware: Middleware<S, T>[],
|
||||
): (context: T, next?: () => Promise<unknown>) => Promise<unknown> {
|
||||
return function composedMiddleware(
|
||||
context: T,
|
||||
next?: () => Promise<unknown>,
|
||||
): 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 fn: Middleware<S, T> | undefined = middleware[i];
|
||||
if (i === middleware.length) {
|
||||
fn = next;
|
||||
}
|
||||
if (!fn) {
|
||||
return;
|
||||
}
|
||||
await fn(context, dispatch.bind(null, i + 1));
|
||||
}
|
||||
|
||||
return dispatch(0);
|
||||
};
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
import type { State } from "../application.ts";
|
||||
import type { Context } from "../context.ts";
|
||||
import type { Middleware } from "../middleware.ts";
|
||||
import type {
|
||||
RouteParams,
|
||||
RouterContext,
|
||||
RouterMiddleware,
|
||||
} from "../router.ts";
|
||||
import { isRouterContext } from "../util.ts";
|
||||
|
||||
export type Fetch = (input: Request) => Promise<Response>;
|
||||
|
||||
export type ProxyMatchFunction<
|
||||
R extends string,
|
||||
P extends RouteParams<R> = RouteParams<R>,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
S extends State = Record<string, any>,
|
||||
> = (ctx: Context<S> | RouterContext<R, P, S>) => boolean;
|
||||
|
||||
export type ProxyMapFunction<R extends string, P extends RouteParams<R>> = (
|
||||
path: R,
|
||||
params?: P,
|
||||
) => R;
|
||||
|
||||
export type ProxyHeadersFunction<S extends State> = (
|
||||
ctx: Context<S>,
|
||||
) => HeadersInit | Promise<HeadersInit>;
|
||||
|
||||
export type ProxyRouterHeadersFunction<
|
||||
R extends string,
|
||||
P extends RouteParams<R>,
|
||||
S extends State,
|
||||
> = (ctx: RouterContext<R, P, S>) => HeadersInit | Promise<HeadersInit>;
|
||||
|
||||
export interface ProxyOptions<
|
||||
R extends string,
|
||||
P extends RouteParams<R> = RouteParams<R>,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
S extends State = Record<string, any>,
|
||||
> {
|
||||
/** A callback hook that is called after the response is received which allows
|
||||
* the response content type to be adjusted. This is for situations where the
|
||||
* content type provided by the proxy server might not be suitable for
|
||||
* responding with. */
|
||||
contentType?(
|
||||
url: string,
|
||||
contentType?: string,
|
||||
): Promise<string | undefined> | string | undefined;
|
||||
/** The fetch function to use to proxy the request. This defaults to the
|
||||
* global `fetch` function. This is designed for test mocking purposes. */
|
||||
fetch?: Fetch;
|
||||
/** Additional headers that should be set in the response. The value can
|
||||
* be a headers init value or a function that returns or resolves with a
|
||||
* headers init value. */
|
||||
headers?:
|
||||
| HeadersInit
|
||||
| ProxyHeadersFunction<S>
|
||||
| ProxyRouterHeadersFunction<R, P, S>;
|
||||
/** Either a record or a proxy map function that will allow proxied requests
|
||||
* being handled by the middleware to be remapped to a different remote
|
||||
* path. */
|
||||
map?: Record<string, R> | ProxyMapFunction<R, P>;
|
||||
/** A string, regular expression or proxy match function what determines if
|
||||
* the proxy middleware should proxy the request.
|
||||
*
|
||||
* If the value is a string the match will be true if the requests pathname
|
||||
* starts with the string. In the case of a regular expression, if the
|
||||
* pathname
|
||||
*/
|
||||
match?:
|
||||
| string
|
||||
| RegExp
|
||||
| ProxyMatchFunction<R, P, S>;
|
||||
/** A flag that indicates if traditional proxy headers should be set in the
|
||||
* response. This defaults to `true`.
|
||||
*/
|
||||
proxyHeaders?: boolean;
|
||||
/** A callback hook which will be called before each proxied fetch request
|
||||
* to allow the native `Request` to be modified or replaced. */
|
||||
request?(req: Request): Request | Promise<Request>;
|
||||
/** A callback hook which will be called after each proxied fetch response
|
||||
* is received to allow the native `Response` to be modified or replaced. */
|
||||
response?(res: Response): Response | Promise<Response>;
|
||||
}
|
||||
|
||||
const FORWARDED_RE =
|
||||
/^(,[ \\t]*)*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*([ \\t]*,([ \\t]*([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?(;([!#$%&'*+.^_`|~0-9A-Za-z-]+=([!#$%&'*+.^_`|~0-9A-Za-z-]+|\"([\\t \\x21\\x23-\\x5B\\x5D-\\x7E\\x80-\\xFF]|\\\\[\\t \\x21-\\x7E\\x80-\\xFF])*\"))?)*)?)*$/;
|
||||
|
||||
function createMatcher<
|
||||
R extends string,
|
||||
P extends RouteParams<R>,
|
||||
S extends State,
|
||||
>(
|
||||
{ match }: ProxyOptions<R, P, S>,
|
||||
) {
|
||||
return function matches(ctx: RouterContext<R, P, S>): boolean {
|
||||
if (!match) {
|
||||
return true;
|
||||
}
|
||||
if (typeof match === "string") {
|
||||
return ctx.request.url.pathname.startsWith(match);
|
||||
}
|
||||
if (match instanceof RegExp) {
|
||||
return match.test(ctx.request.url.pathname);
|
||||
}
|
||||
return match(ctx);
|
||||
};
|
||||
}
|
||||
|
||||
async function createRequest<
|
||||
R extends string,
|
||||
P extends RouteParams<R>,
|
||||
S extends State,
|
||||
>(
|
||||
target: string | URL,
|
||||
ctx: Context<S> | RouterContext<R, P, S>,
|
||||
{ headers: optHeaders, map, proxyHeaders = true, request: reqFn }:
|
||||
ProxyOptions<R, P, S>,
|
||||
): Promise<Request> {
|
||||
let path = ctx.request.url.pathname as R;
|
||||
let params: P | undefined;
|
||||
if (isRouterContext<R, P, S>(ctx)) {
|
||||
params = ctx.params;
|
||||
}
|
||||
if (map && typeof map === "function") {
|
||||
path = map(path, params);
|
||||
} else if (map) {
|
||||
path = map[path] ?? path;
|
||||
}
|
||||
const url = new URL(String(target));
|
||||
if (url.pathname.endsWith("/") && path.startsWith("/")) {
|
||||
url.pathname = `${url.pathname}${path.slice(1)}`;
|
||||
} else if (!url.pathname.endsWith("/") && !path.startsWith("/")) {
|
||||
url.pathname = `${url.pathname}/${path}`;
|
||||
} else {
|
||||
url.pathname = `${url.pathname}${path}`;
|
||||
}
|
||||
url.search = ctx.request.url.search;
|
||||
|
||||
const body = getBodyInit(ctx);
|
||||
const headers = new Headers(ctx.request.headers);
|
||||
if (optHeaders) {
|
||||
if (typeof optHeaders === "function") {
|
||||
optHeaders = await optHeaders(ctx as RouterContext<R, P, S>);
|
||||
}
|
||||
for (const [key, value] of iterableHeaders(optHeaders)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
if (proxyHeaders) {
|
||||
const maybeForwarded = headers.get("forwarded");
|
||||
const ip = ctx.request.ip.startsWith("[")
|
||||
? `"${ctx.request.ip}"`
|
||||
: ctx.request.ip;
|
||||
const host = headers.get("host");
|
||||
if (maybeForwarded && FORWARDED_RE.test(maybeForwarded)) {
|
||||
let value = `for=${ip}`;
|
||||
if (host) {
|
||||
value += `;host=${host}`;
|
||||
}
|
||||
headers.append("forwarded", value);
|
||||
} else {
|
||||
headers.append("x-forwarded-for", ip);
|
||||
if (host) {
|
||||
headers.append("x-forwarded-host", host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
body,
|
||||
headers,
|
||||
method: ctx.request.method,
|
||||
redirect: "follow",
|
||||
};
|
||||
let request = new Request(url.toString(), init);
|
||||
if (reqFn) {
|
||||
request = await reqFn(request);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
function getBodyInit<
|
||||
R extends string,
|
||||
P extends RouteParams<R>,
|
||||
S extends State,
|
||||
>(
|
||||
ctx: Context<S> | RouterContext<R, P, S>,
|
||||
): BodyInit | null {
|
||||
if (!ctx.request.hasBody) {
|
||||
return null;
|
||||
}
|
||||
return ctx.request.body({ type: "stream" }).value;
|
||||
}
|
||||
|
||||
function iterableHeaders(
|
||||
headers: HeadersInit,
|
||||
): IterableIterator<[string, string]> {
|
||||
if (headers instanceof Headers) {
|
||||
return headers.entries();
|
||||
} else if (Array.isArray(headers)) {
|
||||
return headers.values() as IterableIterator<[string, string]>;
|
||||
} else {
|
||||
return Object.entries(headers).values() as IterableIterator<
|
||||
[string, string]
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
async function processResponse<
|
||||
R extends string,
|
||||
P extends RouteParams<R>,
|
||||
S extends State,
|
||||
>(
|
||||
response: Response,
|
||||
ctx: Context<S> | RouterContext<R, P, S>,
|
||||
{ contentType: contentTypeFn, response: resFn }: ProxyOptions<R, P, S>,
|
||||
) {
|
||||
if (resFn) {
|
||||
response = await resFn(response);
|
||||
}
|
||||
if (response.body) {
|
||||
ctx.response.body = response.body;
|
||||
} else {
|
||||
ctx.response.body = null;
|
||||
}
|
||||
ctx.response.status = response.status;
|
||||
for (const [key, value] of response.headers) {
|
||||
ctx.response.headers.append(key, value);
|
||||
}
|
||||
if (contentTypeFn) {
|
||||
const value = await contentTypeFn(
|
||||
response.url,
|
||||
ctx.response.headers.get("content-type") ?? undefined,
|
||||
);
|
||||
if (value != null) {
|
||||
ctx.response.headers.set("content-type", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that provides a back-to-back proxy for requests.
|
||||
*
|
||||
* @param target
|
||||
* @param options
|
||||
*/
|
||||
export function proxy<S extends State>(
|
||||
target: string | URL,
|
||||
options?: ProxyOptions<string, RouteParams<string>, S>,
|
||||
): Middleware<S>;
|
||||
export function proxy<
|
||||
R extends string,
|
||||
P extends RouteParams<R>,
|
||||
S extends State,
|
||||
>(
|
||||
target: string | URL,
|
||||
options: ProxyOptions<R, P, S> = {},
|
||||
): RouterMiddleware<R, P, S> {
|
||||
const matches = createMatcher(options);
|
||||
return async function proxy(ctx, next) {
|
||||
if (!matches(ctx)) {
|
||||
return next();
|
||||
}
|
||||
const request = await createRequest(target, ctx, options);
|
||||
const { fetch = globalThis.fetch } = options;
|
||||
const response = await fetch(request);
|
||||
await processResponse(response, ctx, options);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
/**
|
||||
* A middleware framework for handling HTTP with Deno.
|
||||
*
|
||||
* oak works well on both Deno CLI and Deno deploy, and is inspired by
|
||||
* [koa](https://koajs.com/). It works well with both the Deno CLI and
|
||||
* [Deno Deploy](https://deno.com/deploy).
|
||||
*
|
||||
* ### Example server
|
||||
*
|
||||
* A minimal router server which responds with content on `/`. With Deno CLI
|
||||
* this will listen on port 8080 and on Deploy, this will simply serve requests
|
||||
* received on the application.
|
||||
*
|
||||
* ```ts
|
||||
* import { Application, Router } from "https://deno.land/x/oak/mod.ts";
|
||||
*
|
||||
* const router = new Router();
|
||||
* router.get("/", (ctx) => {
|
||||
* ctx.response.body = `<!DOCTYPE html>
|
||||
* <html>
|
||||
* <head><title>Hello oak!</title><head>
|
||||
* <body>
|
||||
* <h1>Hello oak!</h1>
|
||||
* </body>
|
||||
* </html>
|
||||
* `;
|
||||
* });
|
||||
*
|
||||
* const app = new Application();
|
||||
* app.use(router.routes());
|
||||
* app.use(router.allowedMethods());
|
||||
*
|
||||
* app.listen({ port: 8080 });
|
||||
* ```
|
||||
*
|
||||
* ### Using Deno's flash server
|
||||
*
|
||||
* Currently, Deno's flash server is not the default, even with the `--unstable`
|
||||
* flag. In order to use the flash server, you need to provide the
|
||||
* {@linkcode FlashServer} to the {@linkcode Application} constructor:
|
||||
*
|
||||
* ```ts
|
||||
* import { Application, FlashServer } from "https://deno.land/x/oak/mod.ts";
|
||||
*
|
||||
* const app = new Application({ serverConstructor: FlashServer });
|
||||
*
|
||||
* // register middleware
|
||||
*
|
||||
* app.listen({ port: 8080 });
|
||||
* ```
|
||||
*
|
||||
* Note the currently Deno's flash server requires the `--unstable` flag. If it
|
||||
* isn't present, the application will error on listening.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
export { Application } from "./application.ts";
|
||||
export type {
|
||||
ApplicationOptions,
|
||||
ListenOptions,
|
||||
ListenOptionsBase,
|
||||
ListenOptionsTls,
|
||||
State,
|
||||
} from "./application.ts";
|
||||
export type {
|
||||
BodyBytes,
|
||||
BodyContentTypes,
|
||||
BodyForm,
|
||||
BodyFormData,
|
||||
BodyJson,
|
||||
BodyOptions,
|
||||
BodyOptionsContentTypes,
|
||||
BodyReader,
|
||||
BodyStream,
|
||||
BodyText,
|
||||
BodyType,
|
||||
BodyUndefined,
|
||||
} from "./body.ts";
|
||||
export { Context, type ContextSendOptions } from "./context.ts";
|
||||
export * as helpers from "./helpers.ts";
|
||||
export * as etag from "./etag.ts";
|
||||
export { HttpRequest } from "./http_request.ts";
|
||||
export { FlashServer, hasFlash } from "./http_server_flash.ts";
|
||||
export { HttpServer as HttpServerNative } from "./http_server_native.ts";
|
||||
export { type NativeRequest } from "./http_server_native_request.ts";
|
||||
export { proxy } from "./middleware/proxy.ts";
|
||||
export type { ProxyOptions } from "./middleware/proxy.ts";
|
||||
export { compose as composeMiddleware } from "./middleware.ts";
|
||||
export type { Middleware } from "./middleware.ts";
|
||||
export { FormDataReader } from "./multipart.ts";
|
||||
export type {
|
||||
FormDataBody,
|
||||
FormDataFile,
|
||||
FormDataReadOptions,
|
||||
} from "./multipart.ts";
|
||||
export { ifRange, MultiPartStream, parseRange } from "./range.ts";
|
||||
export type { ByteRange } from "./range.ts";
|
||||
export { Request } from "./request.ts";
|
||||
export { REDIRECT_BACK, Response } from "./response.ts";
|
||||
export { Router } from "./router.ts";
|
||||
export type {
|
||||
Route,
|
||||
RouteParams,
|
||||
RouterAllowedMethodsOptions,
|
||||
RouterContext,
|
||||
RouterMiddleware,
|
||||
RouterOptions,
|
||||
RouterParamMiddleware,
|
||||
} from "./router.ts";
|
||||
export { send } from "./send.ts";
|
||||
export type { SendOptions } from "./send.ts";
|
||||
export { ServerSentEvent } from "./server_sent_event.ts";
|
||||
export type {
|
||||
ServerSentEventInit,
|
||||
ServerSentEventTarget,
|
||||
} from "./server_sent_event.ts";
|
||||
/** Utilities for making testing oak servers easier. */
|
||||
export * as testing from "./testing.ts";
|
||||
export type {
|
||||
ErrorStatus,
|
||||
HTTPMethods,
|
||||
RedirectStatus,
|
||||
ServerConstructor,
|
||||
} from "./types.d.ts";
|
||||
export { isErrorStatus, isRedirectStatus } from "./util.ts";
|
||||
|
||||
// Re-exported from `std/http`
|
||||
export {
|
||||
createHttpError,
|
||||
errors as httpErrors,
|
||||
HttpError,
|
||||
isHttpError,
|
||||
SecureCookieMap as Cookies,
|
||||
type SecureCookieMapGetOptions as CookiesGetOptions,
|
||||
type SecureCookieMapSetDeleteOptions as CookiesSetDeleteOptions,
|
||||
Status,
|
||||
STATUS_TEXT,
|
||||
} from "./deps.ts";
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
// Copyright 2018-2022 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
@@ -0,0 +1,169 @@
|
||||
// Copyright 2018-2022 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;
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
// Copyright 2018-2022 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 } from "./deps.ts";
|
||||
import type { HTTPMethods, 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,
|
||||
)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
import { contentType, 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,
|
||||
isRedirectStatus,
|
||||
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,
|
||||
)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
+1374
File diff suppressed because it is too large.
Load diff
+361
@@ -0,0 +1,361 @@
|
||||
/*!
|
||||
* 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);
|
||||
if (!await ifNoneMatch(request.headers.get("If-None-Match")!, entity)) {
|
||||
response.headers.set("ETag", await calculate(entity));
|
||||
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")) {
|
||||
response.headers.set("ETag", await calculate(entity));
|
||||
}
|
||||
|
||||
if (!response.headers.has("Accept-Ranges")) {
|
||||
response.headers.set("Accept-Ranges", "bytes");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
import type { Context } from "./context.ts";
|
||||
import { assert } from "./util.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const DEFAULT_KEEP_ALIVE_INTERVAL = 30_000;
|
||||
|
||||
export interface ServerSentEventInit extends EventInit {
|
||||
/** An optional `id` which will be sent with the event and exposed in the
|
||||
* client `EventSource`. */
|
||||
id?: number;
|
||||
|
||||
/** The replacer is passed to `JSON.stringify` when converting the `data`
|
||||
* property to a JSON string. */
|
||||
replacer?:
|
||||
| (string | number)[]
|
||||
// deno-lint-ignore no-explicit-any
|
||||
| ((this: any, key: string, value: any) => any);
|
||||
|
||||
/** Space is passed to `JSON.stringify` when converting the `data` property
|
||||
* to a JSON string. */
|
||||
space?: string | number;
|
||||
}
|
||||
|
||||
export interface ServerSentEventTargetOptions {
|
||||
/** Additional headers to send to the client during startup. These headers
|
||||
* will overwrite any of the default headers if the key is duplicated. */
|
||||
headers?: Headers;
|
||||
/** Keep client connections alive by sending a comment event to the client
|
||||
* at a specified interval. If `true`, then it polls every 30000 milliseconds
|
||||
* (30 seconds). If set to a number, then it polls that number of
|
||||
* milliseconds. The feature is disabled if set to `false`. It defaults to
|
||||
* `false`. */
|
||||
keepAlive?: boolean | number;
|
||||
}
|
||||
|
||||
class CloseEvent extends Event {
|
||||
constructor(eventInit: EventInit) {
|
||||
super("close", eventInit);
|
||||
}
|
||||
}
|
||||
|
||||
/** An event which contains information which will be sent to the remote
|
||||
* connection and be made available in an `EventSource` as an event. A server
|
||||
* creates new events and dispatches them on the target which will then be
|
||||
* sent to a client.
|
||||
*
|
||||
* See more about Server-sent events on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)
|
||||
*
|
||||
* ### Example
|
||||
*
|
||||
* ```ts
|
||||
* import { Application, ServerSentEvent } from "https://deno.land/x/oak/mod.ts";
|
||||
*
|
||||
* const app = new Application();
|
||||
*
|
||||
* app.use((ctx) => {
|
||||
* const target = ctx.sendEvents();
|
||||
* const evt = new ServerSentEvent(
|
||||
* "message",
|
||||
* { hello: "world" },
|
||||
* { id: 1 },
|
||||
* );
|
||||
* target.dispatchEvent(evt);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class ServerSentEvent extends Event {
|
||||
#data: string;
|
||||
#id?: number;
|
||||
#type: string;
|
||||
|
||||
/**
|
||||
* @param type the event type that will be available on the client. The type
|
||||
* of `"message"` will be handled specifically as a message
|
||||
* server-side event.
|
||||
* @param data arbitrary data to send to the client, data this is a string
|
||||
* will be sent unmodified, otherwise `JSON.parse()` will be used
|
||||
* to serialize the value
|
||||
* @param eventInit initialization options for the event
|
||||
*/
|
||||
constructor(
|
||||
type: string,
|
||||
data: unknown,
|
||||
eventInit: ServerSentEventInit = {},
|
||||
) {
|
||||
super(type, eventInit);
|
||||
const { replacer, space } = eventInit;
|
||||
this.#type = type;
|
||||
try {
|
||||
this.#data = typeof data === "string"
|
||||
? data
|
||||
: JSON.stringify(data, replacer as (string | number)[], space);
|
||||
} catch (e) {
|
||||
assert(e instanceof Error);
|
||||
throw new TypeError(
|
||||
`data could not be coerced into a serialized string.\n ${e.message}`,
|
||||
);
|
||||
}
|
||||
const { id } = eventInit;
|
||||
this.#id = id;
|
||||
}
|
||||
|
||||
/** The data associated with the event, which will be sent to the client and
|
||||
* be made available in the `EventSource`. */
|
||||
get data(): string {
|
||||
return this.#data;
|
||||
}
|
||||
|
||||
/** The optional ID associated with the event that will be sent to the client
|
||||
* and be made available in the `EventSource`. */
|
||||
get id(): number | undefined {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const data = `data: ${this.#data.split("\n").join("\ndata: ")}\n`;
|
||||
return `${this.#type === "__message" ? "" : `event: ${this.#type}\n`}${
|
||||
this.#id ? `id: ${String(this.#id)}\n` : ""
|
||||
}${data}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const RESPONSE_HEADERS = [
|
||||
["Connection", "Keep-Alive"],
|
||||
["Content-Type", "text/event-stream"],
|
||||
["Cache-Control", "no-cache"],
|
||||
["Keep-Alive", `timeout=${Number.MAX_SAFE_INTEGER}`],
|
||||
] as const;
|
||||
|
||||
export interface ServerSentEventTarget extends EventTarget {
|
||||
/** Is set to `true` if events cannot be sent to the remote connection.
|
||||
* Otherwise it is set to `false`.
|
||||
*
|
||||
* *Note*: This flag is lazily set, and might not reflect a closed state until
|
||||
* another event, comment or message is attempted to be processed. */
|
||||
readonly closed: boolean;
|
||||
|
||||
/** Close the target, refusing to accept any more events. */
|
||||
close(): Promise<void>;
|
||||
|
||||
/** Send a comment to the remote connection. Comments are not exposed to the
|
||||
* client `EventSource` but are used for diagnostics and helping ensure a
|
||||
* connection is kept alive.
|
||||
*
|
||||
* ```ts
|
||||
* import { Application } from "https://deno.land/x/oak/mod.ts";
|
||||
*
|
||||
* const app = new Application();
|
||||
*
|
||||
* app.use((ctx) => {
|
||||
* const sse = ctx.getSSETarget();
|
||||
* sse.dispatchComment("this is a comment");
|
||||
* });
|
||||
*
|
||||
* await app.listen();
|
||||
* ```
|
||||
*/
|
||||
dispatchComment(comment: string): boolean;
|
||||
|
||||
/** Dispatch a message to the client. This message will contain `data: ` only
|
||||
* and be available on the client `EventSource` on the `onmessage` or an event
|
||||
* listener of type `"message"`. */
|
||||
dispatchMessage(data: unknown): boolean;
|
||||
|
||||
/** Dispatch a server sent event to the client. The event `type` will be
|
||||
* sent as `event: ` to the client which will be raised as a `MessageEvent`
|
||||
* on the `EventSource` in the client.
|
||||
*
|
||||
* Any local event handlers will be dispatched to first, and if the event
|
||||
* is cancelled, it will not be sent to the client.
|
||||
*
|
||||
* ```ts
|
||||
* import { Application, ServerSentEvent } from "https://deno.land/x/oak/mod.ts";
|
||||
*
|
||||
* const app = new Application();
|
||||
*
|
||||
* app.use((ctx) => {
|
||||
* const sse = ctx.getSSETarget();
|
||||
* const evt = new ServerSentEvent("ping", "hello");
|
||||
* sse.dispatchEvent(evt);
|
||||
* });
|
||||
*
|
||||
* await app.listen();
|
||||
* ```
|
||||
*/
|
||||
dispatchEvent(event: ServerSentEvent): boolean;
|
||||
|
||||
/** Dispatch a server sent event to the client. The event `type` will be
|
||||
* sent as `event: ` to the client which will be raised as a `MessageEvent`
|
||||
* on the `EventSource` in the client.
|
||||
*
|
||||
* Any local event handlers will be dispatched to first, and if the event
|
||||
* is cancelled, it will not be sent to the client.
|
||||
*
|
||||
* ```ts
|
||||
* import { Application, ServerSentEvent } from "https://deno.land/x/oak/mod.ts";
|
||||
*
|
||||
* const app = new Application();
|
||||
*
|
||||
* app.use((ctx) => {
|
||||
* const sse = ctx.getSSETarget();
|
||||
* const evt = new ServerSentEvent("ping", "hello");
|
||||
* sse.dispatchEvent(evt);
|
||||
* });
|
||||
*
|
||||
* await app.listen();
|
||||
* ```
|
||||
*/
|
||||
dispatchEvent(event: CloseEvent | ErrorEvent): boolean;
|
||||
}
|
||||
|
||||
export class SSEStreamTarget extends EventTarget
|
||||
implements ServerSentEventTarget {
|
||||
#closed = false;
|
||||
#context: Context;
|
||||
#controller?: ReadableStreamDefaultController<Uint8Array>;
|
||||
// we are ignoring any here, because when exporting to npn/Node.js, the timer
|
||||
// handle isn't a number.
|
||||
// deno-lint-ignore no-explicit-any
|
||||
#keepAliveId?: any;
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
#error(error: any) {
|
||||
console.log("error", error);
|
||||
this.dispatchEvent(new CloseEvent({ cancelable: false }));
|
||||
const errorEvent = new ErrorEvent("error", { error });
|
||||
this.dispatchEvent(errorEvent);
|
||||
this.#context.app.dispatchEvent(errorEvent);
|
||||
}
|
||||
|
||||
#push(payload: string) {
|
||||
if (!this.#controller) {
|
||||
this.#error(new Error("The controller has not been set."));
|
||||
return;
|
||||
}
|
||||
if (this.#closed) {
|
||||
return;
|
||||
}
|
||||
this.#controller.enqueue(encoder.encode(payload));
|
||||
}
|
||||
|
||||
get closed(): boolean {
|
||||
return this.#closed;
|
||||
}
|
||||
|
||||
constructor(
|
||||
context: Context,
|
||||
{ headers, keepAlive = false }: ServerSentEventTargetOptions = {},
|
||||
) {
|
||||
super();
|
||||
|
||||
this.#context = context;
|
||||
|
||||
context.response.body = new ReadableStream<Uint8Array>({
|
||||
start: (controller) => {
|
||||
this.#controller = controller;
|
||||
},
|
||||
cancel: (error) => {
|
||||
// connections closing are considered "normal" for SSE events and just
|
||||
// mean the far side has closed.
|
||||
if (
|
||||
error instanceof Error && error.message.includes("connection closed")
|
||||
) {
|
||||
this.close();
|
||||
} else {
|
||||
this.#error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (headers) {
|
||||
for (const [key, value] of headers) {
|
||||
context.response.headers.set(key, value);
|
||||
}
|
||||
}
|
||||
for (const [key, value] of RESPONSE_HEADERS) {
|
||||
context.response.headers.set(key, value);
|
||||
}
|
||||
|
||||
this.addEventListener("close", () => {
|
||||
this.#closed = true;
|
||||
if (this.#keepAliveId != null) {
|
||||
clearInterval(this.#keepAliveId);
|
||||
this.#keepAliveId = undefined;
|
||||
}
|
||||
if (this.#controller) {
|
||||
try {
|
||||
this.#controller.close();
|
||||
} catch {
|
||||
// we ignore any errors here, as it is likely that the controller
|
||||
// is already closed
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (keepAlive) {
|
||||
const interval = typeof keepAlive === "number"
|
||||
? keepAlive
|
||||
: DEFAULT_KEEP_ALIVE_INTERVAL;
|
||||
this.#keepAliveId = setInterval(() => {
|
||||
this.dispatchComment("keep-alive comment");
|
||||
}, interval);
|
||||
}
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.dispatchEvent(new CloseEvent({ cancelable: false }));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
dispatchComment(comment: string): boolean {
|
||||
this.#push(`: ${comment.split("\n").join("\n: ")}\n\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
dispatchMessage(data: any): boolean {
|
||||
const event = new ServerSentEvent("__message", data);
|
||||
return this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
dispatchEvent(event: ServerSentEvent): boolean;
|
||||
dispatchEvent(event: CloseEvent | ErrorEvent): boolean;
|
||||
dispatchEvent(event: ServerSentEvent | CloseEvent | ErrorEvent): boolean {
|
||||
const dispatched = super.dispatchEvent(event);
|
||||
if (dispatched && event instanceof ServerSentEvent) {
|
||||
this.#push(String(event));
|
||||
}
|
||||
return dispatched;
|
||||
}
|
||||
|
||||
[Symbol.for("Deno.customInspect")](inspect: (value: unknown) => string) {
|
||||
return `${this.constructor.name} ${
|
||||
inspect({ "#closed": this.#closed, "#context": this.#context })
|
||||
}`;
|
||||
}
|
||||
|
||||
[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(
|
||||
{ "#closed": this.#closed, "#context": this.#context },
|
||||
newOptions,
|
||||
)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
// Copyright 2018-2022 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;
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
// deno-lint-ignore-file no-explicit-any
|
||||
|
||||
/**
|
||||
* A collection of utility APIs which can make testing of an oak application
|
||||
* easier.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Application, State } from "./application.ts";
|
||||
import { accepts, createHttpError, SecureCookieMap } from "./deps.ts";
|
||||
import type { RouteParams, RouterContext } from "./router.ts";
|
||||
import type { ErrorStatus } from "./types.d.ts";
|
||||
import { Request } from "./request.ts";
|
||||
import { Response } from "./response.ts";
|
||||
|
||||
/** Creates a mock of `Application`. */
|
||||
export function createMockApp<
|
||||
S extends Record<string | number | symbol, any> = Record<string, any>,
|
||||
>(
|
||||
state = {} as S,
|
||||
): Application<S> {
|
||||
const app = {
|
||||
state,
|
||||
use() {
|
||||
return app;
|
||||
},
|
||||
[Symbol.for("Deno.customInspect")]() {
|
||||
return "MockApplication {}";
|
||||
},
|
||||
[Symbol.for("nodejs.util.inspect.custom")](
|
||||
depth: number,
|
||||
options: any,
|
||||
inspect: (value: unknown, options?: unknown) => string,
|
||||
) {
|
||||
if (depth < 0) {
|
||||
return options.stylize(`[MockApplication]`, "special");
|
||||
}
|
||||
|
||||
const newOptions = Object.assign({}, options, {
|
||||
depth: options.depth === null ? null : options.depth - 1,
|
||||
});
|
||||
return `${options.stylize("MockApplication", "special")} ${
|
||||
inspect({}, newOptions)
|
||||
}`;
|
||||
},
|
||||
} as any;
|
||||
return app;
|
||||
}
|
||||
|
||||
/** Options that can be set in a mock context. */
|
||||
export interface MockContextOptions<
|
||||
R extends string,
|
||||
P extends RouteParams<R> = RouteParams<R>,
|
||||
S extends State = Record<string, any>,
|
||||
> {
|
||||
app?: Application<S>;
|
||||
ip?: string;
|
||||
method?: string;
|
||||
params?: P;
|
||||
path?: string;
|
||||
state?: S;
|
||||
headers?: [string, string][];
|
||||
}
|
||||
|
||||
/** Allows external parties to modify the context state. */
|
||||
export const mockContextState = {
|
||||
/** Adjusts the return value of the `acceptedEncodings` in the context's
|
||||
* `request` object. */
|
||||
encodingsAccepted: "identity",
|
||||
};
|
||||
|
||||
/** Create a mock of `Context` or `RouterContext`. */
|
||||
export function createMockContext<
|
||||
R extends string,
|
||||
P extends RouteParams<R> = RouteParams<R>,
|
||||
S extends State = Record<string, any>,
|
||||
>(
|
||||
{
|
||||
ip = "127.0.0.1",
|
||||
method = "GET",
|
||||
params,
|
||||
path = "/",
|
||||
state,
|
||||
app = createMockApp(state),
|
||||
headers: requestHeaders,
|
||||
}: MockContextOptions<R> = {},
|
||||
) {
|
||||
function createMockRequest(): Request {
|
||||
const headers = new Headers(requestHeaders);
|
||||
return {
|
||||
accepts(...types: string[]) {
|
||||
if (!headers.has("Accept")) {
|
||||
return;
|
||||
}
|
||||
if (types.length) {
|
||||
return accepts({ headers }, ...types);
|
||||
}
|
||||
return accepts({ headers });
|
||||
},
|
||||
acceptsEncodings() {
|
||||
return mockContextState.encodingsAccepted;
|
||||
},
|
||||
headers,
|
||||
ip,
|
||||
method,
|
||||
path,
|
||||
search: undefined,
|
||||
searchParams: new URLSearchParams(),
|
||||
url: new URL(path, "http://localhost/"),
|
||||
} as any;
|
||||
}
|
||||
|
||||
const request = createMockRequest();
|
||||
const response = new Response(request);
|
||||
const cookies = new SecureCookieMap(request, { response });
|
||||
|
||||
return ({
|
||||
app,
|
||||
params,
|
||||
request,
|
||||
cookies,
|
||||
response,
|
||||
state: Object.assign({}, app.state),
|
||||
assert(
|
||||
condition: any,
|
||||
errorStatus: ErrorStatus = 500,
|
||||
message?: string,
|
||||
props?: Record<string, unknown>,
|
||||
): asserts condition {
|
||||
if (condition) {
|
||||
return;
|
||||
}
|
||||
const err = createHttpError(errorStatus, message);
|
||||
if (props) {
|
||||
Object.assign(err, props);
|
||||
}
|
||||
throw err;
|
||||
},
|
||||
throw(
|
||||
errorStatus: ErrorStatus,
|
||||
message?: string,
|
||||
props?: Record<string, unknown>,
|
||||
): never {
|
||||
const err = createHttpError(errorStatus, message);
|
||||
if (props) {
|
||||
Object.assign(err, props);
|
||||
}
|
||||
throw err;
|
||||
},
|
||||
[Symbol.for("Deno.customInspect")]() {
|
||||
return `MockContext {}`;
|
||||
},
|
||||
[Symbol.for("nodejs.util.inspect.custom")](
|
||||
depth: number,
|
||||
options: any,
|
||||
inspect: (value: unknown, options?: unknown) => string,
|
||||
) {
|
||||
if (depth < 0) {
|
||||
return options.stylize(`[MockContext]`, "special");
|
||||
}
|
||||
|
||||
const newOptions = Object.assign({}, options, {
|
||||
depth: options.depth === null ? null : options.depth - 1,
|
||||
});
|
||||
return `${options.stylize("MockContext", "special")} ${
|
||||
inspect({}, newOptions)
|
||||
}`;
|
||||
},
|
||||
} as unknown) as RouterContext<R, P, S>;
|
||||
}
|
||||
|
||||
/** Creates a mock `next()` function which can be used when calling
|
||||
* middleware. */
|
||||
export function createMockNext() {
|
||||
return async function next() {};
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// Copyright 2018-2022 the oak authors. All rights reserved. MIT license.
|
||||
|
||||
import type { Application, State } from "./application.ts";
|
||||
import type { Status } from "./deps.ts";
|
||||
|
||||
/** A HTTP status that is an error (4XX and 5XX). */
|
||||
export type ErrorStatus =
|
||||
| Status.BadRequest
|
||||
| Status.Unauthorized
|
||||
| Status.PaymentRequired
|
||||
| Status.Forbidden
|
||||
| Status.NotFound
|
||||
| Status.MethodNotAllowed
|
||||
| Status.NotAcceptable
|
||||
| Status.ProxyAuthRequired
|
||||
| Status.RequestTimeout
|
||||
| Status.Conflict
|
||||
| Status.Gone
|
||||
| Status.LengthRequired
|
||||
| Status.PreconditionFailed
|
||||
| Status.RequestEntityTooLarge
|
||||
| Status.RequestURITooLong
|
||||
| Status.UnsupportedMediaType
|
||||
| Status.RequestedRangeNotSatisfiable
|
||||
| Status.ExpectationFailed
|
||||
| Status.Teapot
|
||||
| Status.MisdirectedRequest
|
||||
| Status.UnprocessableEntity
|
||||
| Status.Locked
|
||||
| Status.FailedDependency
|
||||
| Status.UpgradeRequired
|
||||
| Status.PreconditionRequired
|
||||
| Status.TooManyRequests
|
||||
| Status.RequestHeaderFieldsTooLarge
|
||||
| Status.UnavailableForLegalReasons
|
||||
| Status.InternalServerError
|
||||
| Status.NotImplemented
|
||||
| Status.BadGateway
|
||||
| Status.ServiceUnavailable
|
||||
| Status.GatewayTimeout
|
||||
| Status.HTTPVersionNotSupported
|
||||
| Status.VariantAlsoNegotiates
|
||||
| Status.InsufficientStorage
|
||||
| Status.LoopDetected
|
||||
| Status.NotExtended
|
||||
| Status.NetworkAuthenticationRequired;
|
||||
|
||||
/** A HTTP status that is a redirect (3XX). */
|
||||
export type RedirectStatus =
|
||||
| Status.MultipleChoices // 300
|
||||
| Status.MovedPermanently // 301
|
||||
| Status.Found // 302
|
||||
| Status.SeeOther // 303
|
||||
| Status.UseProxy // 305 - DEPRECATED
|
||||
| Status.TemporaryRedirect // 307
|
||||
| Status.PermanentRedirect; // 308
|
||||
|
||||
export type HTTPMethods =
|
||||
| "HEAD"
|
||||
| "OPTIONS"
|
||||
| "GET"
|
||||
| "PUT"
|
||||
| "PATCH"
|
||||
| "POST"
|
||||
| "DELETE";
|
||||
|
||||
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;
|
||||
}
|
||||
+472
@@ -0,0 +1,472 @@
|
||||
// Copyright 2018-2022 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,
|
||||
Status,
|
||||
} from "./deps.ts";
|
||||
import type { RouteParams, RouterContext } from "./router.ts";
|
||||
import type { Data, ErrorStatus, Key, RedirectStatus } 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 HTTP `Status` is an `ErrorStatus` (4XX or 5XX). */
|
||||
export function isErrorStatus(value: Status): value is ErrorStatus {
|
||||
return [
|
||||
Status.BadRequest,
|
||||
Status.Unauthorized,
|
||||
Status.PaymentRequired,
|
||||
Status.Forbidden,
|
||||
Status.NotFound,
|
||||
Status.MethodNotAllowed,
|
||||
Status.NotAcceptable,
|
||||
Status.ProxyAuthRequired,
|
||||
Status.RequestTimeout,
|
||||
Status.Conflict,
|
||||
Status.Gone,
|
||||
Status.LengthRequired,
|
||||
Status.PreconditionFailed,
|
||||
Status.RequestEntityTooLarge,
|
||||
Status.RequestURITooLong,
|
||||
Status.UnsupportedMediaType,
|
||||
Status.RequestedRangeNotSatisfiable,
|
||||
Status.ExpectationFailed,
|
||||
Status.Teapot,
|
||||
Status.MisdirectedRequest,
|
||||
Status.UnprocessableEntity,
|
||||
Status.Locked,
|
||||
Status.FailedDependency,
|
||||
Status.UpgradeRequired,
|
||||
Status.PreconditionRequired,
|
||||
Status.TooManyRequests,
|
||||
Status.RequestHeaderFieldsTooLarge,
|
||||
Status.UnavailableForLegalReasons,
|
||||
Status.InternalServerError,
|
||||
Status.NotImplemented,
|
||||
Status.BadGateway,
|
||||
Status.ServiceUnavailable,
|
||||
Status.GatewayTimeout,
|
||||
Status.HTTPVersionNotSupported,
|
||||
Status.VariantAlsoNegotiates,
|
||||
Status.InsufficientStorage,
|
||||
Status.LoopDetected,
|
||||
Status.NotExtended,
|
||||
Status.NetworkAuthenticationRequired,
|
||||
].includes(value);
|
||||
}
|
||||
|
||||
/** Determines if a HTTP `Status` is a `RedirectStatus` (3XX). */
|
||||
export function isRedirectStatus(value: Status): value is RedirectStatus {
|
||||
return [
|
||||
Status.MultipleChoices,
|
||||
Status.MovedPermanently,
|
||||
Status.Found,
|
||||
Status.SeeOther,
|
||||
Status.UseProxy,
|
||||
Status.TemporaryRedirect,
|
||||
Status.PermanentRedirect,
|
||||
].includes(value);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
Reference in new issue
Block a user