@cfworker/web 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@cfworker/web",
3
+ "version": "1.9.0",
4
+ "description": "Web framework for Cloudflare Workers and service workers, inspired by Koa and fastify",
5
+ "keywords": [
6
+ "middleware",
7
+ "cloudflare",
8
+ "worker",
9
+ "workers",
10
+ "service-worker"
11
+ ],
12
+ "sideEffects": false,
13
+ "main": "dist/index.js",
14
+ "module": "dist/index.js",
15
+ "source": "src/index.ts",
16
+ "types": "dist/index.d.ts",
17
+ "files": [
18
+ "dist/**/*",
19
+ "src/**/*",
20
+ "tsconfig.json",
21
+ "README.md",
22
+ "package.json"
23
+ ],
24
+ "repository": "https://github.com/cfworker/cfworker",
25
+ "author": "Jeremy Danyow <jdanyow@gmail.com>",
26
+ "homepage": "https://github.com/cfworker/cfworker/tree/master/packages/web/README.md",
27
+ "license": "MIT",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc --build",
33
+ "clean": "tsc --build --clean",
34
+ "test": "cfworker test test/**/*.spec.ts --nocheck"
35
+ },
36
+ "dependencies": {
37
+ "@cfworker/json-schema": "^1.9.0",
38
+ "@cfworker/worker-types": "^1.9.0",
39
+ "@cloudflare/workers-types": "^2.1.0",
40
+ "@types/cookie": "^0.4.0",
41
+ "@types/html-escaper": "^3.0.0",
42
+ "@types/json-pointer": "^1.0.30",
43
+ "@types/koa-compose": "^3.2.5",
44
+ "@types/secure-json-parse": "^1.0.2",
45
+ "@types/statuses": "^1.5.0",
46
+ "cookie": "^0.4.1",
47
+ "html-escaper": "^3.0.3",
48
+ "koa-compose": "^4.1.0",
49
+ "negotiator": "^0.6.2",
50
+ "path-to-regexp": "^6.2.0",
51
+ "secure-json-parse": "^2.3.2",
52
+ "statuses": "^1.5.0"
53
+ },
54
+ "devDependencies": {
55
+ "@cfworker/dev": "^1.9.0",
56
+ "@types/chai": "^4.2.16",
57
+ "@types/mocha": "^8.2.2",
58
+ "chai": "^4.3.4",
59
+ "json-pointer": "^0.6.1",
60
+ "mocha": "^8.3.2",
61
+ "typescript": "^4.4.3"
62
+ },
63
+ "gitHead": "d333ff940566a7acc384ab791d8b73bed2a9a211"
64
+ }
package/src/accepts.ts ADDED
@@ -0,0 +1,87 @@
1
+ // @ts-ignore
2
+ import { preferredCharsets } from 'negotiator/lib/charset.js';
3
+ // @ts-ignore
4
+ import { preferredEncodings } from 'negotiator/lib/encoding.js';
5
+ // @ts-ignore
6
+ import { preferredLanguages } from 'negotiator/lib/language.js';
7
+ // @ts-ignore
8
+ import { preferredMediaTypes } from 'negotiator/lib/mediaType.js';
9
+
10
+ const parseAccept = preferredMediaTypes as (header: string) => string[];
11
+ const parseAcceptLanguage = preferredLanguages as (header: string) => string[];
12
+ const parseAcceptEncoding = preferredEncodings as (header: string) => string[];
13
+ const parseAcceptCharset = preferredCharsets as (header: string) => string[];
14
+
15
+ export class Accepts {
16
+ private _type: string[] | undefined = undefined;
17
+ private _language: string[] | undefined = undefined;
18
+ private _encoding: string[] | undefined = undefined;
19
+ private _charset: string[] | undefined = undefined;
20
+
21
+ constructor(private readonly headers: Headers) {}
22
+
23
+ public type<T extends string>(...values: T[]): T | false {
24
+ if (!this._type) {
25
+ const header = this.headers.get('accept');
26
+ this._type = header ? parseAccept(header.toLowerCase()) : [];
27
+ }
28
+ for (const accepted of this._type) {
29
+ for (const value of values) {
30
+ if (
31
+ value === accepted ||
32
+ (accepted.startsWith('*') && value.endsWith(accepted.substr(1))) ||
33
+ (accepted.endsWith('*') &&
34
+ value.startsWith(accepted.substr(0, accepted.length - 2)))
35
+ ) {
36
+ return value;
37
+ }
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ public language<T extends string>(...values: T[]): T | false {
44
+ if (!this._language) {
45
+ const header = this.headers.get('accept-language');
46
+ this._language = header ? parseAcceptLanguage(header.toLowerCase()) : [];
47
+ }
48
+ for (const accepted of this._language) {
49
+ for (const value of values) {
50
+ if (value === accepted || value.startsWith(accepted)) {
51
+ return value;
52
+ }
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+
58
+ public encoding<T extends string>(...values: T[]): T | false {
59
+ if (!this._encoding) {
60
+ const header = this.headers.get('accept-encoding');
61
+ this._encoding = header ? parseAcceptEncoding(header.toLowerCase()) : [];
62
+ }
63
+ for (const accepted of this._encoding) {
64
+ for (const value of values) {
65
+ if (value === accepted) {
66
+ return value;
67
+ }
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+
73
+ public charset<T extends string>(...values: T[]): T | false {
74
+ if (!this._charset) {
75
+ const header = this.headers.get('accept-charset');
76
+ this._charset = header ? parseAcceptCharset(header.toLowerCase()) : [];
77
+ }
78
+ for (const accepted of this._charset) {
79
+ for (const value of values) {
80
+ if (value === accepted) {
81
+ return value;
82
+ }
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ }
@@ -0,0 +1,48 @@
1
+ import statuses from 'statuses';
2
+ import { Context } from './context.js';
3
+ import { HttpError } from './http-error.js';
4
+ import { composeMiddleware, Middleware } from './middleware.js';
5
+
6
+ const resolved = Promise.resolve();
7
+
8
+ export class Application {
9
+ private readonly middleware: Middleware[] = [];
10
+
11
+ public use(middleware: Middleware) {
12
+ this.middleware.push(middleware);
13
+ return this;
14
+ }
15
+
16
+ public listen() {
17
+ const middleware = composeMiddleware(this.middleware);
18
+ addEventListener('fetch', event => this.handleFetch(event, middleware));
19
+ }
20
+
21
+ private handleFetch(event: FetchEvent, middleware: Middleware) {
22
+ const context = new Context(event);
23
+ event.respondWith(
24
+ Promise.race([
25
+ this.invokeMiddleware(context, middleware),
26
+ context.responded
27
+ ])
28
+ );
29
+ }
30
+
31
+ private async invokeMiddleware(context: Context, middleware: Middleware) {
32
+ try {
33
+ await middleware(context, () => resolved);
34
+ return context.res.create();
35
+ } catch (err) {
36
+ console.error(err);
37
+
38
+ if (err instanceof HttpError) {
39
+ return err.toResponse();
40
+ }
41
+
42
+ const status = 500;
43
+ const statusText = statuses[500]!;
44
+ const headers = { 'content-type': 'text/plain' };
45
+ return new Response(statusText, { status, statusText, headers });
46
+ }
47
+ }
48
+ }
package/src/context.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { Cookies } from './cookies.js';
2
+ import { Req } from './req.js';
3
+ import { ResponseBuilder } from './response-builder.js';
4
+
5
+ export class Context {
6
+ public readonly req: Req;
7
+ public readonly res: ResponseBuilder;
8
+ public readonly cookies: Cookies;
9
+ public readonly respondWith!: (response: Response) => void;
10
+ public readonly responded: Promise<Response>;
11
+ public readonly state: any;
12
+
13
+ constructor(private readonly event: FetchEvent) {
14
+ const request = event.request;
15
+ this.req = new Req(request);
16
+ this.res = new ResponseBuilder();
17
+ this.cookies = new Cookies(request.headers, this.res.headers);
18
+ this.responded = new Promise<Response>(resolve => {
19
+ // @ts-ignore
20
+ this.respondWith = resolve;
21
+ });
22
+ this.state = {};
23
+ }
24
+
25
+ public waitUntil(promise: Promise<any>) {
26
+ this.event.waitUntil(promise);
27
+ }
28
+ }
package/src/cookies.ts ADDED
@@ -0,0 +1,30 @@
1
+ import {
2
+ CookieSerializeOptions,
3
+ parse as parseCookie,
4
+ serialize as serializeCookie
5
+ } from 'cookie';
6
+
7
+ const noCookies = Object.create(null);
8
+
9
+ export class Cookies {
10
+ private readonly requestCookies: Record<string, string>;
11
+
12
+ constructor(
13
+ requestHeaders: Headers,
14
+ private readonly responseHeaders: Headers
15
+ ) {
16
+ const cookie = requestHeaders.get('cookie');
17
+ this.requestCookies = cookie ? parseCookie(cookie) : noCookies;
18
+ }
19
+
20
+ public get(name: string): string | null {
21
+ return this.requestCookies[name] || null;
22
+ }
23
+
24
+ public set(name: string, val: string, options?: CookieSerializeOptions) {
25
+ this.responseHeaders.append(
26
+ 'Set-Cookie',
27
+ serializeCookie(name, val, options)
28
+ );
29
+ }
30
+ }
@@ -0,0 +1,36 @@
1
+ import statuses from 'statuses';
2
+
3
+ export class HttpError extends Error {
4
+ public readonly status: number;
5
+ public readonly statusText: string;
6
+ public readonly body: string | object;
7
+
8
+ constructor(status: number, body: string | object) {
9
+ const statusText = statuses[statuses(status)]!;
10
+
11
+ super(statusText);
12
+
13
+ this.name = this.constructor.name;
14
+ if (typeof Error.captureStackTrace === 'function') {
15
+ Error.captureStackTrace(this, this.constructor);
16
+ }
17
+
18
+ this.status = status;
19
+ this.statusText = statusText;
20
+ this.body = body;
21
+ }
22
+
23
+ public toResponse(): Response {
24
+ let body = this.body || this.statusText;
25
+ let contentType: string;
26
+ if (typeof body === 'string') {
27
+ contentType = 'text/plain';
28
+ } else {
29
+ contentType = 'application/json';
30
+ body = JSON.stringify(body);
31
+ }
32
+ const { status, statusText } = this;
33
+ const headers = { 'content-type': contentType };
34
+ return new Response(body, { status, statusText, headers });
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { escape as htmlEncode } from 'html-escaper';
2
+ export * from './accepts.js';
3
+ export * from './application.js';
4
+ export * from './context.js';
5
+ export * from './cookies.js';
6
+ export * from './http-error.js';
7
+ export * from './middleware.js';
8
+ export * from './normalize-pathname.js';
9
+ export * from './req.js';
10
+ export * from './response-builder.js';
11
+ export * from './router.js';
12
+ export * from './to-object.js';
13
+ export * from './validate.js';
@@ -0,0 +1,6 @@
1
+ import compose, { Middleware as GenericMiddleware } from 'koa-compose';
2
+ import { Context } from './context.js';
3
+
4
+ export type Middleware = GenericMiddleware<Context>;
5
+
6
+ export const composeMiddleware = compose;
@@ -0,0 +1,22 @@
1
+ import { HttpError } from './http-error';
2
+ import { Middleware } from './middleware';
3
+
4
+ export function normalizePathname(pathname: string) {
5
+ return decodeURIComponent(pathname).replace(/\/\/+/g, '/').normalize();
6
+ }
7
+
8
+ export const normalizePathnameMiddleware: Middleware = async (
9
+ { req },
10
+ next
11
+ ) => {
12
+ const pathname = req.url.pathname;
13
+ try {
14
+ req.url.pathname = normalizePathname(pathname);
15
+ } catch (err) {
16
+ if (err instanceof URIError) {
17
+ throw new HttpError(400, `Unable to decode pathname "${pathname}".`);
18
+ }
19
+ throw err;
20
+ }
21
+ await next();
22
+ };
package/src/req.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { Reviver, safeParse } from 'secure-json-parse';
2
+ import { Accepts } from './accepts.js';
3
+
4
+ export class Req {
5
+ public readonly raw: Request;
6
+ public readonly method: string;
7
+ public readonly url: URL;
8
+ public readonly headers: Headers;
9
+ public readonly params: Record<string, string>;
10
+ public readonly accepts: Accepts;
11
+ public readonly body: ReqBody;
12
+
13
+ constructor(request: Request) {
14
+ this.raw = request;
15
+ this.method = request.method;
16
+ this.url = new URL(request.url);
17
+ this.headers = request.headers;
18
+ this.params = Object.create(null);
19
+ this.accepts = new Accepts(request.headers);
20
+ this.body = new ReqBody(request);
21
+ }
22
+ }
23
+
24
+ export class ReqBody {
25
+ private _arrayBuffer: Promise<ArrayBuffer> | undefined;
26
+ private _formData: Promise<FormData> | undefined;
27
+ private _json: Promise<any> | undefined;
28
+ private _text: Promise<string> | undefined;
29
+
30
+ constructor(private readonly request: Request) {}
31
+
32
+ public arrayBuffer() {
33
+ if (!this._arrayBuffer) {
34
+ this._arrayBuffer = this.request.arrayBuffer();
35
+ }
36
+ return this._arrayBuffer;
37
+ }
38
+
39
+ public formData() {
40
+ if (!this._formData) {
41
+ this._formData = this.request.formData();
42
+ }
43
+ return this._formData;
44
+ }
45
+
46
+ public json(reviver?: Reviver) {
47
+ if (!this._json) {
48
+ this._json = this.text().then(text => safeParse(text, reviver));
49
+ }
50
+ return this._json;
51
+ }
52
+
53
+ public text() {
54
+ if (!this._text) {
55
+ this._text = this.request.text();
56
+ }
57
+ return this._text;
58
+ }
59
+ }
@@ -0,0 +1,161 @@
1
+ import statuses from 'statuses';
2
+
3
+ export type ExtendedBodyInit =
4
+ | BodyInit
5
+ | boolean
6
+ | Date
7
+ | number
8
+ | object
9
+ | null;
10
+
11
+ export class ResponseBuilder {
12
+ public readonly headers = new Headers();
13
+ private _status = 404;
14
+ private _explicitStatus = false;
15
+ private _implicitType = false;
16
+ private _body: ExtendedBodyInit = null;
17
+ private _stringifyBody = false;
18
+
19
+ get status() {
20
+ return this._status;
21
+ }
22
+ set status(value) {
23
+ this._explicitStatus = true;
24
+ this._status = value;
25
+ if (this.body && statuses.empty[value]) {
26
+ this.body = null;
27
+ }
28
+ }
29
+
30
+ get statusText() {
31
+ return statuses[this._status]!;
32
+ }
33
+
34
+ get body() {
35
+ return this._body;
36
+ }
37
+ set body(value: ExtendedBodyInit | null) {
38
+ this._body = value;
39
+
40
+ // no content
41
+ if (value === null) {
42
+ if (!statuses.empty[this.status]) {
43
+ this._status = 204;
44
+ }
45
+ this.headers.delete('content-type');
46
+ this.headers.delete('content-length');
47
+ this.headers.delete('transfer-encoding');
48
+ return;
49
+ }
50
+
51
+ // set the status
52
+ if (!this._explicitStatus) {
53
+ this._status = 200;
54
+ }
55
+
56
+ // set Content-Type
57
+ if (
58
+ // Response will automatically use Blob's type.
59
+ value instanceof Blob ||
60
+ // Response will automatically use multipart/form-data.
61
+ value instanceof FormData ||
62
+ // Response will automatically use application/x-www-form-urlencoded.
63
+ value instanceof URLSearchParams ||
64
+ // Cannot determine Content-Type.
65
+ ArrayBuffer.isView(value) ||
66
+ value instanceof ArrayBuffer ||
67
+ value instanceof ReadableStream
68
+ ) {
69
+ this._stringifyBody = false;
70
+ if (this._implicitType) {
71
+ this._implicitType = false;
72
+ this.headers.delete('content-type');
73
+ }
74
+ } else if (typeof value === 'string') {
75
+ this._stringifyBody = false;
76
+ if (!this.headers.has('content-type') || this._implicitType) {
77
+ this._implicitType = true;
78
+ if (/^\s*</.test(value)) {
79
+ this.headers.set('content-type', 'text/html;charset=UTF-8');
80
+ } else {
81
+ this.headers.set('content-type', 'text/plain;charset=UTF-8');
82
+ }
83
+ }
84
+ } else {
85
+ this._stringifyBody = true;
86
+ this._implicitType = true;
87
+ this.headers.set('content-type', 'application/json;charset=UTF-8');
88
+ }
89
+ }
90
+
91
+ redirect(url: string | URL) {
92
+ if (url instanceof URL) {
93
+ url = url.href;
94
+ }
95
+
96
+ this.headers.set('location', url);
97
+
98
+ if (!statuses.redirect[this.status]) {
99
+ this.status = 302;
100
+ }
101
+
102
+ this.type = 'text/plain;charset=UTF-8';
103
+ this.body = `Redirecting to ${url}.`;
104
+ }
105
+
106
+ get type() {
107
+ const type = this.headers.get('content-type');
108
+ if (!type) {
109
+ return '';
110
+ }
111
+ return type.split(';', 1)[0];
112
+ }
113
+ set type(value) {
114
+ this._implicitType = false;
115
+ if (value) {
116
+ this.headers.set('content-type', value);
117
+ } else {
118
+ this.headers.delete('content-type');
119
+ }
120
+ }
121
+
122
+ get lastModified() {
123
+ const date = this.headers.get('last-modified');
124
+ return date ? new Date(date) : null;
125
+ }
126
+ set lastModified(value: string | Date | null) {
127
+ if (value === null) {
128
+ this.headers.delete('last-modified');
129
+ return;
130
+ }
131
+ if (typeof value === 'string') {
132
+ value = new Date(value);
133
+ }
134
+ this.headers.set('last-modified', value.toUTCString());
135
+ }
136
+
137
+ get etag() {
138
+ return this.headers.get('etag');
139
+ }
140
+ set etag(value) {
141
+ if (value) {
142
+ if (!/^(W\/)?"/.test(value)) {
143
+ value = `"${value}"`;
144
+ }
145
+ this.headers.set('etag', value);
146
+ } else {
147
+ this.headers.delete('etag');
148
+ }
149
+ }
150
+
151
+ create() {
152
+ const { body: rawBody, status, statusText, headers } = this;
153
+ const body = this._stringifyBody
154
+ ? JSON.stringify(rawBody)
155
+ : (rawBody as BodyInit);
156
+ return new Response(body, { status, statusText, headers });
157
+ }
158
+ }
159
+
160
+ // Cloudflare workers do not define the Blob class.
161
+ const Blob = self.Blob || ((class {} as any) as Blob);