@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.
- package/.turbo/turbo-build.log +16 -16
- package/CHANGELOG.md +6 -0
- package/dist/api/api.d.ts +1 -1
- package/dist/api/fragment-builder.d.ts +2 -2
- package/dist/api/fragment-instantiation.d.ts +2 -2
- package/dist/api/fragment-instantiation.js +2 -2
- package/dist/{api-Dcr4_-3g.d.ts → api-jKNXmz2B.d.ts} +92 -6
- package/dist/api-jKNXmz2B.d.ts.map +1 -0
- package/dist/client/client.d.ts +2 -2
- package/dist/client/client.js +2 -2
- package/dist/client/client.svelte.d.ts +2 -2
- package/dist/client/client.svelte.js +2 -2
- package/dist/client/react.d.ts +2 -2
- package/dist/client/react.d.ts.map +1 -1
- package/dist/client/react.js +2 -2
- package/dist/client/react.js.map +1 -1
- package/dist/client/solid.d.ts +2 -2
- package/dist/client/solid.js +2 -2
- package/dist/client/vanilla.d.ts +2 -2
- package/dist/client/vanilla.js +2 -2
- package/dist/client/vue.d.ts +2 -2
- package/dist/client/vue.js +2 -2
- package/dist/{client-D5ORmjBP.js → client-CzWq6IlK.js} +2 -2
- package/dist/client-CzWq6IlK.js.map +1 -0
- package/dist/{fragment-builder-D6-oLYnH.d.ts → fragment-builder-B3JXWiZB.d.ts} +18 -5
- package/dist/{fragment-builder-D6-oLYnH.d.ts.map → fragment-builder-B3JXWiZB.d.ts.map} +1 -1
- package/dist/{fragment-instantiation-f4AhwQss.js → fragment-instantiation-D1q7pltx.js} +136 -11
- package/dist/fragment-instantiation-D1q7pltx.js.map +1 -0
- package/dist/mod.d.ts +2 -2
- package/dist/mod.js +2 -2
- package/dist/{route-B4RbOWjd.js → route-DbBZ3Ep9.js} +21 -13
- package/dist/route-DbBZ3Ep9.js.map +1 -0
- package/package.json +1 -1
- package/src/api/fragment-instantiation.ts +16 -3
- package/src/api/mutable-request-state.ts +107 -0
- package/src/api/request-input-context.test.ts +51 -0
- package/src/api/request-input-context.ts +20 -13
- package/src/api/request-middleware.test.ts +88 -2
- package/src/api/request-middleware.ts +28 -6
- package/src/api/request-output-context.test.ts +6 -2
- package/src/api/request-output-context.ts +15 -9
- package/src/client/component.test.svelte +2 -0
- package/src/client/internal/ndjson-streaming.ts +6 -2
- package/src/client/react.ts +3 -1
- package/src/util/async.test.ts +6 -2
- package/.turbo/turbo-test.log +0 -297
- package/.turbo/turbo-types$colon$check.log +0 -1
- package/dist/api-Dcr4_-3g.d.ts.map +0 -1
- package/dist/client-D5ORmjBP.js.map +0 -1
- package/dist/fragment-instantiation-f4AhwQss.js.map +0 -1
- 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:
|
|
41
|
-
|
|
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-
|
|
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
|
@@ -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
|
-
|
|
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:
|
|
72
|
-
|
|
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
|