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 ADDED
@@ -0,0 +1,3 @@
1
+ # two-stroke
2
+
3
+ Simple Cloudflare Worker framework.
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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import { cmd } from "../src/cmd.mjs";
4
+
5
+ await cmd("wrangler dev");
package/bin/format.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import { cmd } from "../src/cmd.mjs";
4
+
5
+ await cmd("prettier --write . !tsconfig.json");
package/bin/lint.mjs ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import { cmd } from "../src/cmd.mjs";
4
+
5
+ await cmd("eslint --max-warnings=0 src");
6
+ await cmd("prettier --check . !tsconfig.json");
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
+ );
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import { cmd } from "../src/cmd.mjs";
4
+
5
+ cmd("tsc --noEmit");
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
@@ -0,0 +1,10 @@
1
+ import { spawn } from "child_process";
2
+
3
+ export function cmd(cmd, args = []) {
4
+ const p = spawn(cmd.split(" ")[0], [...cmd.split(" ").slice(1), ...args], {
5
+ stdio: "inherit",
6
+ });
7
+ return new Promise((resolve) => {
8
+ p.on("exit", resolve);
9
+ });
10
+ }
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
+ }