rouzer 3.0.2 → 3.2.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/README.md CHANGED
@@ -6,12 +6,14 @@ and Zod validation between a Hattip-compatible server and a typed fetch client.
6
6
  ## What it does
7
7
 
8
8
  A Rouzer HTTP route tree defines URL patterns, named actions, method schemas, and
9
- optional response types once, then reuses that contract to:
9
+ optional JSON, error, or newline-delimited JSON response types once, then reuses
10
+ that contract to:
10
11
 
11
12
  - validate client arguments before `fetch`
12
13
  - match and validate server requests before handlers run
13
14
  - type handler context from path, query/body, headers, and middleware
14
15
  - attach typed client action functions such as `client.profiles.get(...)`
16
+ - parse typed JSON responses, declared error responses, and NDJSON streams
15
17
 
16
18
  Rouzer optimizes for shared TypeScript route modules over language-agnostic API
17
19
  schemas or generated SDKs.
@@ -22,6 +24,8 @@ Use Rouzer if:
22
24
 
23
25
  - your server and client can import the same TypeScript route tree
24
26
  - you want Zod request validation on both sides of an HTTP boundary
27
+ - response data is validated at data/client boundaries, not by re-checking every
28
+ handler return
25
29
  - a Hattip-compatible handler fits your server runtime
26
30
  - you prefer named resource/action functions over a generated client class
27
31
 
@@ -29,8 +33,8 @@ Consider something else if:
29
33
 
30
34
  - you need OpenAPI-first workflows, schema files, or generated clients for other
31
35
  languages
32
- - you need runtime response-body validation; `response: $type<T>()` is
33
- compile-time only
36
+ - you want the router to validate every response body at the server boundary;
37
+ `$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are type contracts
34
38
  - you want a framework that owns controllers, data loading, rendering, and
35
39
  deployment adapters
36
40
  - you cannot use ESM or Zod v4+
@@ -53,7 +57,7 @@ Import the primary API from the root package and declare routes through the HTTP
53
57
  subpath:
54
58
 
55
59
  ```ts
56
- import { $type, chain, createClient, createRouter } from 'rouzer'
60
+ import { $error, $type, chain, createClient, createRouter } from 'rouzer'
57
61
  import * as http from 'rouzer/http'
58
62
  ```
59
63
 
@@ -101,10 +105,90 @@ const { message } = await client.hello({
101
105
  route arguments before `fetch`; server handlers validate matched path, query,
102
106
  headers, and JSON bodies before your handler runs.
103
107
 
108
+ ### Typed status responses
109
+
110
+ Use a response map when client code needs declared error statuses as data instead
111
+ of exceptions.
112
+
113
+ ```ts
114
+ import { $error, $type, createClient, createRouter } from 'rouzer'
115
+ import * as http from 'rouzer/http'
116
+
117
+ type User = { id: string; name: string }
118
+ type NotFound = { code: 'NOT_FOUND'; message: string }
119
+
120
+ export const getUser = http.get('users/:id', {
121
+ response: {
122
+ 200: $type<User>(),
123
+ 404: $error<NotFound>(),
124
+ },
125
+ })
126
+ export const routes = { getUser }
127
+
128
+ createRouter().use(routes, {
129
+ getUser(ctx) {
130
+ if (ctx.path.id === 'missing') {
131
+ return ctx.error(404, {
132
+ code: 'NOT_FOUND',
133
+ message: 'User not found',
134
+ })
135
+ }
136
+ return { id: ctx.path.id, name: 'Ada' }
137
+ },
138
+ })
139
+
140
+ const client = createClient({
141
+ baseURL: 'https://example.com/api/',
142
+ routes,
143
+ })
144
+
145
+ const [error, user, status] = await client.getUser({ path: { id: '42' } })
146
+ ```
147
+
148
+ Success entries resolve as `[null, value, status]`; declared error entries
149
+ resolve as `[error, null, status]`.
150
+
151
+ ### NDJSON response streams
152
+
153
+ Use `response: ndjson.$type<T>()` for endpoints that stream
154
+ newline-delimited JSON. Add `ndjson.routerPlugin` to the router and
155
+ `ndjson.clientPlugin` to the client. Handlers return an `Iterable<T>` or
156
+ `AsyncIterable<T>`; Rouzer wraps it in an `application/x-ndjson` response.
157
+ Client action functions resolve to an `AsyncIterable<T>`.
158
+
159
+ ```ts
160
+ import { createClient, createRouter } from 'rouzer'
161
+ import * as http from 'rouzer/http'
162
+ import * as ndjson from 'rouzer/ndjson'
163
+
164
+ export const events = http.get('events', {
165
+ response: ndjson.$type<{ id: number; message: string }>(),
166
+ })
167
+ export const routes = { events }
168
+
169
+ createRouter({ plugins: [ndjson.routerPlugin] }).use(routes, {
170
+ async *events() {
171
+ yield { id: 1, message: 'ready' }
172
+ },
173
+ })
174
+
175
+ const client = createClient({
176
+ baseURL: 'https://example.com/api/',
177
+ routes,
178
+ plugins: [ndjson.clientPlugin],
179
+ })
180
+ for await (const event of await client.events()) {
181
+ console.log(event.message)
182
+ }
183
+ ```
184
+
104
185
  ## Documentation
105
186
 
106
- - [Concepts, API selection, and v2.0.1 migration notes](docs/context.md)
187
+ - [Concepts, API selection, and v2->v3 migration notes](docs/context.md)
107
188
  - [Runnable shared-route example](examples/basic-usage.ts)
189
+ - [Runnable typed error response example](examples/error-responses.ts)
190
+ - [Runnable NDJSON response-stream example](examples/ndjson-stream.ts)
108
191
  - Generated declarations in the published package provide the exact signatures
109
- for every public export, including the `rouzer/http` subpath.
192
+ for every public export, including the `rouzer/http` and `rouzer/ndjson`
193
+ subpaths.
110
194
  - Public TSDoc in `src/` owns symbol-level behavior and option details.
@@ -1,5 +1,6 @@
1
1
  import { Promisable } from '../common.js';
2
2
  import type { HttpAction, HttpResource, HttpRouteTree } from '../http.js';
3
+ import { type ClientResponsePlugin } from '../response.js';
3
4
  import type { RouteArgs } from '../types/args.js';
4
5
  import type { RouteRequest } from '../types/request.js';
5
6
  import type { InferRouteResponse } from '../types/response.js';
@@ -38,13 +39,17 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
38
39
  * ```
39
40
  */
40
41
  routes?: TRoutes;
42
+ /** Response codec plugins used by generated action functions. */
43
+ plugins?: readonly ClientResponsePlugin[];
41
44
  /**
42
- * Custom handler for non-2xx responses from `.json()`.
45
+ * Custom handler for non-2xx responses from `.json()` and generated response
46
+ * helpers.
43
47
  *
44
- * @remarks When provided, the return value is returned from `.json()` as-is;
45
- * Rouzer does not automatically parse a `Response` returned by this hook.
48
+ * @remarks When provided, the return value is returned from the response
49
+ * helper as-is; Rouzer does not automatically parse a `Response` returned by
50
+ * this hook.
46
51
  */
47
- onJsonError?: (response: Response) => Promisable<Response>;
52
+ onJsonError?: (response: Response) => Promisable<unknown>;
48
53
  /** Custom `fetch` implementation to use for requests. */
49
54
  fetch?: typeof globalThis.fetch;
50
55
  }): ClientTree<TRoutes, ""> & {
@@ -73,13 +78,17 @@ export declare function createClient<TRoutes extends HttpRouteTree = Record<stri
73
78
  * ```
74
79
  */
75
80
  routes?: TRoutes;
81
+ /** Response codec plugins used by generated action functions. */
82
+ plugins?: readonly ClientResponsePlugin[];
76
83
  /**
77
- * Custom handler for non-2xx responses from `.json()`.
84
+ * Custom handler for non-2xx responses from `.json()` and generated response
85
+ * helpers.
78
86
  *
79
- * @remarks When provided, the return value is returned from `.json()` as-is;
80
- * Rouzer does not automatically parse a `Response` returned by this hook.
87
+ * @remarks When provided, the return value is returned from the response
88
+ * helper as-is; Rouzer does not automatically parse a `Response` returned by
89
+ * this hook.
81
90
  */
82
- onJsonError?: (response: Response) => Promisable<Response>;
91
+ onJsonError?: (response: Response) => Promisable<unknown>;
83
92
  /** Custom `fetch` implementation to use for requests. */
84
93
  fetch?: typeof globalThis.fetch;
85
94
  };
@@ -97,7 +106,11 @@ export type ClientTree<T extends HttpRouteTree, TPrefix extends string = ''> = {
97
106
  * Client action function attached for each HTTP action leaf.
98
107
  *
99
108
  * @remarks Actions whose schema has `response: $type<T>()` return parsed JSON
100
- * as `T`. Actions without a response marker return the raw `Response`.
109
+ * as `T`. Actions whose schema has a status-keyed response map return a tuple
110
+ * union of `[null, value, status]` success entries and `[error, null, status]`
111
+ * error entries. Actions whose schema has a plugin response marker return the
112
+ * plugin's client result type. Actions without a response marker return the raw
113
+ * `Response`.
101
114
  */
102
115
  export type RouteFunction<T extends RouteSchema, P extends string> = (...p: RouteArgs<T, P> extends infer TArgs ? {} extends TArgs ? [args?: TArgs] : [args: TArgs] : never) => Promise<T extends {
103
116
  response: any;
@@ -1,6 +1,8 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
2
  import { createHref } from '@remix-run/route-pattern/href';
3
3
  import { shake } from '../common.js';
4
+ import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
5
+ import { getResponseMapPluginIds, isErrorMarker, isResponseMap, } from '../response-map.js';
4
6
  /**
5
7
  * Create a typed fetch client for an HTTP route tree.
6
8
  *
@@ -12,6 +14,10 @@ export function createClient(config) {
12
14
  const baseURL = config.baseURL.replace(/\/?$/, '/');
13
15
  const defaultHeaders = config.headers && shake(config.headers);
14
16
  const fetch = config.fetch ?? globalThis.fetch;
17
+ const responsePlugins = createResponsePluginMap(config.plugins, 'client response');
18
+ if (config.routes) {
19
+ validateClientResponsePlugins(config.routes, responsePlugins);
20
+ }
15
21
  async function request({ path: pathBuilder, method, args, schema, }) {
16
22
  let { path, query, body, headers, ...init } = args;
17
23
  if (schema.path) {
@@ -58,37 +64,87 @@ export function createClient(config) {
58
64
  async function json(props) {
59
65
  const response = await request(props);
60
66
  if (!response.ok) {
61
- if (config.onJsonError) {
62
- return config.onJsonError(response);
67
+ return handleResponseError(response, props);
68
+ }
69
+ return response.json();
70
+ }
71
+ async function response(props) {
72
+ const httpResponse = await request(props);
73
+ const responseSchema = props.schema.response;
74
+ // Handle status-keyed response maps
75
+ if (isResponseMap(responseSchema)) {
76
+ const status = httpResponse.status;
77
+ if (status in responseSchema) {
78
+ const marker = responseSchema[status];
79
+ if (isErrorMarker(marker)) {
80
+ return [await httpResponse.json(), null, status];
81
+ }
82
+ const pluginId = getResponsePluginMarkerId(marker);
83
+ if (pluginId) {
84
+ const plugin = responsePlugins.get(pluginId);
85
+ if (!plugin) {
86
+ throw missingClientResponsePlugin(pluginId);
87
+ }
88
+ return [
89
+ null,
90
+ await plugin.decode(httpResponse, {
91
+ marker: marker,
92
+ request: props,
93
+ }),
94
+ status,
95
+ ];
96
+ }
97
+ return [null, await httpResponse.json(), status];
63
98
  }
64
- const error = new Error(`Request to ${props.method} ${createHref(props.path, props.args.path)} failed with status ${response.status}`);
65
- const contentType = response.headers.get('content-type');
66
- if (contentType?.includes('application/json')) {
67
- Object.assign(error, await response.json());
99
+ // Undeclared status reject
100
+ return handleResponseError(httpResponse, props);
101
+ }
102
+ if (!httpResponse.ok) {
103
+ return handleResponseError(httpResponse, props);
104
+ }
105
+ const pluginId = getResponsePluginMarkerId(responseSchema);
106
+ if (pluginId) {
107
+ const plugin = responsePlugins.get(pluginId);
108
+ if (!plugin) {
109
+ throw missingClientResponsePlugin(pluginId);
68
110
  }
69
- throw error;
111
+ return plugin.decode(httpResponse, {
112
+ marker: responseSchema,
113
+ request: props,
114
+ });
70
115
  }
71
- return response.json();
116
+ return httpResponse.json();
117
+ }
118
+ async function handleResponseError(response, props) {
119
+ if (config.onJsonError) {
120
+ return config.onJsonError(response);
121
+ }
122
+ const error = new Error(`Request to ${props.method} ${createHref(props.path, props.args.path)} failed with status ${response.status}`);
123
+ const contentType = response.headers.get('content-type');
124
+ if (contentType?.includes('application/json')) {
125
+ Object.assign(error, await response.json());
126
+ }
127
+ throw error;
72
128
  }
73
129
  return {
74
130
  ...(config.routes
75
- ? connectTree(config.routes, '', request, json)
131
+ ? connectTree(config.routes, '', request, response)
76
132
  : null),
77
133
  config,
78
134
  request,
79
135
  json,
80
136
  };
81
137
  }
82
- function connectTree(tree, prefix, request, json) {
138
+ function connectTree(tree, prefix, request, response) {
83
139
  return Object.fromEntries(Object.entries(tree).map(([key, node]) => {
84
140
  if (node.kind === 'resource') {
85
141
  return [
86
142
  key,
87
- connectTree(node.children, joinPaths(prefix, node.path.source), request, json),
143
+ connectTree(node.children, joinPaths(prefix, node.path.source), request, response),
88
144
  ];
89
145
  }
90
146
  const path = RoutePattern.parse(joinPaths(prefix, node.path?.source ?? ''));
91
- const fetch = node.schema.response ? json : request;
147
+ const fetch = node.schema.response ? response : request;
92
148
  return [
93
149
  key,
94
150
  (args = {}) => fetch({
@@ -101,6 +157,26 @@ function connectTree(tree, prefix, request, json) {
101
157
  ];
102
158
  }));
103
159
  }
160
+ function validateClientResponsePlugins(tree, plugins) {
161
+ for (const node of Object.values(tree)) {
162
+ if (node.kind === 'resource') {
163
+ validateClientResponsePlugins(node.children, plugins);
164
+ }
165
+ else {
166
+ const pluginIds = isResponseMap(node.schema.response)
167
+ ? getResponseMapPluginIds(node.schema.response)
168
+ : [getResponsePluginMarkerId(node.schema.response)].filter(pluginId => pluginId !== undefined);
169
+ for (const pluginId of pluginIds) {
170
+ if (!plugins.has(pluginId)) {
171
+ throw missingClientResponsePlugin(pluginId);
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ function missingClientResponsePlugin(pluginId) {
178
+ return new Error(`Missing client response plugin for ${pluginId}`);
179
+ }
104
180
  function joinPaths(left, right) {
105
181
  return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/');
106
182
  }
package/dist/common.d.ts CHANGED
@@ -6,9 +6,19 @@ export type Promisable<T> = T | Promise<T>;
6
6
  * @remarks Consumers usually use `$type<T>()` instead of constructing this type
7
7
  * directly.
8
8
  */
9
- export type Unchecked<T> = {
9
+ export type Unchecked<T> = Record<number, unknown> & {
10
10
  __unchecked__: T;
11
11
  };
12
+ /**
13
+ * Compile-time-only marker used by `$error<T>()` to carry a declared error
14
+ * response type through route declarations.
15
+ *
16
+ * @remarks Consumers usually use `$error<T>()` instead of constructing this
17
+ * type directly.
18
+ */
19
+ export type UncheckedError<T> = {
20
+ __uncheckedError__: T;
21
+ };
12
22
  /**
13
23
  * Map over all the keys to create a new object.
14
24
  *
package/dist/http.js CHANGED
@@ -30,7 +30,9 @@ function deleteAction(pathOrSchema, schema) {
30
30
  }
31
31
  export { deleteAction as delete };
32
32
  function action(method, pathOrSchema, schema) {
33
- const path = typeof pathOrSchema === 'string' ? RoutePattern.parse(pathOrSchema) : undefined;
33
+ const path = typeof pathOrSchema === 'string'
34
+ ? RoutePattern.parse(pathOrSchema)
35
+ : undefined;
34
36
  schema ??= typeof pathOrSchema === 'string' ? {} : pathOrSchema;
35
37
  const request = ((args = {}) => ({
36
38
  schema,
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './client/index.js';
2
+ export * from './response.js';
2
3
  export * from './type.js';
3
4
  export * from './server/router.js';
4
5
  export type * from './types/index.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './client/index.js';
2
+ export * from './response.js';
2
3
  export * from './type.js';
3
4
  export * from './server/router.js';
4
5
  export * from 'alien-middleware';
@@ -0,0 +1,54 @@
1
+ import { type ClientResponsePlugin, type ResponsePluginMarker, type RouterResponsePlugin } from './response.js';
2
+ declare const codecId = "rouzer/ndjson";
3
+ /** Values accepted by Rouzer's NDJSON response encoder. */
4
+ export type NdjsonSource<T = unknown> = Iterable<T> | AsyncIterable<T>;
5
+ /**
6
+ * Create a compile-time marker for newline-delimited JSON response items.
7
+ *
8
+ * @remarks The returned marker is handled by `clientPlugin` in clients and
9
+ * `routerPlugin` in routers. Generated client action functions resolve to an
10
+ * `AsyncIterable<T>`, while route handlers may return either an `Iterable<T>`
11
+ * or an `AsyncIterable<T>`. Rouzer does not validate streamed items at runtime.
12
+ */
13
+ export declare function $type<T>(): ResponsePluginMarker<AsyncIterable<T>, NdjsonSource<T>, typeof codecId>;
14
+ /**
15
+ * Client plugin that decodes successful NDJSON responses.
16
+ *
17
+ * @remarks Register this plugin with `createClient({ plugins })` when the route
18
+ * tree contains `response: ndjson.$type<T>()` markers.
19
+ */
20
+ export declare const clientPlugin: ClientResponsePlugin;
21
+ /**
22
+ * Router plugin that encodes handler results as NDJSON responses.
23
+ *
24
+ * @remarks Register this plugin with `createRouter({ plugins })` when the route
25
+ * tree contains `response: ndjson.$type<T>()` markers. Handler or generator
26
+ * errors are not encoded as NDJSON items; model application-level errors in the
27
+ * item type when clients should receive them as data.
28
+ */
29
+ export declare const routerPlugin: RouterResponsePlugin;
30
+ /**
31
+ * Encode an iterable of values as a newline-delimited JSON byte stream.
32
+ *
33
+ * @remarks Each yielded value is serialized with `JSON.stringify` and followed
34
+ * by `\n`. Values that cannot be represented as a JSON text, such as
35
+ * `undefined`, cause the stream to error when read.
36
+ */
37
+ export declare function encodeNdjson(source: NdjsonSource): ReadableStream<Uint8Array>;
38
+ /**
39
+ * Decode a newline-delimited JSON byte stream.
40
+ *
41
+ * @remarks UTF-8 chunks may split JSON lines. Both `\n` and `\r\n` line endings
42
+ * are accepted, and a final line does not need a trailing newline. Malformed
43
+ * lines throw a `SyntaxError` that includes the 1-based line number.
44
+ */
45
+ export declare function decodeNdjson<T = unknown>(stream: ReadableStream<Uint8Array>): AsyncIterable<T>;
46
+ /**
47
+ * Create a `Response` whose body is encoded as newline-delimited JSON.
48
+ *
49
+ * @remarks The response defaults to
50
+ * `content-type: application/x-ndjson; charset=utf-8` unless the caller supplies
51
+ * a content type in `init.headers`.
52
+ */
53
+ export declare function ndjsonResponse<T>(source: NdjsonSource<T>, init?: ResponseInit): Response;
54
+ export {};
package/dist/ndjson.js ADDED
@@ -0,0 +1,161 @@
1
+ import { createResponsePluginMarker, } from './response.js';
2
+ const codecId = 'rouzer/ndjson';
3
+ /**
4
+ * Create a compile-time marker for newline-delimited JSON response items.
5
+ *
6
+ * @remarks The returned marker is handled by `clientPlugin` in clients and
7
+ * `routerPlugin` in routers. Generated client action functions resolve to an
8
+ * `AsyncIterable<T>`, while route handlers may return either an `Iterable<T>`
9
+ * or an `AsyncIterable<T>`. Rouzer does not validate streamed items at runtime.
10
+ */
11
+ export function $type() {
12
+ return createResponsePluginMarker(codecId);
13
+ }
14
+ /**
15
+ * Client plugin that decodes successful NDJSON responses.
16
+ *
17
+ * @remarks Register this plugin with `createClient({ plugins })` when the route
18
+ * tree contains `response: ndjson.$type<T>()` markers.
19
+ */
20
+ export const clientPlugin = {
21
+ id: codecId,
22
+ decode(response) {
23
+ if (!response.body) {
24
+ throw new Error('NDJSON response has no body');
25
+ }
26
+ return decodeNdjson(response.body);
27
+ },
28
+ };
29
+ /**
30
+ * Router plugin that encodes handler results as NDJSON responses.
31
+ *
32
+ * @remarks Register this plugin with `createRouter({ plugins })` when the route
33
+ * tree contains `response: ndjson.$type<T>()` markers. Handler or generator
34
+ * errors are not encoded as NDJSON items; model application-level errors in the
35
+ * item type when clients should receive them as data.
36
+ */
37
+ export const routerPlugin = {
38
+ id: codecId,
39
+ encode(value) {
40
+ return ndjsonResponse(value);
41
+ },
42
+ };
43
+ /**
44
+ * Encode an iterable of values as a newline-delimited JSON byte stream.
45
+ *
46
+ * @remarks Each yielded value is serialized with `JSON.stringify` and followed
47
+ * by `\n`. Values that cannot be represented as a JSON text, such as
48
+ * `undefined`, cause the stream to error when read.
49
+ */
50
+ export function encodeNdjson(source) {
51
+ const iterator = getAsyncIterator(source);
52
+ const encoder = new TextEncoder();
53
+ return new ReadableStream({
54
+ async pull(controller) {
55
+ const { done, value } = await iterator.next();
56
+ if (done) {
57
+ controller.close();
58
+ return;
59
+ }
60
+ const line = JSON.stringify(value);
61
+ if (line === undefined) {
62
+ throw new TypeError('NDJSON items must serialize to a JSON text; received undefined');
63
+ }
64
+ controller.enqueue(encoder.encode(`${line}\n`));
65
+ },
66
+ async cancel(reason) {
67
+ await iterator.return?.(reason);
68
+ },
69
+ });
70
+ }
71
+ /**
72
+ * Decode a newline-delimited JSON byte stream.
73
+ *
74
+ * @remarks UTF-8 chunks may split JSON lines. Both `\n` and `\r\n` line endings
75
+ * are accepted, and a final line does not need a trailing newline. Malformed
76
+ * lines throw a `SyntaxError` that includes the 1-based line number.
77
+ */
78
+ export async function* decodeNdjson(stream) {
79
+ const reader = stream.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buffer = '';
82
+ let lineNumber = 0;
83
+ let doneReading = false;
84
+ try {
85
+ while (true) {
86
+ const { done, value } = await reader.read();
87
+ if (done) {
88
+ buffer += decoder.decode();
89
+ doneReading = true;
90
+ break;
91
+ }
92
+ buffer += decoder.decode(value, { stream: true });
93
+ let newlineIndex;
94
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
95
+ const line = stripCarriageReturn(buffer.slice(0, newlineIndex));
96
+ buffer = buffer.slice(newlineIndex + 1);
97
+ lineNumber += 1;
98
+ yield parseNdjsonLine(line, lineNumber);
99
+ }
100
+ }
101
+ if (buffer.length > 0) {
102
+ lineNumber += 1;
103
+ yield parseNdjsonLine(stripCarriageReturn(buffer), lineNumber);
104
+ }
105
+ }
106
+ finally {
107
+ if (!doneReading) {
108
+ await reader.cancel().catch(() => { });
109
+ }
110
+ reader.releaseLock();
111
+ }
112
+ }
113
+ /**
114
+ * Create a `Response` whose body is encoded as newline-delimited JSON.
115
+ *
116
+ * @remarks The response defaults to
117
+ * `content-type: application/x-ndjson; charset=utf-8` unless the caller supplies
118
+ * a content type in `init.headers`.
119
+ */
120
+ export function ndjsonResponse(source, init = {}) {
121
+ const headers = new Headers(init.headers);
122
+ if (!headers.has('content-type')) {
123
+ headers.set('content-type', 'application/x-ndjson; charset=utf-8');
124
+ }
125
+ return new Response(encodeNdjson(source), {
126
+ ...init,
127
+ headers,
128
+ });
129
+ }
130
+ function getAsyncIterator(source) {
131
+ const asyncIterator = source[Symbol.asyncIterator]?.();
132
+ if (asyncIterator) {
133
+ return asyncIterator;
134
+ }
135
+ const iterator = source[Symbol.iterator]?.();
136
+ if (iterator) {
137
+ return {
138
+ next() {
139
+ return Promise.resolve(iterator.next());
140
+ },
141
+ async return() {
142
+ iterator.return?.();
143
+ return { done: true, value: undefined };
144
+ },
145
+ };
146
+ }
147
+ throw new TypeError('NDJSON source must be iterable');
148
+ }
149
+ function stripCarriageReturn(line) {
150
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
151
+ }
152
+ function parseNdjsonLine(line, lineNumber) {
153
+ try {
154
+ return JSON.parse(line);
155
+ }
156
+ catch (cause) {
157
+ const error = new SyntaxError(`Invalid NDJSON at line ${lineNumber}: ${cause instanceof Error ? cause.message : String(cause)}`);
158
+ error.cause = cause;
159
+ throw error;
160
+ }
161
+ }
@@ -0,0 +1,10 @@
1
+ import type { RouteResponseMap, RouteSchema } from './types/schema.js';
2
+ /** Return true when the response schema is a status-keyed response map. */
3
+ export declare function isResponseMap(response: RouteSchema['response']): response is RouteResponseMap;
4
+ /** Return true when the marker represents a declared error response. */
5
+ export declare function isErrorMarker(marker: unknown): boolean;
6
+ /** Return true when the marker represents a success response. */
7
+ export declare function isSuccessMarker(marker: unknown): boolean;
8
+ /** Find the default success status for a direct handler result. */
9
+ export declare function getResponseMapPluginIds(responseMap: RouteResponseMap): string[];
10
+ export declare function getDefaultSuccessStatus(responseMap: RouteResponseMap): number;
@@ -0,0 +1,32 @@
1
+ import { getResponsePluginMarkerId, responsePluginMarker, } from './response.js';
2
+ import { $error } from './type.js';
3
+ /** Return true when the response schema is a status-keyed response map. */
4
+ export function isResponseMap(response) {
5
+ return (typeof response === 'object' &&
6
+ response !== null &&
7
+ !(responsePluginMarker in response));
8
+ }
9
+ /** Return true when the marker represents a declared error response. */
10
+ export function isErrorMarker(marker) {
11
+ return marker === $error.symbol;
12
+ }
13
+ /** Return true when the marker represents a success response. */
14
+ export function isSuccessMarker(marker) {
15
+ return marker !== undefined && !isErrorMarker(marker);
16
+ }
17
+ /** Find the default success status for a direct handler result. */
18
+ export function getResponseMapPluginIds(responseMap) {
19
+ return Object.values(responseMap).flatMap(marker => {
20
+ const pluginId = getResponsePluginMarkerId(marker);
21
+ return pluginId ? [pluginId] : [];
22
+ });
23
+ }
24
+ export function getDefaultSuccessStatus(responseMap) {
25
+ for (const key of Object.keys(responseMap)) {
26
+ const marker = responseMap[Number(key)];
27
+ if (isSuccessMarker(marker)) {
28
+ return Number(key);
29
+ }
30
+ }
31
+ return 200;
32
+ }