Added a very complicated AsyncIterableReader class.

The class was already implemented in
"https://deno.land/std@0.144.0/streams/conversion.ts".  So, I litterally
lost 1 day of work!  I hate myself.
This commit is contained in:
didier amyot committed 2023-06-12 12:02:15 -04:00
1 parent 17a1b4bed3
commit 0d627c9051
125 files changed
+26943 -83

No files matched your search

@@ -0,0 +1,51 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
/*!
* Adapted directly from negotiator at https://github.com/jshttp/negotiator/
* which is licensed as follows:
*
* (The MIT License)
*
* Copyright (c) 2012-2014 Federico Romero
* Copyright (c) 2012-2014 Isaac Z. Schlueter
* Copyright (c) 2014-2015 Douglas Christopher Wilson
*
* 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.
*/
export interface Specificity {
i: number;
o?: number;
q: number;
s?: number;
}
export function compareSpecs(a: Specificity, b: Specificity): number {
return (
b.q - a.q ||
(b.s ?? 0) - (a.s ?? 0) ||
(a.o ?? 0) - (b.o ?? 0) ||
a.i - b.i ||
0
);
}
export function isQuality(spec: Specificity): boolean {
return spec.q > 0;
}
@@ -0,0 +1,162 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
/*!
* Adapted directly from negotiator at https://github.com/jshttp/negotiator/
* which is licensed as follows:
*
* (The MIT License)
*
* Copyright (c) 2012-2014 Federico Romero
* Copyright (c) 2012-2014 Isaac Z. Schlueter
* Copyright (c) 2014-2015 Douglas Christopher Wilson
*
* 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.
*/
import { compareSpecs, isQuality, Specificity } from "./common.ts";
interface EncodingSpecificity extends Specificity {
encoding?: string;
}
const simpleEncodingRegExp = /^\s*([^\s;]+)\s*(?:;(.*))?$/;
function parseEncoding(
str: string,
i: number,
): EncodingSpecificity | undefined {
const match = simpleEncodingRegExp.exec(str);
if (!match) {
return undefined;
}
const encoding = match[1];
let q = 1;
if (match[2]) {
const params = match[2].split(";");
for (const param of params) {
const p = param.trim().split("=");
if (p[0] === "q") {
q = parseFloat(p[1]);
break;
}
}
}
return { encoding, q, i };
}
function specify(
encoding: string,
spec: EncodingSpecificity,
i = -1,
): Specificity | undefined {
if (!spec.encoding) {
return;
}
let s = 0;
if (spec.encoding.toLocaleLowerCase() === encoding.toLocaleLowerCase()) {
s = 1;
} else if (spec.encoding !== "*") {
return;
}
return {
i,
o: spec.i,
q: spec.q,
s,
};
}
function parseAcceptEncoding(accept: string): EncodingSpecificity[] {
const accepts = accept.split(",");
const parsedAccepts: EncodingSpecificity[] = [];
let hasIdentity = false;
let minQuality = 1;
for (let i = 0; i < accepts.length; i++) {
const encoding = parseEncoding(accepts[i].trim(), i);
if (encoding) {
parsedAccepts.push(encoding);
hasIdentity = hasIdentity || !!specify("identity", encoding);
minQuality = Math.min(minQuality, encoding.q || 1);
}
}
if (!hasIdentity) {
parsedAccepts.push({
encoding: "identity",
q: minQuality,
i: accepts.length - 1,
});
}
return parsedAccepts;
}
function getEncodingPriority(
encoding: string,
accepted: Specificity[],
index: number,
): Specificity {
let priority: Specificity = { o: -1, q: 0, s: 0, i: 0 };
for (const s of accepted) {
const spec = specify(encoding, s, index);
if (
spec &&
(priority.s! - spec.s! || priority.q - spec.q ||
priority.o! - spec.o!) <
0
) {
priority = spec;
}
}
return priority;
}
/** Given an `Accept-Encoding` string, parse out the encoding returning a
* negotiated encoding based on the `provided` encodings otherwise just a
* prioritized array of encodings. */
export function preferredEncodings(
accept: string,
provided?: string[],
): string[] {
const accepts = parseAcceptEncoding(accept);
if (!provided) {
return accepts
.filter(isQuality)
.sort(compareSpecs)
.map((spec) => spec.encoding!);
}
const priorities = provided.map((type, index) =>
getEncodingPriority(type, accepts, index)
);
return priorities
.filter(isQuality)
.sort(compareSpecs)
.map((priority) => provided[priorities.indexOf(priority)]);
}
@@ -0,0 +1,144 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
/*!
* Adapted directly from negotiator at https://github.com/jshttp/negotiator/
* which is licensed as follows:
*
* (The MIT License)
*
* Copyright (c) 2012-2014 Federico Romero
* Copyright (c) 2012-2014 Isaac Z. Schlueter
* Copyright (c) 2014-2015 Douglas Christopher Wilson
*
* 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.
*/
import { compareSpecs, isQuality, Specificity } from "./common.ts";
interface LanguageSpecificity extends Specificity {
prefix: string;
suffix?: string;
full: string;
}
const SIMPLE_LANGUAGE_REGEXP = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/;
function parseLanguage(
str: string,
i: number,
): LanguageSpecificity | undefined {
const match = SIMPLE_LANGUAGE_REGEXP.exec(str);
if (!match) {
return undefined;
}
const [, prefix, suffix] = match;
const full = suffix ? `${prefix}-${suffix}` : prefix;
let q = 1;
if (match[3]) {
const params = match[3].split(";");
for (const param of params) {
const [key, value] = param.trim().split("=");
if (key === "q") {
q = parseFloat(value);
break;
}
}
}
return { prefix, suffix, full, q, i };
}
function parseAcceptLanguage(accept: string): LanguageSpecificity[] {
const accepts = accept.split(",");
const result: LanguageSpecificity[] = [];
for (let i = 0; i < accepts.length; i++) {
const language = parseLanguage(accepts[i].trim(), i);
if (language) {
result.push(language);
}
}
return result;
}
function specify(
language: string,
spec: LanguageSpecificity,
i: number,
): Specificity | undefined {
const p = parseLanguage(language, i);
if (!p) {
return undefined;
}
let s = 0;
if (spec.full.toLowerCase() === p.full.toLowerCase()) {
s |= 4;
} else if (spec.prefix.toLowerCase() === p.prefix.toLowerCase()) {
s |= 2;
} else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) {
s |= 1;
} else if (spec.full !== "*") {
return;
}
return { i, o: spec.i, q: spec.q, s };
}
function getLanguagePriority(
language: string,
accepted: LanguageSpecificity[],
index: number,
): Specificity {
let priority: Specificity = { i: -1, o: -1, q: 0, s: 0 };
for (const accepts of accepted) {
const spec = specify(language, accepts, index);
if (
spec &&
((priority.s ?? 0) - (spec.s ?? 0) || priority.q - spec.q ||
(priority.o ?? 0) - (spec.o ?? 0)) < 0
) {
priority = spec;
}
}
return priority;
}
export function preferredLanguages(
accept = "*",
provided?: string[],
): string[] {
const accepts = parseAcceptLanguage(accept);
if (!provided) {
return accepts
.filter(isQuality)
.sort(compareSpecs)
.map((spec) => spec.full);
}
const priorities = provided
.map((type, index) => getLanguagePriority(type, accepts, index));
return priorities
.filter(isQuality)
.sort(compareSpecs)
.map((priority) => provided[priorities.indexOf(priority)]);
}
@@ -0,0 +1,237 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
/*!
* Adapted directly from negotiator at https://github.com/jshttp/negotiator/
* which is licensed as follows:
*
* (The MIT License)
*
* Copyright (c) 2012-2014 Federico Romero
* Copyright (c) 2012-2014 Isaac Z. Schlueter
* Copyright (c) 2014-2015 Douglas Christopher Wilson
*
* 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.
*/
import { compareSpecs, isQuality, Specificity } from "./common.ts";
interface MediaTypeSpecificity extends Specificity {
type: string;
subtype: string;
params: { [param: string]: string | undefined };
}
const simpleMediaTypeRegExp = /^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/;
function quoteCount(str: string): number {
let count = 0;
let index = 0;
while ((index = str.indexOf(`"`, index)) !== -1) {
count++;
index++;
}
return count;
}
function splitMediaTypes(accept: string): string[] {
const accepts = accept.split(",");
let j = 0;
for (let i = 1; i < accepts.length; i++) {
if (quoteCount(accepts[j]) % 2 === 0) {
accepts[++j] = accepts[i];
} else {
accepts[j] += `,${accepts[i]}`;
}
}
accepts.length = j + 1;
return accepts;
}
function splitParameters(str: string): string[] {
const parameters = str.split(";");
let j = 0;
for (let i = 1; i < parameters.length; i++) {
if (quoteCount(parameters[j]) % 2 === 0) {
parameters[++j] = parameters[i];
} else {
parameters[j] += `;${parameters[i]}`;
}
}
parameters.length = j + 1;
return parameters.map((p) => p.trim());
}
function splitKeyValuePair(str: string): [string, string | undefined] {
const [key, value] = str.split("=");
return [key.toLowerCase(), value];
}
function parseMediaType(
str: string,
i: number,
): MediaTypeSpecificity | undefined {
const match = simpleMediaTypeRegExp.exec(str);
if (!match) {
return;
}
const params: { [param: string]: string | undefined } = Object.create(null);
let q = 1;
const [, type, subtype, parameters] = match;
if (parameters) {
const kvps = splitParameters(parameters).map(splitKeyValuePair);
for (const [key, val] of kvps) {
const value = val && val[0] === `"` && val[val.length - 1] === `"`
? val.slice(1, val.length - 1)
: val;
if (key === "q" && value) {
q = parseFloat(value);
break;
}
params[key] = value;
}
}
return { type, subtype, params, q, i };
}
function parseAccept(accept: string): MediaTypeSpecificity[] {
const accepts = splitMediaTypes(accept);
const mediaTypes: MediaTypeSpecificity[] = [];
for (let i = 0; i < accepts.length; i++) {
const mediaType = parseMediaType(accepts[i].trim(), i);
if (mediaType) {
mediaTypes.push(mediaType);
}
}
return mediaTypes;
}
function getFullType(spec: MediaTypeSpecificity) {
return `${spec.type}/${spec.subtype}`;
}
function specify(
type: string,
spec: MediaTypeSpecificity,
index: number,
): Specificity | undefined {
const p = parseMediaType(type, index);
if (!p) {
return;
}
let s = 0;
if (spec.type.toLowerCase() === p.type.toLowerCase()) {
s |= 4;
} else if (spec.type !== "*") {
return;
}
if (spec.subtype.toLowerCase() === p.subtype.toLowerCase()) {
s |= 2;
} else if (spec.subtype !== "*") {
return;
}
const keys = Object.keys(spec.params);
if (keys.length) {
if (
keys.every((key) =>
(spec.params[key] || "").toLowerCase() ===
(p.params[key] || "").toLowerCase()
)
) {
s |= 1;
} else {
return;
}
}
return {
i: index,
o: spec.o,
q: spec.q,
s,
};
}
function getMediaTypePriority(
type: string,
accepted: MediaTypeSpecificity[],
index: number,
) {
let priority: Specificity = { o: -1, q: 0, s: 0, i: index };
for (const accepts of accepted) {
const spec = specify(type, accepts, index);
if (
spec &&
((priority.s || 0) - (spec.s || 0) ||
(priority.q || 0) - (spec.q || 0) ||
(priority.o || 0) - (spec.o || 0)) < 0
) {
priority = spec;
}
}
return priority;
}
export function preferredMediaTypes(
accept?: string | null,
provided?: string[],
): string[] {
const accepts = parseAccept(accept === undefined ? "*/*" : accept || "");
if (!provided) {
return accepts
.filter(isQuality)
.sort(compareSpecs)
.map(getFullType);
}
const priorities = provided.map((type, index) => {
return getMediaTypePriority(type, accepts, index);
});
return priorities
.filter(isQuality)
.sort(compareSpecs)
.map((priority) => provided[priorities.indexOf(priority)]);
}
+806
View File
@@ -0,0 +1,806 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
/** Provides a iterable map interfaces for managing cookies server side.
*
* @example
* To access the keys in a request and have any set keys available for creating
* a response:
*
* ```ts
* import {
* CookieMap,
* mergeHeaders
* } from "https://deno.land/std@$STD_VERSION/http/cookie_map.ts";
*
* const request = new Request("https://localhost/", {
* headers: { "cookie": "foo=bar; bar=baz;"}
* });
*
* const cookies = new CookieMap(request, { secure: true });
* console.log(cookies.get("foo")); // logs "bar"
* cookies.set("session", "1234567", { secure: true });
*
* const response = new Response("hello", {
* headers: mergeHeaders({
* "content-type": "text/plain",
* }, cookies),
* });
* ```
*
* @example
* To have automatic management of cryptographically signed cookies, you can use
* the {@linkcode SecureCookieMap} instead of {@linkcode CookieMap}. The biggest
* difference is that the methods operate async in order to be able to support
* async signing and validation of cookies:
*
* ```ts
* import {
* SecureCookieMap,
* mergeHeaders,
* type KeyRing,
* } from "https://deno.land/std@$STD_VERSION/http/cookie_map.ts";
*
* const request = new Request("https://localhost/", {
* headers: { "cookie": "foo=bar; bar=baz;"}
* });
*
* // The keys must implement the `KeyRing` interface.
* declare const keys: KeyRing;
*
* const cookies = new SecureCookieMap(request, { keys, secure: true });
* console.log(await cookies.get("foo")); // logs "bar"
* // the cookie will be automatically signed using the supplied key ring.
* await cookies.set("session", "1234567");
*
* const response = new Response("hello", {
* headers: mergeHeaders({
* "content-type": "text/plain",
* }, cookies),
* });
* ```
*
* In addition, if you have a {@linkcode Response} or {@linkcode Headers} for a
* response at construction of the cookies object, they can be passed and any
* set cookies will be added directly to those headers:
*
* ```ts
* import { CookieMap } from "https://deno.land/std@$STD_VERSION/http/cookie_map.ts";
*
* const request = new Request("https://localhost/", {
* headers: { "cookie": "foo=bar; bar=baz;"}
* });
*
* const response = new Response("hello", {
* headers: { "content-type": "text/plain" },
* });
*
* const cookies = new CookieMap(request, { response });
* console.log(cookies.get("foo")); // logs "bar"
* cookies.set("session", "1234567");
* ```
*
* @module
*/
export interface CookieMapOptions {
/** The {@linkcode Response} or the headers that will be used with the
* response. When provided, `Set-Cookie` headers will be set in the headers
* when cookies are set or deleted in the map.
*
* An alternative way to extract the headers is to pass the cookie map to the
* {@linkcode mergeHeaders} function to merge various sources of the
* headers to be provided when creating or updating a response.
*/
response?: Headered | Headers;
/** A flag that indicates if the request and response are being handled over
* a secure (e.g. HTTPS/TLS) connection.
*
* @default {false}
*/
secure?: boolean;
}
export interface CookieMapSetDeleteOptions {
/** The domain to scope the cookie for. */
domain?: string;
/** When the cookie expires. */
expires?: Date;
/** A flag that indicates if the cookie is valid over HTTP only. */
httpOnly?: boolean;
/** Do not error when signing and validating cookies over an insecure
* connection. */
ignoreInsecure?: boolean;
/** Overwrite an existing value. */
overwrite?: boolean;
/** The path the cookie is valid for. */
path?: string;
/** Override the flag that was set when the instance was created. */
secure?: boolean;
/** Set the same-site indicator for a cookie. */
sameSite?: "strict" | "lax" | "none" | boolean;
}
/** An object which contains a `headers` property which has a value of an
* instance of {@linkcode Headers}, like {@linkcode Request} and
* {@linkcode Response}. */
export interface Headered {
headers: Headers;
}
export interface Mergeable {
[cookieMapHeadersInitSymbol](): [string, string][];
}
export interface SecureCookieMapOptions {
/** Keys which will be used to validate and sign cookies. The key ring should
* implement the {@linkcode KeyRing} interface. */
keys?: KeyRing;
/** The {@linkcode Response} or the headers that will be used with the
* response. When provided, `Set-Cookie` headers will be set in the headers
* when cookies are set or deleted in the map.
*
* An alternative way to extract the headers is to pass the cookie map to the
* {@linkcode mergeHeaders} function to merge various sources of the
* headers to be provided when creating or updating a response.
*/
response?: Headered | Headers;
/** A flag that indicates if the request and response are being handled over
* a secure (e.g. HTTPS/TLS) connection.
*
* @default {false}
*/
secure?: boolean;
}
export interface SecureCookieMapGetOptions {
/** Overrides the flag that was set when the instance was created. */
signed?: boolean;
}
export interface SecureCookieMapSetDeleteOptions {
/** The domain to scope the cookie for. */
domain?: string;
/** When the cookie expires. */
expires?: Date;
/** A flag that indicates if the cookie is valid over HTTP only. */
httpOnly?: boolean;
/** Do not error when signing and validating cookies over an insecure
* connection. */
ignoreInsecure?: boolean;
/** Overwrite an existing value. */
overwrite?: boolean;
/** The path the cookie is valid for. */
path?: string;
/** Override the flag that was set when the instance was created. */
secure?: boolean;
/** Set the same-site indicator for a cookie. */
sameSite?: "strict" | "lax" | "none" | boolean;
/** Override the default behavior of signing the cookie. */
signed?: boolean;
}
type CookieAttributes = SecureCookieMapSetDeleteOptions;
// deno-lint-ignore no-control-regex
const FIELD_CONTENT_REGEXP = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;
const KEY_REGEXP = /(?:^|;) *([^=]*)=[^;]*/g;
const SAME_SITE_REGEXP = /^(?:lax|none|strict)$/i;
const matchCache: Record<string, RegExp> = {};
function getPattern(name: string): RegExp {
if (name in matchCache) {
return matchCache[name];
}
return matchCache[name] = new RegExp(
`(?:^|;) *${name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&")}=([^;]*)`,
);
}
function pushCookie(values: string[], cookie: Cookie) {
if (cookie.overwrite) {
for (let i = values.length - 1; i >= 0; i--) {
if (values[i].indexOf(`${cookie.name}=`) === 0) {
values.splice(i, 1);
}
}
}
values.push(cookie.toHeaderValue());
}
function validateCookieProperty(
key: string,
value: string | undefined | null,
) {
if (value && !FIELD_CONTENT_REGEXP.test(value)) {
throw new TypeError(`The "${key}" of the cookie (${value}) is invalid.`);
}
}
/** An internal abstraction to manage cookies. */
class Cookie implements CookieAttributes {
domain?: string;
expires?: Date;
httpOnly = true;
maxAge?: number;
name: string;
overwrite = false;
path = "/";
sameSite: "strict" | "lax" | "none" | boolean = false;
secure = false;
signed?: boolean;
value: string;
constructor(
name: string,
value: string | null,
attributes: CookieAttributes,
) {
validateCookieProperty("name", name);
this.name = name;
validateCookieProperty("value", value);
this.value = value ?? "";
Object.assign(this, attributes);
if (!this.value) {
this.expires = new Date(0);
this.maxAge = undefined;
}
validateCookieProperty("path", this.path);
validateCookieProperty("domain", this.domain);
if (
this.sameSite && typeof this.sameSite === "string" &&
!SAME_SITE_REGEXP.test(this.sameSite)
) {
throw new TypeError(
`The "sameSite" of the cookie ("${this.sameSite}") is invalid.`,
);
}
}
toHeaderValue(): string {
let value = this.toString();
if (this.maxAge) {
this.expires = new Date(Date.now() + (this.maxAge * 1000));
}
if (this.path) {
value += `; path=${this.path}`;
}
if (this.expires) {
value += `; expires=${this.expires.toUTCString()}`;
}
if (this.domain) {
value += `; domain=${this.domain}`;
}
if (this.sameSite) {
value += `; samesite=${
this.sameSite === true ? "strict" : this.sameSite.toLowerCase()
}`;
}
if (this.secure) {
value += "; secure";
}
if (this.httpOnly) {
value += "; httponly";
}
return value;
}
toString(): string {
return `${this.name}=${this.value}`;
}
}
/** Symbol which is used in {@link mergeHeaders} to extract a
* `[string | string][]` from an instance to generate the final set of
* headers. */
export const cookieMapHeadersInitSymbol = Symbol.for(
"Deno.std.cookieMap.headersInit",
);
function isMergeable(value: unknown): value is Mergeable {
return value != null && typeof value === "object" &&
cookieMapHeadersInitSymbol in value;
}
/** Allows merging of various sources of headers into a final set of headers
* which can be used in a {@linkcode Response}.
*
* Note, that unlike when passing a `Response` or {@linkcode Headers} used in a
* response to {@linkcode CookieMap} or {@linkcode SecureCookieMap}, merging
* will not ensure that there are no other `Set-Cookie` headers from other
* sources, it will simply append the various headers together. */
export function mergeHeaders(
...sources: (Headered | HeadersInit | Mergeable)[]
): Headers {
const headers = new Headers();
for (const source of sources) {
let entries: Iterable<[string, string]>;
if (source instanceof Headers) {
entries = source;
} else if ("headers" in source && source.headers instanceof Headers) {
entries = source.headers;
} else if (isMergeable(source)) {
entries = source[cookieMapHeadersInitSymbol]();
} else if (Array.isArray(source)) {
entries = source as [string, string][];
} else {
entries = Object.entries(source);
}
for (const [key, value] of entries) {
headers.append(key, value);
}
}
return headers;
}
const keys = Symbol("#keys");
const requestHeaders = Symbol("#requestHeaders");
const responseHeaders = Symbol("#responseHeaders");
const isSecure = Symbol("#secure");
const requestKeys = Symbol("#requestKeys");
/** An internal abstract class which provides common functionality for
* {@link CookieMap} and {@link SecureCookieMap}. */
abstract class CookieMapBase implements Mergeable {
[keys]?: string[];
[requestHeaders]: Headers;
[responseHeaders]: Headers;
[isSecure]: boolean;
[requestKeys](): string[] {
if (this[keys]) {
return this[keys];
}
const result = this[keys] = [] as string[];
const header = this[requestHeaders].get("cookie");
if (!header) {
return result;
}
let matches: RegExpExecArray | null;
while ((matches = KEY_REGEXP.exec(header))) {
const [, key] = matches;
result.push(key);
}
return result;
}
constructor(request: Headers | Headered, options: CookieMapOptions) {
this[requestHeaders] = "headers" in request ? request.headers : request;
const { secure = false, response = new Headers() } = options;
this[responseHeaders] = "headers" in response ? response.headers : response;
this[isSecure] = secure;
}
/** A method used by {@linkcode mergeHeaders} to be able to merge
* headers from various sources when forming a {@linkcode Response}. */
[cookieMapHeadersInitSymbol](): [string, string][] {
const init: [string, string][] = [];
for (const [key, value] of this[responseHeaders]) {
if (key === "set-cookie") {
init.push([key, value]);
}
}
return init;
}
[Symbol.for("Deno.customInspect")]() {
return `${this.constructor.name} []`;
}
[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)
}`;
}
}
/**
* Provides a way to manage cookies in a request and response on the server
* as a single iterable collection.
*
* The methods and properties align to {@linkcode Map}. When constructing a
* {@linkcode Request} or {@linkcode Headers} from the request need to be
* provided, as well as optionally the {@linkcode Response} or `Headers` for the
* response can be provided. Alternatively the {@linkcode mergeHeaders}
* function can be used to generate a final set of headers for sending in the
* response. */
export class CookieMap extends CookieMapBase {
/** Contains the number of valid cookies in the request headers. */
get size(): number {
return [...this].length;
}
constructor(request: Headers | Headered, options: CookieMapOptions = {}) {
super(request, options);
}
/** Deletes all the cookies from the {@linkcode Request} in the response. */
clear(options: CookieMapSetDeleteOptions = {}) {
for (const key of this.keys()) {
this.set(key, null, options);
}
}
/** Set a cookie to be deleted in the response.
*
* This is a convenience function for `set(key, null, options?)`.
*/
delete(key: string, options: CookieMapSetDeleteOptions = {}): boolean {
this.set(key, null, options);
return true;
}
/** Return the value of a matching key present in the {@linkcode Request}. If
* the key is not present `undefined` is returned. */
get(key: string): string | undefined {
const headerValue = this[requestHeaders].get("cookie");
if (!headerValue) {
return undefined;
}
const match = headerValue.match(getPattern(key));
if (!match) {
return undefined;
}
const [, value] = match;
return value;
}
/** Returns `true` if the matching key is present in the {@linkcode Request},
* otherwise `false`. */
has(key: string): boolean {
const headerValue = this[requestHeaders].get("cookie");
if (!headerValue) {
return false;
}
return getPattern(key).test(headerValue);
}
/** Set a named cookie in the response. The optional
* {@linkcode CookieMapSetDeleteOptions} are applied to the cookie being set.
*/
set(
key: string,
value: string | null,
options: CookieMapSetDeleteOptions = {},
): this {
const resHeaders = this[responseHeaders];
const values: string[] = [];
for (const [key, value] of resHeaders) {
if (key === "set-cookie") {
values.push(value);
}
}
const secure = this[isSecure];
if (!secure && options.secure && !options.ignoreInsecure) {
throw new TypeError(
"Cannot send secure cookie over unencrypted connection.",
);
}
const cookie = new Cookie(key, value, options);
cookie.secure = options.secure ?? secure;
pushCookie(values, cookie);
resHeaders.delete("set-cookie");
for (const value of values) {
resHeaders.append("set-cookie", value);
}
return this;
}
/** Iterate over the cookie keys and values that are present in the
* {@linkcode Request}. This is an alias of the `[Symbol.iterator]` method
* present on the class. */
entries(): IterableIterator<[string, string]> {
return this[Symbol.iterator]();
}
/** Iterate over the cookie keys that are present in the
* {@linkcode Request}. */
*keys(): IterableIterator<string> {
for (const [key] of this) {
yield key;
}
}
/** Iterate over the cookie values that are present in the
* {@linkcode Request}. */
*values(): IterableIterator<string> {
for (const [, value] of this) {
yield value;
}
}
/** Iterate over the cookie keys and values that are present in the
* {@linkcode Request}. */
*[Symbol.iterator](): IterableIterator<[string, string]> {
const keys = this[requestKeys]();
for (const key of keys) {
const value = this.get(key);
if (value) {
yield [key, value];
}
}
}
}
/** Types of data that can be signed cryptographically. */
export type Data = string | number[] | ArrayBuffer | Uint8Array;
/** An interface which describes the methods that {@linkcode SecureCookieMap}
* uses to sign and verify cookies. */
export interface KeyRing {
/** Given a set of data and a digest, return the key index of the key used
* to sign the data. The index is 0 based. A non-negative number indices the
* digest is valid and a key was found. */
indexOf(data: Data, digest: string): Promise<number> | number;
/** Sign the data, returning a string based digest of the data. */
sign(data: Data): Promise<string> | string;
/** Verifies the digest matches the provided data, indicating the data was
* signed by the keyring and has not been tampered with. */
verify(data: Data, digest: string): Promise<boolean> | boolean;
}
/** Provides an way to manage cookies in a request and response on the server
* as a single iterable collection, as well as the ability to sign and verify
* cookies to prevent tampering.
*
* The methods and properties align to {@linkcode Map}, but due to the need to
* support asynchronous cryptographic keys, all the APIs operate async. When
* constructing a {@linkcode Request} or {@linkcode Headers} from the request
* need to be provided, as well as optionally the {@linkcode Response} or
* `Headers` for the response can be provided. Alternatively the
* {@linkcode mergeHeaders} function can be used to generate a final set
* of headers for sending in the response.
*
* On construction, the optional set of keys implementing the
* {@linkcode KeyRing} interface. While it is optional, if you don't plan to use
* keys, you might want to consider using just the {@linkcode CookieMap}.
*
* @example
*/
export class SecureCookieMap extends CookieMapBase {
#keyRing?: KeyRing;
/** Is set to a promise which resolves with the number of cookies in the
* {@linkcode Request}. */
get size(): Promise<number> {
return (async () => {
let size = 0;
for await (const _ of this) {
size++;
}
return size;
})();
}
constructor(
request: Headers | Headered,
options: SecureCookieMapOptions = {},
) {
super(request, options);
const { keys } = options;
this.#keyRing = keys;
}
/** Sets all cookies in the {@linkcode Request} to be deleted in the
* response. */
async clear(options: SecureCookieMapSetDeleteOptions) {
for await (const key of this.keys()) {
await this.set(key, null, options);
}
}
/** Set a cookie to be deleted in the response.
*
* This is a convenience function for `set(key, null, options?)`. */
async delete(
key: string,
options: SecureCookieMapSetDeleteOptions = {},
): Promise<boolean> {
await this.set(key, null, options);
return true;
}
/** Get the value of a cookie from the {@linkcode Request}.
*
* If the cookie is signed, and the signature is invalid, `undefined` will be
* returned and the cookie will be set to be deleted in the response. If the
* cookie is using an "old" key from the keyring, the cookie will be re-signed
* with the current key and be added to the response to be updated. */
async get(
key: string,
options: SecureCookieMapGetOptions = {},
): Promise<string | undefined> {
const signed = options.signed ?? !!this.#keyRing;
const nameSig = `${key}.sig`;
const header = this[requestHeaders].get("cookie");
if (!header) {
return;
}
const match = header.match(getPattern(key));
if (!match) {
return;
}
const [, value] = match;
if (!signed) {
return value;
}
const digest = await this.get(nameSig, { signed: false });
if (!digest) {
return;
}
const data = `${key}=${value}`;
if (!this.#keyRing) {
throw new TypeError("key ring required for signed cookies");
}
const index = await this.#keyRing.indexOf(data, digest);
if (index < 0) {
await this.delete(nameSig, { path: "/", signed: false });
} else {
if (index) {
await this.set(nameSig, await this.#keyRing.sign(data), {
signed: false,
});
}
return value;
}
}
/** Returns `true` if the key is in the {@linkcode Request}.
*
* If the cookie is signed, and the signature is invalid, `false` will be
* returned and the cookie will be set to be deleted in the response. If the
* cookie is using an "old" key from the keyring, the cookie will be re-signed
* with the current key and be added to the response to be updated. */
async has(
key: string,
options: SecureCookieMapGetOptions = {},
): Promise<boolean> {
const signed = options.signed ?? !!this.#keyRing;
const nameSig = `${key}.sig`;
const header = this[requestHeaders].get("cookie");
if (!header) {
return false;
}
const match = header.match(getPattern(key));
if (!match) {
return false;
}
if (!signed) {
return true;
}
const digest = await this.get(nameSig, { signed: false });
if (!digest) {
return false;
}
const [, value] = match;
const data = `${key}=${value}`;
if (!this.#keyRing) {
throw new TypeError("key ring required for signed cookies");
}
const index = await this.#keyRing.indexOf(data, digest);
if (index < 0) {
await this.delete(nameSig, { path: "/", signed: false });
return false;
} else {
if (index) {
await this.set(nameSig, await this.#keyRing.sign(data), {
signed: false,
});
}
return true;
}
}
/** Set a cookie in the response headers.
*
* If there was a keyring set, cookies will be automatically signed, unless
* overridden by the passed options. Cookies can be deleted by setting the
* value to `null`. */
async set(
key: string,
value: string | null,
options: SecureCookieMapSetDeleteOptions = {},
): Promise<this> {
const resHeaders = this[responseHeaders];
const headers: string[] = [];
for (const [key, value] of resHeaders.entries()) {
if (key === "set-cookie") {
headers.push(value);
}
}
const secure = this[isSecure];
const signed = options.signed ?? !!this.#keyRing;
if (!secure && options.secure && !options.ignoreInsecure) {
throw new TypeError(
"Cannot send secure cookie over unencrypted connection.",
);
}
const cookie = new Cookie(key, value, options);
cookie.secure = options.secure ?? secure;
pushCookie(headers, cookie);
if (signed) {
if (!this.#keyRing) {
throw new TypeError("keys required for signed cookies.");
}
cookie.value = await this.#keyRing.sign(cookie.toString());
cookie.name += ".sig";
pushCookie(headers, cookie);
}
resHeaders.delete("set-cookie");
for (const header of headers) {
resHeaders.append("set-cookie", header);
}
return this;
}
/** Iterate over the {@linkcode Request} cookies, yielding up a tuple
* containing the key and value of each cookie.
*
* If a key ring was provided, only properly signed cookie keys and values are
* returned. */
entries(): AsyncIterableIterator<[string, string]> {
return this[Symbol.asyncIterator]();
}
/** Iterate over the request's cookies, yielding up the key of each cookie.
*
* If a keyring was provided, only properly signed cookie keys are
* returned. */
async *keys(): AsyncIterableIterator<string> {
for await (const [key] of this) {
yield key;
}
}
/** Iterate over the request's cookies, yielding up the value of each cookie.
*
* If a keyring was provided, only properly signed cookie values are
* returned. */
async *values(): AsyncIterableIterator<string> {
for await (const [, value] of this) {
yield value;
}
}
/** Iterate over the {@linkcode Request} cookies, yielding up a tuple
* containing the key and value of each cookie.
*
* If a key ring was provided, only properly signed cookie keys and values are
* returned. */
async *[Symbol.asyncIterator](): AsyncIterableIterator<[string, string]> {
const keys = this[requestKeys]();
for (const key of keys) {
const value = await this.get(key);
if (value) {
yield [key, value];
}
}
}
}
+197
View File
@@ -0,0 +1,197 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
/** Provides functions for dealing with and matching ETags, including
* {@linkcode calculate} to calculate an etag for a given entity,
* {@linkcode ifMatch} for validating if an ETag matches against a `If-Match`
* header and {@linkcode ifNoneMatch} for validating an Etag against an
* `If-None-Match` header.
*
* See further information on the `ETag` header on
* [MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag).
*
* @module
*/
import { encode as base64Encode } from "../encoding/base64.ts";
/** 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;
}
type Entity = string | Uint8Array | FileInfo;
const encoder = new TextEncoder();
const DEFAULT_ALGORITHM: AlgorithmIdentifier = "SHA-256";
export interface ETagOptions {
/** A digest algorithm to use to calculate the etag. Defaults to
* `"FNV32A"`. */
algorithm?: AlgorithmIdentifier;
/** Override the default behavior of calculating the `ETag`, either forcing
* a tag to be labelled weak or not. */
weak?: boolean;
}
function isFileInfo(value: unknown): value is FileInfo {
return Boolean(
value && typeof value === "object" && "mtime" in value && "size" in value,
);
}
async function calcEntity(
entity: string | Uint8Array,
{ algorithm = DEFAULT_ALGORITHM }: ETagOptions,
) {
// a short circuit for zero length entities
if (entity.length === 0) {
return `0-47DEQpj8HBSa+/TImW+5JCeuQeR`;
}
if (typeof entity === "string") {
entity = encoder.encode(entity);
}
const hash = base64Encode(await crypto.subtle.digest(algorithm, entity))
.substring(0, 27);
return `${entity.length.toString(16)}-${hash}`;
}
async function calcFileInfo(
fileInfo: FileInfo,
{ algorithm = DEFAULT_ALGORITHM }: ETagOptions,
) {
if (fileInfo.mtime) {
const hash = base64Encode(
await crypto.subtle.digest(
algorithm,
encoder.encode(fileInfo.mtime.toJSON()),
),
).substring(0, 27);
return `${fileInfo.size.toString(16)}-${hash}`;
}
}
/** Calculate an ETag for an entity. When the entity is a specific set of data
* it will be fingerprinted as a "strong" tag, otherwise if it is just file
* information, it will be calculated as a weak tag.
*
* ```ts
* import { calculate } from "https://deno.land/std@$STD_VERSION/http/etag.ts";
* import { assert } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts"
*
* const body = "hello deno!";
*
* const etag = await calculate(body);
* assert(etag);
*
* const res = new Response(body, { headers: { etag } });
* ```
*/
export async function calculate(
entity: Entity,
options: ETagOptions = {},
): Promise<string | undefined> {
const weak = options.weak ?? isFileInfo(entity);
const tag =
await (isFileInfo(entity)
? calcFileInfo(entity, options)
: calcEntity(entity, options));
return tag ? weak ? `W/"${tag}"` : `"${tag}"` : undefined;
}
/** A helper function that takes the value from the `If-Match` header and a
* calculated etag for the target. By using strong comparison, return `true` if
* the values match, 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.
*
* ```ts
* import {
* calculate,
* ifMatch,
* } from "https://deno.land/std@$STD_VERSION/http/etag.ts";
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts";
* import { assert } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts"
*
* const body = "hello deno!";
*
* await serve(async (req) => {
* const ifMatchValue = req.headers.get("if-match");
* const etag = await calculate(body);
* assert(etag);
* if (!ifMatchValue || ifMatch(ifMatchValue, etag)) {
* return new Response(body, { status: 200, headers: { etag } });
* } else {
* return new Response(null, { status: 412, statusText: "Precondition Failed"});
* }
* });
* ```
*/
export function ifMatch(
value: string | null,
etag: string | undefined,
): boolean {
// Weak tags cannot be matched and return false.
if (!value || !etag || 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-None-Match` header and
* a calculated etag for the target entity and returns `false` if the etag for
* the entity matches the supplied value, otherwise `true`.
*
* 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.
*
* ```ts
* import {
* calculate,
* ifNoneMatch,
* } from "https://deno.land/std@$STD_VERSION/http/etag.ts";
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts";
* import { assert } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts"
*
* const body = "hello deno!";
*
* await serve(async (req) => {
* const ifNoneMatchValue = req.headers.get("if-none-match");
* const etag = await calculate(body);
* assert(etag);
* if (!ifNoneMatch(ifNoneMatchValue, etag)) {
* return new Response(null, { status: 304, headers: { etag } });
* } else {
* return new Response(body, { status: 200, headers: { etag } });
* }
* });
* ```
*/
export function ifNoneMatch(
value: string | null,
etag: string | undefined,
): boolean {
if (!value || !etag) {
return true;
}
if (value.trim() === "*") {
return false;
}
etag = etag.startsWith("W/") ? etag.slice(2) : etag;
const tags = value.split(/\s*,\s*/).map((tag) =>
tag.startsWith("W/") ? tag.slice(2) : tag
);
return !tags.includes(etag);
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
/** A collection of HTTP errors and utilities.
*
* The export {@linkcode errors} contains an individual class that extends
* {@linkcode HttpError} which makes handling HTTP errors in a structured way.
*
* The function {@linkcode createHttpError} provides a way to create instances
* of errors in a factory pattern.
*
* The function {@linkcode isHttpError} is a type guard that will narrow a value
* to an `HttpError` instance.
*
* @example
* ```ts
* import { errors, isHttpError } from "https://deno.land/std@$STD_VERSION/http/http_errors.ts";
*
* try {
* throw new errors.NotFound();
* } catch (e) {
* if (isHttpError(e)) {
* const response = new Response(e.message, { status: e.status });
* } else {
* throw e;
* }
* }
* ```
*
* @example
* ```ts
* import { createHttpError } from "https://deno.land/std@$STD_VERSION/http/http_errors.ts";
* import { Status } from "https://deno.land/std@$STD_VERSION/http/http_status.ts";
*
* try {
* throw createHttpError(
* Status.BadRequest,
* "The request was bad.",
* { expose: false }
* );
* } catch (e) {
* // handle errors
* }
* ```
*
* @module
*/
import {
type ErrorStatus,
isClientErrorStatus,
Status,
STATUS_TEXT,
} from "./http_status.ts";
const ERROR_STATUS_MAP = {
"BadRequest": 400,
"Unauthorized": 401,
"PaymentRequired": 402,
"Forbidden": 403,
"NotFound": 404,
"MethodNotAllowed": 405,
"NotAcceptable": 406,
"ProxyAuthRequired": 407,
"RequestTimeout": 408,
"Conflict": 409,
"Gone": 410,
"LengthRequired": 411,
"PreconditionFailed": 412,
"RequestEntityTooLarge": 413,
"RequestURITooLong": 414,
"UnsupportedMediaType": 415,
"RequestedRangeNotSatisfiable": 416,
"ExpectationFailed": 417,
"Teapot": 418,
"MisdirectedRequest": 421,
"UnprocessableEntity": 422,
"Locked": 423,
"FailedDependency": 424,
"UpgradeRequired": 426,
"PreconditionRequired": 428,
"TooManyRequests": 429,
"RequestHeaderFieldsTooLarge": 431,
"UnavailableForLegalReasons": 451,
"InternalServerError": 500,
"NotImplemented": 501,
"BadGateway": 502,
"ServiceUnavailable": 503,
"GatewayTimeout": 504,
"HTTPVersionNotSupported": 505,
"VariantAlsoNegotiates": 506,
"InsufficientStorage": 507,
"LoopDetected": 508,
"NotExtended": 510,
"NetworkAuthenticationRequired": 511,
} as const;
export type ErrorStatusKeys = keyof typeof ERROR_STATUS_MAP;
export interface HttpErrorOptions extends ErrorOptions {
expose?: boolean;
headers?: HeadersInit;
}
/** The base class that all derivative HTTP extend, providing a `status` and an
* `expose` property. */
export class HttpError extends Error {
#status: ErrorStatus = Status.InternalServerError;
#expose: boolean;
#headers?: Headers;
constructor(
message = "Http Error",
options?: HttpErrorOptions,
) {
super(message, options);
this.#expose = options?.expose === undefined
? isClientErrorStatus(this.status)
: options.expose;
if (options?.headers) {
this.#headers = new Headers(options.headers);
}
}
/** A flag to indicate if the internals of the error, like the stack, should
* be exposed to a client, or if they are "private" and should not be leaked.
* By default, all client errors are `true` and all server errors are
* `false`. */
get expose(): boolean {
return this.#expose;
}
/** The optional headers object that is set on the error. */
get headers(): Headers | undefined {
return this.#headers;
}
/** The error status that is set on the error. */
get status(): ErrorStatus {
return this.#status;
}
}
function createHttpErrorConstructor(status: ErrorStatus): typeof HttpError {
const name = `${Status[status]}Error`;
const ErrorCtor = class extends HttpError {
constructor(
message = STATUS_TEXT[status],
options?: HttpErrorOptions,
) {
super(message, options);
Object.defineProperty(this, "name", {
configurable: true,
enumerable: false,
value: name,
writable: true,
});
}
override get status() {
return status;
}
};
return ErrorCtor;
}
/**
* A namespace that contains each error constructor. Each error extends
* `HTTPError` and provides `.status` and `.expose` properties, where the
* `.status` will be an error `Status` value and `.expose` indicates if
* information, like a stack trace, should be shared in the response.
*
* By default, `.expose` is set to false in server errors, and true for client
* errors.
*
* @example
* ```ts
* import { errors } from "https://deno.land/std@$STD_VERSION/http/http_errors.ts";
*
* throw new errors.InternalServerError("Ooops!");
* ```
*/
export const errors: Record<ErrorStatusKeys, typeof HttpError> = {} as Record<
ErrorStatusKeys,
typeof HttpError
>;
for (const [key, value] of Object.entries(ERROR_STATUS_MAP)) {
errors[key as ErrorStatusKeys] = createHttpErrorConstructor(value);
}
/**
* A factory function which provides a way to create errors. It takes up to 3
* arguments, the error `Status`, an message, which defaults to the status text
* and error options, which incudes the `expose` property to set the `.expose`
* value on the error.
*/
export function createHttpError(
status: ErrorStatus = Status.InternalServerError,
message?: string,
options?: HttpErrorOptions,
): HttpError {
return new errors[Status[status] as ErrorStatusKeys](message, options);
}
/** A type guard that determines if the value is an HttpError or not. */
export function isHttpError(value: unknown): value is HttpError {
return value instanceof HttpError;
}
+343
View File
@@ -0,0 +1,343 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
/**
* Contains the enum {@linkcode Status} which enumerates standard HTTP status
* codes and provides several type guards for handling status codes with type
* safety.
*
* @example
* ```ts
* import {
* Status,
* STATUS_TEXT,
* } from "https://deno.land/std@$STD_VERSION/http/http_status.ts";
*
* console.log(Status.NotFound); //=> 404
* console.log(STATUS_TEXT[Status.NotFound]); //=> "Not Found"
* ```
*
* ```ts
* import { isErrorStatus } from "https://deno.land/std@$STD_VERSION/http/http_status.ts";
*
* const res = await fetch("https://example.com/");
*
* if (isErrorStatus(res.status)) {
* // error handling here...
* }
* ```
*
* @module
*/
/** Standard HTTP status codes. */
export enum Status {
/** RFC 7231, 6.2.1 */
Continue = 100,
/** RFC 7231, 6.2.2 */
SwitchingProtocols = 101,
/** RFC 2518, 10.1 */
Processing = 102,
/** RFC 8297 **/
EarlyHints = 103,
/** RFC 7231, 6.3.1 */
OK = 200,
/** RFC 7231, 6.3.2 */
Created = 201,
/** RFC 7231, 6.3.3 */
Accepted = 202,
/** RFC 7231, 6.3.4 */
NonAuthoritativeInfo = 203,
/** RFC 7231, 6.3.5 */
NoContent = 204,
/** RFC 7231, 6.3.6 */
ResetContent = 205,
/** RFC 7233, 4.1 */
PartialContent = 206,
/** RFC 4918, 11.1 */
MultiStatus = 207,
/** RFC 5842, 7.1 */
AlreadyReported = 208,
/** RFC 3229, 10.4.1 */
IMUsed = 226,
/** RFC 7231, 6.4.1 */
MultipleChoices = 300,
/** RFC 7231, 6.4.2 */
MovedPermanently = 301,
/** RFC 7231, 6.4.3 */
Found = 302,
/** RFC 7231, 6.4.4 */
SeeOther = 303,
/** RFC 7232, 4.1 */
NotModified = 304,
/** RFC 7231, 6.4.5 */
UseProxy = 305,
/** RFC 7231, 6.4.7 */
TemporaryRedirect = 307,
/** RFC 7538, 3 */
PermanentRedirect = 308,
/** RFC 7231, 6.5.1 */
BadRequest = 400,
/** RFC 7235, 3.1 */
Unauthorized = 401,
/** RFC 7231, 6.5.2 */
PaymentRequired = 402,
/** RFC 7231, 6.5.3 */
Forbidden = 403,
/** RFC 7231, 6.5.4 */
NotFound = 404,
/** RFC 7231, 6.5.5 */
MethodNotAllowed = 405,
/** RFC 7231, 6.5.6 */
NotAcceptable = 406,
/** RFC 7235, 3.2 */
ProxyAuthRequired = 407,
/** RFC 7231, 6.5.7 */
RequestTimeout = 408,
/** RFC 7231, 6.5.8 */
Conflict = 409,
/** RFC 7231, 6.5.9 */
Gone = 410,
/** RFC 7231, 6.5.10 */
LengthRequired = 411,
/** RFC 7232, 4.2 */
PreconditionFailed = 412,
/** RFC 7231, 6.5.11 */
RequestEntityTooLarge = 413,
/** RFC 7231, 6.5.12 */
RequestURITooLong = 414,
/** RFC 7231, 6.5.13 */
UnsupportedMediaType = 415,
/** RFC 7233, 4.4 */
RequestedRangeNotSatisfiable = 416,
/** RFC 7231, 6.5.14 */
ExpectationFailed = 417,
/** RFC 7168, 2.3.3 */
Teapot = 418,
/** RFC 7540, 9.1.2 */
MisdirectedRequest = 421,
/** RFC 4918, 11.2 */
UnprocessableEntity = 422,
/** RFC 4918, 11.3 */
Locked = 423,
/** RFC 4918, 11.4 */
FailedDependency = 424,
/** RFC 8470, 5.2 */
TooEarly = 425,
/** RFC 7231, 6.5.15 */
UpgradeRequired = 426,
/** RFC 6585, 3 */
PreconditionRequired = 428,
/** RFC 6585, 4 */
TooManyRequests = 429,
/** RFC 6585, 5 */
RequestHeaderFieldsTooLarge = 431,
/** RFC 7725, 3 */
UnavailableForLegalReasons = 451,
/** RFC 7231, 6.6.1 */
InternalServerError = 500,
/** RFC 7231, 6.6.2 */
NotImplemented = 501,
/** RFC 7231, 6.6.3 */
BadGateway = 502,
/** RFC 7231, 6.6.4 */
ServiceUnavailable = 503,
/** RFC 7231, 6.6.5 */
GatewayTimeout = 504,
/** RFC 7231, 6.6.6 */
HTTPVersionNotSupported = 505,
/** RFC 2295, 8.1 */
VariantAlsoNegotiates = 506,
/** RFC 4918, 11.5 */
InsufficientStorage = 507,
/** RFC 5842, 7.2 */
LoopDetected = 508,
/** RFC 2774, 7 */
NotExtended = 510,
/** RFC 6585, 6 */
NetworkAuthenticationRequired = 511,
}
/** A record of all the status codes text. */
export const STATUS_TEXT: Readonly<Record<Status, string>> = {
[Status.Accepted]: "Accepted",
[Status.AlreadyReported]: "Already Reported",
[Status.BadGateway]: "Bad Gateway",
[Status.BadRequest]: "Bad Request",
[Status.Conflict]: "Conflict",
[Status.Continue]: "Continue",
[Status.Created]: "Created",
[Status.EarlyHints]: "Early Hints",
[Status.ExpectationFailed]: "Expectation Failed",
[Status.FailedDependency]: "Failed Dependency",
[Status.Forbidden]: "Forbidden",
[Status.Found]: "Found",
[Status.GatewayTimeout]: "Gateway Timeout",
[Status.Gone]: "Gone",
[Status.HTTPVersionNotSupported]: "HTTP Version Not Supported",
[Status.IMUsed]: "IM Used",
[Status.InsufficientStorage]: "Insufficient Storage",
[Status.InternalServerError]: "Internal Server Error",
[Status.LengthRequired]: "Length Required",
[Status.Locked]: "Locked",
[Status.LoopDetected]: "Loop Detected",
[Status.MethodNotAllowed]: "Method Not Allowed",
[Status.MisdirectedRequest]: "Misdirected Request",
[Status.MovedPermanently]: "Moved Permanently",
[Status.MultiStatus]: "Multi Status",
[Status.MultipleChoices]: "Multiple Choices",
[Status.NetworkAuthenticationRequired]: "Network Authentication Required",
[Status.NoContent]: "No Content",
[Status.NonAuthoritativeInfo]: "Non Authoritative Info",
[Status.NotAcceptable]: "Not Acceptable",
[Status.NotExtended]: "Not Extended",
[Status.NotFound]: "Not Found",
[Status.NotImplemented]: "Not Implemented",
[Status.NotModified]: "Not Modified",
[Status.OK]: "OK",
[Status.PartialContent]: "Partial Content",
[Status.PaymentRequired]: "Payment Required",
[Status.PermanentRedirect]: "Permanent Redirect",
[Status.PreconditionFailed]: "Precondition Failed",
[Status.PreconditionRequired]: "Precondition Required",
[Status.Processing]: "Processing",
[Status.ProxyAuthRequired]: "Proxy Auth Required",
[Status.RequestEntityTooLarge]: "Request Entity Too Large",
[Status.RequestHeaderFieldsTooLarge]: "Request Header Fields Too Large",
[Status.RequestTimeout]: "Request Timeout",
[Status.RequestURITooLong]: "Request URI Too Long",
[Status.RequestedRangeNotSatisfiable]: "Requested Range Not Satisfiable",
[Status.ResetContent]: "Reset Content",
[Status.SeeOther]: "See Other",
[Status.ServiceUnavailable]: "Service Unavailable",
[Status.SwitchingProtocols]: "Switching Protocols",
[Status.Teapot]: "I'm a teapot",
[Status.TemporaryRedirect]: "Temporary Redirect",
[Status.TooEarly]: "Too Early",
[Status.TooManyRequests]: "Too Many Requests",
[Status.Unauthorized]: "Unauthorized",
[Status.UnavailableForLegalReasons]: "Unavailable For Legal Reasons",
[Status.UnprocessableEntity]: "Unprocessable Entity",
[Status.UnsupportedMediaType]: "Unsupported Media Type",
[Status.UpgradeRequired]: "Upgrade Required",
[Status.UseProxy]: "Use Proxy",
[Status.VariantAlsoNegotiates]: "Variant Also Negotiates",
};
/** An HTTP status that is a informational (1XX). */
export type InformationalStatus =
| Status.Continue
| Status.SwitchingProtocols
| Status.Processing
| Status.EarlyHints;
/** An HTTP status that is a success (2XX). */
export type SuccessfulStatus =
| Status.OK
| Status.Created
| Status.Accepted
| Status.NonAuthoritativeInfo
| Status.NoContent
| Status.ResetContent
| Status.PartialContent
| Status.MultiStatus
| Status.AlreadyReported
| Status.IMUsed;
/** An 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
/** An HTTP status that is a client error (4XX). */
export type ClientErrorStatus =
| 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;
/** An HTTP status that is a server error (5XX). */
export type ServerErrorStatus =
| Status.InternalServerError
| Status.NotImplemented
| Status.BadGateway
| Status.ServiceUnavailable
| Status.GatewayTimeout
| Status.HTTPVersionNotSupported
| Status.VariantAlsoNegotiates
| Status.InsufficientStorage
| Status.LoopDetected
| Status.NotExtended
| Status.NetworkAuthenticationRequired;
/** An HTTP status that is an error (4XX and 5XX). */
export type ErrorStatus = ClientErrorStatus | ServerErrorStatus;
/** A type guard that determines if the status code is informational. */
export function isInformationalStatus(
status: Status,
): status is InformationalStatus {
return status >= 100 && status < 200;
}
/** A type guard that determines if the status code is successful. */
export function isSuccessfulStatus(status: Status): status is SuccessfulStatus {
return status >= 200 && status < 300;
}
/** A type guard that determines if the status code is a redirection. */
export function isRedirectStatus(status: Status): status is RedirectStatus {
return status >= 300 && status < 400;
}
/** A type guard that determines if the status code is a client error. */
export function isClientErrorStatus(
status: Status,
): status is ClientErrorStatus {
return status >= 400 && status < 500;
}
/** A type guard that determines if the status code is a server error. */
export function isServerErrorStatus(
status: Status,
): status is ServerErrorStatus {
return status >= 500 && status < 600;
}
/** A type guard that determines if the status code is an error. */
export function isErrorStatus(status: Status): status is ErrorStatus {
return status >= 400 && status < 600;
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
/**
* Contains the constant {@linkcode HTTP_METHODS} and the type
* {@linkcode HttpMethod} and the type guard {@linkcode isHttpMethod} for
* working with HTTP methods with type safety.
*
* @module
*/
/** A constant array of common HTTP methods.
*
* This list is compatible with Node.js `http` module.
*/
export const HTTP_METHODS = [
"ACL",
"BIND",
"CHECKOUT",
"CONNECT",
"COPY",
"DELETE",
"GET",
"HEAD",
"LINK",
"LOCK",
"M-SEARCH",
"MERGE",
"MKACTIVITY",
"MKCALENDAR",
"MKCOL",
"MOVE",
"NOTIFY",
"OPTIONS",
"PATCH",
"POST",
"PROPFIND",
"PROPPATCH",
"PURGE",
"PUT",
"REBIND",
"REPORT",
"SEARCH",
"SOURCE",
"SUBSCRIBE",
"TRACE",
"UNBIND",
"UNLINK",
"UNLOCK",
"UNSUBSCRIBE",
] as const;
/** A type representing string literals of each of the common HTTP method. */
export type HttpMethod = typeof HTTP_METHODS[number];
/** A type guard that determines if a value is a valid HTTP method. */
export function isHttpMethod(value: unknown): value is HttpMethod {
return HTTP_METHODS.includes(value as HttpMethod);
}
+187
View File
@@ -0,0 +1,187 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
/**
* Contains the functions {@linkcode accepts}, {@linkcode acceptsEncodings}, and
* {@linkcode acceptsLanguages} to provide content negotiation capabilities.
*
* @module
*/
import { preferredEncodings } from "./_negotiation/encoding.ts";
import { preferredLanguages } from "./_negotiation/language.ts";
import { preferredMediaTypes } from "./_negotiation/media_type.ts";
export type Request = {
headers: {
get(key: string): string | null;
};
};
/**
* Returns an array of media types accepted by the request, in order of
* preference. If there are no media types supplied in the request, then any
* media type selector will be returned.
*
* @example
* ```ts
* import { accepts } from "https://deno.land/std@$STD_VERSION/http/negotiation.ts";
*
* const req = new Request("https://example.com/", {
* headers: {
* "accept":
* "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, *\/*;q=0.8",
* },
* });
*
* console.log(accepts(req));
* // [
* // "text/html",
* // "application/xhtml+xml",
* // "image/webp",
* // "application/xml",
* // "*\/*",
* // ]
* ```
*/
export function accepts(request: Request): string[];
/**
* For a given set of media types, return the best match accepted in the
* request. If no media type matches, then the function returns `undefined`.
*
* @example
* ```ts
* import { accepts } from "https://deno.land/std@$STD_VERSION/http/negotiation.ts";
*
* const req = new Request("https://example.com/", {
* headers: {
* "accept":
* "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, *\/*;q=0.8",
* },
* });
*
* accepts(req, "text/html", "image/webp"); // "text/html";
* ```
*/
export function accepts(
request: Request,
...types: string[]
): string | undefined;
export function accepts(
request: Request,
...types: string[]
): string | string[] | undefined {
const accept = request.headers.get("accept");
return types.length
? accept ? preferredMediaTypes(accept, types)[0] : types[0]
: accept
? preferredMediaTypes(accept)
: ["*/*"];
}
/**
* Returns an array of content encodings accepted by the request, in order of
* preference. If there are no encoding supplied in the request, then `["*"]`
* is returned, implying any encoding is accepted.
*
* @example
* ```ts
* import { acceptsEncodings } from "https://deno.land/std@$STD_VERSION/http/negotiation.ts";
*
* const req = new Request("https://example.com/", {
* headers: { "accept-encoding": "deflate, gzip;q=1.0, *;q=0.5" },
* });
*
* acceptsEncodings(req); // ["deflate", "gzip", "*"]
* ```
*/
export function acceptsEncodings(request: Request): string[];
/**
* For a given set of content encodings, return the best match accepted in the
* request. If no content encodings match, then the function 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.
*
* @example
* ```ts
* import { acceptsEncodings } from "https://deno.land/std@$STD_VERSION/http/negotiation.ts";
*
* const req = new Request("https://example.com/", {
* headers: { "accept-encoding": "deflate, gzip;q=1.0, *;q=0.5" },
* });
*
* acceptsEncodings(req, "gzip", "identity"); // "gzip"
* ```
*/
export function acceptsEncodings(
request: Request,
...encodings: string[]
): string | undefined;
export function acceptsEncodings(
request: Request,
...encodings: string[]
): string | string[] | undefined {
const acceptEncoding = request.headers.get("accept-encoding");
return encodings.length
? acceptEncoding
? preferredEncodings(acceptEncoding, encodings)[0]
: encodings[0]
: acceptEncoding
? preferredEncodings(acceptEncoding)
: ["*"];
}
/**
* Returns an array of languages accepted by the request, in order of
* preference. If there are no languages supplied in the request, then `["*"]`
* is returned, imply any language is accepted.
*
* @example
* ```ts
* import { acceptsLanguages } from "https://deno.land/std@$STD_VERSION/http/negotiation.ts";
*
* const req = new Request("https://example.com/", {
* headers: {
* "accept-language": "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5",
* },
* });
*
* acceptsLanguages(req); // ["fr-CH", "fr", "en", "de", "*"]
* ```
*/
export function acceptsLanguages(request: Request): string[];
/**
* For a given set of languages, return the best match accepted in the request.
* If no languages match, then the function returns `undefined`.
*
* @example
* ```ts
* import { acceptsLanguages } from "https://deno.land/std@$STD_VERSION/http/negotiation.ts";
*
* const req = new Request("https://example.com/", {
* headers: {
* "accept-language": "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5",
* },
* });
*
* acceptsLanguages(req, "en-gb", "en-us", "en"); // "en"
* ```
*/
export function acceptsLanguages(
request: Request,
...langs: string[]
): string | undefined;
export function acceptsLanguages(
request: Request,
...langs: string[]
): string | string[] | undefined {
const acceptLanguage = request.headers.get("accept-language");
return langs.length
? acceptLanguage ? preferredLanguages(acceptLanguage, langs)[0] : langs[0]
: acceptLanguage
? preferredLanguages(acceptLanguage)
: ["*"];
}
+409
View File
@@ -0,0 +1,409 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
// This module is browser compatible.
/**
* Provides {@linkcode ServerSentEvent} and
* {@linkcode ServerSentEventStreamTarget} which provides an interface to send
* server sent events to a browser using the DOM event model.
*
* The {@linkcode ServerSentEventStreamTarget} provides the `.asResponse()` or
* `.asResponseInit()` to provide a body and headers to the client to establish
* the event connection. This is accomplished by keeping a connection open to
* the client by not closing the body, which allows events to be sent down the
* connection and processed by the client browser.
*
* 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 {
* ServerSentEvent,
* ServerSentEventStreamTarget,
* } from "https://deno.land/std@$STD_VERSION/http/server_sent_event.ts";
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts";
*
* await serve((request) => {
* const target = new ServerSentEventStreamTarget();
* let counter = 0;
*
* // Sends an event every 2 seconds, incrementing the ID
* const id = setInterval(() => {
* const evt = new ServerSentEvent(
* "message",
* { data: { hello: "world" }, id: counter++ },
* );
* target.dispatchEvent(evt);
* }, 2000);
*
* target.addEventListener("close", () => clearInterval(id));
* return target.asResponse();
* }, { port: 8000 });
* ```
*
* @module
*/
import { assert } from "../_util/asserts.ts";
const encoder = new TextEncoder();
const DEFAULT_KEEP_ALIVE_INTERVAL = 30_000;
export interface ServerSentEventInit extends EventInit {
/** Optional 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. */
data?: unknown;
/** 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 {
/** 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 {
* ServerSentEvent,
* ServerSentEventStreamTarget,
* } from "https://deno.land/std@$STD_VERSION/http/server_sent_event.ts";
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts";
*
* await serve((request) => {
* const target = new ServerSentEventStreamTarget();
* const evt = new ServerSentEvent("message", {
* data: { hello: "world" },
* id: 1
* });
* target.dispatchEvent(evt);
* return target.asResponse();
* }, { port: 8000 });
* ```
*/
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 eventInit initialization options for the event
*/
constructor(type: string, eventInit: ServerSentEventInit = {}) {
super(type, eventInit);
const { data, replacer, space } = eventInit;
this.#type = type;
try {
this.#data = typeof data === "string"
? data
: data !== undefined
? 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;
}
override 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 { ServerSentEventStreamTarget } from "https://deno.land/std@$STD_VERSION/http/server_sent_event.ts";
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts";
*
* await serve((request) => {
* const target = new ServerSentEventStreamTarget();
* target.dispatchComment("this is a comment");
* return target.asResponse();
* }, { port: 8000 });
* ```
*/
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 {
* ServerSentEvent,
* ServerSentEventStreamTarget,
* } from "https://deno.land/std@$STD_VERSION/http/server_sent_event.ts";
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts";
*
* await serve((request) => {
* const target = new ServerSentEventStreamTarget();
* const evt = new ServerSentEvent("ping", { data: "hello" });
* target.dispatchEvent(evt);
* return target.asResponse();
* }, { port: 8000 });
* ```
*/
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 {
* ServerSentEvent,
* ServerSentEventStreamTarget,
* } from "https://deno.land/std@$STD_VERSION/http/server_sent_event.ts";
* import { serve } from "https://deno.land/std@$STD_VERSION/http/server.ts";
*
* await serve((request) => {
* const target = new ServerSentEventStreamTarget();
* const evt = new ServerSentEvent("ping", { data: "hello" });
* target.dispatchEvent(evt);
* return target.asResponse();
* }, { port: 8000 });
* ```
*/
dispatchEvent(event: CloseEvent | ErrorEvent): boolean;
}
/** An implementation of {@linkcode ServerSentEventTarget} that provides a
* readable stream as a body of a response to establish a connection to a
* client. */
export class ServerSentEventStreamTarget extends EventTarget
implements ServerSentEventTarget {
#bodyInit: ReadableStream<Uint8Array>;
#closed = false;
#controller?: ReadableStreamDefaultController<Uint8Array>;
// we are ignoring any here, because when exporting to npm/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) {
this.dispatchEvent(new CloseEvent({ cancelable: false }));
const errorEvent = new ErrorEvent("error", { error });
this.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({ keepAlive = false }: ServerSentEventTargetOptions = {}) {
super();
this.#bodyInit = 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);
}
},
});
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);
}
}
/** Returns a {@linkcode Response} which contains the body and headers needed
* to initiate a SSE connection with the client. */
asResponse(responseInit?: ResponseInit): Response {
return new Response(...this.asResponseInit(responseInit));
}
/** Returns a tuple which contains the {@linkcode BodyInit} and
* {@linkcode ResponseInit} needed to create a response that will establish
* a SSE connection with the client. */
asResponseInit(responseInit: ResponseInit = {}): [BodyInit, ResponseInit] {
responseInit.headers = new Headers(responseInit.headers);
for (const [key, value] of RESPONSE_HEADERS) {
responseInit.headers.set(key, value);
}
return [this.#bodyInit, responseInit];
}
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);
}
override dispatchEvent(event: ServerSentEvent): boolean;
override dispatchEvent(event: CloseEvent | ErrorEvent): boolean;
override 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({ "#bodyInit": this.#bodyInit, "#closed": this.#closed })
}`;
}
[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(
{ "#bodyInit": this.#bodyInit, "#closed": this.#closed },
newOptions,
)
}`;
}
}