@zeyolabs/kit 0.0.0-stage → 2026.11.5

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.
Files changed (75) hide show
  1. package/README.md +37 -2
  2. package/cloudflare/access.ts +134 -0
  3. package/cloudflare/amqp/client.ts +276 -0
  4. package/cloudflare/amqp/testing.ts +9 -0
  5. package/cloudflare/context.ts +66 -0
  6. package/cloudflare/dispatch/client.ts +30 -0
  7. package/cloudflare/email/client.ts +77 -0
  8. package/cloudflare/email/dev.ts +10 -0
  9. package/cloudflare/email/testing.ts +13 -0
  10. package/cloudflare/r2/client.ts +154 -0
  11. package/cloudflare/r2/testing.ts +43 -0
  12. package/cloudflare/rate-limiter.ts +65 -0
  13. package/cloudflare/wait-until.ts +12 -0
  14. package/cloudflare/workers/index.ts +311 -0
  15. package/cloudflare/workers/testing.ts +94 -0
  16. package/cloudflare/workers/ticking-do.ts +104 -0
  17. package/cloudflare/workers-ai/client.ts +30 -0
  18. package/cloudflare/workers-ai/testing.ts +17 -0
  19. package/cloudflare/workers-env.ts +12 -0
  20. package/eslint-config.js +115 -0
  21. package/monitoring/alerts/client.ts +48 -0
  22. package/monitoring/alerts/event-config.ts +33 -0
  23. package/monitoring/alerts/policy.ts +39 -0
  24. package/monitoring/alerts/registry.ts +44 -0
  25. package/monitoring/gates/client.ts +90 -0
  26. package/monitoring/gates/dev.ts +29 -0
  27. package/monitoring/gates/testing.ts +30 -0
  28. package/monitoring/logger/client.ts +66 -0
  29. package/monitoring/prometheus/client.ts +147 -0
  30. package/monitoring/prometheus/dev.ts +23 -0
  31. package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
  32. package/monitoring/prometheus/metrics.ts +180 -0
  33. package/monitoring/prometheus/testing.ts +19 -0
  34. package/monitoring/slack/api.ts +68 -0
  35. package/monitoring/slack/client.ts +33 -0
  36. package/monitoring/slack/events.ts +15 -0
  37. package/package.json +150 -5
  38. package/server/logger.ts +63 -0
  39. package/server/rate-limit-client.ts +88 -0
  40. package/server/rate-limit.ts +24 -0
  41. package/server/remote-functions.ts +139 -0
  42. package/server/result.ts +18 -0
  43. package/server/turnstile.ts +19 -0
  44. package/storage/clickhouse/client.ts +441 -0
  45. package/storage/clickhouse/compiler.ts +218 -0
  46. package/storage/clickhouse/config.ts +13 -0
  47. package/storage/clickhouse/dev.ts +18 -0
  48. package/storage/clickhouse/error.ts +61 -0
  49. package/storage/clickhouse/index.ts +4 -0
  50. package/storage/clickhouse/native-decoder.ts +192 -0
  51. package/storage/clickhouse/readers.ts +167 -0
  52. package/storage/clickhouse/retry.ts +30 -0
  53. package/storage/clickhouse/schema.ts +163 -0
  54. package/storage/clickhouse/testing.ts +81 -0
  55. package/storage/manticore/client.ts +165 -0
  56. package/storage/manticore/schema.ts +23 -0
  57. package/storage/postgres/client.ts +44 -0
  58. package/storage/postgres/testing.ts +275 -0
  59. package/storage/redis/client.ts +219 -0
  60. package/storage/redis/protocol.ts +119 -0
  61. package/storage/redis/schema.ts +17 -0
  62. package/storage/redis/testing.ts +122 -0
  63. package/utils/crypto.ts +36 -0
  64. package/utils/datetime.ts +338 -0
  65. package/utils/email.ts +173 -0
  66. package/utils/env.ts +171 -0
  67. package/utils/errors.ts +67 -0
  68. package/utils/form.ts +135 -0
  69. package/utils/http-status.ts +42 -0
  70. package/utils/money.ts +10 -0
  71. package/utils/public-host.ts +66 -0
  72. package/utils/schema.ts +211 -0
  73. package/utils/strings/format.ts +51 -0
  74. package/utils/strings/normalize.ts +129 -0
  75. package/utils/transport.ts +25 -0
@@ -0,0 +1,154 @@
1
+ import { AwsClient } from 'aws4fetch';
2
+
3
+ export type PresignedUpload = {
4
+ /** Headers the browser MUST replay verbatim on the PUT (they're signed). */
5
+ headers: Record<string, string>;
6
+ /** Fully-signed PUT URL (auth in query string). */
7
+ url: string;
8
+ };
9
+
10
+ /** Keyed by the actual env/secret binding names — the caller reads them off `EnvStore`
11
+ * (`string()`/`secret()`), no separate camelCase shape to keep in sync. */
12
+ export type R2Config = {
13
+ CF_ACCOUNT_ID: string;
14
+ R2_ACCESS_KEY_ID: string;
15
+ R2_SECRET_ACCESS_KEY: string;
16
+ R2_UPLOADS_BUCKET: string;
17
+ };
18
+
19
+ export interface IR2Client {
20
+ deleteObject(key: string): Promise<void>;
21
+ getObject(key: string): Promise<ArrayBuffer>;
22
+ getObjectWithHeaders(
23
+ key: string
24
+ ): Promise<null | { body: ArrayBuffer; contentEncoding: null | string }>;
25
+ presignDownload(key: string, fileName?: string, expiresSeconds?: number): Promise<string>;
26
+ presignUpload(data: {
27
+ contentType?: string;
28
+ key: string;
29
+ size: number;
30
+ }): Promise<PresignedUpload>;
31
+ putObject(key: string, body: BodyInit, contentType?: string): Promise<void>;
32
+ }
33
+
34
+ /**
35
+ * Presigned direct-to-R2 access over the S3 API. Large CSVs go browser → R2 (and back) without
36
+ * streaming through the worker. No ambient context, no app-specific knowledge — the credentials
37
+ * are passed in.
38
+ */
39
+ export class R2Client implements IR2Client {
40
+ private awsClient: AwsClient | null = null;
41
+
42
+ /** `AwsClient` construction memoized on first access — every call after the first reuses it. */
43
+ private get client(): { base: string; client: AwsClient } {
44
+ this.awsClient ??= new AwsClient({
45
+ accessKeyId: this.config.R2_ACCESS_KEY_ID,
46
+ region: 'auto',
47
+ secretAccessKey: this.config.R2_SECRET_ACCESS_KEY,
48
+ service: 's3',
49
+ });
50
+ return {
51
+ base: `https://${this.config.CF_ACCOUNT_ID}.r2.cloudflarestorage.com/${this.config.R2_UPLOADS_BUCKET}`,
52
+ client: this.awsClient,
53
+ };
54
+ }
55
+
56
+ constructor(private readonly config: R2Config) {}
57
+
58
+ /** Delete an object outright (authenticated S3 DELETE) — e.g. a user removing a stored export
59
+ * before its R2 lifecycle rule would otherwise expire it. */
60
+ async deleteObject(key: string): Promise<void> {
61
+ const { base, client } = this.client;
62
+ const res = await client.fetch(`${base}/${key}`, { method: 'DELETE' });
63
+ if (!res.ok && res.status !== 404) throw new Error(`R2 delete ${key} failed (${res.status})`);
64
+ }
65
+
66
+ /**
67
+ * Read an object's full bytes straight from R2 (authenticated S3 GET, not presigned) — e.g.
68
+ * pulling a stored attachment back out at send time to embed as base64 in an outgoing email.
69
+ */
70
+ async getObject(key: string): Promise<ArrayBuffer> {
71
+ const { base, client } = this.client;
72
+ const res = await client.fetch(`${base}/${key}`);
73
+ if (!res.ok) throw new Error(`R2 get ${key} failed (${res.status})`);
74
+ return res.arrayBuffer();
75
+ }
76
+
77
+ /**
78
+ * Read an object's bytes plus its `Content-Encoding`, treating a 404 as "not there" rather
79
+ * than throwing — e.g. reading a stored raw email that may predate whatever compression a
80
+ * caller now requires. Any other non-2xx still throws (an unexpected failure, not a missing
81
+ * object).
82
+ */
83
+ async getObjectWithHeaders(
84
+ key: string
85
+ ): Promise<null | { body: ArrayBuffer; contentEncoding: null | string }> {
86
+ const { base, client } = this.client;
87
+ const res = await client.fetch(`${base}/${key}`);
88
+ if (res.status === 404) return null;
89
+ if (!res.ok) throw new Error(`R2 get ${key} failed (${res.status})`);
90
+ return { body: await res.arrayBuffer(), contentEncoding: res.headers.get('content-encoding') };
91
+ }
92
+
93
+ /**
94
+ * Presign a one-shot GET for an export object so the browser downloads it straight from R2. The
95
+ * object lives ~3 days (R2 lifecycle); each link is presigned fresh with a short validity.
96
+ * `response-content-disposition` forces a download with a friendly filename (signed, so it can't
97
+ * be tampered with after signing).
98
+ */
99
+ async presignDownload(key: string, fileName?: string, expiresSeconds = 3600): Promise<string> {
100
+ const { base, client } = this.client;
101
+ const disposition = fileName
102
+ ? `&response-content-disposition=${encodeURIComponent(`attachment; filename="${fileName}"`)}`
103
+ : '';
104
+ const endpoint = `${base}/${key}?X-Amz-Expires=${expiresSeconds}${disposition}`;
105
+ const signed = await client.sign(endpoint, { aws: { signQuery: true }, method: 'GET' });
106
+ return signed.url;
107
+ }
108
+
109
+ /**
110
+ * Presign a one-shot PUT straight to R2 so a large file goes browser → R2 directly and never
111
+ * streams through the worker. The object carries NO metadata — the mapping/listId/userId live in
112
+ * the `jobs.list_upload` row (keyed by this object key). Content-Length pins the exact body size; a
113
+ * PUT of any other length fails R2's signature check. `contentType` defaults to the original
114
+ * CSV-only caller's assumption so existing call sites are unaffected.
115
+ */
116
+ async presignUpload(data: {
117
+ contentType?: string;
118
+ key: string;
119
+ size: number;
120
+ }): Promise<PresignedUpload> {
121
+ const { base, client } = this.client;
122
+ // aws4fetch reads validity from the X-Amz-Expires query param — 300s.
123
+ const endpoint = `${base}/${data.key}?X-Amz-Expires=300`;
124
+ const signHeaders: Record<string, string> = {
125
+ 'content-length': String(data.size),
126
+ 'content-type': data.contentType ?? 'text/csv',
127
+ };
128
+ const signed = await client.sign(endpoint, {
129
+ aws: { signQuery: true },
130
+ headers: signHeaders,
131
+ method: 'PUT',
132
+ });
133
+ // The browser sets Content-Length automatically from the body (forbidden to set manually) —
134
+ // return only the headers the client must replay verbatim.
135
+ const { 'content-length': _contentLength, ...replay } = signHeaders;
136
+ return { headers: replay, url: signed.url };
137
+ }
138
+
139
+ /**
140
+ * Write an object straight to R2 (authenticated S3 PUT, not presigned) — a small trigger file
141
+ * (drops into a notification-watched folder so the existing R2 → queue → processor pipeline
142
+ * picks it up, since the app has no queue producer binding) or a reply's own file attachment,
143
+ * whose bytes now arrive in the same request as the send rather than a presigned browser PUT.
144
+ */
145
+ async putObject(key: string, body: BodyInit, contentType = 'application/json'): Promise<void> {
146
+ const { base, client } = this.client;
147
+ const res = await client.fetch(`${base}/${key}`, {
148
+ body,
149
+ headers: { 'content-type': contentType },
150
+ method: 'PUT',
151
+ });
152
+ if (!res.ok) throw new Error(`R2 put ${key} failed (${res.status})`);
153
+ }
154
+ }
@@ -0,0 +1,43 @@
1
+ import type { IR2Client, PresignedUpload } from './client';
2
+
3
+ export class FakeR2Client implements IR2Client {
4
+ private readonly objects = new Map<
5
+ string,
6
+ { body: ArrayBuffer; contentEncoding: null | string }
7
+ >();
8
+
9
+ async deleteObject(key: string): Promise<void> {
10
+ this.objects.delete(key);
11
+ }
12
+
13
+ async getObject(key: string): Promise<ArrayBuffer> {
14
+ const obj = this.objects.get(key);
15
+ if (!obj) throw new Error(`R2 get ${key} failed (404)`);
16
+ return obj.body;
17
+ }
18
+
19
+ async getObjectWithHeaders(
20
+ key: string
21
+ ): Promise<null | { body: ArrayBuffer; contentEncoding: null | string }> {
22
+ return this.objects.get(key) ?? null;
23
+ }
24
+
25
+ async presignDownload(key: string): Promise<string> {
26
+ return `https://fake-r2.test/${key}`;
27
+ }
28
+
29
+ async presignUpload(data: { key: string }): Promise<PresignedUpload> {
30
+ return { headers: {}, url: `https://fake-r2.test/${data.key}` };
31
+ }
32
+
33
+ async putObject(key: string, body: BodyInit, contentType = 'application/json'): Promise<void> {
34
+ const arrayBuffer =
35
+ body instanceof ArrayBuffer ? body : await new Response(body as BodyInit).arrayBuffer();
36
+ this.objects.set(key, { body: arrayBuffer, contentEncoding: null });
37
+ void contentType;
38
+ }
39
+
40
+ seed(key: string, body: ArrayBuffer, contentEncoding: null | string = null): void {
41
+ this.objects.set(key, { body, contentEncoding });
42
+ }
43
+ }
@@ -0,0 +1,65 @@
1
+ // RateLimiter Durable Object.
2
+ // Sliding-window limiter over DO storage. Handles both the single-window form (rate-limit
3
+ // categories: {limit, window}) and the multi-window form ({windows: [[limit, secs]...]}).
4
+ // Allowed only when every window has budget; one timestamp is recorded against all of them.
5
+ // Host it in a standalone worker (see workers/rate-limiter) and bind it with `script_name`.
6
+ import { DurableObject } from 'cloudflare:workers';
7
+
8
+ type RateLimitRequest = {
9
+ category: string;
10
+ limit?: number;
11
+ userId: string;
12
+ window?: number;
13
+ windows?: [number, number][];
14
+ };
15
+
16
+ export class RateLimiter extends DurableObject {
17
+ async fetch(req: Request): Promise<Response> {
18
+ const body = await req.json<RateLimitRequest>();
19
+
20
+ // Normalise both request forms to a list of [limit, windowSeconds].
21
+ const windows: [number, number][] =
22
+ body.windows && body.windows.length > 0
23
+ ? body.windows
24
+ : [[body.limit ?? 0, body.window ?? 0]];
25
+
26
+ const key = `${body.userId}:${body.category}`;
27
+ const stored = (await this.ctx.storage.get<number[]>(key)) ?? [];
28
+
29
+ const now = Date.now();
30
+ // Drop timestamps older than the largest window: they can't count toward any window.
31
+ const maxWindow = Math.max(...windows.map(([, secs]) => secs));
32
+ const cutoff = now - maxWindow * 1000;
33
+ const valid = stored.filter((ts) => ts > cutoff);
34
+
35
+ // Deny, recording nothing, if any single window is already at its limit.
36
+ for (const [limit, secs] of windows) {
37
+ const start = now - secs * 1000;
38
+ const inWindow = valid.filter((ts) => ts > start);
39
+ if (inWindow.length >= limit) {
40
+ const oldest = Math.min(...inWindow);
41
+ const resetAt = oldest + secs * 1000;
42
+ const retryAfter = Math.ceil((resetAt - now) / 1000);
43
+ return Response.json(
44
+ { allowed: false, remaining: 0, resetAt, retryAfter },
45
+ { status: 429 }
46
+ );
47
+ }
48
+ }
49
+
50
+ valid.push(now);
51
+ await this.ctx.storage.put(key, valid);
52
+
53
+ // Report remaining/reset for the tightest window.
54
+ const remaining = Math.max(
55
+ 0,
56
+ Math.min(
57
+ ...windows.map(
58
+ ([limit, secs]) => limit - valid.filter((ts) => ts > now - secs * 1000).length
59
+ )
60
+ )
61
+ );
62
+ const soonest = Math.min(...windows.map(([, secs]) => secs));
63
+ return Response.json({ allowed: true, remaining, resetAt: now + soonest * 1000 });
64
+ }
65
+ }
@@ -0,0 +1,12 @@
1
+ /** `cloudflare:workers`'s `waitUntil` only exists in the real Workers runtime — a plain Node
2
+ * process (local SvelteKit dev, route analysis during `vite build`, most test runs) has no such
3
+ * module, so it's imported dynamically here and swallowed if that fails: there's nothing to do to
4
+ * extend a promise's lifetime outside the Workers runtime anyway. */
5
+ export async function waitUntil(promise: Promise<unknown>): Promise<void> {
6
+ try {
7
+ const workers = await import('cloudflare:workers');
8
+ workers.waitUntil(promise);
9
+ } catch {
10
+ // Not in the Workers runtime — no way to keep this alive past the caller's response.
11
+ }
12
+ }
@@ -0,0 +1,311 @@
1
+ // Minimal hand-rolled router + middleware chain for a plain Worker's fetch/queue/cron — no
2
+ // dependency, and queue/cron registration gets the same chaining shape fetch does.
3
+ //
4
+ // Two classes, not one: a Cloudflare Service Binding entrypoint (`WorkerEntrypoint`) is only ever
5
+ // reachable via RPC methods like `fetch()` — queues and Cron Triggers both bind to a worker's
6
+ // top-level default export, never to a named entrypoint class, so a binding can be neither a queue
7
+ // consumer nor scheduled. `WorkerBindingRouter` has no `.queue()`/`.cron()` at all, so that
8
+ // mismatch is a compile error instead of a handler that's registered but silently unreachable.
9
+ import type { CloudflareOptions } from '@sentry/cloudflare';
10
+ import type { DurableObject } from 'cloudflare:workers';
11
+
12
+ import * as Sentry from '@sentry/cloudflare';
13
+ import { WorkerEntrypoint } from 'cloudflare:workers';
14
+
15
+ import { runWithContext } from '../context';
16
+
17
+ type WorkerSentryEnv = { SENTRY_DSN: string; SENTRY_RELEASE?: string };
18
+ function sentryOptionsFor(env: WorkerSentryEnv): CloudflareOptions {
19
+ return {
20
+ dsn: env.SENTRY_DSN,
21
+ environment: 'production',
22
+ release: env.SENTRY_RELEASE,
23
+ tracesSampleRate: 0,
24
+ };
25
+ }
26
+
27
+ export type WorkerContext<Env> = {
28
+ env: Env;
29
+ executionCtx: ExecutionContext;
30
+ json(data: unknown, status?: number): Response;
31
+ req: Request;
32
+ /** The matched route pattern (e.g. `/health`), or `"unmatched"` — for a metrics middleware's
33
+ * label, so nothing needs its own path-collapsing allowlist. */
34
+ route: string;
35
+ /** Pre-parsed once per request — handlers that need query params/pathname read this instead of
36
+ * re-parsing `req.url`. */
37
+ url: URL;
38
+ };
39
+
40
+ export type CronMiddleware<Env> = (
41
+ cronExpression: string,
42
+ env: Env,
43
+ executionCtx: ExecutionContext,
44
+ next: () => Promise<void>
45
+ ) => Promise<void>;
46
+ export type HttpHandler<Env> = (c: WorkerContext<Env>) => Promise<Response> | Response;
47
+ export type HttpMiddleware<Env> = (
48
+ c: WorkerContext<Env>,
49
+ next: () => Promise<Response>
50
+ ) => Promise<Response>;
51
+ export type QueueHandler<Env> = (
52
+ batch: MessageBatch<unknown>,
53
+ env: Env,
54
+ executionCtx: ExecutionContext
55
+ ) => Promise<void>;
56
+ export type QueueMiddleware<Env> = (
57
+ queueName: string,
58
+ env: Env,
59
+ executionCtx: ExecutionContext,
60
+ next: () => Promise<void>
61
+ ) => Promise<void>;
62
+ /** Return type is `unknown`, not `void` — the runtime ignores it either way (Cloudflare never
63
+ * reads a `scheduled()` return value), but this lets a handler double as a directly-testable
64
+ * function that returns something (a tally, a summary) without a separate void-returning wrapper
65
+ * just to satisfy this type. */
66
+ export type ScheduledHandler<Env> = (
67
+ controller: ScheduledController,
68
+ env: Env,
69
+ executionCtx: ExecutionContext
70
+ ) => Promise<unknown>;
71
+ /** What `.use()` takes on both router classes — one bundle instead of a separate call per surface
72
+ * (`.use(Metrics.WorkerMiddleware('inbox'))` wires HTTP, queue, and cron in one go). Each field is
73
+ * optional: `WorkerBindingRouter` only ever applies `http` (it has no queue/cron to wrap), and a
74
+ * bundle that only cares about one surface just omits the others. */
75
+ export type WorkerMiddleware<Env> = {
76
+ cron?: CronMiddleware<Env>;
77
+ http?: HttpMiddleware<Env>;
78
+ queue?: QueueMiddleware<Env>;
79
+ };
80
+
81
+ type RawFetch<Env> = (
82
+ request: Request,
83
+ env: Env,
84
+ executionCtx: ExecutionContext
85
+ ) => Promise<Response>;
86
+
87
+ /** The route-match + middleware-chain dispatcher shared by both router classes below. Neither
88
+ * class exposes this directly — each wraps the result in Sentry differently (fetch alone for
89
+ * `WorkerBindingRouter`, fetch+queue together for `WorkerRouter`). */
90
+ function buildFetch<Env extends object>(
91
+ routes: Map<string, HttpHandler<Env>>,
92
+ middlewares: HttpMiddleware<Env>[],
93
+ secretNames: ReadonlySet<string>
94
+ ): RawFetch<Env> {
95
+ return (request, env, executionCtx) =>
96
+ runWithContext(env, secretNames, () => {
97
+ const url = new URL(request.url);
98
+ const handler = routes.get(`${request.method} ${url.pathname}`);
99
+ const c: WorkerContext<Env> = {
100
+ env,
101
+ executionCtx,
102
+ json: (data, status = 200) => Response.json(data, { status }),
103
+ req: request,
104
+ route: handler ? url.pathname : 'unmatched',
105
+ url,
106
+ };
107
+ const run = () => Promise.resolve(handler ? handler(c) : new Response(null, { status: 404 }));
108
+ const dispatch = (i: number): Promise<Response> =>
109
+ i < middlewares.length ? middlewares[i](c, () => dispatch(i + 1)) : run();
110
+ return dispatch(0);
111
+ });
112
+ }
113
+
114
+ /** Fetch-only router for a Service Binding entrypoint — never public, only reachable by another
115
+ * worker holding a binding to it. No `.queue()`: see the file header for why. */
116
+ export class WorkerBindingRouter<Env extends object> {
117
+ readonly fetch: RawFetch<Env>;
118
+ private readonly middlewares: HttpMiddleware<Env>[] = [];
119
+ private readonly routes = new Map<string, HttpHandler<Env>>();
120
+
121
+ /** Prefer `WorkerBindingRouter.withSentry()` — this bare constructor skips Sentry
122
+ * instrumentation entirely, which no real worker should ship without. */
123
+ constructor(
124
+ secretNames: ReadonlySet<string>,
125
+ sentryOptions?: (env: Env) => CloudflareOptions | undefined
126
+ ) {
127
+ const rawFetch = buildFetch(this.routes, this.middlewares, secretNames);
128
+ this.fetch = sentryOptions
129
+ ? Sentry.withSentry(sentryOptions, { fetch: rawFetch }).fetch!
130
+ : rawFetch;
131
+ }
132
+
133
+ /** `secretNames`: the app's secret-store binding names. */
134
+ static withSentry<Env extends WorkerSentryEnv>(
135
+ secretNames: ReadonlySet<string>
136
+ ): WorkerBindingRouter<Env> {
137
+ return new WorkerBindingRouter<Env>(secretNames, sentryOptionsFor);
138
+ }
139
+
140
+ /** Terminal call — builds the `WorkerEntrypoint` class a `wrangler.jsonc` binding names via
141
+ * `entrypoint: "..."`. */
142
+ build(): new (ctx: ExecutionContext, env: Env) => WorkerEntrypoint<Env> {
143
+ const fetch = this.fetch;
144
+ return class extends WorkerEntrypoint<Env> {
145
+ fetch(request: Request): Promise<Response> {
146
+ return fetch(request, this.env, this.ctx);
147
+ }
148
+ };
149
+ }
150
+
151
+ get(path: string, handler: HttpHandler<Env>): this {
152
+ this.routes.set(`GET ${path}`, handler);
153
+ return this;
154
+ }
155
+
156
+ post(path: string, handler: HttpHandler<Env>): this {
157
+ this.routes.set(`POST ${path}`, handler);
158
+ return this;
159
+ }
160
+
161
+ /** Only `middleware.http` applies here — see `WorkerMiddleware`'s doc comment. */
162
+ use(middleware: WorkerMiddleware<Env>): this {
163
+ if (middleware.http) this.middlewares.push(middleware.http);
164
+ return this;
165
+ }
166
+ }
167
+
168
+ /** Router for a worker's top-level default export — fetch, queue, or both. Terminal `.build()`
169
+ * returns an `ExportedHandler`. For a Service Binding entrypoint, use `WorkerBindingRouter`
170
+ * instead — its `.build()` returns a `WorkerEntrypoint` class, not an `ExportedHandler`. */
171
+ export class WorkerRouter<Env extends object> {
172
+ readonly fetch: RawFetch<Env>;
173
+ readonly handleQueue: (
174
+ batch: MessageBatch<unknown>,
175
+ env: Env,
176
+ executionCtx: ExecutionContext
177
+ ) => Promise<void>;
178
+ readonly handleScheduled: (
179
+ controller: ScheduledController,
180
+ env: Env,
181
+ executionCtx: ExecutionContext
182
+ ) => Promise<void>;
183
+ private readonly cronHandlers = new Map<string, ScheduledHandler<Env>>();
184
+ private readonly cronMiddlewares: CronMiddleware<Env>[] = [];
185
+ private readonly middlewares: HttpMiddleware<Env>[] = [];
186
+ private readonly queueHandlers = new Map<string, QueueHandler<Env>>();
187
+ private readonly queueMiddlewares: QueueMiddleware<Env>[] = [];
188
+ private readonly routes = new Map<string, HttpHandler<Env>>();
189
+
190
+ /** Prefer `WorkerRouter.withSentry()` — this bare constructor skips Sentry instrumentation
191
+ * entirely, which no real worker should ship without. */
192
+ constructor(
193
+ secretNames: ReadonlySet<string>,
194
+ sentryOptions?: (env: Env) => CloudflareOptions | undefined
195
+ ) {
196
+ const rawFetch = buildFetch(this.routes, this.middlewares, secretNames);
197
+ const rawHandleQueue = (
198
+ batch: MessageBatch<unknown>,
199
+ env: Env,
200
+ executionCtx: ExecutionContext
201
+ ): Promise<void> =>
202
+ runWithContext(env, secretNames, () => {
203
+ const handler = this.queueHandlers.get(batch.queue);
204
+ if (!handler)
205
+ throw new Error(`WorkerRouter: no queue handler registered for "${batch.queue}"`);
206
+ const run = () => handler(batch, env, executionCtx);
207
+ const dispatch = (i: number): Promise<void> =>
208
+ i < this.queueMiddlewares.length
209
+ ? this.queueMiddlewares[i](batch.queue, env, executionCtx, () => dispatch(i + 1))
210
+ : run();
211
+ return dispatch(0);
212
+ });
213
+ const rawHandleScheduled = (
214
+ controller: ScheduledController,
215
+ env: Env,
216
+ executionCtx: ExecutionContext
217
+ ): Promise<void> =>
218
+ runWithContext(env, secretNames, () => {
219
+ const handler = this.cronHandlers.get(controller.cron);
220
+ if (!handler)
221
+ throw new Error(`WorkerRouter: no cron handler registered for "${controller.cron}"`);
222
+ // Discard the handler's return value here — Cloudflare's own `scheduled()` contract is
223
+ // `Promise<void>`, but `ScheduledHandler` itself returns `unknown` so a handler can double
224
+ // as a directly-testable function (see that type's doc comment).
225
+ const run = async () => {
226
+ await handler(controller, env, executionCtx);
227
+ };
228
+ const dispatch = (i: number): Promise<void> =>
229
+ i < this.cronMiddlewares.length
230
+ ? this.cronMiddlewares[i](controller.cron, env, executionCtx, () => dispatch(i + 1))
231
+ : run();
232
+ return dispatch(0);
233
+ });
234
+
235
+ if (sentryOptions) {
236
+ const wrapped = Sentry.withSentry(sentryOptions, {
237
+ fetch: rawFetch,
238
+ queue: rawHandleQueue,
239
+ scheduled: rawHandleScheduled,
240
+ });
241
+ this.fetch = wrapped.fetch!;
242
+ this.handleQueue = wrapped.queue!;
243
+ this.handleScheduled = wrapped.scheduled!;
244
+ } else {
245
+ this.fetch = rawFetch;
246
+ this.handleQueue = rawHandleQueue;
247
+ this.handleScheduled = rawHandleScheduled;
248
+ }
249
+ }
250
+
251
+ /** The one real entry point — every route/queue registration still chains off the returned
252
+ * instance (`.use(...).post(...).queue(...)`), but `fetch`/`handleQueue` come out the other
253
+ * end already Sentry-instrumented, same as every worker's export today, just without a
254
+ * separate `Sentry.withSentry(...)` wrap at the bottom of the file. `secretNames`:
255
+ * the app's secret-store binding names. */
256
+ static withSentry<Env extends WorkerSentryEnv>(
257
+ secretNames: ReadonlySet<string>
258
+ ): WorkerRouter<Env> {
259
+ return new WorkerRouter<Env>(secretNames, sentryOptionsFor);
260
+ }
261
+
262
+ /** Terminal call for a worker's default export — fetch, queue, scheduled, or any mix. */
263
+ build(): ExportedHandler<Env> {
264
+ return { fetch: this.fetch, queue: this.handleQueue, scheduled: this.handleScheduled };
265
+ }
266
+
267
+ /** Registers a handler for one of `wrangler.jsonc`'s `triggers.crons` entries — dispatched by
268
+ * `ScheduledController.cron` at runtime, same shape as `.queue(name, handler)`. `expression`
269
+ * must match that config exactly, or this cron silently has no handler to run. */
270
+ cron(expression: string, handler: ScheduledHandler<Env>): this {
271
+ this.cronHandlers.set(expression, handler);
272
+ return this;
273
+ }
274
+
275
+ get(path: string, handler: HttpHandler<Env>): this {
276
+ this.routes.set(`GET ${path}`, handler);
277
+ return this;
278
+ }
279
+
280
+ post(path: string, handler: HttpHandler<Env>): this {
281
+ this.routes.set(`POST ${path}`, handler);
282
+ return this;
283
+ }
284
+
285
+ queue(name: string, handler: QueueHandler<Env>): this {
286
+ this.queueHandlers.set(name, handler);
287
+ return this;
288
+ }
289
+
290
+ /** Applies whichever of `middleware.http`/`.queue`/`.cron` are present — see `WorkerMiddleware`'s
291
+ * doc comment. One call wires every surface this router has at once. */
292
+ use(middleware: WorkerMiddleware<Env>): this {
293
+ if (middleware.http) this.middlewares.push(middleware.http);
294
+ if (middleware.queue) this.queueMiddlewares.push(middleware.queue);
295
+ if (middleware.cron) this.cronMiddlewares.push(middleware.cron);
296
+ return this;
297
+ }
298
+ }
299
+
300
+ export const WorkerDOBinding = {
301
+ /** Same `.withSentry()` instrumentation as the router classes, for a Durable Object class. */
302
+ withSentry<Env extends WorkerSentryEnv, T extends DurableObject<Env>>(
303
+ DurableObjectClass: new (state: DurableObjectState, env: Env) => T
304
+ ): new (state: DurableObjectState, env: Env) => T {
305
+ return Sentry.instrumentDurableObjectWithSentry<
306
+ Env,
307
+ T,
308
+ new (state: DurableObjectState, env: Env) => T
309
+ >(sentryOptionsFor, DurableObjectClass);
310
+ },
311
+ };
@@ -0,0 +1,94 @@
1
+ import type { WorkerContext } from '.';
2
+
3
+ /** Builds a `WorkerContext` for calling an `HttpHandler` directly in a test, same shape
4
+ * `WorkerRouter.fetch` would build for a real request. */
5
+ export function fakeContext<Env>(request: Request, url: URL, env: Env): WorkerContext<Env> {
6
+ return {
7
+ env,
8
+ executionCtx: { waitUntil: () => {} } as unknown as ExecutionContext,
9
+ json: (data, status = 200) => Response.json(data, { status }),
10
+ req: request,
11
+ route: url.pathname,
12
+ url,
13
+ };
14
+ }
15
+
16
+ /** In-memory `DurableObjectStorage` stand-in for tests: `get`/`put`/`list` (single-key and
17
+ * bulk forms) and a single-slot alarm (`getAlarm`/`setAlarm`/`deleteAlarm`) round-trip for
18
+ * real, no real DO runtime needed. `list`'s `prefix`/`reverse` match real key-ordering
19
+ * semantics (lexicographic). The returned client also carries a `reset()`.
20
+ *
21
+ * `DurableObjectStorage` is a large interface (`sql`, `kv`, `transaction`, bookmarks) — only
22
+ * `get`/`put`/`list`/the alarm methods are implemented, since that's the entire surface any
23
+ * current caller uses. Everything else throws instead of silently no-op'ing, so a caller that
24
+ * reaches past these gets a loud failure at the call site, not a silent gap — same stance as
25
+ * `mockRedis()` (`packages/kit/storage/redis/testing.ts`). Add a case if a test needs one. */
26
+ export function mockDurableObjectStorage(): DurableObjectStorage & { reset: () => void } {
27
+ const rows = new Map<string, unknown>();
28
+ let alarm: null | number = null;
29
+
30
+ function get<T>(key: string): Promise<T | undefined>;
31
+ function get<T>(keys: string[]): Promise<Map<string, T>>;
32
+ function get<T>(keyOrKeys: string | string[]): Promise<Map<string, T> | T | undefined> {
33
+ if (Array.isArray(keyOrKeys)) {
34
+ const out = new Map<string, T>();
35
+ for (const k of keyOrKeys) {
36
+ if (rows.has(k)) out.set(k, rows.get(k) as T);
37
+ }
38
+ return Promise.resolve(out);
39
+ }
40
+ return Promise.resolve(rows.get(keyOrKeys) as T | undefined);
41
+ }
42
+
43
+ function put<T>(key: string, value: T): Promise<void>;
44
+ function put<T>(entries: Record<string, T>): Promise<void>;
45
+ function put<T>(keyOrEntries: Record<string, T> | string, value?: T): Promise<void> {
46
+ if (typeof keyOrEntries === 'string') {
47
+ rows.set(keyOrEntries, value);
48
+ } else {
49
+ for (const [k, v] of Object.entries(keyOrEntries)) rows.set(k, v);
50
+ }
51
+ return Promise.resolve();
52
+ }
53
+
54
+ function list<T>(options?: DurableObjectListOptions): Promise<Map<string, T>> {
55
+ const prefix = options?.prefix ?? '';
56
+ let keys = [...rows.keys()].filter((k) => k.startsWith(prefix)).sort();
57
+ if (options?.reverse) keys = keys.reverse();
58
+ if (options?.limit !== undefined) keys = keys.slice(0, options.limit);
59
+ const out = new Map<T extends never ? never : string, T>();
60
+ for (const k of keys) out.set(k as never, rows.get(k) as T);
61
+ return Promise.resolve(out as Map<string, T>);
62
+ }
63
+
64
+ const notImplemented = (method: string) => () => {
65
+ throw new Error(`mockDurableObjectStorage(): "${method}" is not implemented`);
66
+ };
67
+
68
+ return {
69
+ delete: notImplemented('delete'),
70
+ deleteAlarm: () => {
71
+ alarm = null;
72
+ return Promise.resolve();
73
+ },
74
+ deleteAll: notImplemented('deleteAll'),
75
+ get,
76
+ getAlarm: () => Promise.resolve(alarm),
77
+ getBookmarkForTime: notImplemented('getBookmarkForTime'),
78
+ getCurrentBookmark: notImplemented('getCurrentBookmark'),
79
+ list,
80
+ onNextSessionRestoreBookmark: notImplemented('onNextSessionRestoreBookmark'),
81
+ put,
82
+ reset: () => {
83
+ rows.clear();
84
+ alarm = null;
85
+ },
86
+ setAlarm: (time: Date | number) => {
87
+ alarm = time instanceof Date ? time.getTime() : time;
88
+ return Promise.resolve();
89
+ },
90
+ sync: notImplemented('sync'),
91
+ transaction: notImplemented('transaction'),
92
+ transactionSync: notImplemented('transactionSync'),
93
+ } as unknown as DurableObjectStorage & { reset: () => void };
94
+ }