@zeyolabs/kit 0.0.0-stage → 2026.11.6
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 +102 -2
- package/auth/accounts/accounts.remote.ts +116 -0
- package/auth/accounts/accounts.ts +332 -0
- package/auth/accounts/handle.ts +102 -0
- package/auth/accounts/invites.ts +149 -0
- package/auth/accounts/permissions.ts +90 -0
- package/auth/accounts/schemas.ts +22 -0
- package/auth/config.ts +77 -0
- package/auth/emails.ts +57 -0
- package/auth/schema.ts +152 -0
- package/auth/sessions/handle.ts +124 -0
- package/auth/sessions/magic-link/magic-link.ts +71 -0
- package/auth/sessions/passkeys/client.ts +115 -0
- package/auth/sessions/passkeys/passkeys.ts +295 -0
- package/auth/sessions/sessions.ts +101 -0
- package/auth/sveltekit/loads.ts +59 -0
- package/auth/sveltekit/login.ts +28 -0
- package/auth/sveltekit/routes.ts +264 -0
- package/auth/users.ts +49 -0
- package/cloudflare/access.ts +134 -0
- package/cloudflare/amqp/client.ts +276 -0
- package/cloudflare/amqp/testing.ts +9 -0
- package/cloudflare/context.ts +66 -0
- package/cloudflare/dispatch/client.ts +30 -0
- package/cloudflare/email/base.ts +59 -0
- package/cloudflare/email/client.ts +51 -0
- package/cloudflare/email/dev.ts +14 -0
- package/cloudflare/email/smtp.ts +65 -0
- package/cloudflare/email/testing.ts +18 -0
- package/cloudflare/r2/client.ts +154 -0
- package/cloudflare/r2/testing.ts +43 -0
- package/cloudflare/rate-limiter.ts +65 -0
- package/cloudflare/wait-until.ts +12 -0
- package/cloudflare/workers/index.ts +311 -0
- package/cloudflare/workers/testing.ts +94 -0
- package/cloudflare/workers/ticking-do.ts +104 -0
- package/cloudflare/workers-ai/client.ts +30 -0
- package/cloudflare/workers-ai/testing.ts +17 -0
- package/cloudflare/workers-env.ts +12 -0
- package/eslint-config.js +115 -0
- package/monitoring/alerts/client.ts +48 -0
- package/monitoring/alerts/event-config.ts +33 -0
- package/monitoring/alerts/policy.ts +39 -0
- package/monitoring/alerts/registry.ts +44 -0
- package/monitoring/gates/client.ts +90 -0
- package/monitoring/gates/dev.ts +29 -0
- package/monitoring/gates/testing.ts +30 -0
- package/monitoring/logger/client.ts +66 -0
- package/monitoring/prometheus/client.ts +147 -0
- package/monitoring/prometheus/dev.ts +23 -0
- package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
- package/monitoring/prometheus/metrics.ts +180 -0
- package/monitoring/prometheus/testing.ts +19 -0
- package/monitoring/slack/api.ts +68 -0
- package/monitoring/slack/client.ts +33 -0
- package/monitoring/slack/events.ts +15 -0
- package/package.json +156 -5
- package/server/logger.ts +63 -0
- package/server/rate-limit-client.ts +88 -0
- package/server/rate-limit.ts +24 -0
- package/server/remote-functions.ts +139 -0
- package/server/result.ts +18 -0
- package/server/turnstile.ts +19 -0
- package/storage/clickhouse/client.ts +441 -0
- package/storage/clickhouse/compiler.ts +218 -0
- package/storage/clickhouse/config.ts +13 -0
- package/storage/clickhouse/dev.ts +18 -0
- package/storage/clickhouse/error.ts +61 -0
- package/storage/clickhouse/index.ts +4 -0
- package/storage/clickhouse/native-decoder.ts +192 -0
- package/storage/clickhouse/readers.ts +167 -0
- package/storage/clickhouse/retry.ts +30 -0
- package/storage/clickhouse/schema.ts +163 -0
- package/storage/clickhouse/testing.ts +81 -0
- package/storage/manticore/client.ts +165 -0
- package/storage/manticore/schema.ts +23 -0
- package/storage/postgres/client.ts +44 -0
- package/storage/postgres/testing.ts +275 -0
- package/storage/redis/client.ts +219 -0
- package/storage/redis/protocol.ts +119 -0
- package/storage/redis/schema.ts +17 -0
- package/storage/redis/testing.ts +122 -0
- package/utils/crypto.ts +36 -0
- package/utils/datetime.ts +338 -0
- package/utils/email.ts +173 -0
- package/utils/env.ts +171 -0
- package/utils/errors.ts +67 -0
- package/utils/form.ts +135 -0
- package/utils/http-status.ts +42 -0
- package/utils/money.ts +10 -0
- package/utils/public-host.ts +66 -0
- package/utils/schema.ts +176 -0
- package/utils/strings/format.ts +51 -0
- package/utils/strings/normalize.ts +129 -0
- 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
|
+
}
|