@fragno-dev/core 0.1.1 → 0.1.2

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.
Files changed (51) hide show
  1. package/.turbo/turbo-build.log +16 -16
  2. package/CHANGELOG.md +6 -0
  3. package/dist/api/api.d.ts +1 -1
  4. package/dist/api/fragment-builder.d.ts +2 -2
  5. package/dist/api/fragment-instantiation.d.ts +2 -2
  6. package/dist/api/fragment-instantiation.js +2 -2
  7. package/dist/{api-Dcr4_-3g.d.ts → api-jKNXmz2B.d.ts} +92 -6
  8. package/dist/api-jKNXmz2B.d.ts.map +1 -0
  9. package/dist/client/client.d.ts +2 -2
  10. package/dist/client/client.js +2 -2
  11. package/dist/client/client.svelte.d.ts +2 -2
  12. package/dist/client/client.svelte.js +2 -2
  13. package/dist/client/react.d.ts +2 -2
  14. package/dist/client/react.d.ts.map +1 -1
  15. package/dist/client/react.js +2 -2
  16. package/dist/client/react.js.map +1 -1
  17. package/dist/client/solid.d.ts +2 -2
  18. package/dist/client/solid.js +2 -2
  19. package/dist/client/vanilla.d.ts +2 -2
  20. package/dist/client/vanilla.js +2 -2
  21. package/dist/client/vue.d.ts +2 -2
  22. package/dist/client/vue.js +2 -2
  23. package/dist/{client-D5ORmjBP.js → client-CzWq6IlK.js} +2 -2
  24. package/dist/client-CzWq6IlK.js.map +1 -0
  25. package/dist/{fragment-builder-D6-oLYnH.d.ts → fragment-builder-B3JXWiZB.d.ts} +18 -5
  26. package/dist/{fragment-builder-D6-oLYnH.d.ts.map → fragment-builder-B3JXWiZB.d.ts.map} +1 -1
  27. package/dist/{fragment-instantiation-f4AhwQss.js → fragment-instantiation-D1q7pltx.js} +136 -11
  28. package/dist/fragment-instantiation-D1q7pltx.js.map +1 -0
  29. package/dist/mod.d.ts +2 -2
  30. package/dist/mod.js +2 -2
  31. package/dist/{route-B4RbOWjd.js → route-DbBZ3Ep9.js} +21 -13
  32. package/dist/route-DbBZ3Ep9.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/api/fragment-instantiation.ts +16 -3
  35. package/src/api/mutable-request-state.ts +107 -0
  36. package/src/api/request-input-context.test.ts +51 -0
  37. package/src/api/request-input-context.ts +20 -13
  38. package/src/api/request-middleware.test.ts +88 -2
  39. package/src/api/request-middleware.ts +28 -6
  40. package/src/api/request-output-context.test.ts +6 -2
  41. package/src/api/request-output-context.ts +15 -9
  42. package/src/client/component.test.svelte +2 -0
  43. package/src/client/internal/ndjson-streaming.ts +6 -2
  44. package/src/client/react.ts +3 -1
  45. package/src/util/async.test.ts +6 -2
  46. package/.turbo/turbo-test.log +0 -297
  47. package/.turbo/turbo-types$colon$check.log +0 -1
  48. package/dist/api-Dcr4_-3g.d.ts.map +0 -1
  49. package/dist/client-D5ORmjBP.js.map +0 -1
  50. package/dist/fragment-instantiation-f4AhwQss.js.map +0 -1
  51. package/dist/route-B4RbOWjd.js.map +0 -1
@@ -14,6 +14,7 @@ var RequestInputContext = class RequestInputContext {
14
14
  #method;
15
15
  #pathParams;
16
16
  #searchParams;
17
+ #headers;
17
18
  #body;
18
19
  #inputSchema;
19
20
  #shouldValidateInput;
@@ -22,6 +23,7 @@ var RequestInputContext = class RequestInputContext {
22
23
  this.#method = config.method;
23
24
  this.#pathParams = config.pathParams;
24
25
  this.#searchParams = config.searchParams;
26
+ this.#headers = config.headers;
25
27
  this.#body = config.body;
26
28
  this.#inputSchema = config.inputSchema;
27
29
  this.#shouldValidateInput = config.shouldValidateInput ?? true;
@@ -30,15 +32,13 @@ var RequestInputContext = class RequestInputContext {
30
32
  * Create a RequestContext from a Request object for server-side handling
31
33
  */
32
34
  static async fromRequest(config) {
33
- const url = new URL(config.request.url);
34
- const request = config.request.clone();
35
- const json = request.body instanceof ReadableStream ? await request.json() : void 0;
36
35
  return new RequestInputContext({
37
36
  method: config.method,
38
37
  path: config.path,
39
- pathParams: config.pathParams,
40
- searchParams: url.searchParams,
41
- body: json,
38
+ pathParams: config.state.pathParams,
39
+ searchParams: config.state.searchParams,
40
+ headers: config.state.headers,
41
+ body: config.state.body,
42
42
  inputSchema: config.inputSchema,
43
43
  shouldValidateInput: config.shouldValidateInput
44
44
  });
@@ -52,6 +52,7 @@ var RequestInputContext = class RequestInputContext {
52
52
  path: config.path,
53
53
  pathParams: config.pathParams,
54
54
  searchParams: config.searchParams ?? new URLSearchParams(),
55
+ headers: config.headers ?? new Headers(),
55
56
  body: "body" in config ? config.body : void 0,
56
57
  inputSchema: "inputSchema" in config ? config.inputSchema : void 0,
57
58
  shouldValidateInput: false
@@ -85,6 +86,13 @@ var RequestInputContext = class RequestInputContext {
85
86
  return this.#searchParams;
86
87
  }
87
88
  /**
89
+ * [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object for request headers
90
+ * @remarks `Headers`
91
+ */
92
+ get headers() {
93
+ return this.#headers;
94
+ }
95
+ /**
88
96
  * @internal
89
97
  */
90
98
  get rawBody() {
@@ -214,7 +222,7 @@ var OutputContext = class {
214
222
  *
215
223
  * Shortcut for `throw new FragnoApiError(...)`
216
224
  */
217
- error({ message, code }, initOrStatus, headers) {
225
+ error = ({ message, code }, initOrStatus, headers) => {
218
226
  if (typeof initOrStatus === "undefined") return Response.json({
219
227
  message,
220
228
  code
@@ -237,8 +245,8 @@ var OutputContext = class {
237
245
  status: initOrStatus.status,
238
246
  headers: mergedHeaders
239
247
  });
240
- }
241
- empty(initOrStatus, headers) {
248
+ };
249
+ empty = (initOrStatus, headers) => {
242
250
  const defaultHeaders = {};
243
251
  if (typeof initOrStatus === "undefined") {
244
252
  const mergedHeaders$1 = mergeHeaders(defaultHeaders, headers);
@@ -259,8 +267,8 @@ var OutputContext = class {
259
267
  status: initOrStatus.status,
260
268
  headers: mergedHeaders
261
269
  });
262
- }
263
- json(object, initOrStatus, headers) {
270
+ };
271
+ json = (object, initOrStatus, headers) => {
264
272
  if (typeof initOrStatus === "undefined") return Response.json(object, {
265
273
  status: 200,
266
274
  headers
@@ -274,7 +282,7 @@ var OutputContext = class {
274
282
  status: initOrStatus.status,
275
283
  headers: mergedHeaders
276
284
  });
277
- }
285
+ };
278
286
  jsonStream = (cb, { onError, headers } = {}) => {
279
287
  const defaultHeaders = {
280
288
  "content-type": "application/x-ndjson; charset=utf-8",
@@ -328,4 +336,4 @@ function defineRoutes() {
328
336
 
329
337
  //#endregion
330
338
  export { RequestOutputContext as a, OutputContext as i, defineRoutes as n, RequestInputContext as o, resolveRouteFactories as r, getMountRoute as s, defineRoute as t };
331
- //# sourceMappingURL=route-B4RbOWjd.js.map
339
+ //# sourceMappingURL=route-DbBZ3Ep9.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-DbBZ3Ep9.js","names":["#path","#method","#pathParams","#searchParams","#headers","#body","#inputSchema","#shouldValidateInput","#validateInput","#aborted","#closed","#responseReadable","#writer","#encoder","#abortSubscribers","mergedHeaders","#outputSchema","routes: any[]"],"sources":["../src/api/internal/route.ts","../src/api/request-input-context.ts","../src/api/internal/response-stream.ts","../src/api/request-output-context.ts","../src/api/route.ts"],"sourcesContent":["export function getMountRoute(opts: { mountRoute?: string; name: string }) {\n const mountRoute = opts.mountRoute ?? `/api/${opts.name}`;\n\n if (mountRoute.endsWith(\"/\")) {\n return mountRoute.slice(0, -1);\n }\n\n return mountRoute;\n}\n","import type { StandardSchemaV1 } from \"@standard-schema/spec\";\nimport type { ExtractPathParams } from \"./internal/path\";\nimport { FragnoApiValidationError, type HTTPMethod } from \"./api\";\nimport type { MutableRequestState } from \"./mutable-request-state\";\n\nexport type RequestBodyType =\n | unknown // JSON\n | FormData\n | Blob\n | null\n | undefined;\n\nexport class RequestInputContext<\n TPath extends string = string,\n TInputSchema extends StandardSchemaV1 | undefined = undefined,\n> {\n readonly #path: TPath;\n readonly #method: string;\n readonly #pathParams: ExtractPathParams<TPath>;\n readonly #searchParams: URLSearchParams;\n readonly #headers: Headers;\n readonly #body: RequestBodyType;\n readonly #inputSchema: TInputSchema | undefined;\n readonly #shouldValidateInput: boolean;\n\n constructor(config: {\n path: TPath;\n method: string;\n pathParams: ExtractPathParams<TPath>;\n searchParams: URLSearchParams;\n headers: Headers;\n body: RequestBodyType;\n\n request?: Request;\n inputSchema?: TInputSchema;\n shouldValidateInput?: boolean;\n }) {\n this.#path = config.path;\n this.#method = config.method;\n this.#pathParams = config.pathParams;\n this.#searchParams = config.searchParams;\n this.#headers = config.headers;\n this.#body = config.body;\n this.#inputSchema = config.inputSchema;\n this.#shouldValidateInput = config.shouldValidateInput ?? true;\n }\n\n /**\n * Create a RequestContext from a Request object for server-side handling\n */\n static async fromRequest<\n TPath extends string,\n TInputSchema extends StandardSchemaV1 | undefined = undefined,\n >(config: {\n request: Request;\n method: string;\n path: TPath;\n pathParams: ExtractPathParams<TPath>;\n inputSchema?: TInputSchema;\n shouldValidateInput?: boolean;\n state: MutableRequestState;\n }): Promise<RequestInputContext<TPath, TInputSchema>> {\n // Use the mutable state (potentially modified by middleware)\n return new RequestInputContext({\n method: config.method,\n path: config.path,\n pathParams: config.state.pathParams as ExtractPathParams<TPath>,\n searchParams: config.state.searchParams,\n headers: config.state.headers,\n body: config.state.body,\n inputSchema: config.inputSchema,\n shouldValidateInput: config.shouldValidateInput,\n });\n }\n\n /**\n * Create a RequestContext for server-side rendering contexts (no Request object)\n */\n static fromSSRContext<\n TPath extends string,\n TInputSchema extends StandardSchemaV1 | undefined = undefined,\n >(\n config:\n | {\n method: \"GET\";\n path: TPath;\n pathParams: ExtractPathParams<TPath>;\n searchParams?: URLSearchParams;\n headers?: Headers;\n }\n | {\n method: Exclude<HTTPMethod, \"GET\">;\n path: TPath;\n pathParams: ExtractPathParams<TPath>;\n searchParams?: URLSearchParams;\n headers?: Headers;\n body: RequestBodyType;\n inputSchema?: TInputSchema;\n },\n ): RequestInputContext<TPath, TInputSchema> {\n return new RequestInputContext({\n method: config.method,\n path: config.path,\n pathParams: config.pathParams,\n searchParams: config.searchParams ?? new URLSearchParams(),\n headers: config.headers ?? new Headers(),\n body: \"body\" in config ? config.body : undefined,\n inputSchema: \"inputSchema\" in config ? config.inputSchema : undefined,\n shouldValidateInput: false, // No input validation in SSR context\n });\n }\n\n /**\n * The HTTP method as string (e.g., `GET`, `POST`)\n */\n get method(): string {\n return this.#method;\n }\n /**\n * The matched route path (e.g., `/users/:id`)\n * @remarks `string`\n */\n get path(): TPath {\n return this.#path;\n }\n /**\n * Extracted path parameters as object (e.g., `{ id: '123' }`)\n * @remarks `Record<string, string>`\n */\n get pathParams(): ExtractPathParams<TPath> {\n return this.#pathParams;\n }\n /**\n * [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) object for query parameters\n * @remarks `URLSearchParams`\n */\n get query(): URLSearchParams {\n return this.#searchParams;\n }\n /**\n * [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object for request headers\n * @remarks `Headers`\n */\n get headers(): Headers {\n return this.#headers;\n }\n // TODO: Should probably remove this\n /**\n * @internal\n */\n get rawBody(): RequestBodyType {\n return this.#body;\n }\n /**\n * Input validation context (only if inputSchema is defined)\n * @remarks `InputContext`\n */\n get input(): TInputSchema extends undefined\n ? undefined\n : {\n schema: TInputSchema;\n valid: () => Promise<\n TInputSchema extends StandardSchemaV1\n ? StandardSchemaV1.InferOutput<TInputSchema>\n : unknown\n >;\n } {\n if (!this.#inputSchema) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return undefined as any;\n }\n\n return {\n schema: this.#inputSchema,\n valid: async () => {\n if (!this.#shouldValidateInput) {\n // In SSR context, return the body directly without validation\n return this.#body;\n }\n\n return this.#validateInput();\n },\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n } as any;\n }\n\n async #validateInput(): Promise<\n TInputSchema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<TInputSchema> : never\n > {\n if (!this.#inputSchema) {\n throw new Error(\"No input schema defined for this route\");\n }\n\n if (this.#body instanceof FormData || this.#body instanceof Blob) {\n throw new Error(\"Schema validation is only supported for JSON data, not FormData or Blob\");\n }\n\n const result = await this.#inputSchema[\"~standard\"].validate(this.#body);\n\n if (result.issues) {\n throw new FragnoApiValidationError(\"Validation failed\", result.issues);\n }\n\n return result.value as TInputSchema extends StandardSchemaV1\n ? StandardSchemaV1.InferOutput<TInputSchema>\n : never;\n }\n}\n","/**\n * @module\n * Stream utility.\n *\n * Modified from honojs/hono\n * Original source: https://github.com/honojs/hono/blob/0e3db674ad3f40be215a55a18062dd8e387ce525/src/utils/stream.ts\n * License: MIT\n * Date obtained: August 28 2025\n * Copyright (c) 2021-present Yusuke Wada and Hono contributors\n */\n\ntype Error<Message extends string> = { __errorMessage: Message };\n\nexport class ResponseStream<TArray> {\n #writer: WritableStreamDefaultWriter<Uint8Array>;\n #encoder: TextEncoder;\n #abortSubscribers: (() => void | Promise<void>)[] = [];\n #responseReadable: ReadableStream;\n\n #aborted: boolean = false;\n #closed: boolean = false;\n\n /**\n * Whether the stream has been aborted.\n */\n get aborted(): boolean {\n return this.#aborted;\n }\n\n /**\n * Whether the stream has been closed normally.\n */\n get closed(): boolean {\n return this.#closed;\n }\n\n /**\n * The readable stream that the response is piped to.\n */\n get responseReadable(): ReadableStream {\n return this.#responseReadable;\n }\n\n constructor(writable: WritableStream, readable: ReadableStream) {\n this.#writer = writable.getWriter();\n this.#encoder = new TextEncoder();\n const reader = readable.getReader();\n\n // in case the user disconnects, let the reader know to cancel\n // this in-turn results in responseReadable being closed\n // and writeSSE method no longer blocks indefinitely\n this.#abortSubscribers.push(async () => {\n await reader.cancel();\n });\n\n this.#responseReadable = new ReadableStream({\n async pull(controller) {\n const { done, value } = await reader.read();\n if (done) {\n controller.close();\n } else {\n controller.enqueue(value);\n }\n },\n cancel: () => {\n this.abort();\n },\n });\n }\n\n async writeRaw(input: Uint8Array | string): Promise<void> {\n try {\n if (typeof input === \"string\") {\n input = this.#encoder.encode(input);\n }\n await this.#writer.write(input);\n } catch {\n // Do nothing.\n }\n }\n\n write(\n input: TArray extends (infer U)[]\n ? U\n : Error<\"To use a streaming response, outputSchema must be an array.\">,\n ): Promise<void> {\n return this.writeRaw(JSON.stringify(input) + \"\\n\");\n }\n\n sleep(ms: number): Promise<unknown> {\n return new Promise((res) => setTimeout(res, ms));\n }\n\n async close() {\n try {\n await this.#writer.close();\n } catch {\n // Do nothing. If you want to handle errors, create a stream by yourself.\n } finally {\n this.#closed = true;\n }\n }\n\n onAbort(listener: () => void | Promise<void>) {\n this.#abortSubscribers.push(listener);\n }\n\n /**\n * Abort the stream.\n * You can call this method when stream is aborted by external event.\n */\n abort() {\n if (!this.aborted) {\n this.#aborted = true;\n this.#abortSubscribers.forEach((subscriber) => subscriber());\n }\n }\n}\n","import type { StandardSchemaV1 } from \"@standard-schema/spec\";\nimport type { ContentlessStatusCode, StatusCode } from \"../http/http-status\";\nimport { ResponseStream } from \"./internal/response-stream\";\nimport type { InferOrUnknown } from \"../util/types-util\";\n\nexport type ResponseData = string | ArrayBuffer | ReadableStream | Uint8Array<ArrayBuffer>;\n\ninterface ResponseInit<T extends StatusCode = StatusCode> {\n headers?: HeadersInit;\n status?: T;\n statusText?: string;\n}\n\n/**\n * Utility function to merge headers from multiple sources.\n * Later headers override earlier ones.\n */\nfunction mergeHeaders(...headerSources: (HeadersInit | undefined)[]): HeadersInit | undefined {\n const mergedHeaders = new Headers();\n\n for (const headerSource of headerSources) {\n if (!headerSource) {\n continue;\n }\n\n if (headerSource instanceof Headers) {\n for (const [key, value] of headerSource.entries()) {\n mergedHeaders.set(key, value);\n }\n } else if (Array.isArray(headerSource)) {\n for (const [key, value] of headerSource) {\n mergedHeaders.set(key, value);\n }\n } else {\n for (const [key, value] of Object.entries(headerSource)) {\n mergedHeaders.set(key, value);\n }\n }\n }\n\n return mergedHeaders;\n}\n\nexport abstract class OutputContext<const TOutput, const TErrorCode extends string> {\n /**\n * Creates an error response.\n *\n * Shortcut for `throw new FragnoApiError(...)`\n */\n error = (\n { message, code }: { message: string; code: TErrorCode },\n initOrStatus?: ResponseInit | StatusCode,\n headers?: HeadersInit,\n ): Response => {\n if (typeof initOrStatus === \"undefined\") {\n return Response.json({ message: message, code }, { status: 500, headers });\n }\n\n if (typeof initOrStatus === \"number\") {\n return Response.json({ message: message, code }, { status: initOrStatus, headers });\n }\n\n const mergedHeaders = mergeHeaders(initOrStatus.headers, headers);\n return Response.json(\n { message: message, code },\n { status: initOrStatus.status, headers: mergedHeaders },\n );\n };\n\n empty = (\n initOrStatus?: ResponseInit<ContentlessStatusCode> | ContentlessStatusCode,\n headers?: HeadersInit,\n ): Response => {\n const defaultHeaders = {};\n\n if (typeof initOrStatus === \"undefined\") {\n const mergedHeaders = mergeHeaders(defaultHeaders, headers);\n return Response.json(null, {\n status: 201,\n headers: mergedHeaders,\n });\n }\n\n if (typeof initOrStatus === \"number\") {\n const mergedHeaders = mergeHeaders(defaultHeaders, headers);\n return Response.json(null, {\n status: initOrStatus,\n headers: mergedHeaders,\n });\n }\n\n const mergedHeaders = mergeHeaders(defaultHeaders, initOrStatus.headers, headers);\n return Response.json(null, {\n status: initOrStatus.status,\n headers: mergedHeaders,\n });\n };\n\n json = (\n object: TOutput,\n initOrStatus?: ResponseInit | StatusCode,\n headers?: HeadersInit,\n ): Response => {\n if (typeof initOrStatus === \"undefined\") {\n return Response.json(object, {\n status: 200,\n headers,\n });\n }\n\n if (typeof initOrStatus === \"number\") {\n return Response.json(object, {\n status: initOrStatus,\n headers,\n });\n }\n\n const mergedHeaders = mergeHeaders(initOrStatus.headers, headers);\n return Response.json(object, {\n status: initOrStatus.status,\n headers: mergedHeaders,\n });\n };\n\n jsonStream = (\n cb: (stream: ResponseStream<TOutput>) => void | Promise<void>,\n {\n onError,\n headers,\n }: {\n onError?: (error: Error, stream: ResponseStream<TOutput>) => void | Promise<void>;\n headers?: HeadersInit;\n } = {},\n ): Response => {\n // Note: this is intentionally an arrow function (=>) to keep `this` context.\n const defaultHeaders = {\n \"content-type\": \"application/x-ndjson; charset=utf-8\",\n \"transfer-encoding\": \"chunked\",\n \"cache-control\": \"no-cache\",\n };\n\n const { readable, writable } = new TransformStream();\n const stream = new ResponseStream(writable, readable);\n\n (async () => {\n try {\n await cb(stream);\n } catch (e) {\n if (e === undefined) {\n // If reading is canceled without a reason value (e.g. by StreamingApi)\n // then the .pipeTo() promise will reject with undefined.\n // In this case, do nothing because the stream is already closed.\n } else if (e instanceof Error && onError) {\n await onError(e, stream);\n } else {\n console.error(e);\n }\n } finally {\n stream.close();\n }\n })();\n\n return new Response(stream.responseReadable, {\n status: 200,\n headers: mergeHeaders(defaultHeaders, headers),\n });\n };\n}\n\nexport class RequestOutputContext<\n const TOutputSchema extends StandardSchemaV1 | undefined = undefined,\n const TErrorCode extends string = string,\n> extends OutputContext<InferOrUnknown<TOutputSchema>, TErrorCode> {\n // eslint-disable-next-line no-unused-private-class-members\n #outputSchema?: TOutputSchema;\n\n constructor(outputSchema?: TOutputSchema) {\n super();\n this.#outputSchema = outputSchema;\n }\n}\n","import type { StandardSchemaV1 } from \"@standard-schema/spec\";\nimport type { FragnoRouteConfig, HTTPMethod } from \"./api\";\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type AnyFragnoRouteConfig = FragnoRouteConfig<HTTPMethod, string, any, any, any, any>;\n\nexport interface RouteFactoryContext<TConfig, TDeps, TServices> {\n config: TConfig;\n deps: TDeps;\n services: TServices;\n}\n\nexport type RouteFactory<\n TConfig,\n TDeps,\n TServices,\n TRoutes extends readonly FragnoRouteConfig<\n HTTPMethod,\n string,\n StandardSchemaV1 | undefined,\n StandardSchemaV1 | undefined,\n string,\n string\n >[],\n> = (context: RouteFactoryContext<TConfig, TDeps, TServices>) => TRoutes;\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type AnyRouteOrFactory = AnyFragnoRouteConfig | RouteFactory<any, any, any, any>;\n\nexport type FlattenRouteFactories<T extends readonly AnyRouteOrFactory[]> = T extends readonly [\n infer First,\n ...infer Rest extends readonly AnyRouteOrFactory[],\n]\n ? // eslint-disable-next-line @typescript-eslint/no-explicit-any\n First extends RouteFactory<any, any, any, infer TRoutes>\n ? [...TRoutes, ...FlattenRouteFactories<Rest>]\n : [First, ...FlattenRouteFactories<Rest>]\n : [];\n\n// Helper to resolve route factories into routes\nexport function resolveRouteFactories<\n TConfig,\n TDeps,\n TServices,\n const TRoutesOrFactories extends readonly AnyRouteOrFactory[],\n>(\n context: RouteFactoryContext<TConfig, TDeps, TServices>,\n routesOrFactories: TRoutesOrFactories,\n): FlattenRouteFactories<TRoutesOrFactories> {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const routes: any[] = [];\n\n for (const item of routesOrFactories) {\n if (typeof item === \"function\") {\n // It's a route factory\n const factoryRoutes = item(context);\n routes.push(...factoryRoutes);\n } else {\n // It's a direct route\n routes.push(item);\n }\n }\n\n return routes as FlattenRouteFactories<TRoutesOrFactories>;\n}\n\n// TODO(Wilco): Do these overloads actually do anything?\n// TODO(Wilco): ValidPath<T> should be added back in here.\n\n// Overload for routes without inputSchema\nexport function defineRoute<\n const TMethod extends HTTPMethod,\n const TPath extends string,\n const TOutputSchema extends StandardSchemaV1 | undefined,\n const TErrorCode extends string = string,\n const TQueryParameters extends string = string,\n>(\n config: FragnoRouteConfig<\n TMethod,\n TPath,\n undefined,\n TOutputSchema,\n TErrorCode,\n TQueryParameters\n > & { inputSchema?: undefined },\n): FragnoRouteConfig<TMethod, TPath, undefined, TOutputSchema, TErrorCode, TQueryParameters>;\n\n// Overload for routes with inputSchema\nexport function defineRoute<\n const TMethod extends HTTPMethod,\n const TPath extends string,\n const TInputSchema extends StandardSchemaV1,\n const TOutputSchema extends StandardSchemaV1 | undefined,\n const TErrorCode extends string = string,\n const TQueryParameters extends string = string,\n>(\n config: FragnoRouteConfig<\n TMethod,\n TPath,\n TInputSchema,\n TOutputSchema,\n TErrorCode,\n TQueryParameters\n > & { inputSchema: TInputSchema },\n): FragnoRouteConfig<TMethod, TPath, TInputSchema, TOutputSchema, TErrorCode, TQueryParameters>;\n\n// implementation\nexport function defineRoute<\n const TMethod extends HTTPMethod,\n const TPath extends string,\n const TInputSchema extends StandardSchemaV1 | undefined,\n const TOutputSchema extends StandardSchemaV1 | undefined,\n const TErrorCode extends string = string,\n const TQueryParameters extends string = string,\n>(\n config: FragnoRouteConfig<\n TMethod,\n TPath,\n TInputSchema,\n TOutputSchema,\n TErrorCode,\n TQueryParameters\n >,\n): FragnoRouteConfig<TMethod, TPath, TInputSchema, TOutputSchema, TErrorCode, TQueryParameters> {\n return config;\n}\n\nexport function defineRoutes<TConfig = {}, TDeps = {}, TServices = {}>() {\n return {\n create: <\n const TRoutes extends readonly FragnoRouteConfig<\n HTTPMethod,\n string,\n StandardSchemaV1 | undefined,\n StandardSchemaV1 | undefined,\n string,\n string\n >[],\n >(\n fn: (context: RouteFactoryContext<TConfig, TDeps, TServices>) => TRoutes,\n ): RouteFactory<TConfig, TDeps, TServices, TRoutes> => {\n return fn;\n },\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fragno-dev/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "exports": {
5
5
  ".": {
6
6
  "bun": "./src/mod.ts",
@@ -18,6 +18,7 @@ import {
18
18
  type FragnoMiddlewareCallback,
19
19
  } from "./request-middleware";
20
20
  import type { FragmentDefinition } from "./fragment-builder";
21
+ import { MutableRequestState } from "./mutable-request-state";
21
22
 
22
23
  export interface FragnoPublicConfig {
23
24
  mountRoute?: string;
@@ -267,14 +268,25 @@ export function createFragment<
267
268
 
268
269
  const outputContext = new RequestOutputContext(outputSchema);
269
270
 
271
+ // Create mutable request state that can be modified by middleware
272
+ // Clone the request to avoid consuming the body stream
273
+ const clonedReq = req.clone();
274
+ const requestBody =
275
+ clonedReq.body instanceof ReadableStream ? await clonedReq.json() : undefined;
276
+
277
+ const requestState = new MutableRequestState({
278
+ pathParams: route.params ?? {},
279
+ searchParams: url.searchParams,
280
+ body: requestBody,
281
+ headers: new Headers(req.headers),
282
+ });
283
+
270
284
  if (middlewareHandler) {
271
285
  const middlewareInputContext = new RequestMiddlewareInputContext(routes, {
272
286
  method: req.method as HTTPMethod,
273
287
  path,
274
- pathParams: route.params,
275
- searchParams: new URL(req.url).searchParams,
276
- body: req.body,
277
288
  request: req,
289
+ state: requestState,
278
290
  });
279
291
 
280
292
  const middlewareOutputContext = new RequestMiddlewareOutputContext(dependencies, services);
@@ -310,6 +322,7 @@ export function createFragment<
310
322
  path,
311
323
  pathParams: (route.params ?? {}) as ExtractPathParams<typeof path>,
312
324
  inputSchema,
325
+ state: requestState,
313
326
  });
314
327
 
315
328
  try {
@@ -0,0 +1,107 @@
1
+ import type { RequestBodyType } from "./request-input-context";
2
+
3
+ /**
4
+ * Holds mutable request state that can be modified by middleware and consumed by handlers.
5
+ *
6
+ * This class provides a structural way for middleware to modify request data:
7
+ * - Path parameters can be modified
8
+ * - Query/search parameters can be modified
9
+ * - Request body can be overridden
10
+ * - Request headers can be modified
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * // In middleware
15
+ * const state = new MutableRequestState({
16
+ * pathParams: { id: "123" },
17
+ * searchParams: new URLSearchParams("?role=user"),
18
+ * body: { name: "John" },
19
+ * headers: new Headers()
20
+ * });
21
+ *
22
+ * // Modify query parameters
23
+ * state.searchParams.set("role", "admin");
24
+ *
25
+ * // Override body
26
+ * state.setBody({ name: "Jane" });
27
+ *
28
+ * // Modify headers
29
+ * state.headers.set("X-Custom", "value");
30
+ * ```
31
+ */
32
+ export class MutableRequestState {
33
+ readonly #pathParams: Record<string, string>;
34
+ readonly #searchParams: URLSearchParams;
35
+ readonly #headers: Headers;
36
+ // oxlint-disable-next-line no-unused-private-class-members False Positive?
37
+ readonly #initialBody: RequestBodyType;
38
+ #bodyOverride: RequestBodyType | undefined;
39
+
40
+ constructor(config: {
41
+ pathParams: Record<string, string>;
42
+ searchParams: URLSearchParams;
43
+ body: RequestBodyType;
44
+ headers: Headers;
45
+ }) {
46
+ this.#pathParams = config.pathParams;
47
+ this.#searchParams = config.searchParams;
48
+ this.#headers = config.headers;
49
+ this.#initialBody = config.body;
50
+ this.#bodyOverride = undefined;
51
+ }
52
+
53
+ /**
54
+ * Path parameters extracted from the route.
55
+ * Can be modified directly (e.g., `state.pathParams.id = "456"`).
56
+ */
57
+ get pathParams(): Record<string, string> {
58
+ return this.#pathParams;
59
+ }
60
+
61
+ /**
62
+ * URLSearchParams for query parameters.
63
+ * Can be modified using URLSearchParams API (e.g., `state.searchParams.set("key", "value")`).
64
+ */
65
+ get searchParams(): URLSearchParams {
66
+ return this.#searchParams;
67
+ }
68
+
69
+ /**
70
+ * Request headers.
71
+ * Can be modified using Headers API (e.g., `state.headers.set("X-Custom", "value")`).
72
+ */
73
+ get headers(): Headers {
74
+ return this.#headers;
75
+ }
76
+
77
+ /**
78
+ * Get the current body value.
79
+ * Returns the override if set, otherwise the initial body.
80
+ */
81
+ get body(): RequestBodyType {
82
+ return this.#bodyOverride !== undefined ? this.#bodyOverride : this.#initialBody;
83
+ }
84
+
85
+ /**
86
+ * Override the request body.
87
+ * This allows middleware to replace the body that will be seen by the handler.
88
+ *
89
+ * @param body - The new body value
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * // In middleware
94
+ * state.setBody({ modifiedField: "new value" });
95
+ * ```
96
+ */
97
+ setBody(body: RequestBodyType): void {
98
+ this.#bodyOverride = body;
99
+ }
100
+
101
+ /**
102
+ * Check if the body has been overridden by middleware.
103
+ */
104
+ get hasBodyOverride(): boolean {
105
+ return this.#bodyOverride !== undefined;
106
+ }
107
+ }
@@ -2,6 +2,7 @@ import { test, expect, describe } from "vitest";
2
2
  import { RequestInputContext } from "./request-input-context";
3
3
  import { FragnoApiValidationError } from "./api";
4
4
  import type { StandardSchemaV1 } from "@standard-schema/spec";
5
+ import { MutableRequestState } from "./mutable-request-state";
5
6
 
6
7
  // Mock schema implementations for testing
7
8
  const createMockSchema = (shouldPass: boolean, returnValue?: unknown): StandardSchemaV1 => ({
@@ -40,6 +41,7 @@ describe("RequestContext", () => {
40
41
  path: "/",
41
42
  pathParams: {},
42
43
  searchParams: new URLSearchParams(),
44
+ headers: new Headers(),
43
45
  body: undefined,
44
46
  });
45
47
 
@@ -59,6 +61,7 @@ describe("RequestContext", () => {
59
61
  path: "/api/test",
60
62
  pathParams: {},
61
63
  searchParams: new URLSearchParams(),
64
+ headers: new Headers(),
62
65
  body: jsonBody,
63
66
  });
64
67
 
@@ -73,6 +76,7 @@ describe("RequestContext", () => {
73
76
  path: "/api/form",
74
77
  pathParams: {},
75
78
  searchParams: new URLSearchParams(),
79
+ headers: new Headers(),
76
80
  body: formData,
77
81
  method: "POST",
78
82
  });
@@ -87,6 +91,7 @@ describe("RequestContext", () => {
87
91
  path: "/api/upload",
88
92
  pathParams: {},
89
93
  searchParams: new URLSearchParams(),
94
+ headers: new Headers(),
90
95
  body: blob,
91
96
  method: "POST",
92
97
  });
@@ -101,11 +106,23 @@ describe("RequestContext", () => {
101
106
  });
102
107
 
103
108
  const bodyData = { test: "data" };
109
+ const url = new URL(request.url);
110
+ const clonedReq = request.clone();
111
+ const body = clonedReq.body instanceof ReadableStream ? await clonedReq.json() : undefined;
112
+
113
+ const state = new MutableRequestState({
114
+ pathParams: {},
115
+ searchParams: url.searchParams,
116
+ body,
117
+ headers: new Headers(request.headers),
118
+ });
119
+
104
120
  const ctx = await RequestInputContext.fromRequest({
105
121
  request,
106
122
  method: "POST",
107
123
  path: "/api/test",
108
124
  pathParams: {},
125
+ state,
109
126
  });
110
127
 
111
128
  expect(ctx.path).toBe("/api/test");
@@ -131,6 +148,7 @@ describe("RequestContext", () => {
131
148
  path: "/test",
132
149
  pathParams: {},
133
150
  searchParams: new URLSearchParams(),
151
+ headers: new Headers(),
134
152
  body: { test: "data" },
135
153
  method: "POST",
136
154
  });
@@ -143,6 +161,7 @@ describe("RequestContext", () => {
143
161
  path: "/test",
144
162
  pathParams: {},
145
163
  searchParams: new URLSearchParams(),
164
+ headers: new Headers(),
146
165
  body: { test: "data" },
147
166
  inputSchema: validStringSchema,
148
167
  method: "POST",
@@ -158,6 +177,7 @@ describe("RequestContext", () => {
158
177
  path: "/test",
159
178
  pathParams: {},
160
179
  searchParams: new URLSearchParams(),
180
+ headers: new Headers(),
161
181
  body: "test string",
162
182
  inputSchema: validStringSchema,
163
183
  method: "POST",
@@ -172,6 +192,7 @@ describe("RequestContext", () => {
172
192
  path: "/test",
173
193
  pathParams: {},
174
194
  searchParams: new URLSearchParams(),
195
+ headers: new Headers(),
175
196
  body: 123, // Invalid for string schema
176
197
  inputSchema: invalidSchema,
177
198
  method: "POST",
@@ -185,6 +206,7 @@ describe("RequestContext", () => {
185
206
  path: "/test",
186
207
  pathParams: {},
187
208
  searchParams: new URLSearchParams(),
209
+ headers: new Headers(),
188
210
  body: 123,
189
211
  inputSchema: invalidSchema,
190
212
  method: "POST",
@@ -213,6 +235,7 @@ describe("RequestContext", () => {
213
235
  path: "/test",
214
236
  pathParams: {},
215
237
  searchParams: new URLSearchParams(),
238
+ headers: new Headers(),
216
239
  body: 123,
217
240
  inputSchema: invalidSchema,
218
241
  shouldValidateInput: false,
@@ -232,6 +255,7 @@ describe("RequestContext", () => {
232
255
  path: "/test",
233
256
  pathParams: {},
234
257
  searchParams: new URLSearchParams(),
258
+ headers: new Headers(),
235
259
  body: formData,
236
260
  inputSchema: validStringSchema,
237
261
  method: "POST",
@@ -249,6 +273,7 @@ describe("RequestContext", () => {
249
273
  path: "/test",
250
274
  pathParams: {},
251
275
  searchParams: new URLSearchParams(),
276
+ headers: new Headers(),
252
277
  body: blob,
253
278
  inputSchema: validStringSchema,
254
279
  method: "POST",
@@ -264,6 +289,7 @@ describe("RequestContext", () => {
264
289
  path: "/test",
265
290
  pathParams: {},
266
291
  searchParams: new URLSearchParams(),
292
+ headers: new Headers(),
267
293
  body: null,
268
294
  inputSchema: validStringSchema,
269
295
  method: "POST",
@@ -278,6 +304,7 @@ describe("RequestContext", () => {
278
304
  path: "/test",
279
305
  pathParams: {},
280
306
  searchParams: new URLSearchParams(),
307
+ headers: new Headers(),
281
308
  body: undefined,
282
309
  inputSchema: validStringSchema,
283
310
  method: "POST",
@@ -294,6 +321,7 @@ describe("RequestContext", () => {
294
321
  path: "/test",
295
322
  pathParams: {},
296
323
  searchParams: new URLSearchParams(),
324
+ headers: new Headers(),
297
325
  inputSchema: validStringSchema,
298
326
  method: "POST",
299
327
  body: undefined,
@@ -308,6 +336,7 @@ describe("RequestContext", () => {
308
336
  path: "/test",
309
337
  pathParams: {},
310
338
  searchParams: new URLSearchParams(),
339
+ headers: new Headers(),
311
340
  inputSchema: validStringSchema,
312
341
  shouldValidateInput: true,
313
342
  method: "POST",
@@ -322,6 +351,7 @@ describe("RequestContext", () => {
322
351
  path: "/test",
323
352
  pathParams: {},
324
353
  searchParams: new URLSearchParams(),
354
+ headers: new Headers(),
325
355
  inputSchema: validStringSchema,
326
356
  shouldValidateInput: false,
327
357
  method: "POST",
@@ -346,6 +376,14 @@ describe("RequestContext", () => {
346
376
 
347
377
  test("Should pass through shouldValidateInput from fromRequest", async () => {
348
378
  const request = new Request("https://example.com/api/test");
379
+ const url = new URL(request.url);
380
+ const state = new MutableRequestState({
381
+ pathParams: {},
382
+ searchParams: url.searchParams,
383
+ body: undefined,
384
+ headers: new Headers(request.headers),
385
+ });
386
+
349
387
  const ctx = await RequestInputContext.fromRequest({
350
388
  request,
351
389
  path: "/test",
@@ -353,6 +391,7 @@ describe("RequestContext", () => {
353
391
  inputSchema: validStringSchema,
354
392
  shouldValidateInput: false,
355
393
  method: "POST",
394
+ state,
356
395
  });
357
396
 
358
397
  expect(ctx.input).toBeDefined();
@@ -365,6 +404,7 @@ describe("RequestContext", () => {
365
404
  path: "/test",
366
405
  pathParams: {},
367
406
  searchParams: new URLSearchParams(),
407
+ headers: new Headers(),
368
408
  method: "POST",
369
409
  body: undefined,
370
410
  });
@@ -377,6 +417,7 @@ describe("RequestContext", () => {
377
417
  path: "/test",
378
418
  pathParams: {},
379
419
  searchParams: new URLSearchParams(),
420
+ headers: new Headers(),
380
421
  method: "POST",
381
422
  body: undefined,
382
423
  });
@@ -390,6 +431,7 @@ describe("RequestContext", () => {
390
431
  path: "/test",
391
432
  pathParams: {},
392
433
  searchParams,
434
+ headers: new Headers(),
393
435
  method: "POST",
394
436
  body: undefined,
395
437
  });
@@ -400,11 +442,20 @@ describe("RequestContext", () => {
400
442
 
401
443
  test("Should extract search params from request URL in fromRequest", async () => {
402
444
  const request = new Request("https://example.com/api/test?param=value");
445
+ const url = new URL(request.url);
446
+ const state = new MutableRequestState({
447
+ pathParams: {},
448
+ searchParams: url.searchParams,
449
+ body: undefined,
450
+ headers: new Headers(request.headers),
451
+ });
452
+
403
453
  const ctx = await RequestInputContext.fromRequest({
404
454
  request,
405
455
  path: "/test",
406
456
  pathParams: {},
407
457
  method: "POST",
458
+ state,
408
459
  });
409
460
 
410
461
  expect(ctx.query.get("param")).toBe("value");
@@ -1,6 +1,7 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import type { ExtractPathParams } from "./internal/path";
3
3
  import { FragnoApiValidationError, type HTTPMethod } from "./api";
4
+ import type { MutableRequestState } from "./mutable-request-state";
4
5
 
5
6
  export type RequestBodyType =
6
7
  | unknown // JSON
@@ -17,6 +18,7 @@ export class RequestInputContext<
17
18
  readonly #method: string;
18
19
  readonly #pathParams: ExtractPathParams<TPath>;
19
20
  readonly #searchParams: URLSearchParams;
21
+ readonly #headers: Headers;
20
22
  readonly #body: RequestBodyType;
21
23
  readonly #inputSchema: TInputSchema | undefined;
22
24
  readonly #shouldValidateInput: boolean;
@@ -26,6 +28,7 @@ export class RequestInputContext<
26
28
  method: string;
27
29
  pathParams: ExtractPathParams<TPath>;
28
30
  searchParams: URLSearchParams;
31
+ headers: Headers;
29
32
  body: RequestBodyType;
30
33
 
31
34
  request?: Request;
@@ -36,6 +39,7 @@ export class RequestInputContext<
36
39
  this.#method = config.method;
37
40
  this.#pathParams = config.pathParams;
38
41
  this.#searchParams = config.searchParams;
42
+ this.#headers = config.headers;
39
43
  this.#body = config.body;
40
44
  this.#inputSchema = config.inputSchema;
41
45
  this.#shouldValidateInput = config.shouldValidateInput ?? true;
@@ -54,22 +58,16 @@ export class RequestInputContext<
54
58
  pathParams: ExtractPathParams<TPath>;
55
59
  inputSchema?: TInputSchema;
56
60
  shouldValidateInput?: boolean;
61
+ state: MutableRequestState;
57
62
  }): Promise<RequestInputContext<TPath, TInputSchema>> {
58
- const url = new URL(config.request.url);
59
-
60
- // Clone the request to avoid consuming the body stream
61
- // TODO: Probably we should just cache the result instead
62
- const request = config.request.clone();
63
-
64
- // TODO: Support other body types other than json
65
- const json = request.body instanceof ReadableStream ? await request.json() : undefined;
66
-
63
+ // Use the mutable state (potentially modified by middleware)
67
64
  return new RequestInputContext({
68
65
  method: config.method,
69
66
  path: config.path,
70
- pathParams: config.pathParams,
71
- searchParams: url.searchParams,
72
- body: json,
67
+ pathParams: config.state.pathParams as ExtractPathParams<TPath>,
68
+ searchParams: config.state.searchParams,
69
+ headers: config.state.headers,
70
+ body: config.state.body,
73
71
  inputSchema: config.inputSchema,
74
72
  shouldValidateInput: config.shouldValidateInput,
75
73
  });
@@ -88,12 +86,14 @@ export class RequestInputContext<
88
86
  path: TPath;
89
87
  pathParams: ExtractPathParams<TPath>;
90
88
  searchParams?: URLSearchParams;
89
+ headers?: Headers;
91
90
  }
92
91
  | {
93
92
  method: Exclude<HTTPMethod, "GET">;
94
93
  path: TPath;
95
94
  pathParams: ExtractPathParams<TPath>;
96
95
  searchParams?: URLSearchParams;
96
+ headers?: Headers;
97
97
  body: RequestBodyType;
98
98
  inputSchema?: TInputSchema;
99
99
  },
@@ -103,13 +103,13 @@ export class RequestInputContext<
103
103
  path: config.path,
104
104
  pathParams: config.pathParams,
105
105
  searchParams: config.searchParams ?? new URLSearchParams(),
106
+ headers: config.headers ?? new Headers(),
106
107
  body: "body" in config ? config.body : undefined,
107
108
  inputSchema: "inputSchema" in config ? config.inputSchema : undefined,
108
109
  shouldValidateInput: false, // No input validation in SSR context
109
110
  });
110
111
  }
111
112
 
112
- // TODO(Wilco): We should support reading/modifying headers here.
113
113
  /**
114
114
  * The HTTP method as string (e.g., `GET`, `POST`)
115
115
  */
@@ -137,6 +137,13 @@ export class RequestInputContext<
137
137
  get query(): URLSearchParams {
138
138
  return this.#searchParams;
139
139
  }
140
+ /**
141
+ * [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) object for request headers
142
+ * @remarks `Headers`
143
+ */
144
+ get headers(): Headers {
145
+ return this.#headers;
146
+ }
140
147
  // TODO: Should probably remove this
141
148
  /**
142
149
  * @internal