@fresho/router 0.0.1 → 0.1.0

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.mjs ADDED
@@ -0,0 +1,1046 @@
1
+ import { n as runMiddleware } from "./middleware-BpFh0XrR.mjs";
2
+
3
+ //#region src/schema.ts
4
+ /** Valid base types for primitives and arrays. */
5
+ const PRIMITIVE_TYPES = [
6
+ "string",
7
+ "number",
8
+ "boolean",
9
+ "unknown"
10
+ ];
11
+ /** Checks if a schema field is a nested object (not a type string). */
12
+ function isNestedSchema(field) {
13
+ return typeof field === "object" && field !== null;
14
+ }
15
+ /**
16
+ * Validates and coerces a single primitive value.
17
+ * Returns [success, result, errorMessage].
18
+ */
19
+ function validatePrimitive(value, baseType) {
20
+ switch (baseType) {
21
+ case "string": return [
22
+ true,
23
+ String(value),
24
+ null
25
+ ];
26
+ case "number": {
27
+ const num = Number(value);
28
+ if (Number.isNaN(num)) return [
29
+ false,
30
+ void 0,
31
+ "Expected number"
32
+ ];
33
+ return [
34
+ true,
35
+ num,
36
+ null
37
+ ];
38
+ }
39
+ case "boolean":
40
+ if (value === "true" || value === "1" || value === true) return [
41
+ true,
42
+ true,
43
+ null
44
+ ];
45
+ else if (value === "false" || value === "0" || value === false) return [
46
+ true,
47
+ false,
48
+ null
49
+ ];
50
+ return [
51
+ false,
52
+ void 0,
53
+ "Expected boolean (true/false)"
54
+ ];
55
+ case "unknown": return [
56
+ true,
57
+ value,
58
+ null
59
+ ];
60
+ default: return [
61
+ false,
62
+ void 0,
63
+ `Unknown type: ${baseType}`
64
+ ];
65
+ }
66
+ }
67
+ /**
68
+ * Validates a schema field value (primitive, array, or nested object).
69
+ * Returns [success, result, errors].
70
+ */
71
+ function validateField(key, field, value, parentPath = "") {
72
+ const fieldPath = parentPath ? `${parentPath}.${key}` : key;
73
+ const errors = {};
74
+ if (isNestedSchema(field)) {
75
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
76
+ errors[fieldPath] = ["Expected object"];
77
+ return [
78
+ false,
79
+ void 0,
80
+ errors
81
+ ];
82
+ }
83
+ const nestedResult = validateSchema(field, value, fieldPath);
84
+ return [
85
+ nestedResult.success,
86
+ nestedResult.data,
87
+ nestedResult.errors
88
+ ];
89
+ }
90
+ const typeStr = field;
91
+ const isOptional = typeStr.endsWith("?");
92
+ const isArray = typeStr.includes("[]");
93
+ let baseType = isOptional ? typeStr.slice(0, -1) : typeStr;
94
+ if (isArray) baseType = baseType.replace("[]", "");
95
+ if (value === void 0 || value === "") {
96
+ if (!isOptional) {
97
+ errors[fieldPath] = ["Required"];
98
+ return [
99
+ false,
100
+ void 0,
101
+ errors
102
+ ];
103
+ }
104
+ return [
105
+ true,
106
+ void 0,
107
+ {}
108
+ ];
109
+ }
110
+ if (isArray) {
111
+ if (!Array.isArray(value)) {
112
+ errors[fieldPath] = ["Expected array"];
113
+ return [
114
+ false,
115
+ void 0,
116
+ errors
117
+ ];
118
+ }
119
+ const resultArray = [];
120
+ for (let i = 0; i < value.length; i++) {
121
+ const [success$1, coerced$1, errorMsg$1] = validatePrimitive(value[i], baseType);
122
+ if (!success$1) {
123
+ errors[`${fieldPath}[${i}]`] = [errorMsg$1];
124
+ return [
125
+ false,
126
+ void 0,
127
+ errors
128
+ ];
129
+ }
130
+ resultArray.push(coerced$1);
131
+ }
132
+ return [
133
+ true,
134
+ resultArray,
135
+ {}
136
+ ];
137
+ }
138
+ const [success, coerced, errorMsg] = validatePrimitive(value, baseType);
139
+ if (!success) {
140
+ errors[fieldPath] = [errorMsg];
141
+ return [
142
+ false,
143
+ void 0,
144
+ errors
145
+ ];
146
+ }
147
+ return [
148
+ true,
149
+ coerced,
150
+ {}
151
+ ];
152
+ }
153
+ /**
154
+ * Validates a full schema against data.
155
+ * Returns { success, data, errors }.
156
+ */
157
+ function validateSchema(schema, data, parentPath = "") {
158
+ const result = {};
159
+ const allErrors = {};
160
+ for (const [key, field] of Object.entries(schema)) {
161
+ const value = data[key];
162
+ const [success, coerced, fieldErrors] = validateField(key, field, value, parentPath);
163
+ if (!success) Object.assign(allErrors, fieldErrors);
164
+ else if (coerced !== void 0) result[key] = coerced;
165
+ }
166
+ return {
167
+ success: Object.keys(allErrors).length === 0,
168
+ data: result,
169
+ errors: allErrors
170
+ };
171
+ }
172
+ /** Validates schema definition at compile time. */
173
+ function validateSchemaDefinition(schema, path = "") {
174
+ for (const [key, field] of Object.entries(schema)) {
175
+ const fieldPath = path ? `${path}.${key}` : key;
176
+ if (isNestedSchema(field)) validateSchemaDefinition(field, fieldPath);
177
+ else {
178
+ const typeStr = field;
179
+ let baseType = typeStr.endsWith("?") ? typeStr.slice(0, -1) : typeStr;
180
+ baseType = baseType.replace("[]", "");
181
+ if (!PRIMITIVE_TYPES.includes(baseType)) throw new Error(`Unknown type '${typeStr}' at '${fieldPath}'`);
182
+ }
183
+ }
184
+ }
185
+ /**
186
+ * Compiles a schema definition into a validator.
187
+ *
188
+ * Used internally by the router to validate query parameters and request bodies.
189
+ * Provides a Zod-compatible `safeParse` API for consistency.
190
+ *
191
+ * @param schema - The schema definition object
192
+ * @returns A compiled schema with `safeParse` method
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * const userSchema = compileSchema({
197
+ * name: 'string',
198
+ * age: 'number',
199
+ * email: 'string?',
200
+ * });
201
+ *
202
+ * const result = userSchema.safeParse({ name: 'Alice', age: 30 });
203
+ * if (result.success) {
204
+ * console.log(result.data.name); // 'Alice'
205
+ * } else {
206
+ * console.log(result.error.flatten().fieldErrors);
207
+ * }
208
+ * ```
209
+ */
210
+ function compileSchema(schema) {
211
+ validateSchemaDefinition(schema);
212
+ return { safeParse(data) {
213
+ const { success, data: result, errors } = validateSchema(schema, data);
214
+ if (!success) return {
215
+ success: false,
216
+ error: { flatten: () => ({ fieldErrors: errors }) }
217
+ };
218
+ return {
219
+ success: true,
220
+ data: result
221
+ };
222
+ } };
223
+ }
224
+
225
+ //#endregion
226
+ //#region src/types.ts
227
+ /** HTTP method property names. */
228
+ const HTTP_METHODS = new Set([
229
+ "get",
230
+ "post",
231
+ "put",
232
+ "patch",
233
+ "delete",
234
+ "options",
235
+ "head"
236
+ ]);
237
+ /** Symbol to mark an object as a route definition. */
238
+ const ROUTE_MARKER = Symbol.for("@fresho/router:route");
239
+ /** Symbol to mark an object as a router. */
240
+ const ROUTER_MARKER = Symbol.for("@fresho/router:router");
241
+ /** Checks if a value is a function. */
242
+ function isFunction(value) {
243
+ return typeof value === "function";
244
+ }
245
+ /** Checks if entry is a Route (marked with ROUTE_MARKER). */
246
+ function isRoute(entry) {
247
+ return typeof entry === "object" && entry !== null && ROUTE_MARKER in entry;
248
+ }
249
+ /** Checks if entry is a Router (marked with ROUTER_MARKER). */
250
+ function isRouter(entry) {
251
+ return typeof entry === "object" && entry !== null && ROUTER_MARKER in entry;
252
+ }
253
+
254
+ //#endregion
255
+ //#region src/handler.ts
256
+ /**
257
+ * Escapes special regex characters in a string.
258
+ */
259
+ function escapeRegex(str) {
260
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
261
+ }
262
+ /**
263
+ * Converts a path with :param segments to a regex pattern.
264
+ */
265
+ function pathToRegex(path) {
266
+ const paramNames = [];
267
+ const parts = path.split(/:(\w+)/g);
268
+ let regexStr = "";
269
+ for (let i = 0; i < parts.length; i++) if (i % 2 === 0) regexStr += escapeRegex(parts[i]);
270
+ else {
271
+ paramNames.push(parts[i]);
272
+ regexStr += "([^/]+)";
273
+ }
274
+ return {
275
+ pattern: /* @__PURE__ */ new RegExp(`^${regexStr}$`),
276
+ paramNames
277
+ };
278
+ }
279
+ /**
280
+ * Converts a property name to a path segment.
281
+ * - `$param` becomes `:param`
282
+ * - Other names become literal segments
283
+ */
284
+ function propertyToSegment$1(prop) {
285
+ if (prop.startsWith("$")) return `:${prop.slice(1)}`;
286
+ return prop;
287
+ }
288
+ /** Collects and compiles all routes from a router tree. */
289
+ function compileRoutes(routerDef, parentPath = "", parentMiddleware = []) {
290
+ const currentMiddleware = [...parentMiddleware, ...routerDef.middleware || []];
291
+ const compiled = [];
292
+ for (const [prop, entry] of Object.entries(routerDef.routes)) if (isRouter(entry)) {
293
+ const segment = propertyToSegment$1(prop);
294
+ const newPath = parentPath ? `${parentPath}/${segment}` : `/${segment}`;
295
+ compiled.push(...compileRoutes(entry, newPath, currentMiddleware));
296
+ } else if (HTTP_METHODS.has(prop)) {
297
+ const method = prop.toUpperCase();
298
+ const { pattern, paramNames } = pathToRegex(parentPath || "/");
299
+ if (isFunction(entry)) compiled.push({
300
+ method,
301
+ pattern,
302
+ paramNames,
303
+ handler: entry,
304
+ middleware: currentMiddleware
305
+ });
306
+ else if (isRoute(entry)) {
307
+ const routeDef = entry;
308
+ compiled.push({
309
+ method,
310
+ pattern,
311
+ paramNames,
312
+ handler: routeDef.handler,
313
+ middleware: currentMiddleware,
314
+ querySchema: routeDef.query ? compileSchema(routeDef.query) : void 0,
315
+ bodySchema: routeDef.body ? compileSchema(routeDef.body) : void 0
316
+ });
317
+ }
318
+ }
319
+ return compiled;
320
+ }
321
+ /**
322
+ * Strips the body from a response for HEAD requests.
323
+ */
324
+ function stripBodyForHead(response, isHead) {
325
+ if (!isHead) return response;
326
+ return new Response(null, {
327
+ status: response.status,
328
+ statusText: response.statusText,
329
+ headers: response.headers
330
+ });
331
+ }
332
+ /**
333
+ * Creates a fetch handler from a router definition.
334
+ *
335
+ * @example
336
+ * ```typescript
337
+ * const api = router({
338
+ * health: router({
339
+ * get: async () => ({ status: 'ok' }),
340
+ * }),
341
+ * users: router({
342
+ * get: async () => [...],
343
+ * $id: router({
344
+ * get: async (c) => ({ id: c.path.id }),
345
+ * }),
346
+ * }),
347
+ * });
348
+ *
349
+ * export default { fetch: api.handler() };
350
+ * ```
351
+ */
352
+ function createHandler(routerDef) {
353
+ const compiledRoutes = compileRoutes(routerDef);
354
+ return async (request, env, ctx) => {
355
+ const url = new URL(request.url);
356
+ const method = request.method.toUpperCase();
357
+ const isHead = method === "HEAD";
358
+ for (const compiled of compiledRoutes) {
359
+ if (!(compiled.method === method || method === "OPTIONS" || method === "HEAD" && compiled.method === "GET")) continue;
360
+ const match = compiled.pattern.exec(url.pathname);
361
+ if (!match) continue;
362
+ const path = {};
363
+ for (let i = 0; i < compiled.paramNames.length; i++) path[compiled.paramNames[i]] = decodeURIComponent(match[i + 1]);
364
+ const context = {
365
+ request,
366
+ path,
367
+ query: {},
368
+ body: {},
369
+ env,
370
+ executionCtx: ctx
371
+ };
372
+ if (compiled.middleware.length > 0 || compiled.handler || compiled.querySchema || compiled.bodySchema) return stripBodyForHead(await runMiddleware(compiled.middleware, context, async () => {
373
+ const routeId = `${compiled.method} ${url.pathname}`;
374
+ let query = {};
375
+ if (compiled.querySchema) {
376
+ const raw = Object.fromEntries(url.searchParams);
377
+ const result = compiled.querySchema.safeParse(raw);
378
+ if (!result.success) return Response.json({
379
+ error: "Invalid query parameters",
380
+ route: routeId,
381
+ details: result.error.flatten()
382
+ }, { status: 400 });
383
+ query = result.data;
384
+ }
385
+ let body = {};
386
+ if (compiled.bodySchema && [
387
+ "POST",
388
+ "PUT",
389
+ "PATCH"
390
+ ].includes(method)) {
391
+ const raw = await request.json().catch(() => ({}));
392
+ const result = compiled.bodySchema.safeParse(raw);
393
+ if (!result.success) return Response.json({
394
+ error: "Invalid request body",
395
+ route: routeId,
396
+ details: result.error.flatten()
397
+ }, { status: 400 });
398
+ body = result.data;
399
+ }
400
+ context.query = query;
401
+ context.body = body;
402
+ if (compiled.handler) {
403
+ const handlerContext = {
404
+ request,
405
+ path,
406
+ query,
407
+ body,
408
+ env,
409
+ executionCtx: ctx
410
+ };
411
+ for (const key of Object.keys(context)) if (![
412
+ "request",
413
+ "path",
414
+ "query",
415
+ "body",
416
+ "env",
417
+ "executionCtx"
418
+ ].includes(key)) handlerContext[key] = context[key];
419
+ const result = await compiled.handler(handlerContext);
420
+ if (result instanceof Response) return result;
421
+ return Response.json(result);
422
+ }
423
+ return Response.json({});
424
+ }), isHead);
425
+ return stripBodyForHead(Response.json({}), isHead);
426
+ }
427
+ return Response.json({ error: "Not Found" }, { status: 404 });
428
+ };
429
+ }
430
+
431
+ //#endregion
432
+ //#region src/core.ts
433
+ /**
434
+ * @fileoverview Core route and router creation functions.
435
+ *
436
+ * ## Path Convention
437
+ * - Property names = URL path segments
438
+ * - `$param` prefix = dynamic segment (`:param`)
439
+ * - `get`, `post`, `put`, `patch`, `delete` = HTTP method handlers
440
+ *
441
+ * ## Typing Best Practices
442
+ *
443
+ * **Schemas** (`query`/`body`) provide runtime validation AND type inference.
444
+ * Use them for any input that needs validation:
445
+ * ```typescript
446
+ * get: route({
447
+ * query: { limit: 'number?' },
448
+ * handler: async (c) => c.query.limit, // limit: number | undefined
449
+ * })
450
+ * ```
451
+ *
452
+ * **Context** (`route.ctx<T>()`) provides types only (no validation).
453
+ * Use it for path params, env bindings, and middleware-injected values:
454
+ * ```typescript
455
+ * interface MyContext {
456
+ * path: { id: string };
457
+ * env: { DB: Database };
458
+ * user: { name: string }; // from auth middleware
459
+ * }
460
+ * get: route.ctx<MyContext>()({
461
+ * handler: async (c) => ({ id: c.path.id, user: c.user.name }),
462
+ * })
463
+ * ```
464
+ *
465
+ * **Important:** Don't add explicit type annotations to handler parameters.
466
+ * Let types flow from schemas and context:
467
+ * ```typescript
468
+ * // GOOD: types inferred from schema
469
+ * handler: async (c) => c.query.limit
470
+ *
471
+ * // BAD: redundant type annotation
472
+ * handler: async (c: { query: { limit?: number } }) => c.query.limit
473
+ * ```
474
+ */
475
+ /**
476
+ * Creates a route with query/body validation schemas.
477
+ *
478
+ * Use `route()` when you need request validation. For simple handlers
479
+ * without validation, you can use bare functions directly in the router.
480
+ *
481
+ * @example
482
+ * ```typescript
483
+ * // Route with query validation
484
+ * get: route({
485
+ * query: { limit: 'number?', offset: 'number?' },
486
+ * handler: async (c) => {
487
+ * const items = await db.list(c.query.limit, c.query.offset);
488
+ * return { items };
489
+ * },
490
+ * })
491
+ *
492
+ * // Route with body validation
493
+ * post: route({
494
+ * body: { name: 'string', email: 'string' },
495
+ * handler: async (c) => {
496
+ * const user = await db.create(c.body);
497
+ * return user;
498
+ * },
499
+ * })
500
+ *
501
+ * // Simple handler without validation (bare function)
502
+ * get: async () => ({ status: 'ok' })
503
+ * ```
504
+ */
505
+ function route(definition) {
506
+ return {
507
+ ...definition,
508
+ [ROUTE_MARKER]: true
509
+ };
510
+ }
511
+ /** Creates a chainable context builder. */
512
+ function createCtxBuilder() {
513
+ const builder = (definition) => ({
514
+ ...definition,
515
+ [ROUTE_MARKER]: true
516
+ });
517
+ builder.ctx = () => createCtxBuilder();
518
+ return builder;
519
+ }
520
+ /**
521
+ * Creates a route with typed context for values not covered by schemas.
522
+ *
523
+ * Use `route.ctx<T>()` to type:
524
+ * - **Path params**: `{ path: { id: string } }` for `$id` segments
525
+ * - **Env bindings**: `{ env: { DB: Database } }` for runtime environment
526
+ * - **Middleware values**: `{ user: User }` for auth middleware, etc.
527
+ *
528
+ * Context provides types only, not runtime validation. For validated inputs,
529
+ * use `query` and `body` schemas instead.
530
+ *
531
+ * @example
532
+ * ```typescript
533
+ * interface AppContext {
534
+ * path: { id: string };
535
+ * env: { DB: D1Database };
536
+ * user: { id: string }; // injected by auth middleware
537
+ * }
538
+ *
539
+ * $id: router({
540
+ * get: route.ctx<AppContext>()({
541
+ * query: { include: 'string?' }, // schema for validation
542
+ * handler: async (c) => {
543
+ * // c.path.id, c.env.DB, c.user.id - from context
544
+ * // c.query.include - from schema
545
+ * return c.env.DB.get(c.path.id);
546
+ * },
547
+ * }),
548
+ * })
549
+ * ```
550
+ */
551
+ route.ctx = () => createCtxBuilder();
552
+ /**
553
+ * Creates a router that groups paths and applies middleware.
554
+ *
555
+ * @example
556
+ * ```typescript
557
+ * const api = router({
558
+ * health: router({
559
+ * get: async () => ({ status: 'ok' }),
560
+ * }),
561
+ *
562
+ * users: router({
563
+ * // GET /users - list users
564
+ * get: route({
565
+ * query: { limit: 'number?' },
566
+ * handler: async (c) => db.users.list(c.query.limit),
567
+ * }),
568
+ *
569
+ * // POST /users - create user
570
+ * post: route({
571
+ * body: { name: 'string', email: 'string' },
572
+ * handler: async (c) => db.users.create(c.body),
573
+ * }),
574
+ *
575
+ * // /users/:id
576
+ * $id: router({
577
+ * get: async (c) => db.users.get(c.path.id),
578
+ * delete: async (c) => db.users.delete(c.path.id),
579
+ * }),
580
+ * }),
581
+ * });
582
+ *
583
+ * export default { fetch: api.handler() };
584
+ * ```
585
+ */
586
+ function router(routes, ...middleware) {
587
+ const self = {
588
+ routes,
589
+ middleware: middleware.length > 0 ? middleware : void 0,
590
+ handler() {
591
+ return createHandler(self);
592
+ }
593
+ };
594
+ self[ROUTER_MARKER] = true;
595
+ return self;
596
+ }
597
+
598
+ //#endregion
599
+ //#region src/docs.ts
600
+ /** Converts property name to path segment, handling $param convention. */
601
+ function propertyToSegment(prop) {
602
+ if (prop.startsWith("$")) return `{${prop.slice(1)}}`;
603
+ return prop;
604
+ }
605
+ /** Collects all routes from a router tree. */
606
+ function collectRoutes(routerDef, parentPath = "") {
607
+ const collectedRoutes = [];
608
+ for (const [prop, entry] of Object.entries(routerDef.routes)) if (HTTP_METHODS.has(prop)) {
609
+ const path = parentPath || "/";
610
+ if (isFunction(entry)) collectedRoutes.push({
611
+ path,
612
+ method: prop,
613
+ route: null
614
+ });
615
+ else if (isRoute(entry)) collectedRoutes.push({
616
+ path,
617
+ method: prop,
618
+ route: entry
619
+ });
620
+ } else if (isRouter(entry)) {
621
+ const segment = propertyToSegment(prop);
622
+ const newPath = parentPath ? `${parentPath}/${segment}` : `/${segment}`;
623
+ collectedRoutes.push(...collectRoutes(entry, newPath));
624
+ }
625
+ return collectedRoutes;
626
+ }
627
+ /** Generates OpenAPI documentation from a router. */
628
+ function generateDocs(config) {
629
+ const paths = {};
630
+ const allRoutes = collectRoutes(config.router);
631
+ for (const { path, method, route: route$1 } of allRoutes) {
632
+ if (!paths[path]) paths[path] = {};
633
+ const operation = {
634
+ description: route$1?.description,
635
+ responses: {
636
+ 200: { description: "Successful response" },
637
+ 400: { description: "Validation error" }
638
+ }
639
+ };
640
+ if (route$1?.query) {
641
+ const querySchema = route$1.query;
642
+ operation.parameters = Object.entries(querySchema).filter(([, type]) => typeof type === "string").map(([name, type]) => ({
643
+ name,
644
+ in: "query",
645
+ required: !type.endsWith("?"),
646
+ schema: { type: type.replace("?", "").replace("[]", "") }
647
+ }));
648
+ }
649
+ if (route$1?.body && [
650
+ "post",
651
+ "put",
652
+ "patch"
653
+ ].includes(method)) {
654
+ const bodySchema = route$1.body;
655
+ operation.requestBody = {
656
+ required: true,
657
+ content: { "application/json": { schema: {
658
+ type: "object",
659
+ properties: Object.fromEntries(Object.entries(bodySchema).map(([name, type]) => {
660
+ if (typeof type === "string") return [name, { type: type.replace("?", "").replace("[]", "") }];
661
+ return [name, { type: "object" }];
662
+ })),
663
+ required: Object.entries(bodySchema).filter(([, type]) => typeof type === "string" && !type.endsWith("?")).map(([name]) => name)
664
+ } } }
665
+ };
666
+ }
667
+ paths[path][method] = operation;
668
+ }
669
+ return {
670
+ openapi: "3.0.0",
671
+ info: {
672
+ title: config.title,
673
+ version: config.version,
674
+ description: config.description
675
+ },
676
+ paths
677
+ };
678
+ }
679
+
680
+ //#endregion
681
+ //#region src/http-client.ts
682
+ const BODY_METHODS = new Set([
683
+ "post",
684
+ "put",
685
+ "patch"
686
+ ]);
687
+ const HTTP_METHODS$1 = new Set([
688
+ "$get",
689
+ "$post",
690
+ "$put",
691
+ "$patch",
692
+ "$delete",
693
+ "$options",
694
+ "$head"
695
+ ]);
696
+ /**
697
+ * Creates a typed HTTP client.
698
+ *
699
+ * HTTP methods are prefixed with `$` to distinguish them from path segments:
700
+ * - `.$get()`, `.$post()`, `.$put()`, `.$patch()`, `.$delete()` - execute HTTP methods
701
+ * - `.propertyName` - navigate to path segment (works for any name including `get`, `post`, etc.)
702
+ * - Direct call `()` - implicit GET request
703
+ *
704
+ * @example
705
+ * ```typescript
706
+ * import type { api } from './server';
707
+ * import { createHttpClient } from '@fresho/router';
708
+ *
709
+ * const client = createHttpClient<typeof api>({
710
+ * baseUrl: 'https://api.example.com',
711
+ * headers: { Authorization: () => `Bearer ${getToken()}` },
712
+ * });
713
+ *
714
+ * // GET /health (implicit)
715
+ * await client.health();
716
+ *
717
+ * // GET /users (explicit)
718
+ * await client.users.$get();
719
+ *
720
+ * // POST /users
721
+ * await client.users.$post({ body: { name: 'Alice' } });
722
+ *
723
+ * // GET /users/123
724
+ * await client.users.$id({ path: { id: '123' } });
725
+ *
726
+ * // Navigate through path segments named after HTTP methods:
727
+ * // GET /api/get
728
+ * await client.api.get.$get();
729
+ * ```
730
+ */
731
+ function createHttpClient(config = {}) {
732
+ const sharedConfig = { current: { ...config } };
733
+ return new Proxy({ configure(newConfig) {
734
+ Object.assign(sharedConfig.current, newConfig);
735
+ } }, {
736
+ get(target, prop) {
737
+ if (prop === "configure") return target.configure;
738
+ if (typeof prop === "string") return createPathProxy$1(sharedConfig, [prop]);
739
+ },
740
+ apply(_target, _thisArg, args) {
741
+ return executeRequest(sharedConfig, [], "get", args[0]);
742
+ }
743
+ });
744
+ }
745
+ /** Creates a proxy that tracks path segments. */
746
+ function createPathProxy$1(sharedConfig, segments) {
747
+ const callable = (options) => {
748
+ return executeRequest(sharedConfig, segments, "get", options);
749
+ };
750
+ return new Proxy(callable, {
751
+ get(_target, prop) {
752
+ if (typeof prop !== "string") return void 0;
753
+ if (HTTP_METHODS$1.has(prop)) {
754
+ const method = prop.slice(1);
755
+ return (options) => {
756
+ return executeRequest(sharedConfig, segments, method, options);
757
+ };
758
+ }
759
+ return createPathProxy$1(sharedConfig, [...segments, prop]);
760
+ },
761
+ apply(_target, _thisArg, args) {
762
+ return callable(args[0]);
763
+ }
764
+ });
765
+ }
766
+ /** Builds a URL path from segments, substituting $param with values. */
767
+ function buildPath(segments, pathParams) {
768
+ const parts = [];
769
+ for (const segment of segments) if (segment.startsWith("$")) {
770
+ const paramName = segment.slice(1);
771
+ const value = pathParams?.[paramName];
772
+ if (!value) throw new Error(`Missing path parameter: ${paramName}`);
773
+ parts.push(encodeURIComponent(value));
774
+ } else parts.push(segment);
775
+ return `/${parts.join("/")}`;
776
+ }
777
+ /** Executes an HTTP request. */
778
+ async function executeRequest(sharedConfig, segments, method, options) {
779
+ const config = sharedConfig.current;
780
+ const path = buildPath(segments, options?.path);
781
+ const baseUrl = config.baseUrl || (typeof window !== "undefined" ? window.location?.origin : "");
782
+ const url = new URL(path, baseUrl || "http://localhost");
783
+ if (options?.query) {
784
+ for (const [key, value] of Object.entries(options.query)) if (value !== void 0) url.searchParams.set(key, String(value));
785
+ }
786
+ const headers = new Headers();
787
+ if (config.headers) for (const [key, value] of Object.entries(config.headers)) {
788
+ const resolved = typeof value === "function" ? await value() : value;
789
+ if (resolved != null) headers.set(key, resolved);
790
+ }
791
+ if (options?.headers) new Headers(options.headers).forEach((v, k) => headers.set(k, v));
792
+ const init = {
793
+ method: method.toUpperCase(),
794
+ headers,
795
+ credentials: config.credentials
796
+ };
797
+ if (options?.body && BODY_METHODS.has(method)) {
798
+ headers.set("Content-Type", "application/json");
799
+ init.body = JSON.stringify(options.body);
800
+ }
801
+ const fetchUrl = config.baseUrl ? url.toString() : url.pathname + url.search;
802
+ const response = await fetch(fetchUrl, init);
803
+ if (!response.ok) throw new Error(await response.text() || response.statusText);
804
+ return response.json();
805
+ }
806
+
807
+ //#endregion
808
+ //#region src/local-client.ts
809
+ /**
810
+ * Finds a method handler in a router tree.
811
+ */
812
+ function findHandler(routerDef, segments, method) {
813
+ let current = routerDef;
814
+ for (const segment of segments) {
815
+ if (!current) return null;
816
+ const entry$1 = current.routes[segment];
817
+ if (isRouter(entry$1)) current = entry$1;
818
+ else return null;
819
+ }
820
+ if (!current) return null;
821
+ const entry = current.routes[method];
822
+ if (!entry) return null;
823
+ if (isFunction(entry)) return { handler: entry };
824
+ if (isRoute(entry)) {
825
+ const routeDef = entry;
826
+ return {
827
+ handler: routeDef.handler,
828
+ querySchema: routeDef.query ? compileSchema(routeDef.query) : void 0,
829
+ bodySchema: routeDef.body ? compileSchema(routeDef.body) : void 0
830
+ };
831
+ }
832
+ return null;
833
+ }
834
+ /**
835
+ * Collects path parameters from segments.
836
+ */
837
+ function collectPathParams(segments, pathValues) {
838
+ const params = {};
839
+ for (const segment of segments) if (segment.startsWith("$")) {
840
+ const paramName = segment.slice(1);
841
+ const value = pathValues?.[paramName];
842
+ if (!value) throw new Error(`Missing path parameter: ${paramName}`);
843
+ params[paramName] = value;
844
+ }
845
+ return params;
846
+ }
847
+ /**
848
+ * Creates a typed local client from a router definition.
849
+ *
850
+ * @example
851
+ * ```typescript
852
+ * import { createLocalClient } from '@fresho/router';
853
+ * import { api } from './api.js';
854
+ *
855
+ * const client = createLocalClient(api);
856
+ * client.configure({ env: { DB: mockDatabase } });
857
+ *
858
+ * await client.health();
859
+ * await client.users();
860
+ * await client.users.post({ body: { name: 'Alice' } });
861
+ * await client.users.$id({ path: { id: '123' } });
862
+ * ```
863
+ */
864
+ function createLocalClient(routerDef) {
865
+ const sharedConfig = { current: {} };
866
+ return new Proxy({ configure(config) {
867
+ Object.assign(sharedConfig.current, config);
868
+ } }, {
869
+ get(target, prop) {
870
+ if (prop === "configure") return target.configure;
871
+ if (typeof prop === "string") return createPathProxy(sharedConfig, routerDef, [prop]);
872
+ },
873
+ apply(_target, _thisArg, args) {
874
+ return invokeHandler(sharedConfig, routerDef, [], "get", args[0]);
875
+ }
876
+ });
877
+ }
878
+ /** Creates a proxy that tracks path segments. */
879
+ function createPathProxy(sharedConfig, routerDef, segments) {
880
+ const callable = (options) => {
881
+ return invokeHandler(sharedConfig, routerDef, segments, "get", options);
882
+ };
883
+ return new Proxy(callable, {
884
+ get(_target, prop) {
885
+ if (typeof prop !== "string") return void 0;
886
+ if (HTTP_METHODS.has(prop)) return (options) => {
887
+ return invokeHandler(sharedConfig, routerDef, segments, prop, options);
888
+ };
889
+ return createPathProxy(sharedConfig, routerDef, [...segments, prop]);
890
+ },
891
+ apply(_target, _thisArg, args) {
892
+ return callable(args[0]);
893
+ }
894
+ });
895
+ }
896
+ /** Invokes a handler directly. */
897
+ async function invokeHandler(sharedConfig, routerDef, segments, method, options) {
898
+ const config = sharedConfig.current;
899
+ const routeInfo = findHandler(routerDef, segments, method);
900
+ if (!routeInfo) throw new Error(`No handler found for ${method.toUpperCase()} /${segments.join("/")}`);
901
+ const pathParams = collectPathParams(segments, options?.path);
902
+ let query = {};
903
+ if (routeInfo.querySchema) {
904
+ const result$1 = routeInfo.querySchema.safeParse(options?.query ?? {});
905
+ if (!result$1.success) throw new Error(`Invalid query parameters: ${JSON.stringify(result$1.error.flatten())}`);
906
+ query = result$1.data;
907
+ }
908
+ let body = {};
909
+ if (routeInfo.bodySchema) {
910
+ const result$1 = routeInfo.bodySchema.safeParse(options?.body ?? {});
911
+ if (!result$1.success) throw new Error(`Invalid request body: ${JSON.stringify(result$1.error.flatten())}`);
912
+ body = result$1.data;
913
+ }
914
+ const urlPath = `/${segments.map((s) => s.startsWith("$") ? encodeURIComponent(pathParams[s.slice(1)] || "") : s).join("/")}`;
915
+ const url = new URL(urlPath, "http://localhost");
916
+ if (options?.query) {
917
+ for (const [key, value] of Object.entries(options.query)) if (value !== void 0) url.searchParams.set(key, String(value));
918
+ }
919
+ const requestInit = { method: method.toUpperCase() };
920
+ if (options?.body && [
921
+ "post",
922
+ "put",
923
+ "patch"
924
+ ].includes(method)) {
925
+ requestInit.headers = { "Content-Type": "application/json" };
926
+ requestInit.body = JSON.stringify(options.body);
927
+ }
928
+ const context = {
929
+ request: new Request(url.toString(), requestInit),
930
+ path: pathParams,
931
+ query,
932
+ body,
933
+ env: options?.env ?? config.env,
934
+ executionCtx: options?.ctx ?? config.ctx
935
+ };
936
+ const result = await routeInfo.handler(context);
937
+ if (result instanceof Response) return result.json();
938
+ return result;
939
+ }
940
+
941
+ //#endregion
942
+ //#region src/streaming.ts
943
+ /**
944
+ * Formats an SSE message for transmission.
945
+ */
946
+ function formatSSEMessage(message) {
947
+ const lines = [];
948
+ if (message.event) lines.push(`event: ${message.event}`);
949
+ if (message.id) lines.push(`id: ${message.id}`);
950
+ if (message.retry !== void 0) lines.push(`retry: ${message.retry}`);
951
+ const data = typeof message.data === "string" ? message.data : JSON.stringify(message.data);
952
+ for (const line of data.split("\n")) lines.push(`data: ${line}`);
953
+ return `${lines.join("\n")}\n\n`;
954
+ }
955
+ /**
956
+ * Creates a Server-Sent Events (SSE) response.
957
+ *
958
+ * @example
959
+ * ```typescript
960
+ * const handler = async (c) => {
961
+ * return sseResponse(async (send, close) => {
962
+ * for (let i = 0; i < 5; i++) {
963
+ * await send({ data: { count: i } });
964
+ * await new Promise(r => setTimeout(r, 1000));
965
+ * }
966
+ * close();
967
+ * });
968
+ * };
969
+ * ```
970
+ */
971
+ function sseResponse(handler, options = {}) {
972
+ const encoder = new TextEncoder();
973
+ let isClosed = false;
974
+ const stream = new ReadableStream({ async start(controller) {
975
+ const send = (message) => {
976
+ if (!isClosed) controller.enqueue(encoder.encode(formatSSEMessage(message)));
977
+ };
978
+ const close = () => {
979
+ if (!isClosed) {
980
+ isClosed = true;
981
+ controller.close();
982
+ }
983
+ };
984
+ const keepAliveInterval = options.keepAliveInterval ?? 3e4;
985
+ let keepAliveTimer;
986
+ if (keepAliveInterval > 0) keepAliveTimer = setInterval(() => {
987
+ if (!isClosed) controller.enqueue(encoder.encode(": keep-alive\n\n"));
988
+ }, keepAliveInterval);
989
+ try {
990
+ await handler(send, close);
991
+ } finally {
992
+ if (keepAliveTimer) clearInterval(keepAliveTimer);
993
+ if (!isClosed) controller.close();
994
+ }
995
+ } });
996
+ const headers = new Headers(options.headers);
997
+ headers.set("Content-Type", "text/event-stream");
998
+ headers.set("Cache-Control", "no-cache");
999
+ headers.set("Connection", "keep-alive");
1000
+ return new Response(stream, { headers });
1001
+ }
1002
+ /**
1003
+ * Creates a streaming JSON response where each line is a JSON object.
1004
+ *
1005
+ * Useful for streaming large datasets or real-time data where each item
1006
+ * can be processed as it arrives (NDJSON format).
1007
+ *
1008
+ * @example
1009
+ * ```typescript
1010
+ * const handler = async (c) => {
1011
+ * return streamJsonLines(async (send, close) => {
1012
+ * for (const item of largeDataset) {
1013
+ * await send(item);
1014
+ * }
1015
+ * close();
1016
+ * });
1017
+ * };
1018
+ * ```
1019
+ */
1020
+ function streamJsonLines(handler, options = {}) {
1021
+ const encoder = new TextEncoder();
1022
+ let isClosed = false;
1023
+ const stream = new ReadableStream({ async start(controller) {
1024
+ const send = (data) => {
1025
+ if (!isClosed) controller.enqueue(encoder.encode(`${JSON.stringify(data)}\n`));
1026
+ };
1027
+ const close = () => {
1028
+ if (!isClosed) {
1029
+ isClosed = true;
1030
+ controller.close();
1031
+ }
1032
+ };
1033
+ try {
1034
+ await handler(send, close);
1035
+ } finally {
1036
+ if (!isClosed) controller.close();
1037
+ }
1038
+ } });
1039
+ const headers = new Headers(options.headers);
1040
+ headers.set("Content-Type", "application/x-ndjson");
1041
+ return new Response(stream, { headers });
1042
+ }
1043
+
1044
+ //#endregion
1045
+ export { compileSchema, createHttpClient, createLocalClient, generateDocs, isFunction, isRoute, isRouter, route, router, runMiddleware, sseResponse, streamJsonLines };
1046
+ //# sourceMappingURL=index.mjs.map