@naarang/ccc 3.2.0-beta.2 → 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.
- package/dist/index.js +3 -3
- package/dist/scripts/api-test-server.ts +340 -0
- package/package.json +1 -1
- package/scripts/api-test-server.ts +340 -0
|
@@ -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
|
@@ -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
|
+
}
|