@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.
- package/README.md +37 -2
- 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/client.ts +77 -0
- package/cloudflare/email/dev.ts +10 -0
- package/cloudflare/email/testing.ts +13 -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 +150 -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 +211 -0
- package/utils/strings/format.ts +51 -0
- package/utils/strings/normalize.ts +129 -0
- package/utils/transport.ts +25 -0
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zeyolabs/kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Shared server toolkit for SvelteKit 3 on Cloudflare Workers: remote-function wrappers
|
|
4
|
+
(`Remote.Query/Cmd/Form`), errors and results, the per-request Cloudflare context
|
|
5
|
+
(`runWithContext`, `workersEnv`), rate limiting, Turnstile, logging and monitoring, storage
|
|
6
|
+
clients, utils, and the shared ESLint config.
|
|
7
|
+
|
|
8
|
+
Published as source (TypeScript, no build step): the consuming SvelteKit app compiles it.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
pnpm add -E @zeyolabs/kit
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Peer dependencies: `@sveltejs/kit` 3, `drizzle-orm`, `zod`, `@sentry/sveltekit`; `eslint`,
|
|
17
|
+
`vitest`, `@electric-sql/pglite` and `@cloudflare/workers-types` only for the parts that use them.
|
|
18
|
+
|
|
19
|
+
## Versions
|
|
20
|
+
|
|
21
|
+
Date versions: `YYYY.M.D` (the release day in London; semver forbids leading zeros, so
|
|
22
|
+
`2026.11.5`). One release per day.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
pnpm release # sets the version, commits, tags vYYYY.M.D
|
|
26
|
+
git push origin HEAD vYYYY.M.D # the tag publishes to npm (publish.yaml)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Publishing uses npm trusted publishing: the package's npmjs.com settings trust this repo's
|
|
30
|
+
`publish.yaml`, so no npm token is stored anywhere.
|
|
31
|
+
|
|
32
|
+
## Developing against an app
|
|
33
|
+
|
|
34
|
+
`pnpm link ../kit` in the app while working on both; publish, then pin the new version.
|
|
35
|
+
|
|
36
|
+
## Checks
|
|
37
|
+
|
|
38
|
+
`pnpm check`: prettier, eslint, `tsc` (source, not tests), vitest.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Cloudflare Access: verify the signed `Cf-Access-Jwt-Assertion` header an Access-protected
|
|
2
|
+
// Worker receives. Access already blocks unauthenticated requests at the edge; this is the
|
|
3
|
+
// defence in depth the docs ask for, so the app still fails closed if the Access policy is ever
|
|
4
|
+
// removed, or the Worker is reached some other way than through the protected hostname.
|
|
5
|
+
// https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/
|
|
6
|
+
|
|
7
|
+
export type AccessConfig = {
|
|
8
|
+
/** The AUD tag of the Access application (a 64-char hex string, not a secret). */
|
|
9
|
+
aud: string;
|
|
10
|
+
/** e.g. `my-team.cloudflareaccess.com` */
|
|
11
|
+
teamDomain: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type AccessIdentity = { email: string; sub: string };
|
|
15
|
+
|
|
16
|
+
type Jwk = JsonWebKey & { kid?: string };
|
|
17
|
+
|
|
18
|
+
export const ACCESS_JWT_HEADER = 'Cf-Access-Jwt-Assertion';
|
|
19
|
+
|
|
20
|
+
const KEY_TTL_MS = 60 * 60 * 1000;
|
|
21
|
+
const keyCache = new Map<string, { fetchedAt: number; keys: Jwk[] }>();
|
|
22
|
+
|
|
23
|
+
export class AccessError extends Error {
|
|
24
|
+
override name = 'AccessError';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function decodePart<T>(part: string): T {
|
|
28
|
+
const base64 = part.replaceAll('-', '+').replaceAll('_', '/');
|
|
29
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
30
|
+
return JSON.parse(
|
|
31
|
+
new TextDecoder().decode(Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)))
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodeBytes(part: string): Uint8Array<ArrayBuffer> {
|
|
36
|
+
const base64 = part.replaceAll('-', '+').replaceAll('_', '/');
|
|
37
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
38
|
+
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function fetchAccessKeys(teamDomain: string): Promise<Jwk[]> {
|
|
42
|
+
const response = await fetch(`https://${teamDomain}/cdn-cgi/access/certs`);
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new AccessError(`Could not load Access signing keys (${response.status})`);
|
|
45
|
+
const body = (await response.json()) as { keys?: Jwk[] };
|
|
46
|
+
return body.keys ?? [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function keyFor(
|
|
50
|
+
kid: string,
|
|
51
|
+
teamDomain: string,
|
|
52
|
+
loadKeys: (teamDomain: string) => Promise<Jwk[]>,
|
|
53
|
+
now: number
|
|
54
|
+
): Promise<Jwk> {
|
|
55
|
+
const cached = keyCache.get(teamDomain);
|
|
56
|
+
const fresh = cached && now - cached.fetchedAt < KEY_TTL_MS;
|
|
57
|
+
let keys = fresh ? cached.keys : undefined;
|
|
58
|
+
let found = keys?.find((k) => k.kid === kid);
|
|
59
|
+
if (!found) {
|
|
60
|
+
// Unknown kid (or stale cache): Access rotates keys, so refetch once before giving up.
|
|
61
|
+
keys = await loadKeys(teamDomain);
|
|
62
|
+
keyCache.set(teamDomain, { fetchedAt: now, keys });
|
|
63
|
+
found = keys.find((k) => k.kid === kid);
|
|
64
|
+
}
|
|
65
|
+
if (!found) throw new AccessError('Unknown signing key');
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Verifies an Access JWT (RS256 signature, issuer, audience, expiry) and returns who it is for.
|
|
71
|
+
* Throws `AccessError` for anything that is not a valid token for this application.
|
|
72
|
+
* `loadKeys` and `now` exist for tests.
|
|
73
|
+
*/
|
|
74
|
+
export async function verifyAccessJwt(
|
|
75
|
+
token: string,
|
|
76
|
+
config: AccessConfig,
|
|
77
|
+
{
|
|
78
|
+
loadKeys = fetchAccessKeys,
|
|
79
|
+
now = Date.now(),
|
|
80
|
+
}: { loadKeys?: (teamDomain: string) => Promise<Jwk[]>; now?: number } = {}
|
|
81
|
+
): Promise<AccessIdentity> {
|
|
82
|
+
const parts = token.split('.');
|
|
83
|
+
if (parts.length !== 3) throw new AccessError('Malformed token');
|
|
84
|
+
const [rawHeader, rawPayload, rawSignature] = parts;
|
|
85
|
+
|
|
86
|
+
let header: { alg?: string; kid?: string };
|
|
87
|
+
let payload: {
|
|
88
|
+
aud?: string | string[];
|
|
89
|
+
email?: string;
|
|
90
|
+
exp?: number;
|
|
91
|
+
iss?: string;
|
|
92
|
+
nbf?: number;
|
|
93
|
+
sub?: string;
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
header = decodePart(rawHeader);
|
|
97
|
+
payload = decodePart(rawPayload);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new AccessError('Malformed token');
|
|
100
|
+
}
|
|
101
|
+
if (header.alg !== 'RS256' || !header.kid) throw new AccessError('Unsupported token');
|
|
102
|
+
|
|
103
|
+
const jwk = await keyFor(header.kid, config.teamDomain, loadKeys, now);
|
|
104
|
+
const key = await crypto.subtle.importKey(
|
|
105
|
+
'jwk',
|
|
106
|
+
jwk,
|
|
107
|
+
{ hash: 'SHA-256', name: 'RSASSA-PKCS1-v1_5' },
|
|
108
|
+
false,
|
|
109
|
+
['verify']
|
|
110
|
+
);
|
|
111
|
+
const valid = await crypto.subtle.verify(
|
|
112
|
+
'RSASSA-PKCS1-v1_5',
|
|
113
|
+
key,
|
|
114
|
+
decodeBytes(rawSignature),
|
|
115
|
+
new TextEncoder().encode(`${rawHeader}.${rawPayload}`)
|
|
116
|
+
);
|
|
117
|
+
if (!valid) throw new AccessError('Bad signature');
|
|
118
|
+
|
|
119
|
+
if (payload.iss !== `https://${config.teamDomain}`) throw new AccessError('Wrong issuer');
|
|
120
|
+
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
121
|
+
if (!audiences.includes(config.aud)) throw new AccessError('Wrong audience');
|
|
122
|
+
const seconds = Math.floor(now / 1000);
|
|
123
|
+
if (typeof payload.exp !== 'number' || payload.exp <= seconds) throw new AccessError('Expired');
|
|
124
|
+
if (typeof payload.nbf === 'number' && payload.nbf > seconds)
|
|
125
|
+
throw new AccessError('Not yet valid');
|
|
126
|
+
if (!payload.email || !payload.sub) throw new AccessError('Not a user token');
|
|
127
|
+
|
|
128
|
+
return { email: payload.email, sub: payload.sub };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Test helper: forget cached signing keys. */
|
|
132
|
+
export function resetAccessKeyCache(): void {
|
|
133
|
+
keyCache.clear();
|
|
134
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const PROTOCOL_HEADER = new Uint8Array([0x41, 0x4d, 0x51, 0x50, 0, 0, 9, 1]);
|
|
2
|
+
const FRAME_END = 0xce;
|
|
3
|
+
const FRAME_TYPE_METHOD = 1;
|
|
4
|
+
const FRAME_TYPE_HEADER = 2;
|
|
5
|
+
const FRAME_TYPE_BODY = 3;
|
|
6
|
+
|
|
7
|
+
const CLASS_CONNECTION = 10;
|
|
8
|
+
const CLASS_CHANNEL = 20;
|
|
9
|
+
const CLASS_BASIC = 60;
|
|
10
|
+
|
|
11
|
+
type MethodFrame = { args: Uint8Array; channelId: number; classId: number; methodId: number };
|
|
12
|
+
|
|
13
|
+
class ByteWriter {
|
|
14
|
+
private chunks: number[] = [];
|
|
15
|
+
|
|
16
|
+
build(): Uint8Array {
|
|
17
|
+
return new Uint8Array(this.chunks);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
bytes(b: Uint8Array): this {
|
|
21
|
+
for (const byte of b) this.chunks.push(byte);
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
emptyTable(): this {
|
|
26
|
+
this.long(0);
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
long(v: number): this {
|
|
31
|
+
this.chunks.push((v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff);
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
longlong(v: number): this {
|
|
36
|
+
this.long(0);
|
|
37
|
+
this.long(v);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
longstr(bytes: Uint8Array): this {
|
|
42
|
+
this.long(bytes.length);
|
|
43
|
+
this.bytes(bytes);
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
octet(v: number): this {
|
|
48
|
+
this.chunks.push(v & 0xff);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
short(v: number): this {
|
|
53
|
+
this.chunks.push((v >>> 8) & 0xff, v & 0xff);
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
shortstr(s: string): this {
|
|
58
|
+
const bytes = new TextEncoder().encode(s);
|
|
59
|
+
this.octet(bytes.length);
|
|
60
|
+
this.bytes(bytes);
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class ByteReader {
|
|
66
|
+
private buf = new Uint8Array(0);
|
|
67
|
+
|
|
68
|
+
constructor(private reader: ReadableStreamDefaultReader<Uint8Array>) {}
|
|
69
|
+
|
|
70
|
+
async readExact(n: number): Promise<Uint8Array> {
|
|
71
|
+
while (this.buf.length < n) {
|
|
72
|
+
const { done, value } = await this.reader.read();
|
|
73
|
+
if (done) throw new Error(`AMQP: connection closed while expecting ${n} bytes`);
|
|
74
|
+
const merged = new Uint8Array(this.buf.length + value.length);
|
|
75
|
+
merged.set(this.buf, 0);
|
|
76
|
+
merged.set(value, this.buf.length);
|
|
77
|
+
this.buf = merged;
|
|
78
|
+
}
|
|
79
|
+
const out = this.buf.slice(0, n);
|
|
80
|
+
this.buf = this.buf.slice(n);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function u16(b: Uint8Array, offset: number): number {
|
|
86
|
+
return (b[offset] << 8) | b[offset + 1];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function u32(b: Uint8Array, offset: number): number {
|
|
90
|
+
return ((b[offset] << 24) | (b[offset + 1] << 16) | (b[offset + 2] << 8) | b[offset + 3]) >>> 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function writeFrame(
|
|
94
|
+
writer: WritableStreamDefaultWriter<Uint8Array>,
|
|
95
|
+
type: number,
|
|
96
|
+
channelId: number,
|
|
97
|
+
payload: Uint8Array
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const header = new ByteWriter().octet(type).short(channelId).long(payload.length).build();
|
|
100
|
+
await writer.write(header);
|
|
101
|
+
await writer.write(payload);
|
|
102
|
+
await writer.write(new Uint8Array([FRAME_END]));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readFrame(
|
|
106
|
+
reader: ByteReader
|
|
107
|
+
): Promise<{ channelId: number; payload: Uint8Array; type: number }> {
|
|
108
|
+
const header = await reader.readExact(7);
|
|
109
|
+
const type = header[0];
|
|
110
|
+
const channelId = u16(header, 1);
|
|
111
|
+
const size = u32(header, 3);
|
|
112
|
+
const payload = await reader.readExact(size);
|
|
113
|
+
const trailer = await reader.readExact(1);
|
|
114
|
+
if (trailer[0] !== FRAME_END) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`AMQP: malformed frame (expected 0xCE trailer, got 0x${trailer[0].toString(16)})`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return { channelId, payload, type };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readMethodFrame(reader: ByteReader): Promise<MethodFrame> {
|
|
123
|
+
const frame = await readFrame(reader);
|
|
124
|
+
if (frame.type !== FRAME_TYPE_METHOD) {
|
|
125
|
+
throw new Error(`AMQP: expected a method frame, got frame type ${frame.type}`);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
args: frame.payload.slice(4),
|
|
129
|
+
channelId: frame.channelId,
|
|
130
|
+
classId: u16(frame.payload, 0),
|
|
131
|
+
methodId: u16(frame.payload, 2),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseConnectionClose(args: Uint8Array): { replyCode: number; replyText: string } {
|
|
136
|
+
const replyCode = u16(args, 0);
|
|
137
|
+
const textLen = args[2];
|
|
138
|
+
const replyText = new TextDecoder().decode(args.slice(3, 3 + textLen));
|
|
139
|
+
return { replyCode, replyText };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type AmqpSocket = {
|
|
143
|
+
readable: ReadableStream<Uint8Array>;
|
|
144
|
+
writable: WritableStream<Uint8Array>;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type AmqpConfig = {
|
|
148
|
+
password: string;
|
|
149
|
+
username: string;
|
|
150
|
+
vpc: { connect(address: string): AmqpSocket };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export interface IAmqpClient {
|
|
154
|
+
publishJson(queue: string, body: unknown): Promise<void>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class AmqpClient implements IAmqpClient {
|
|
158
|
+
constructor(private readonly config: AmqpConfig) {}
|
|
159
|
+
|
|
160
|
+
async publishJson(queue: string, body: unknown): Promise<void> {
|
|
161
|
+
const { password, username, vpc } = this.config;
|
|
162
|
+
const socket = vpc.connect('127.0.0.1:5672');
|
|
163
|
+
const writer = socket.writable.getWriter();
|
|
164
|
+
const reader = new ByteReader(socket.readable.getReader());
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await writer.write(PROTOCOL_HEADER);
|
|
168
|
+
|
|
169
|
+
const start = await readMethodFrame(reader);
|
|
170
|
+
if (start.classId !== CLASS_CONNECTION || start.methodId !== 10) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`AMQP: expected Connection.Start, got class ${start.classId} method ${start.methodId}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const saslResponse = new TextEncoder().encode(`\0${username}\0${password}`);
|
|
177
|
+
const startOk = new ByteWriter()
|
|
178
|
+
.short(CLASS_CONNECTION)
|
|
179
|
+
.short(11)
|
|
180
|
+
.emptyTable()
|
|
181
|
+
.shortstr('PLAIN')
|
|
182
|
+
.longstr(saslResponse)
|
|
183
|
+
.shortstr('en_US')
|
|
184
|
+
.build();
|
|
185
|
+
await writeFrame(writer, FRAME_TYPE_METHOD, 0, startOk);
|
|
186
|
+
|
|
187
|
+
const tune = await readMethodFrame(reader);
|
|
188
|
+
if (tune.classId !== CLASS_CONNECTION || tune.methodId !== 30) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`AMQP: expected Connection.Tune, got class ${tune.classId} method ${tune.methodId}`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const channelMax = u16(tune.args, 0);
|
|
194
|
+
const frameMax = u32(tune.args, 2);
|
|
195
|
+
|
|
196
|
+
const tuneOk = new ByteWriter()
|
|
197
|
+
.short(CLASS_CONNECTION)
|
|
198
|
+
.short(31)
|
|
199
|
+
.short(channelMax)
|
|
200
|
+
.long(frameMax)
|
|
201
|
+
.short(0)
|
|
202
|
+
.build();
|
|
203
|
+
await writeFrame(writer, FRAME_TYPE_METHOD, 0, tuneOk);
|
|
204
|
+
|
|
205
|
+
const open = new ByteWriter()
|
|
206
|
+
.short(CLASS_CONNECTION)
|
|
207
|
+
.short(40)
|
|
208
|
+
.shortstr('/')
|
|
209
|
+
.shortstr('')
|
|
210
|
+
.octet(0)
|
|
211
|
+
.build();
|
|
212
|
+
await writeFrame(writer, FRAME_TYPE_METHOD, 0, open);
|
|
213
|
+
|
|
214
|
+
const openOk = await readMethodFrame(reader);
|
|
215
|
+
if (openOk.classId !== CLASS_CONNECTION || openOk.methodId !== 41) {
|
|
216
|
+
if (openOk.classId === CLASS_CONNECTION && openOk.methodId === 50) {
|
|
217
|
+
const { replyCode, replyText } = parseConnectionClose(openOk.args);
|
|
218
|
+
throw new Error(`AMQP: connection rejected (${replyCode}): ${replyText}`);
|
|
219
|
+
}
|
|
220
|
+
throw new Error(
|
|
221
|
+
`AMQP: expected Connection.OpenOk, got class ${openOk.classId} method ${openOk.methodId}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const channelOpen = new ByteWriter().short(CLASS_CHANNEL).short(10).shortstr('').build();
|
|
226
|
+
await writeFrame(writer, FRAME_TYPE_METHOD, 1, channelOpen);
|
|
227
|
+
const channelOpenOk = await readMethodFrame(reader);
|
|
228
|
+
if (channelOpenOk.classId !== CLASS_CHANNEL || channelOpenOk.methodId !== 11) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`AMQP: expected Channel.OpenOk, got class ${channelOpenOk.classId} method ${channelOpenOk.methodId}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const publish = new ByteWriter()
|
|
235
|
+
.short(CLASS_BASIC)
|
|
236
|
+
.short(40)
|
|
237
|
+
.short(0)
|
|
238
|
+
.shortstr('')
|
|
239
|
+
.shortstr(queue)
|
|
240
|
+
.octet(0)
|
|
241
|
+
.build();
|
|
242
|
+
await writeFrame(writer, FRAME_TYPE_METHOD, 1, publish);
|
|
243
|
+
|
|
244
|
+
const bodyBytes = new TextEncoder().encode(JSON.stringify(body));
|
|
245
|
+
const header = new ByteWriter()
|
|
246
|
+
.short(CLASS_BASIC)
|
|
247
|
+
.short(0)
|
|
248
|
+
.longlong(bodyBytes.length)
|
|
249
|
+
.short(0x9000)
|
|
250
|
+
.shortstr('application/json')
|
|
251
|
+
.octet(2)
|
|
252
|
+
.build();
|
|
253
|
+
await writeFrame(writer, FRAME_TYPE_HEADER, 1, header);
|
|
254
|
+
|
|
255
|
+
await writeFrame(writer, FRAME_TYPE_BODY, 1, bodyBytes);
|
|
256
|
+
|
|
257
|
+
const close = new ByteWriter()
|
|
258
|
+
.short(CLASS_CONNECTION)
|
|
259
|
+
.short(50)
|
|
260
|
+
.short(200)
|
|
261
|
+
.shortstr('')
|
|
262
|
+
.short(0)
|
|
263
|
+
.short(0)
|
|
264
|
+
.build();
|
|
265
|
+
await writeFrame(writer, FRAME_TYPE_METHOD, 0, close);
|
|
266
|
+
const closeOk = await readMethodFrame(reader);
|
|
267
|
+
if (closeOk.classId !== CLASS_CONNECTION || closeOk.methodId !== 51) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`AMQP: expected Connection.CloseOk, got class ${closeOk.classId} method ${closeOk.methodId}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
} finally {
|
|
273
|
+
await writer.close().catch(() => {});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IAmqpClient } from './client';
|
|
2
|
+
|
|
3
|
+
export class FakeAmqpClient implements IAmqpClient {
|
|
4
|
+
readonly published: { body: unknown; queue: string }[] = [];
|
|
5
|
+
|
|
6
|
+
async publishJson(queue: string, body: unknown): Promise<void> {
|
|
7
|
+
this.published.push({ body, queue });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
import { EnvStore } from '../utils/env';
|
|
4
|
+
|
|
5
|
+
/** Per-request/invocation cache with teardown tracking, holding the raw runtime `env`. */
|
|
6
|
+
export class ContextStore {
|
|
7
|
+
private cache = new Map<string, unknown>();
|
|
8
|
+
private closers: Array<() => Promise<void>> = [];
|
|
9
|
+
|
|
10
|
+
constructor(readonly env: object) {}
|
|
11
|
+
|
|
12
|
+
async closeAll(): Promise<void> {
|
|
13
|
+
await Promise.allSettled(this.closers.map((c) => c()));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
once<T>(key: string, build: () => T, close?: (v: T) => Promise<void>): T {
|
|
17
|
+
if (!this.cache.has(key)) {
|
|
18
|
+
const v = build();
|
|
19
|
+
this.cache.set(key, v);
|
|
20
|
+
if (close) this.closers.push(() => close(v));
|
|
21
|
+
}
|
|
22
|
+
return this.cache.get(key) as T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** For a binding whose construction is app-specific and can't be built generically here. */
|
|
26
|
+
provide<T>(key: string, value: T): void {
|
|
27
|
+
this.cache.set(key, value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
require<T>(key: string): T {
|
|
31
|
+
if (!this.cache.has(key)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Context key "${key}" was never provided — call store.provide("${key}", …) first`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return this.cache.get(key) as T;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const als = new AsyncLocalStorage<ContextStore>();
|
|
41
|
+
|
|
42
|
+
/** Wrap one request/invocation in this — whatever gets built during the call is closed
|
|
43
|
+
* automatically when it returns. `secretNames`: the app's list of Secrets Store binding names. */
|
|
44
|
+
export async function runWithContext<T>(
|
|
45
|
+
env: object,
|
|
46
|
+
secretNames: ReadonlySet<string>,
|
|
47
|
+
fn: () => Promise<T>
|
|
48
|
+
): Promise<T> {
|
|
49
|
+
await EnvStore.resolve(env, secretNames);
|
|
50
|
+
const store = new ContextStore(env);
|
|
51
|
+
try {
|
|
52
|
+
return await als.run(store, fn);
|
|
53
|
+
} finally {
|
|
54
|
+
await store.closeAll();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function currentContext(): ContextStore {
|
|
59
|
+
const store = als.getStore();
|
|
60
|
+
if (!store) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'No context — call runWithContext(env, fn) at the top of the request/invocation'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return store;
|
|
66
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Structural shape of the dispatch queue binding — kept minimal so kit doesn't pull in a
|
|
2
|
+
* Cloudflare Workers type it doesn't already depend on. */
|
|
3
|
+
export type DispatchQueueBinding = { send(body: unknown): Promise<void> };
|
|
4
|
+
|
|
5
|
+
export interface IDispatchClient<M> {
|
|
6
|
+
send(message: M): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hands a message to the `dispatch` queue, generic over the caller's own message union `M` — kit
|
|
11
|
+
* has no notion of what a dispatch type is called. Stamps `enqueuedAt` (unix seconds) on the way
|
|
12
|
+
* out, which is the one envelope field every consumer relies on and no producer should have to
|
|
13
|
+
* remember.
|
|
14
|
+
*/
|
|
15
|
+
export class DispatchClient<M extends { type: string }> implements IDispatchClient<M> {
|
|
16
|
+
constructor(private readonly queue: DispatchQueueBinding) {}
|
|
17
|
+
|
|
18
|
+
async send(message: M): Promise<void> {
|
|
19
|
+
await this.queue.send({ ...message, enqueuedAt: Math.floor(Date.now() / 1000) });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Used when no `DISPATCH_QUEUE` binding exists (local dev) — logs instead of delivering, so a
|
|
24
|
+
* missing binding degrades the same way `DevPrometheusClient` does rather than throwing. */
|
|
25
|
+
export class DevDispatchClient<M extends { type: string }> implements IDispatchClient<M> {
|
|
26
|
+
async send(message: M): Promise<void> {
|
|
27
|
+
console.info('[dispatch] no DISPATCH_QUEUE binding — dropping message', { type: message.type });
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createMimeMessage } from 'mimetext/browser';
|
|
2
|
+
|
|
3
|
+
import { logger as defaultLogger, type LoggerClient } from '../../monitoring/logger/client';
|
|
4
|
+
|
|
5
|
+
export type EmailAttachment = { base64Data: string; contentType: string; filename: string };
|
|
6
|
+
|
|
7
|
+
export type EmailInput = {
|
|
8
|
+
attachments?: EmailAttachment[];
|
|
9
|
+
html: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
to: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** The shape of a Workers `send_email` binding. */
|
|
15
|
+
export type EmailBinding = { send(message: unknown): Promise<unknown> };
|
|
16
|
+
|
|
17
|
+
export interface IEmailClient {
|
|
18
|
+
/** Sends one message. Logs and returns `false` on failure instead of throwing. */
|
|
19
|
+
send(input: EmailInput): Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildRawEmail(
|
|
23
|
+
from: string,
|
|
24
|
+
fromName: string | undefined,
|
|
25
|
+
{ attachments, html, subject, to }: EmailInput
|
|
26
|
+
): string {
|
|
27
|
+
const msg = createMimeMessage();
|
|
28
|
+
msg.setSender(fromName ? { addr: from, name: fromName } : { addr: from });
|
|
29
|
+
msg.setRecipient(to);
|
|
30
|
+
msg.setSubject(subject);
|
|
31
|
+
msg.addMessage({ contentType: 'text/html', data: html });
|
|
32
|
+
for (const attachment of attachments ?? []) {
|
|
33
|
+
msg.addAttachment({
|
|
34
|
+
contentType: attachment.contentType,
|
|
35
|
+
data: attachment.base64Data,
|
|
36
|
+
filename: attachment.filename,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return msg.asRaw();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Sends transactional email over a Workers `send_email` binding (Cloudflare Email Routing).
|
|
43
|
+
* Use `DevEmailClient` where there's no binding. */
|
|
44
|
+
export class EmailClient implements IEmailClient {
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly config: {
|
|
47
|
+
binding: EmailBinding;
|
|
48
|
+
/** Envelope and header sender. */
|
|
49
|
+
from: string;
|
|
50
|
+
/** Display name next to `from`. */
|
|
51
|
+
fromName?: string;
|
|
52
|
+
logger?: LoggerClient;
|
|
53
|
+
}
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
/** Like `send`, but throws on failure — for callers that retry (queue consumers). */
|
|
57
|
+
async deliver(input: EmailInput): Promise<void> {
|
|
58
|
+
const { binding, from, fromName } = this.config;
|
|
59
|
+
// Dynamic: `cloudflare:email` only resolves inside the Workers runtime.
|
|
60
|
+
const { EmailMessage } = await import('cloudflare:email');
|
|
61
|
+
await binding.send(new EmailMessage(from, input.to, buildRawEmail(from, fromName, input)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async send(input: EmailInput): Promise<boolean> {
|
|
65
|
+
try {
|
|
66
|
+
await this.deliver(input);
|
|
67
|
+
return true;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
await (this.config.logger ?? defaultLogger).error(err, {
|
|
70
|
+
from: this.config.from,
|
|
71
|
+
subject: input.subject,
|
|
72
|
+
to: input.to,
|
|
73
|
+
});
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EmailInput, IEmailClient } from './client';
|
|
2
|
+
|
|
3
|
+
/** Stands in for `EmailClient` where there's no `send_email` binding (local dev) — logs instead of
|
|
4
|
+
* sending, so the message is still visible. */
|
|
5
|
+
export class DevEmailClient implements IEmailClient {
|
|
6
|
+
async send({ subject, to }: EmailInput): Promise<boolean> {
|
|
7
|
+
console.log(`[email:dev] to=${to} subject=${subject}`);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { EmailInput, IEmailClient } from './client';
|
|
2
|
+
|
|
3
|
+
/** Records every message passed to `send` instead of sending anything — for asserting what a
|
|
4
|
+
* service emailed, without a mock binding. Set `succeeds = false` to simulate a delivery failure. */
|
|
5
|
+
export class FakeEmailClient implements IEmailClient {
|
|
6
|
+
readonly sent: EmailInput[] = [];
|
|
7
|
+
succeeds = true;
|
|
8
|
+
|
|
9
|
+
async send(input: EmailInput): Promise<boolean> {
|
|
10
|
+
this.sent.push(input);
|
|
11
|
+
return this.succeeds;
|
|
12
|
+
}
|
|
13
|
+
}
|