@naarang/ccc 3.2.0-beta.1 → 3.3.0-beta.1

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.
@@ -0,0 +1,340 @@
1
+ /**
2
+ * api-test-server.ts — echo backend for verifying the C3 mobile API
3
+ * client end-to-end.
4
+ *
5
+ * Run:
6
+ * bun run scripts/api-test-server.ts # listens on :3000
7
+ * PORT=4000 bun run scripts/api-test-server.ts
8
+ *
9
+ * Then forward port 3000 from the C3 mobile app, open the API
10
+ * client, and hit `localhost:3000/<endpoint>` from any of the
11
+ * editor's body types.
12
+ *
13
+ * Routes:
14
+ * ANY / — Echo: method, headers, query,
15
+ * body (auto-parsed by content-type),
16
+ * duration, content-length.
17
+ * ANY /echo — Same as `/`. Two paths so users
18
+ * can save requests against either.
19
+ * GET /status/:code — Returns the given HTTP status code
20
+ * (200, 404, 500, etc.) with a small
21
+ * JSON body. Useful for verifying
22
+ * status badge coloring.
23
+ * GET /delay?ms=N — Sleeps N ms before responding 200.
24
+ * Caps at 30s to avoid hangs.
25
+ * GET /large?bytes=N — Streams N bytes of `A`s. Caps at
26
+ * 20 MB so the mobile 10 MB cap can
27
+ * be exercised without OOM here.
28
+ * GET /headers — Returns the request headers as
29
+ * JSON. Useful for auth preset
30
+ * verification (Authorization,
31
+ * Bearer, custom X- headers).
32
+ * GET /redirect?to=URL — 302 redirect to the given URL.
33
+ * Lets you verify the client surfaces
34
+ * the redirect rather than following
35
+ * silently.
36
+ * POST /upload — Accepts multipart/form-data with any
37
+ * number of file and text fields.
38
+ * Returns each field's metadata
39
+ * (name, size, mimeType for files;
40
+ * raw value for text).
41
+ * POST /binary — Accepts a raw binary body. Returns
42
+ * the byte count, content-type, and
43
+ * a SHA-256 hex digest so the client
44
+ * can verify integrity.
45
+ * GET /healthz — Liveness check. Returns 200 OK.
46
+ *
47
+ * Logging: every request prints a one-line summary to stdout
48
+ * (method, path, status, duration). Bodies aren't logged to avoid
49
+ * leaking secrets while you're testing auth.
50
+ */
51
+
52
+ const PORT = Number(process.env.PORT ?? 3000);
53
+
54
+ const server = Bun.serve({
55
+ port: PORT,
56
+ hostname: '0.0.0.0',
57
+
58
+ async fetch(req) {
59
+ const start = Date.now();
60
+ const url = new URL(req.url);
61
+ const path = url.pathname;
62
+
63
+ let response: Response;
64
+ try {
65
+ response = await route(req, url, path);
66
+ } catch (err) {
67
+ response = jsonResponse(
68
+ 500,
69
+ {
70
+ error: 'handler_error',
71
+ message: err instanceof Error ? err.message : String(err),
72
+ },
73
+ );
74
+ }
75
+
76
+ const duration = Date.now() - start;
77
+ log(req.method, path, response.status, duration);
78
+ return response;
79
+ },
80
+ });
81
+
82
+ console.log(`[api-test-server] listening on http://0.0.0.0:${server.port}`);
83
+ console.log(`[api-test-server] try: curl http://localhost:${server.port}/`);
84
+
85
+ // ────────────────────────────────────────────────────────────────────
86
+ // Router
87
+ // ────────────────────────────────────────────────────────────────────
88
+
89
+ async function route(req: Request, url: URL, path: string): Promise<Response> {
90
+ // Health check — no body, fast path.
91
+ if (path === '/healthz') return new Response('ok', { status: 200 });
92
+
93
+ // Status-code probe.
94
+ const statusMatch = path.match(/^\/status\/(\d{3})$/);
95
+ if (statusMatch) {
96
+ const code = Number(statusMatch[1]);
97
+ return jsonResponse(code, { status: code, message: statusText(code) });
98
+ }
99
+
100
+ if (path === '/delay') return await handleDelay(url);
101
+ if (path === '/large') return handleLarge(url);
102
+ if (path === '/headers') return handleHeaders(req);
103
+ if (path === '/redirect') return handleRedirect(url);
104
+ if (path === '/upload') return await handleUpload(req);
105
+ if (path === '/binary') return await handleBinary(req);
106
+
107
+ // Default: echo.
108
+ if (path === '/' || path === '/echo') return await handleEcho(req, url);
109
+
110
+ return jsonResponse(404, { error: 'not_found', path });
111
+ }
112
+
113
+ // ────────────────────────────────────────────────────────────────────
114
+ // Handlers
115
+ // ────────────────────────────────────────────────────────────────────
116
+
117
+ async function handleEcho(req: Request, url: URL): Promise<Response> {
118
+ const headers = headerObject(req);
119
+ const query = Object.fromEntries(url.searchParams.entries());
120
+ const body = await parseRequestBody(req);
121
+
122
+ return jsonResponse(200, {
123
+ ok: true,
124
+ method: req.method,
125
+ path: url.pathname,
126
+ headers,
127
+ query,
128
+ body,
129
+ });
130
+ }
131
+
132
+ async function handleDelay(url: URL): Promise<Response> {
133
+ const ms = clamp(Number(url.searchParams.get('ms') ?? 1000), 0, 30_000);
134
+ await new Promise((r) => setTimeout(r, ms));
135
+ return jsonResponse(200, { ok: true, delayedMs: ms });
136
+ }
137
+
138
+ function handleLarge(url: URL): Response {
139
+ const requested = Number(url.searchParams.get('bytes') ?? 1024 * 1024);
140
+ const bytes = clamp(requested, 1, 20 * 1024 * 1024);
141
+ // Single allocation — `A`.charCodeAt = 0x41
142
+ const buf = new Uint8Array(bytes).fill(0x41);
143
+ return new Response(buf, {
144
+ status: 200,
145
+ headers: {
146
+ 'content-type': 'text/plain; charset=utf-8',
147
+ 'content-length': String(bytes),
148
+ },
149
+ });
150
+ }
151
+
152
+ function handleHeaders(req: Request): Response {
153
+ return jsonResponse(200, { headers: headerObject(req) });
154
+ }
155
+
156
+ function handleRedirect(url: URL): Response {
157
+ const to = url.searchParams.get('to') ?? '/';
158
+ return new Response(null, {
159
+ status: 302,
160
+ headers: { location: to },
161
+ });
162
+ }
163
+
164
+ async function handleUpload(req: Request): Promise<Response> {
165
+ const ct = req.headers.get('content-type') ?? '';
166
+ if (!ct.includes('multipart/form-data')) {
167
+ return jsonResponse(415, {
168
+ error: 'wrong_content_type',
169
+ expected: 'multipart/form-data',
170
+ got: ct,
171
+ });
172
+ }
173
+
174
+ const fd = await req.formData();
175
+ const fields: Array<Record<string, unknown>> = [];
176
+ for (const [key, value] of fd.entries()) {
177
+ if (value instanceof Blob) {
178
+ // Read enough bytes to compute a SHA-256 digest. We hash the
179
+ // whole file — if the test file is huge this will be slow, but
180
+ // honest checksums beat fast-but-misleading.
181
+ const buf = await value.arrayBuffer();
182
+ fields.push({
183
+ key,
184
+ kind: 'file',
185
+ // `name` exists when the client sent a `filename=` in the part.
186
+ name: (value as File).name ?? null,
187
+ mimeType: value.type || null,
188
+ size: buf.byteLength,
189
+ sha256: await sha256Hex(buf),
190
+ });
191
+ } else {
192
+ fields.push({ key, kind: 'text', value });
193
+ }
194
+ }
195
+ return jsonResponse(200, { ok: true, fields });
196
+ }
197
+
198
+ async function handleBinary(req: Request): Promise<Response> {
199
+ const ct = req.headers.get('content-type') ?? 'application/octet-stream';
200
+ const buf = await req.arrayBuffer();
201
+ return jsonResponse(200, {
202
+ ok: true,
203
+ contentType: ct,
204
+ size: buf.byteLength,
205
+ sha256: await sha256Hex(buf),
206
+ });
207
+ }
208
+
209
+ // ────────────────────────────────────────────────────────────────────
210
+ // Body parsing — auto-detect by Content-Type so JSON / form / text
211
+ // each come back in their natural shape.
212
+ // ────────────────────────────────────────────────────────────────────
213
+
214
+ async function parseRequestBody(req: Request): Promise<unknown> {
215
+ if (req.method === 'GET' || req.method === 'HEAD') return null;
216
+
217
+ const ct = (req.headers.get('content-type') ?? '').toLowerCase();
218
+ if (!ct) {
219
+ // No content-type → try as text; empty body is fine.
220
+ const text = await req.text();
221
+ return text.length === 0 ? null : { kind: 'unknown', text };
222
+ }
223
+
224
+ if (ct.includes('application/json')) {
225
+ try {
226
+ return { kind: 'json', value: await req.json() };
227
+ } catch (err) {
228
+ const text = await req.text().catch(() => '');
229
+ return { kind: 'json_parse_error', error: String(err), raw: text };
230
+ }
231
+ }
232
+
233
+ if (ct.includes('application/x-www-form-urlencoded')) {
234
+ const text = await req.text();
235
+ const pairs: Record<string, string> = {};
236
+ for (const [k, v] of new URLSearchParams(text).entries()) pairs[k] = v;
237
+ return { kind: 'form-urlencoded', value: pairs };
238
+ }
239
+
240
+ if (ct.includes('multipart/form-data')) {
241
+ const fd = await req.formData();
242
+ const fields: Array<Record<string, unknown>> = [];
243
+ for (const [k, v] of fd.entries()) {
244
+ if (v instanceof Blob) {
245
+ const buf = await v.arrayBuffer();
246
+ fields.push({
247
+ key: k,
248
+ kind: 'file',
249
+ name: (v as File).name ?? null,
250
+ mimeType: v.type || null,
251
+ size: buf.byteLength,
252
+ });
253
+ } else {
254
+ fields.push({ key: k, kind: 'text', value: v });
255
+ }
256
+ }
257
+ return { kind: 'multipart', fields };
258
+ }
259
+
260
+ if (ct.includes('xml')) {
261
+ return { kind: 'xml', value: await req.text() };
262
+ }
263
+
264
+ if (ct.startsWith('text/')) {
265
+ return { kind: 'text', value: await req.text() };
266
+ }
267
+
268
+ // Anything else — treat as binary, return size + checksum so the
269
+ // client can verify integrity without us shipping the bytes back.
270
+ const buf = await req.arrayBuffer();
271
+ return {
272
+ kind: 'binary',
273
+ contentType: ct,
274
+ size: buf.byteLength,
275
+ sha256: await sha256Hex(buf),
276
+ };
277
+ }
278
+
279
+ // ────────────────────────────────────────────────────────────────────
280
+ // Helpers
281
+ // ────────────────────────────────────────────────────────────────────
282
+
283
+ function headerObject(req: Request): Record<string, string> {
284
+ const out: Record<string, string> = {};
285
+ req.headers.forEach((v, k) => {
286
+ out[k] = v;
287
+ });
288
+ return out;
289
+ }
290
+
291
+ function jsonResponse(status: number, body: unknown): Response {
292
+ return new Response(JSON.stringify(body, null, 2), {
293
+ status,
294
+ headers: { 'content-type': 'application/json; charset=utf-8' },
295
+ });
296
+ }
297
+
298
+ function clamp(n: number, lo: number, hi: number): number {
299
+ if (!Number.isFinite(n)) return lo;
300
+ return Math.max(lo, Math.min(hi, n));
301
+ }
302
+
303
+ async function sha256Hex(buf: ArrayBuffer): Promise<string> {
304
+ const digest = await crypto.subtle.digest('SHA-256', buf);
305
+ return Array.from(new Uint8Array(digest))
306
+ .map((b) => b.toString(16).padStart(2, '0'))
307
+ .join('');
308
+ }
309
+
310
+ function statusText(code: number): string {
311
+ // Spot-check common ones; everything else gets a generic label.
312
+ const known: Record<number, string> = {
313
+ 200: 'OK',
314
+ 201: 'Created',
315
+ 204: 'No Content',
316
+ 301: 'Moved Permanently',
317
+ 302: 'Found',
318
+ 400: 'Bad Request',
319
+ 401: 'Unauthorized',
320
+ 403: 'Forbidden',
321
+ 404: 'Not Found',
322
+ 409: 'Conflict',
323
+ 418: "I'm a teapot",
324
+ 422: 'Unprocessable Entity',
325
+ 429: 'Too Many Requests',
326
+ 500: 'Internal Server Error',
327
+ 502: 'Bad Gateway',
328
+ 503: 'Service Unavailable',
329
+ 504: 'Gateway Timeout',
330
+ };
331
+ return known[code] ?? 'Status';
332
+ }
333
+
334
+ function log(method: string, path: string, status: number, durationMs: number): void {
335
+ const color = status >= 500 ? '\x1b[31m' : status >= 400 ? '\x1b[33m' : '\x1b[32m';
336
+ const reset = '\x1b[0m';
337
+ console.log(
338
+ `${color}${status}${reset} ${method.padEnd(6)} ${path} ${durationMs}ms`,
339
+ );
340
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naarang/ccc",
3
- "version": "3.2.0-beta.1",
3
+ "version": "3.3.0-beta.1",
4
4
  "description": "Code Chat Connect - Control Claude Code from your mobile device",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,340 @@
1
+ /**
2
+ * api-test-server.ts — echo backend for verifying the C3 mobile API
3
+ * client end-to-end.
4
+ *
5
+ * Run:
6
+ * bun run scripts/api-test-server.ts # listens on :3000
7
+ * PORT=4000 bun run scripts/api-test-server.ts
8
+ *
9
+ * Then forward port 3000 from the C3 mobile app, open the API
10
+ * client, and hit `localhost:3000/<endpoint>` from any of the
11
+ * editor's body types.
12
+ *
13
+ * Routes:
14
+ * ANY / — Echo: method, headers, query,
15
+ * body (auto-parsed by content-type),
16
+ * duration, content-length.
17
+ * ANY /echo — Same as `/`. Two paths so users
18
+ * can save requests against either.
19
+ * GET /status/:code — Returns the given HTTP status code
20
+ * (200, 404, 500, etc.) with a small
21
+ * JSON body. Useful for verifying
22
+ * status badge coloring.
23
+ * GET /delay?ms=N — Sleeps N ms before responding 200.
24
+ * Caps at 30s to avoid hangs.
25
+ * GET /large?bytes=N — Streams N bytes of `A`s. Caps at
26
+ * 20 MB so the mobile 10 MB cap can
27
+ * be exercised without OOM here.
28
+ * GET /headers — Returns the request headers as
29
+ * JSON. Useful for auth preset
30
+ * verification (Authorization,
31
+ * Bearer, custom X- headers).
32
+ * GET /redirect?to=URL — 302 redirect to the given URL.
33
+ * Lets you verify the client surfaces
34
+ * the redirect rather than following
35
+ * silently.
36
+ * POST /upload — Accepts multipart/form-data with any
37
+ * number of file and text fields.
38
+ * Returns each field's metadata
39
+ * (name, size, mimeType for files;
40
+ * raw value for text).
41
+ * POST /binary — Accepts a raw binary body. Returns
42
+ * the byte count, content-type, and
43
+ * a SHA-256 hex digest so the client
44
+ * can verify integrity.
45
+ * GET /healthz — Liveness check. Returns 200 OK.
46
+ *
47
+ * Logging: every request prints a one-line summary to stdout
48
+ * (method, path, status, duration). Bodies aren't logged to avoid
49
+ * leaking secrets while you're testing auth.
50
+ */
51
+
52
+ const PORT = Number(process.env.PORT ?? 3000);
53
+
54
+ const server = Bun.serve({
55
+ port: PORT,
56
+ hostname: '0.0.0.0',
57
+
58
+ async fetch(req) {
59
+ const start = Date.now();
60
+ const url = new URL(req.url);
61
+ const path = url.pathname;
62
+
63
+ let response: Response;
64
+ try {
65
+ response = await route(req, url, path);
66
+ } catch (err) {
67
+ response = jsonResponse(
68
+ 500,
69
+ {
70
+ error: 'handler_error',
71
+ message: err instanceof Error ? err.message : String(err),
72
+ },
73
+ );
74
+ }
75
+
76
+ const duration = Date.now() - start;
77
+ log(req.method, path, response.status, duration);
78
+ return response;
79
+ },
80
+ });
81
+
82
+ console.log(`[api-test-server] listening on http://0.0.0.0:${server.port}`);
83
+ console.log(`[api-test-server] try: curl http://localhost:${server.port}/`);
84
+
85
+ // ────────────────────────────────────────────────────────────────────
86
+ // Router
87
+ // ────────────────────────────────────────────────────────────────────
88
+
89
+ async function route(req: Request, url: URL, path: string): Promise<Response> {
90
+ // Health check — no body, fast path.
91
+ if (path === '/healthz') return new Response('ok', { status: 200 });
92
+
93
+ // Status-code probe.
94
+ const statusMatch = path.match(/^\/status\/(\d{3})$/);
95
+ if (statusMatch) {
96
+ const code = Number(statusMatch[1]);
97
+ return jsonResponse(code, { status: code, message: statusText(code) });
98
+ }
99
+
100
+ if (path === '/delay') return await handleDelay(url);
101
+ if (path === '/large') return handleLarge(url);
102
+ if (path === '/headers') return handleHeaders(req);
103
+ if (path === '/redirect') return handleRedirect(url);
104
+ if (path === '/upload') return await handleUpload(req);
105
+ if (path === '/binary') return await handleBinary(req);
106
+
107
+ // Default: echo.
108
+ if (path === '/' || path === '/echo') return await handleEcho(req, url);
109
+
110
+ return jsonResponse(404, { error: 'not_found', path });
111
+ }
112
+
113
+ // ────────────────────────────────────────────────────────────────────
114
+ // Handlers
115
+ // ────────────────────────────────────────────────────────────────────
116
+
117
+ async function handleEcho(req: Request, url: URL): Promise<Response> {
118
+ const headers = headerObject(req);
119
+ const query = Object.fromEntries(url.searchParams.entries());
120
+ const body = await parseRequestBody(req);
121
+
122
+ return jsonResponse(200, {
123
+ ok: true,
124
+ method: req.method,
125
+ path: url.pathname,
126
+ headers,
127
+ query,
128
+ body,
129
+ });
130
+ }
131
+
132
+ async function handleDelay(url: URL): Promise<Response> {
133
+ const ms = clamp(Number(url.searchParams.get('ms') ?? 1000), 0, 30_000);
134
+ await new Promise((r) => setTimeout(r, ms));
135
+ return jsonResponse(200, { ok: true, delayedMs: ms });
136
+ }
137
+
138
+ function handleLarge(url: URL): Response {
139
+ const requested = Number(url.searchParams.get('bytes') ?? 1024 * 1024);
140
+ const bytes = clamp(requested, 1, 20 * 1024 * 1024);
141
+ // Single allocation — `A`.charCodeAt = 0x41
142
+ const buf = new Uint8Array(bytes).fill(0x41);
143
+ return new Response(buf, {
144
+ status: 200,
145
+ headers: {
146
+ 'content-type': 'text/plain; charset=utf-8',
147
+ 'content-length': String(bytes),
148
+ },
149
+ });
150
+ }
151
+
152
+ function handleHeaders(req: Request): Response {
153
+ return jsonResponse(200, { headers: headerObject(req) });
154
+ }
155
+
156
+ function handleRedirect(url: URL): Response {
157
+ const to = url.searchParams.get('to') ?? '/';
158
+ return new Response(null, {
159
+ status: 302,
160
+ headers: { location: to },
161
+ });
162
+ }
163
+
164
+ async function handleUpload(req: Request): Promise<Response> {
165
+ const ct = req.headers.get('content-type') ?? '';
166
+ if (!ct.includes('multipart/form-data')) {
167
+ return jsonResponse(415, {
168
+ error: 'wrong_content_type',
169
+ expected: 'multipart/form-data',
170
+ got: ct,
171
+ });
172
+ }
173
+
174
+ const fd = await req.formData();
175
+ const fields: Array<Record<string, unknown>> = [];
176
+ for (const [key, value] of fd.entries()) {
177
+ if (value instanceof Blob) {
178
+ // Read enough bytes to compute a SHA-256 digest. We hash the
179
+ // whole file — if the test file is huge this will be slow, but
180
+ // honest checksums beat fast-but-misleading.
181
+ const buf = await value.arrayBuffer();
182
+ fields.push({
183
+ key,
184
+ kind: 'file',
185
+ // `name` exists when the client sent a `filename=` in the part.
186
+ name: (value as File).name ?? null,
187
+ mimeType: value.type || null,
188
+ size: buf.byteLength,
189
+ sha256: await sha256Hex(buf),
190
+ });
191
+ } else {
192
+ fields.push({ key, kind: 'text', value });
193
+ }
194
+ }
195
+ return jsonResponse(200, { ok: true, fields });
196
+ }
197
+
198
+ async function handleBinary(req: Request): Promise<Response> {
199
+ const ct = req.headers.get('content-type') ?? 'application/octet-stream';
200
+ const buf = await req.arrayBuffer();
201
+ return jsonResponse(200, {
202
+ ok: true,
203
+ contentType: ct,
204
+ size: buf.byteLength,
205
+ sha256: await sha256Hex(buf),
206
+ });
207
+ }
208
+
209
+ // ────────────────────────────────────────────────────────────────────
210
+ // Body parsing — auto-detect by Content-Type so JSON / form / text
211
+ // each come back in their natural shape.
212
+ // ────────────────────────────────────────────────────────────────────
213
+
214
+ async function parseRequestBody(req: Request): Promise<unknown> {
215
+ if (req.method === 'GET' || req.method === 'HEAD') return null;
216
+
217
+ const ct = (req.headers.get('content-type') ?? '').toLowerCase();
218
+ if (!ct) {
219
+ // No content-type → try as text; empty body is fine.
220
+ const text = await req.text();
221
+ return text.length === 0 ? null : { kind: 'unknown', text };
222
+ }
223
+
224
+ if (ct.includes('application/json')) {
225
+ try {
226
+ return { kind: 'json', value: await req.json() };
227
+ } catch (err) {
228
+ const text = await req.text().catch(() => '');
229
+ return { kind: 'json_parse_error', error: String(err), raw: text };
230
+ }
231
+ }
232
+
233
+ if (ct.includes('application/x-www-form-urlencoded')) {
234
+ const text = await req.text();
235
+ const pairs: Record<string, string> = {};
236
+ for (const [k, v] of new URLSearchParams(text).entries()) pairs[k] = v;
237
+ return { kind: 'form-urlencoded', value: pairs };
238
+ }
239
+
240
+ if (ct.includes('multipart/form-data')) {
241
+ const fd = await req.formData();
242
+ const fields: Array<Record<string, unknown>> = [];
243
+ for (const [k, v] of fd.entries()) {
244
+ if (v instanceof Blob) {
245
+ const buf = await v.arrayBuffer();
246
+ fields.push({
247
+ key: k,
248
+ kind: 'file',
249
+ name: (v as File).name ?? null,
250
+ mimeType: v.type || null,
251
+ size: buf.byteLength,
252
+ });
253
+ } else {
254
+ fields.push({ key: k, kind: 'text', value: v });
255
+ }
256
+ }
257
+ return { kind: 'multipart', fields };
258
+ }
259
+
260
+ if (ct.includes('xml')) {
261
+ return { kind: 'xml', value: await req.text() };
262
+ }
263
+
264
+ if (ct.startsWith('text/')) {
265
+ return { kind: 'text', value: await req.text() };
266
+ }
267
+
268
+ // Anything else — treat as binary, return size + checksum so the
269
+ // client can verify integrity without us shipping the bytes back.
270
+ const buf = await req.arrayBuffer();
271
+ return {
272
+ kind: 'binary',
273
+ contentType: ct,
274
+ size: buf.byteLength,
275
+ sha256: await sha256Hex(buf),
276
+ };
277
+ }
278
+
279
+ // ────────────────────────────────────────────────────────────────────
280
+ // Helpers
281
+ // ────────────────────────────────────────────────────────────────────
282
+
283
+ function headerObject(req: Request): Record<string, string> {
284
+ const out: Record<string, string> = {};
285
+ req.headers.forEach((v, k) => {
286
+ out[k] = v;
287
+ });
288
+ return out;
289
+ }
290
+
291
+ function jsonResponse(status: number, body: unknown): Response {
292
+ return new Response(JSON.stringify(body, null, 2), {
293
+ status,
294
+ headers: { 'content-type': 'application/json; charset=utf-8' },
295
+ });
296
+ }
297
+
298
+ function clamp(n: number, lo: number, hi: number): number {
299
+ if (!Number.isFinite(n)) return lo;
300
+ return Math.max(lo, Math.min(hi, n));
301
+ }
302
+
303
+ async function sha256Hex(buf: ArrayBuffer): Promise<string> {
304
+ const digest = await crypto.subtle.digest('SHA-256', buf);
305
+ return Array.from(new Uint8Array(digest))
306
+ .map((b) => b.toString(16).padStart(2, '0'))
307
+ .join('');
308
+ }
309
+
310
+ function statusText(code: number): string {
311
+ // Spot-check common ones; everything else gets a generic label.
312
+ const known: Record<number, string> = {
313
+ 200: 'OK',
314
+ 201: 'Created',
315
+ 204: 'No Content',
316
+ 301: 'Moved Permanently',
317
+ 302: 'Found',
318
+ 400: 'Bad Request',
319
+ 401: 'Unauthorized',
320
+ 403: 'Forbidden',
321
+ 404: 'Not Found',
322
+ 409: 'Conflict',
323
+ 418: "I'm a teapot",
324
+ 422: 'Unprocessable Entity',
325
+ 429: 'Too Many Requests',
326
+ 500: 'Internal Server Error',
327
+ 502: 'Bad Gateway',
328
+ 503: 'Service Unavailable',
329
+ 504: 'Gateway Timeout',
330
+ };
331
+ return known[code] ?? 'Status';
332
+ }
333
+
334
+ function log(method: string, path: string, status: number, durationMs: number): void {
335
+ const color = status >= 500 ? '\x1b[31m' : status >= 400 ? '\x1b[33m' : '\x1b[32m';
336
+ const reset = '\x1b[0m';
337
+ console.log(
338
+ `${color}${status}${reset} ${method.padEnd(6)} ${path} ${durationMs}ms`,
339
+ );
340
+ }