two-stroke 1.0.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/README.md +3 -0
- package/bin/deploy.mjs +24 -0
- package/bin/dev.mjs +5 -0
- package/bin/format.mjs +5 -0
- package/bin/lint.mjs +6 -0
- package/bin/test.mjs +22 -0
- package/bin/type-check.mjs +5 -0
- package/package.json +61 -0
- package/src/cmd.mjs +10 -0
- package/src/index.ts +344 -0
- package/src/openAPI.ts +104 -0
- package/src/test.ts +145 -0
- package/src/types.ts +54 -0
- package/tsconfig.json +20 -0
package/README.md
ADDED
package/bin/deploy.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
import { cmd } from "../src/cmd.mjs";
|
|
4
|
+
import { basename } from "path";
|
|
5
|
+
|
|
6
|
+
const env = process.argv[2];
|
|
7
|
+
const release = process.argv[3];
|
|
8
|
+
|
|
9
|
+
fs.writeFileSync("src/release.ts", `export default "${release}";`);
|
|
10
|
+
|
|
11
|
+
await cmd(`wrangler secret:bulk /dev/stdin --env ${env}`);
|
|
12
|
+
await cmd(
|
|
13
|
+
`sentry cli releases --org change-engine --project ${basename(process.cwd())} new ${release} --finalize`,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
await cmd(
|
|
17
|
+
`sentry cli releases --org change-engine --project ${basename(process.cwd())} set-commits ${release}`,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
await cmd(`wrangler deploy --env ${env} --outdir dist`);
|
|
21
|
+
|
|
22
|
+
await cmd(
|
|
23
|
+
`sentry-cli sourcemaps --org change-engine --project odometer upload --release="${release}" dist`,
|
|
24
|
+
);
|
package/bin/dev.mjs
ADDED
package/bin/format.mjs
ADDED
package/bin/lint.mjs
ADDED
package/bin/test.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { cmd } from "../src/cmd.mjs";
|
|
5
|
+
import consumers from "stream/consumers";
|
|
6
|
+
import openapiTS from "openapi-typescript";
|
|
7
|
+
|
|
8
|
+
if (fs.existsSync("wrangler.toml")) {
|
|
9
|
+
await cmd("wrangler deploy --dry-run --outdir=dist");
|
|
10
|
+
const app = await import(`${process.cwd()}/dist/index.js`);
|
|
11
|
+
const request = await app.default.fetch(
|
|
12
|
+
{ url: "http://example.com/doc/", method: "GET" },
|
|
13
|
+
{ SENTRY_DSN: null, SENTRY_ENVIRONMENT: null },
|
|
14
|
+
null,
|
|
15
|
+
);
|
|
16
|
+
const types = await openapiTS(await consumers.json(request.body));
|
|
17
|
+
fs.writeFileSync("test/api.d.ts", types);
|
|
18
|
+
}
|
|
19
|
+
await cmd(
|
|
20
|
+
"vitest --globals --no-file-parallelism --run --coverage",
|
|
21
|
+
process.argv.slice(2),
|
|
22
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bin": {
|
|
3
|
+
"dev": "./bin/dev.mjs",
|
|
4
|
+
"format": "./bin/format.mjs",
|
|
5
|
+
"lint": "./bin/lint.mjs",
|
|
6
|
+
"test": "./bin/test.mjs",
|
|
7
|
+
"type-check": "./bin/type-check.mjs"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@anatine/zod-openapi": "^2.2.3",
|
|
11
|
+
"@asteasolutions/zod-to-openapi": "^6.3.1",
|
|
12
|
+
"@cloudflare/workers-types": "^4.20240208.0",
|
|
13
|
+
"@sentry/cli": "^2.28.6",
|
|
14
|
+
"@typescript-eslint/eslint-plugin": "^7.0.1",
|
|
15
|
+
"@typescript-eslint/parser": "^7.0.1",
|
|
16
|
+
"@vitest/coverage-v8": "^1.2.2",
|
|
17
|
+
"eslint-config-prettier": "^9.1.0",
|
|
18
|
+
"eslint-config-two-stroke": "^1.0.0",
|
|
19
|
+
"eslint-config-typescript": "^3.0.0",
|
|
20
|
+
"jwk-subtle": "^1.0.6",
|
|
21
|
+
"miniflare": "^3.20240129.1",
|
|
22
|
+
"openapi-fetch": "^0.8.2",
|
|
23
|
+
"openapi-typescript": "^6.7.4",
|
|
24
|
+
"openapi3-ts": "^4.2.1",
|
|
25
|
+
"pbkdf-subtle": "^1.0.0",
|
|
26
|
+
"toml": "^3.0.0",
|
|
27
|
+
"toucan-js": "^3.3.1",
|
|
28
|
+
"zod": "^3.22.4"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@sentry/cli": ">=2",
|
|
32
|
+
"eslint": ">=8",
|
|
33
|
+
"prettier": ">=3",
|
|
34
|
+
"typescript": ">=5",
|
|
35
|
+
"vitest": ">=1",
|
|
36
|
+
"wrangler": ">=3"
|
|
37
|
+
},
|
|
38
|
+
"description": "Simple Cloudflare Worker framework.",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": "^20.10.0"
|
|
41
|
+
},
|
|
42
|
+
"eslintConfig": {
|
|
43
|
+
"extends": [
|
|
44
|
+
"two-stroke"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"main": "src/index.ts",
|
|
49
|
+
"name": "two-stroke",
|
|
50
|
+
"packageManager": "yarn@4.1.0",
|
|
51
|
+
"type": "module",
|
|
52
|
+
"version": "1.0.0",
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/eslint": "^8.56.2",
|
|
55
|
+
"@types/node": "^20.11.17",
|
|
56
|
+
"eslint": "^8.56.0",
|
|
57
|
+
"prettier": "^3.2.5",
|
|
58
|
+
"typescript": "^5.3.3",
|
|
59
|
+
"vitest": "^1.2.2"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/cmd.mjs
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { Toucan } from "toucan-js";
|
|
2
|
+
import { ZodObject, ZodSchema, z } from "zod";
|
|
3
|
+
import { verify as pbkdfVerify } from "pbkdf-subtle";
|
|
4
|
+
import { verify as jwkVerify } from "jwk-subtle";
|
|
5
|
+
import { Env, Handler, Route } from "./types";
|
|
6
|
+
import { openAPI } from "./openAPI";
|
|
7
|
+
|
|
8
|
+
const noAuth = async () => null;
|
|
9
|
+
|
|
10
|
+
const escapeRegex = (str: string) =>
|
|
11
|
+
str.replace(/([.*+?^=!:$()|[\]\\])/g, "\\$&");
|
|
12
|
+
|
|
13
|
+
export function twoStroke<T extends Env>(title: string, release: string) {
|
|
14
|
+
let _queue: (c: {
|
|
15
|
+
batch: MessageBatch;
|
|
16
|
+
env: T;
|
|
17
|
+
sentry: Toucan;
|
|
18
|
+
}) => Promise<void>;
|
|
19
|
+
const routes: Route<T>[] = [];
|
|
20
|
+
routes.push({
|
|
21
|
+
auth: noAuth,
|
|
22
|
+
method: "GET",
|
|
23
|
+
path: "/doc/",
|
|
24
|
+
matcher: /^\/doc\/$/,
|
|
25
|
+
output: z.object({}),
|
|
26
|
+
handler: openAPI(title, release, noAuth, routes),
|
|
27
|
+
});
|
|
28
|
+
const crons: {
|
|
29
|
+
[cron: string]: (c: { env: T; sentry: Toucan }) => Promise<void>;
|
|
30
|
+
} = {};
|
|
31
|
+
let _email: (c: {
|
|
32
|
+
message: ForwardableEmailMessage;
|
|
33
|
+
env: T;
|
|
34
|
+
sentry: Toucan;
|
|
35
|
+
}) => Promise<void>;
|
|
36
|
+
return {
|
|
37
|
+
async fetch(
|
|
38
|
+
req: Request,
|
|
39
|
+
env: T & {
|
|
40
|
+
readonly SENTRY_DSN: string;
|
|
41
|
+
readonly SENTRY_ENVIRONMENT: string;
|
|
42
|
+
},
|
|
43
|
+
context: ExecutionContext,
|
|
44
|
+
): Promise<Response> {
|
|
45
|
+
const sentry = new Toucan({
|
|
46
|
+
dsn: env.SENTRY_DSN,
|
|
47
|
+
context,
|
|
48
|
+
request: req,
|
|
49
|
+
requestDataOptions: {
|
|
50
|
+
allowedHeaders: ["user-agent"],
|
|
51
|
+
},
|
|
52
|
+
environment: env.SENTRY_ENVIRONMENT,
|
|
53
|
+
release,
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
const { pathname } = new URL(req.url);
|
|
57
|
+
let response;
|
|
58
|
+
for (const route of routes) {
|
|
59
|
+
if (req.method === route.method && route.matcher.test(pathname)) {
|
|
60
|
+
const params = pathname.match(route.matcher)?.groups ?? {};
|
|
61
|
+
let claims;
|
|
62
|
+
try {
|
|
63
|
+
claims = await route.auth({ req, env });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.warn(err);
|
|
66
|
+
return new Response("", {
|
|
67
|
+
status: 401,
|
|
68
|
+
statusText: "Invalid Authorization",
|
|
69
|
+
headers: {
|
|
70
|
+
"WWW-Authenticate": "Bearer",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (route.method === "POST" || route.method === "PUT") {
|
|
75
|
+
const rawBody =
|
|
76
|
+
req.headers.get("Content-Type") ===
|
|
77
|
+
"application/x-www-form-urlencoded"
|
|
78
|
+
? Object.fromEntries(new URLSearchParams(await req.text()))
|
|
79
|
+
: await req.json();
|
|
80
|
+
const body = route.input.safeParse(rawBody);
|
|
81
|
+
if (body.success)
|
|
82
|
+
response = await route.handler({
|
|
83
|
+
req,
|
|
84
|
+
env,
|
|
85
|
+
body: body.data,
|
|
86
|
+
claims,
|
|
87
|
+
params,
|
|
88
|
+
searchParams: new URL(req.url).searchParams,
|
|
89
|
+
sentry,
|
|
90
|
+
});
|
|
91
|
+
else {
|
|
92
|
+
console.error(body.error, rawBody);
|
|
93
|
+
return new Response(JSON.stringify(body.error), {
|
|
94
|
+
status: 400,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
response = await route.handler({
|
|
99
|
+
req,
|
|
100
|
+
env,
|
|
101
|
+
claims,
|
|
102
|
+
body: undefined,
|
|
103
|
+
params,
|
|
104
|
+
searchParams: new URL(req.url).searchParams,
|
|
105
|
+
sentry,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const output = route.output.safeParse(response.body);
|
|
109
|
+
if (!output.success) {
|
|
110
|
+
console.error(output.error, response.body);
|
|
111
|
+
}
|
|
112
|
+
response.headers = response.headers ?? {};
|
|
113
|
+
response.headers["Content-Type"] =
|
|
114
|
+
response.headers["Content-Type"] ?? "application/json";
|
|
115
|
+
return new Response(
|
|
116
|
+
response.headers["Content-Type"] == "application/json"
|
|
117
|
+
? JSON.stringify(response.body)
|
|
118
|
+
: response.body,
|
|
119
|
+
response,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return new Response("", { status: 404 });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.warn(err);
|
|
126
|
+
sentry.captureException(err);
|
|
127
|
+
return new Response("", {
|
|
128
|
+
status: 500,
|
|
129
|
+
statusText: "Internal Server Error",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
async queue(
|
|
134
|
+
batch: MessageBatch,
|
|
135
|
+
env: T & {
|
|
136
|
+
readonly SENTRY_DSN: string;
|
|
137
|
+
readonly SENTRY_ENVIRONMENT: string;
|
|
138
|
+
},
|
|
139
|
+
context: ExecutionContext,
|
|
140
|
+
) {
|
|
141
|
+
const sentry = new Toucan({
|
|
142
|
+
dsn: env.SENTRY_DSN,
|
|
143
|
+
context,
|
|
144
|
+
environment: env.SENTRY_ENVIRONMENT,
|
|
145
|
+
release,
|
|
146
|
+
});
|
|
147
|
+
return await _queue({ batch, env, sentry });
|
|
148
|
+
},
|
|
149
|
+
async scheduled(
|
|
150
|
+
event: ScheduledEvent,
|
|
151
|
+
env: T & {
|
|
152
|
+
readonly SENTRY_DSN: string;
|
|
153
|
+
readonly SENTRY_ENVIRONMENT: string;
|
|
154
|
+
},
|
|
155
|
+
context: ExecutionContext,
|
|
156
|
+
) {
|
|
157
|
+
const sentry = new Toucan({
|
|
158
|
+
dsn: env.SENTRY_DSN,
|
|
159
|
+
context,
|
|
160
|
+
environment: env.SENTRY_ENVIRONMENT,
|
|
161
|
+
release,
|
|
162
|
+
});
|
|
163
|
+
const handler = crons[event.cron];
|
|
164
|
+
if (!handler) {
|
|
165
|
+
throw new Error("CRON Handler not found");
|
|
166
|
+
}
|
|
167
|
+
await handler({ env, sentry });
|
|
168
|
+
},
|
|
169
|
+
async email(
|
|
170
|
+
message: ForwardableEmailMessage,
|
|
171
|
+
env: T & {
|
|
172
|
+
readonly SENTRY_DSN: string;
|
|
173
|
+
readonly SENTRY_ENVIRONMENT: string;
|
|
174
|
+
},
|
|
175
|
+
context: ExecutionContext,
|
|
176
|
+
) {
|
|
177
|
+
const sentry = new Toucan({
|
|
178
|
+
dsn: env.SENTRY_DSN,
|
|
179
|
+
context,
|
|
180
|
+
environment: env.SENTRY_ENVIRONMENT,
|
|
181
|
+
release,
|
|
182
|
+
});
|
|
183
|
+
await _email({ message, env, sentry });
|
|
184
|
+
},
|
|
185
|
+
emailHandler(
|
|
186
|
+
handler: (c: {
|
|
187
|
+
env: T;
|
|
188
|
+
message: ForwardableEmailMessage;
|
|
189
|
+
sentry: Toucan;
|
|
190
|
+
}) => Promise<void>,
|
|
191
|
+
) {
|
|
192
|
+
_email = handler;
|
|
193
|
+
},
|
|
194
|
+
schedule(
|
|
195
|
+
cron: string,
|
|
196
|
+
handler: (c: { env: T; sentry: Toucan }) => Promise<void>,
|
|
197
|
+
) {
|
|
198
|
+
crons[cron] = handler;
|
|
199
|
+
},
|
|
200
|
+
noAuth,
|
|
201
|
+
pbkdf:
|
|
202
|
+
(k: keyof T) =>
|
|
203
|
+
async ({ req, env }: { req: Request; env: T }) => {
|
|
204
|
+
const [scheme, token] = (req.headers.get("Authorization") ?? " ").split(
|
|
205
|
+
" ",
|
|
206
|
+
);
|
|
207
|
+
if (
|
|
208
|
+
(scheme === "token" || scheme === "Bearer") &&
|
|
209
|
+
(await pbkdfVerify(env[k] as string, token ?? ""))
|
|
210
|
+
)
|
|
211
|
+
return;
|
|
212
|
+
throw Error("Invalid");
|
|
213
|
+
},
|
|
214
|
+
jwt:
|
|
215
|
+
<J>(k: keyof T, ak: keyof T) =>
|
|
216
|
+
async ({ req, env }: { req: Request; env: T }) => {
|
|
217
|
+
const [scheme, token] = (req.headers.get("Authorization") ?? " ").split(
|
|
218
|
+
" ",
|
|
219
|
+
);
|
|
220
|
+
if (scheme == "Bearer") {
|
|
221
|
+
const claims = await jwkVerify<J>(
|
|
222
|
+
token ?? "",
|
|
223
|
+
env[k] as string,
|
|
224
|
+
env[ak] as string,
|
|
225
|
+
);
|
|
226
|
+
if (!claims) {
|
|
227
|
+
throw Error("Invalid");
|
|
228
|
+
}
|
|
229
|
+
return claims;
|
|
230
|
+
}
|
|
231
|
+
throw Error("Invalid");
|
|
232
|
+
},
|
|
233
|
+
queueHandler<I extends ZodSchema>(
|
|
234
|
+
input: I,
|
|
235
|
+
handler: (c: {
|
|
236
|
+
env: T;
|
|
237
|
+
batch: MessageBatch<z.infer<I>>;
|
|
238
|
+
sentry: Toucan;
|
|
239
|
+
}) => Promise<void>,
|
|
240
|
+
) {
|
|
241
|
+
_queue = async ({ batch, env, sentry }) => {
|
|
242
|
+
batch.messages.map((message): void => {
|
|
243
|
+
const body = input.safeParse(message.body);
|
|
244
|
+
if (!body.success) {
|
|
245
|
+
console.error(body.error, message);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
await handler({ batch, env, sentry });
|
|
249
|
+
console.log("Queue batch finished");
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
put<I extends ZodSchema, O extends ZodSchema, A, P extends string>(
|
|
253
|
+
auth: Route<T>["auth"],
|
|
254
|
+
path: P,
|
|
255
|
+
input: I,
|
|
256
|
+
output: O,
|
|
257
|
+
handler: Handler<T, I, O, A, P>,
|
|
258
|
+
) {
|
|
259
|
+
routes.push({
|
|
260
|
+
auth,
|
|
261
|
+
method: "PUT",
|
|
262
|
+
path,
|
|
263
|
+
matcher: new RegExp(
|
|
264
|
+
`^${escapeRegex(path).replaceAll(/\/{([^}]*)}/g, "/(?<$1>[^\\/]*)")}$`,
|
|
265
|
+
),
|
|
266
|
+
input,
|
|
267
|
+
output,
|
|
268
|
+
handler,
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
272
|
+
post<
|
|
273
|
+
I extends ZodSchema,
|
|
274
|
+
O extends ZodSchema,
|
|
275
|
+
A,
|
|
276
|
+
P extends string,
|
|
277
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
278
|
+
PP extends ZodObject<any> | undefined,
|
|
279
|
+
>(
|
|
280
|
+
auth: Route<T>["auth"],
|
|
281
|
+
path: P,
|
|
282
|
+
input: I,
|
|
283
|
+
output: O,
|
|
284
|
+
handler: Handler<T, I, O, A, P>,
|
|
285
|
+
params?: PP,
|
|
286
|
+
) {
|
|
287
|
+
routes.push({
|
|
288
|
+
auth,
|
|
289
|
+
method: "POST",
|
|
290
|
+
path,
|
|
291
|
+
matcher: new RegExp(
|
|
292
|
+
`^${escapeRegex(path).replaceAll(/\/{([^}]*)}/g, "/(?<$1>[^\\/]*)")}$`,
|
|
293
|
+
),
|
|
294
|
+
input,
|
|
295
|
+
output,
|
|
296
|
+
handler,
|
|
297
|
+
params,
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
301
|
+
get<
|
|
302
|
+
O extends ZodSchema,
|
|
303
|
+
A,
|
|
304
|
+
P extends string,
|
|
305
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
306
|
+
PP extends ZodObject<any> | undefined,
|
|
307
|
+
>(
|
|
308
|
+
auth: Route<T>["auth"],
|
|
309
|
+
path: P,
|
|
310
|
+
output: O,
|
|
311
|
+
handler: Handler<T, undefined, O, A, P>,
|
|
312
|
+
params?: PP,
|
|
313
|
+
) {
|
|
314
|
+
routes.push({
|
|
315
|
+
auth,
|
|
316
|
+
method: "GET",
|
|
317
|
+
path,
|
|
318
|
+
matcher: new RegExp(
|
|
319
|
+
`^${escapeRegex(path).replaceAll(/\/{([^}]*)}/g, "/(?<$1>[^\\/]*)")}$`,
|
|
320
|
+
),
|
|
321
|
+
output,
|
|
322
|
+
handler,
|
|
323
|
+
params,
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
delete<O extends ZodSchema, A, P extends string>(
|
|
327
|
+
auth: Route<T>["auth"],
|
|
328
|
+
path: P,
|
|
329
|
+
output: O,
|
|
330
|
+
handler: Handler<T, undefined, O, A, P>,
|
|
331
|
+
) {
|
|
332
|
+
routes.push({
|
|
333
|
+
auth,
|
|
334
|
+
method: "DELETE",
|
|
335
|
+
path,
|
|
336
|
+
matcher: new RegExp(
|
|
337
|
+
`^${escapeRegex(path).replaceAll(/\/{([^}]*)}/g, "/(?<$1>[^\\/]*)")}$`,
|
|
338
|
+
),
|
|
339
|
+
output,
|
|
340
|
+
handler,
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
package/src/openAPI.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OpenAPIRegistry,
|
|
3
|
+
OpenApiGeneratorV31,
|
|
4
|
+
extendZodWithOpenApi,
|
|
5
|
+
} from "@asteasolutions/zod-to-openapi";
|
|
6
|
+
import { Env, Route } from "./types";
|
|
7
|
+
import { ZodIssue, ZodType, z } from "zod";
|
|
8
|
+
|
|
9
|
+
const ZodErrorSchema: ZodType<{ issues: ZodIssue[] }> = z.object({
|
|
10
|
+
issues: z.array(
|
|
11
|
+
z.object({
|
|
12
|
+
code: z.literal("invalid_literal"),
|
|
13
|
+
expected: z.string(),
|
|
14
|
+
received: z.string(),
|
|
15
|
+
path: z.array(z.string()),
|
|
16
|
+
message: z.string(),
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type Method =
|
|
22
|
+
| "get"
|
|
23
|
+
| "post"
|
|
24
|
+
| "put"
|
|
25
|
+
| "delete"
|
|
26
|
+
| "patch"
|
|
27
|
+
| "head"
|
|
28
|
+
| "options"
|
|
29
|
+
| "trace";
|
|
30
|
+
|
|
31
|
+
extendZodWithOpenApi(z);
|
|
32
|
+
|
|
33
|
+
export const openAPI =
|
|
34
|
+
<T extends Env>(
|
|
35
|
+
title: string,
|
|
36
|
+
release: string,
|
|
37
|
+
noAuth: () => void,
|
|
38
|
+
routes: Route<T>[],
|
|
39
|
+
) =>
|
|
40
|
+
async () => {
|
|
41
|
+
const openAPIRegistry = new OpenAPIRegistry();
|
|
42
|
+
openAPIRegistry.registerComponent("securitySchemes", "auth", {
|
|
43
|
+
type: "http",
|
|
44
|
+
scheme: "bearer",
|
|
45
|
+
});
|
|
46
|
+
routes.map((route): void => {
|
|
47
|
+
const params = z.object(
|
|
48
|
+
Object.fromEntries(
|
|
49
|
+
Array.from(route.path.matchAll(/\/{(?<name>[^}]*)}/g), (match) => [
|
|
50
|
+
match.groups!.name,
|
|
51
|
+
z.string(),
|
|
52
|
+
]),
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
openAPIRegistry.registerPath({
|
|
56
|
+
method: route.method.toLowerCase() as Method,
|
|
57
|
+
path: route.path.toString(),
|
|
58
|
+
...(route.auth === noAuth ? {} : { security: [{ auth: [] }] }),
|
|
59
|
+
request:
|
|
60
|
+
route.method === "POST" || route.method === "PUT"
|
|
61
|
+
? {
|
|
62
|
+
body: {
|
|
63
|
+
content: {
|
|
64
|
+
"application/json": {
|
|
65
|
+
schema: route.input,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: true,
|
|
69
|
+
},
|
|
70
|
+
query: route.params,
|
|
71
|
+
params,
|
|
72
|
+
}
|
|
73
|
+
: { query: route.params, params },
|
|
74
|
+
responses: {
|
|
75
|
+
200: {
|
|
76
|
+
description: "OK",
|
|
77
|
+
content: {
|
|
78
|
+
"application/json": {
|
|
79
|
+
schema: route.output,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
400: {
|
|
84
|
+
description: "Invalid Request",
|
|
85
|
+
content: {
|
|
86
|
+
"application/json": {
|
|
87
|
+
schema: ZodErrorSchema,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
const generator = new OpenApiGeneratorV31(openAPIRegistry.definitions);
|
|
95
|
+
return {
|
|
96
|
+
body: generator.generateDocument({
|
|
97
|
+
openapi: "3.1",
|
|
98
|
+
info: {
|
|
99
|
+
title,
|
|
100
|
+
version: release,
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
};
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Miniflare, createFetchMock } from "miniflare";
|
|
2
|
+
import toml from "toml";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { Env } from "./types";
|
|
5
|
+
import consumers from "stream/consumers";
|
|
6
|
+
import { URLSearchParams } from "url";
|
|
7
|
+
import createClient from "openapi-fetch";
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
10
|
+
export const setupTests = async <Paths extends {}>(bindings: Env) => {
|
|
11
|
+
const fetchMock = createFetchMock();
|
|
12
|
+
fetchMock.disableNetConnect();
|
|
13
|
+
|
|
14
|
+
const config = toml.parse(fs.readFileSync("wrangler.toml", "utf8"));
|
|
15
|
+
|
|
16
|
+
const miniflare = new Miniflare({
|
|
17
|
+
modules: true,
|
|
18
|
+
scriptPath: "dist/index.js",
|
|
19
|
+
bindings: {
|
|
20
|
+
TOKEN_HASH:
|
|
21
|
+
"djAxlhzT1IU9QIP3UKdipECQPAGGoPQK86/GnTBcbHLtPC3ni6JkTQ/iIeF0KG0y1CZ+J+9W",
|
|
22
|
+
...bindings,
|
|
23
|
+
},
|
|
24
|
+
queueConsumers: (config.queues?.consumers ?? []).map(
|
|
25
|
+
({ queue }: { queue: string }) => queue,
|
|
26
|
+
),
|
|
27
|
+
queueProducers: Object.fromEntries(
|
|
28
|
+
(config.queues?.producers ?? []).map(
|
|
29
|
+
({ binding, queue }: { queue: string; binding: string }) => [
|
|
30
|
+
binding,
|
|
31
|
+
queue,
|
|
32
|
+
],
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
r2Buckets: (config.r2_buckets ?? []).map(
|
|
36
|
+
({ binding }: { binding: string }) => binding,
|
|
37
|
+
),
|
|
38
|
+
kvNamespaces: (config.kv_namespaces ?? []).map(
|
|
39
|
+
({ binding }: { binding: string }) => binding,
|
|
40
|
+
),
|
|
41
|
+
d1Databases: (config.d1_databases ?? []).map(
|
|
42
|
+
({ binding }: { binding: string }) => binding,
|
|
43
|
+
),
|
|
44
|
+
fetchMock,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const url = await miniflare.ready;
|
|
48
|
+
|
|
49
|
+
const client = createClient<Paths>({ baseUrl: url.toString() });
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
url,
|
|
53
|
+
miniflare,
|
|
54
|
+
fetchMock,
|
|
55
|
+
client,
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
57
|
+
testFetch(path: string, config: any) {
|
|
58
|
+
const contentType = config.body
|
|
59
|
+
? config.headers["Content-Type"] ?? "application/json"
|
|
60
|
+
: null;
|
|
61
|
+
return fetch(`${url}${path}`, {
|
|
62
|
+
...config,
|
|
63
|
+
headers: {
|
|
64
|
+
...(config.body ? { "Content-Type": contentType } : {}),
|
|
65
|
+
...(config.headers ?? {}),
|
|
66
|
+
},
|
|
67
|
+
body:
|
|
68
|
+
contentType === "application/json"
|
|
69
|
+
? JSON.stringify(config.body)
|
|
70
|
+
: contentType === "application/x-www-form-urlencoded"
|
|
71
|
+
? new URLSearchParams(config.body)
|
|
72
|
+
: null,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
async waitForQueue(trigger: () => Promise<void>) {
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
|
+
const log: any = [];
|
|
78
|
+
const orig = console.log;
|
|
79
|
+
console.log = function (message) {
|
|
80
|
+
orig(message);
|
|
81
|
+
log.push(message);
|
|
82
|
+
};
|
|
83
|
+
await trigger();
|
|
84
|
+
await waitUntil(() => expect(log).contains("Queue batch finished"));
|
|
85
|
+
console.log = orig;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
async function waitUntil(condition: () => void, time = 100) {
|
|
91
|
+
try {
|
|
92
|
+
condition();
|
|
93
|
+
return;
|
|
94
|
+
} catch {
|
|
95
|
+
await new Promise((resolve) => setTimeout(resolve, time));
|
|
96
|
+
await waitUntil(condition, time);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function recordRequest(
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
|
+
cb: (data: any) => void,
|
|
103
|
+
statusCode: number,
|
|
104
|
+
data: string | object | Buffer | undefined,
|
|
105
|
+
) {
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
107
|
+
return ({ body }: any) => {
|
|
108
|
+
consumers.json(body).then(cb);
|
|
109
|
+
return { statusCode, data };
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function recordFormRequest(
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
115
|
+
cb: (data: any) => void,
|
|
116
|
+
statusCode: number,
|
|
117
|
+
data: string | object | Buffer | undefined,
|
|
118
|
+
) {
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
120
|
+
return ({ body }: any) => {
|
|
121
|
+
consumers
|
|
122
|
+
.text(body)
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
124
|
+
.then((data: any) =>
|
|
125
|
+
cb(Object.fromEntries(new URLSearchParams(data).entries())),
|
|
126
|
+
);
|
|
127
|
+
return { statusCode, data };
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function recordFirehoseRequest(
|
|
132
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
133
|
+
cb: (data: any) => void,
|
|
134
|
+
statusCode: number,
|
|
135
|
+
data: string | object | Buffer | undefined,
|
|
136
|
+
) {
|
|
137
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
138
|
+
return ({ body }: any) => {
|
|
139
|
+
consumers
|
|
140
|
+
.json(body)
|
|
141
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
142
|
+
.then((data: any) => cb(JSON.parse(atob(data["Record"]["Data"]))));
|
|
143
|
+
return { statusCode, data };
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Toucan } from "toucan-js";
|
|
2
|
+
import { ZodObject, ZodSchema, z } from "zod";
|
|
3
|
+
export type Env = {
|
|
4
|
+
[k: string]: string | Queue | KVNamespace | R2Bucket | D1Database;
|
|
5
|
+
};
|
|
6
|
+
export type Route<T extends Env> =
|
|
7
|
+
| {
|
|
8
|
+
auth: (c: { req: Request; env: T }) => unknown;
|
|
9
|
+
method: "GET" | "DELETE";
|
|
10
|
+
path: string;
|
|
11
|
+
matcher: RegExp;
|
|
12
|
+
output: ZodSchema;
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
handler: Handler<T, undefined, any, any, string>;
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
params?: ZodObject<any>;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
auth: (c: { req: Request; env: T }) => unknown;
|
|
20
|
+
method: "POST" | "PUT";
|
|
21
|
+
path: string;
|
|
22
|
+
matcher: RegExp;
|
|
23
|
+
input: ZodSchema;
|
|
24
|
+
output: ZodSchema;
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
+
handler: Handler<T, any, any, any, string>;
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
params?: ZodObject<any>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ExtractParameterNames<S extends string> =
|
|
32
|
+
S extends `${string}{${infer Name}}${infer Rest}`
|
|
33
|
+
? Record<Name, string> & ExtractParameterNames<Rest>
|
|
34
|
+
: Record<string, string>;
|
|
35
|
+
|
|
36
|
+
export type Handler<
|
|
37
|
+
T extends Env,
|
|
38
|
+
I extends ZodSchema | undefined,
|
|
39
|
+
O extends ZodSchema,
|
|
40
|
+
A,
|
|
41
|
+
P extends string,
|
|
42
|
+
> = (c: {
|
|
43
|
+
req: Request;
|
|
44
|
+
env: T;
|
|
45
|
+
body: I extends ZodSchema ? z.infer<I> : undefined;
|
|
46
|
+
params: ExtractParameterNames<P>;
|
|
47
|
+
searchParams: URLSearchParams;
|
|
48
|
+
claims: A;
|
|
49
|
+
sentry: Toucan;
|
|
50
|
+
}) => Promise<{
|
|
51
|
+
body: z.infer<O>;
|
|
52
|
+
status?: number;
|
|
53
|
+
headers?: Record<string, string>;
|
|
54
|
+
}>;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"esModuleInterop": true,
|
|
4
|
+
"isolatedModules": true,
|
|
5
|
+
"lib": [
|
|
6
|
+
"ESNext"
|
|
7
|
+
],
|
|
8
|
+
"module": "ESNext",
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"target": "ESNext",
|
|
15
|
+
"types": [
|
|
16
|
+
"@cloudflare/workers-types",
|
|
17
|
+
"vitest/globals"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|