rouzer 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 or newline-delimited JSON response types once, then reuses that
10
+ 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 and typed NDJSON response streams
15
17
 
16
18
  Rouzer optimizes for shared TypeScript route modules over language-agnostic API
17
19
  schemas or generated SDKs.
@@ -29,8 +31,8 @@ Consider something else if:
29
31
 
30
32
  - you need OpenAPI-first workflows, schema files, or generated clients for other
31
33
  languages
32
- - you need runtime response-body validation; `response: $type<T>()` is
33
- compile-time only
34
+ - you need runtime response-body validation; `response: $type<T>()` and
35
+ `response: ndjson.$type<T>()` are compile-time only
34
36
  - you want a framework that owns controllers, data loading, rendering, and
35
37
  deployment adapters
36
38
  - you cannot use ESM or Zod v4+
@@ -101,10 +103,46 @@ const { message } = await client.hello({
101
103
  route arguments before `fetch`; server handlers validate matched path, query,
102
104
  headers, and JSON bodies before your handler runs.
103
105
 
106
+ ### NDJSON response streams
107
+
108
+ Use `response: ndjson.$type<T>()` for endpoints that stream
109
+ newline-delimited JSON. Add `ndjson.routerPlugin` to the router and
110
+ `ndjson.clientPlugin` to the client. Handlers return an `Iterable<T>` or
111
+ `AsyncIterable<T>`; Rouzer wraps it in an `application/x-ndjson` response.
112
+ Client action functions resolve to an `AsyncIterable<T>`.
113
+
114
+ ```ts
115
+ import { createClient, createRouter } from 'rouzer'
116
+ import * as http from 'rouzer/http'
117
+ import * as ndjson from 'rouzer/ndjson'
118
+
119
+ export const events = http.get('events', {
120
+ response: ndjson.$type<{ id: number; message: string }>(),
121
+ })
122
+ export const routes = { events }
123
+
124
+ createRouter({ plugins: [ndjson.routerPlugin] }).use(routes, {
125
+ async *events() {
126
+ yield { id: 1, message: 'ready' }
127
+ },
128
+ })
129
+
130
+ const client = createClient({
131
+ baseURL: 'https://example.com/api/',
132
+ routes,
133
+ plugins: [ndjson.clientPlugin],
134
+ })
135
+ for await (const event of await client.events()) {
136
+ console.log(event.message)
137
+ }
138
+ ```
139
+
104
140
  ## Documentation
105
141
 
106
- - [Concepts, API selection, and v2.0.1 migration notes](docs/context.md)
142
+ - [Concepts, API selection, and v2->v3 migration notes](docs/context.md)
107
143
  - [Runnable shared-route example](examples/basic-usage.ts)
144
+ - [Runnable NDJSON response-stream example](examples/ndjson-stream.ts)
108
145
  - Generated declarations in the published package provide the exact signatures
109
- for every public export, including the `rouzer/http` subpath.
146
+ for every public export, including the `rouzer/http` and `rouzer/ndjson`
147
+ subpaths.
110
148
  - 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,9 @@ 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 plugin response marker return the plugin's
110
+ * client result type. Actions without a response marker return the raw
111
+ * `Response`.
101
112
  */
102
113
  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
114
  response: any;
@@ -1,6 +1,7 @@
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';
4
5
  /**
5
6
  * Create a typed fetch client for an HTTP route tree.
6
7
  *
@@ -12,6 +13,10 @@ export function createClient(config) {
12
13
  const baseURL = config.baseURL.replace(/\/?$/, '/');
13
14
  const defaultHeaders = config.headers && shake(config.headers);
14
15
  const fetch = config.fetch ?? globalThis.fetch;
16
+ const responsePlugins = createResponsePluginMap(config.plugins, 'client response');
17
+ if (config.routes) {
18
+ validateClientResponsePlugins(config.routes, responsePlugins);
19
+ }
15
20
  async function request({ path: pathBuilder, method, args, schema, }) {
16
21
  let { path, query, body, headers, ...init } = args;
17
22
  if (schema.path) {
@@ -58,37 +63,58 @@ export function createClient(config) {
58
63
  async function json(props) {
59
64
  const response = await request(props);
60
65
  if (!response.ok) {
61
- if (config.onJsonError) {
62
- return config.onJsonError(response);
63
- }
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());
68
- }
69
- throw error;
66
+ return handleResponseError(response, props);
70
67
  }
71
68
  return response.json();
72
69
  }
70
+ async function response(props) {
71
+ const httpResponse = await request(props);
72
+ if (!httpResponse.ok) {
73
+ return handleResponseError(httpResponse, props);
74
+ }
75
+ const pluginId = getResponsePluginMarkerId(props.schema.response);
76
+ if (pluginId) {
77
+ const plugin = responsePlugins.get(pluginId);
78
+ if (!plugin) {
79
+ throw missingClientResponsePlugin(pluginId);
80
+ }
81
+ return plugin.decode(httpResponse, {
82
+ marker: props.schema.response,
83
+ request: props,
84
+ });
85
+ }
86
+ return httpResponse.json();
87
+ }
88
+ async function handleResponseError(response, props) {
89
+ if (config.onJsonError) {
90
+ return config.onJsonError(response);
91
+ }
92
+ const error = new Error(`Request to ${props.method} ${createHref(props.path, props.args.path)} failed with status ${response.status}`);
93
+ const contentType = response.headers.get('content-type');
94
+ if (contentType?.includes('application/json')) {
95
+ Object.assign(error, await response.json());
96
+ }
97
+ throw error;
98
+ }
73
99
  return {
74
100
  ...(config.routes
75
- ? connectTree(config.routes, '', request, json)
101
+ ? connectTree(config.routes, '', request, response)
76
102
  : null),
77
103
  config,
78
104
  request,
79
105
  json,
80
106
  };
81
107
  }
82
- function connectTree(tree, prefix, request, json) {
108
+ function connectTree(tree, prefix, request, response) {
83
109
  return Object.fromEntries(Object.entries(tree).map(([key, node]) => {
84
110
  if (node.kind === 'resource') {
85
111
  return [
86
112
  key,
87
- connectTree(node.children, joinPaths(prefix, node.path.source), request, json),
113
+ connectTree(node.children, joinPaths(prefix, node.path.source), request, response),
88
114
  ];
89
115
  }
90
116
  const path = RoutePattern.parse(joinPaths(prefix, node.path?.source ?? ''));
91
- const fetch = node.schema.response ? json : request;
117
+ const fetch = node.schema.response ? response : request;
92
118
  return [
93
119
  key,
94
120
  (args = {}) => fetch({
@@ -101,6 +127,22 @@ function connectTree(tree, prefix, request, json) {
101
127
  ];
102
128
  }));
103
129
  }
130
+ function validateClientResponsePlugins(tree, plugins) {
131
+ for (const node of Object.values(tree)) {
132
+ if (node.kind === 'resource') {
133
+ validateClientResponsePlugins(node.children, plugins);
134
+ }
135
+ else {
136
+ const pluginId = getResponsePluginMarkerId(node.schema.response);
137
+ if (pluginId && !plugins.has(pluginId)) {
138
+ throw missingClientResponsePlugin(pluginId);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ function missingClientResponsePlugin(pluginId) {
144
+ return new Error(`Missing client response plugin for ${pluginId}`);
145
+ }
104
146
  function joinPaths(left, right) {
105
147
  return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/');
106
148
  }
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,47 @@
1
+ import type { Promisable } from './common.js';
2
+ import type { RouteRequest } from './types/request.js';
3
+ /** Runtime key carried by response plugin markers. */
4
+ export declare const responsePluginMarkerSymbol: unique symbol;
5
+ /**
6
+ * Compile-time response marker handled by a client/router response plugin pair.
7
+ *
8
+ * @remarks `TClient` is the value returned by generated client action
9
+ * functions. `TRouter` is the non-`Response` value accepted from route handlers.
10
+ */
11
+ export type ResponsePluginMarker<TClient, TRouter = TClient, TId extends string = string> = {
12
+ readonly [responsePluginMarkerSymbol]: {
13
+ readonly id: TId;
14
+ readonly client: TClient;
15
+ readonly router: TRouter;
16
+ };
17
+ };
18
+ /** Client-side response plugin used by `createClient({ plugins })`. */
19
+ export type ClientResponsePlugin = {
20
+ /** Stable response codec id matched against route response markers. */
21
+ readonly id: string;
22
+ /** Decode a successful `Response` into the client action result. */
23
+ decode(response: Response, context: {
24
+ marker: ResponsePluginMarker<any, any>;
25
+ request: RouteRequest;
26
+ }): Promisable<unknown>;
27
+ };
28
+ /** Router-side response plugin used by `createRouter({ plugins })`. */
29
+ export type RouterResponsePlugin = {
30
+ /** Stable response codec id matched against route response markers. */
31
+ readonly id: string;
32
+ /** Encode a handler result into the HTTP response. */
33
+ encode(value: unknown, context: {
34
+ marker: ResponsePluginMarker<any, any>;
35
+ request: Request;
36
+ }): Promisable<Response>;
37
+ };
38
+ /** Create a response marker for a response plugin. */
39
+ export declare function createResponsePluginMarker<TClient, TRouter = TClient, const TId extends string = string>(id: TId): ResponsePluginMarker<TClient, TRouter, TId>;
40
+ /** Get the response plugin id from a plugin marker, if present. */
41
+ export declare function getResponsePluginMarkerId(value: unknown): string | undefined;
42
+ /** Return true when a route response marker is handled by a response plugin. */
43
+ export declare function isResponsePluginMarker(value: unknown): value is ResponsePluginMarker<unknown, unknown>;
44
+ /** Create a plugin lookup map and reject duplicate plugin ids. */
45
+ export declare function createResponsePluginMap<TPlugin extends {
46
+ readonly id: string;
47
+ }>(plugins?: readonly TPlugin[], label?: string): Map<string, TPlugin>;
@@ -0,0 +1,35 @@
1
+ /** Runtime key carried by response plugin markers. */
2
+ export const responsePluginMarkerSymbol = Symbol.for('rouzer.response-plugin');
3
+ /** Create a response marker for a response plugin. */
4
+ export function createResponsePluginMarker(id) {
5
+ return {
6
+ [responsePluginMarkerSymbol]: {
7
+ id,
8
+ client: undefined,
9
+ router: undefined,
10
+ },
11
+ };
12
+ }
13
+ /** Get the response plugin id from a plugin marker, if present. */
14
+ export function getResponsePluginMarkerId(value) {
15
+ return isResponsePluginMarker(value)
16
+ ? value[responsePluginMarkerSymbol].id
17
+ : undefined;
18
+ }
19
+ /** Return true when a route response marker is handled by a response plugin. */
20
+ export function isResponsePluginMarker(value) {
21
+ return (typeof value === 'object' &&
22
+ value !== null &&
23
+ responsePluginMarkerSymbol in value);
24
+ }
25
+ /** Create a plugin lookup map and reject duplicate plugin ids. */
26
+ export function createResponsePluginMap(plugins = [], label = 'response') {
27
+ const map = new Map();
28
+ for (const plugin of plugins) {
29
+ if (map.has(plugin.id)) {
30
+ throw new Error(`Duplicate ${label} plugin: ${plugin.id}`);
31
+ }
32
+ map.set(plugin.id, plugin);
33
+ }
34
+ return map;
35
+ }
@@ -1,6 +1,7 @@
1
1
  import type { HattipHandler } from '@hattip/core';
2
2
  import { ApplyMiddleware, chain, ExtractMiddleware, MiddlewareChain, MiddlewareTypes } from 'alien-middleware';
3
3
  import type { HttpRouteTree } from '../http.js';
4
+ import { type RouterResponsePlugin } from '../response.js';
4
5
  import type { RouteRequestHandlerMap } from '../types/server.js';
5
6
  export { chain };
6
7
  /** Configuration for `createRouter`. */
@@ -25,6 +26,8 @@ export type RouterConfig = {
25
26
  * and logs missing route handlers to the console.
26
27
  */
27
28
  debug?: boolean;
29
+ /** Response codec plugins used for route handler results. */
30
+ plugins?: readonly RouterResponsePlugin[];
28
31
  /** CORS configuration for requests with an `Origin` header. */
29
32
  cors?: {
30
33
  /**
@@ -67,8 +70,8 @@ export interface Router<T extends MiddlewareTypes = any> extends HattipHandler<T
67
70
  /**
68
71
  * Create a Rouzer router that can be mounted by any Hattip adapter.
69
72
  *
70
- * @param config Optional router configuration for base path, debug behavior, and
71
- * CORS origin restrictions.
73
+ * @param config Optional router configuration for base path, debug behavior,
74
+ * response plugins, and CORS origin restrictions.
72
75
  * @returns A Hattip-compatible handler with `.use(...)` methods for middleware
73
76
  * and route registration.
74
77
  */
@@ -3,15 +3,18 @@ import { createMatcher } from '@remix-run/route-pattern/match';
3
3
  import { chain, MiddlewareChain, } from 'alien-middleware';
4
4
  import * as z from 'zod';
5
5
  import { mapValues } from '../common.js';
6
+ import { createResponsePluginMap, getResponsePluginMarkerId, } from '../response.js';
6
7
  export { chain };
7
8
  // Internal prototype for the router instance.
8
9
  class RouterObject extends MiddlewareChain {
9
10
  config;
10
11
  basePath;
12
+ responsePlugins;
11
13
  constructor(config) {
12
14
  super();
13
15
  this.config = config;
14
16
  this.basePath = config.basePath?.replace(/\/?$/, '/');
17
+ this.responsePlugins = createResponsePluginMap(config.plugins, 'router response');
15
18
  const allowOrigins = config.cors?.allowOrigins?.map(createOriginPattern);
16
19
  if (allowOrigins) {
17
20
  super.use(((ctx) => {
@@ -31,14 +34,15 @@ class RouterObject extends MiddlewareChain {
31
34
  }
32
35
  /** @internal */
33
36
  useRoutes(routeSchemas, handlers) {
34
- const { config, basePath } = this;
37
+ const { config, basePath, responsePlugins } = this;
35
38
  const routes = flattenRoutes(routeSchemas, handlers, basePath ?? '', config.debug);
39
+ validateRouterResponsePlugins(routes, responsePlugins);
36
40
  const addDebugHeaders = config.debug
37
41
  ? (context, route) => {
38
42
  context.setHeader('X-Route-Name', route.name);
39
43
  }
40
44
  : null;
41
- return super.use((async function (context) {
45
+ return super.use(async function (context) {
42
46
  const request = context.request;
43
47
  const origin = request.headers.get('Origin');
44
48
  const url = (context.url ??= new URL(request.url));
@@ -110,16 +114,27 @@ class RouterObject extends MiddlewareChain {
110
114
  if (result instanceof Response) {
111
115
  return result;
112
116
  }
117
+ const pluginId = getResponsePluginMarkerId(schema.response);
118
+ if (pluginId) {
119
+ const plugin = responsePlugins.get(pluginId);
120
+ if (!plugin) {
121
+ throw missingRouterResponsePlugin(pluginId);
122
+ }
123
+ return plugin.encode(result, {
124
+ marker: schema.response,
125
+ request,
126
+ });
127
+ }
113
128
  return Response.json(result);
114
129
  }
115
- }));
130
+ });
116
131
  }
117
132
  }
118
133
  /**
119
134
  * Create a Rouzer router that can be mounted by any Hattip adapter.
120
135
  *
121
- * @param config Optional router configuration for base path, debug behavior, and
122
- * CORS origin restrictions.
136
+ * @param config Optional router configuration for base path, debug behavior,
137
+ * response plugins, and CORS origin restrictions.
123
138
  * @returns A Hattip-compatible handler with `.use(...)` methods for middleware
124
139
  * and route registration.
125
140
  */
@@ -152,6 +167,17 @@ function flattenRoutes(tree, handlers, prefix, debug) {
152
167
  }
153
168
  return routes;
154
169
  }
170
+ function validateRouterResponsePlugins(routes, plugins) {
171
+ for (const route of routes) {
172
+ const pluginId = getResponsePluginMarkerId(route.schema.response);
173
+ if (pluginId && !plugins.has(pluginId)) {
174
+ throw missingRouterResponsePlugin(pluginId);
175
+ }
176
+ }
177
+ }
178
+ function missingRouterResponsePlugin(pluginId) {
179
+ return new Error(`Missing router response plugin for ${pluginId}`);
180
+ }
155
181
  function joinPaths(left, right) {
156
182
  return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/');
157
183
  }
package/dist/type.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Unchecked } from './common.js';
1
+ import type { Unchecked } from './common.js';
2
2
  /**
3
3
  * Create a compile-time-only marker for an action's JSON response payload type.
4
4
  *
@@ -3,7 +3,7 @@ import type { AnyMiddlewareChain, MiddlewareContext } from 'alien-middleware';
3
3
  import type * as z from 'zod';
4
4
  import { Promisable } from '../common.js';
5
5
  import type { HttpAction } from '../http.js';
6
- import type { InferRouteResponse } from './response.js';
6
+ import type { InferRouteHandlerResult } from './response.js';
7
7
  import type { RouteSchema } from './schema.js';
8
8
  type RequestContext<TMiddleware extends AnyMiddlewareChain> = MiddlewareContext<TMiddleware>;
9
9
  export type RouteRequestHandler<TMiddleware extends AnyMiddlewareChain, TArgs extends object, TResult> = (context: RequestContext<TMiddleware> & TArgs) => Promisable<TResult | Response>;
@@ -17,7 +17,7 @@ export type InferActionHandler<TMiddleware extends AnyMiddlewareChain, TAction e
17
17
  headers: TAction['schema'] extends {
18
18
  headers: any;
19
19
  } ? z.infer<TAction['schema']['headers']> : undefined;
20
- }, InferRouteResponse<Extract<TAction['schema'], RouteSchema>>> : RouteRequestHandler<TMiddleware, {
20
+ }, InferRouteHandlerResult<Extract<TAction['schema'], RouteSchema>>> : RouteRequestHandler<TMiddleware, {
21
21
  path: TAction['schema'] extends {
22
22
  path: any;
23
23
  } ? z.infer<TAction['schema']['path']> : MatchParams<TPath>;
@@ -27,5 +27,5 @@ export type InferActionHandler<TMiddleware extends AnyMiddlewareChain, TAction e
27
27
  headers: TAction['schema'] extends {
28
28
  headers: any;
29
29
  } ? z.infer<TAction['schema']['headers']> : undefined;
30
- }, InferRouteResponse<Extract<TAction['schema'], RouteSchema>>>;
30
+ }, InferRouteHandlerResult<Extract<TAction['schema'], RouteSchema>>>;
31
31
  export {};
@@ -30,6 +30,6 @@ export type RouteRequestFactory<T extends RouteSchema, P extends string> = {
30
30
  (...p: RouteArgs<T, P> extends infer TArgs ? {} extends TArgs ? [args?: TArgs] : [args: TArgs] : never): RouteRequest<InferRouteResponse<T>>;
31
31
  /** Inferred argument type for this request factory. */
32
32
  $args: RouteArgs<T, P>;
33
- /** Inferred JSON response type for this request factory. */
33
+ /** Inferred response type for this request factory. */
34
34
  $response: InferRouteResponse<T>;
35
35
  };
@@ -1,9 +1,17 @@
1
- import type { Unchecked, RouteSchema } from './schema.js';
1
+ import type { ResponsePluginMarker, RouteSchema, Unchecked } from './schema.js';
2
2
  /** `Response` whose `.json()` method resolves to a known payload type. */
3
3
  export type RouteResponse<TResult = any> = Response & {
4
4
  json(): Promise<TResult>;
5
5
  };
6
- /** Infer the JSON response payload type from an action schema. */
6
+ /** Infer the client response type from an action schema. */
7
7
  export type InferRouteResponse<T extends RouteSchema> = T extends {
8
+ response: ResponsePluginMarker<infer TClient, any>;
9
+ } ? TClient : T extends {
10
+ response: Unchecked<infer TResponse>;
11
+ } ? TResponse : void;
12
+ /** Infer the non-`Response` handler result type from an action schema. */
13
+ export type InferRouteHandlerResult<T extends RouteSchema> = T extends {
14
+ response: ResponsePluginMarker<any, infer TRouter>;
15
+ } ? TRouter : T extends {
8
16
  response: Unchecked<infer TResponse>;
9
17
  } ? TResponse : void;
@@ -1,12 +1,17 @@
1
1
  import * as z from 'zod';
2
- import { Unchecked } from '../common.js';
2
+ import type { Unchecked } from '../common.js';
3
+ import type { ResponsePluginMarker } from '../response.js';
3
4
  /**
4
- * Compile-time-only marker used by `$type<T>()` for unchecked response types.
5
+ * Compile-time-only marker used by `$type<T>()` for unchecked JSON response
6
+ * types.
5
7
  *
6
8
  * @remarks Application code should usually call `$type<T>()` instead of naming
7
9
  * this marker directly.
8
10
  */
9
11
  export type { Unchecked };
12
+ export type { ResponsePluginMarker };
13
+ /** Response marker accepted by HTTP action schemas. */
14
+ export type RouteResponseSchema = Unchecked<any> | ResponsePluginMarker<any, any>;
10
15
  /** Schema shape for `GET` route methods. */
11
16
  export type QueryRouteSchema = {
12
17
  /** Optional Zod object used to validate path params. */
@@ -17,8 +22,8 @@ export type QueryRouteSchema = {
17
22
  body?: never;
18
23
  /** Optional Zod object used to validate request headers. */
19
24
  headers?: z.ZodObject<any>;
20
- /** Optional compile-time-only JSON response type marker. */
21
- response?: Unchecked<any>;
25
+ /** Optional compile-time-only JSON or plugin response type marker. */
26
+ response?: RouteResponseSchema;
22
27
  };
23
28
  /** Schema shape for mutation route methods. */
24
29
  export type MutationRouteSchema = {
@@ -30,8 +35,8 @@ export type MutationRouteSchema = {
30
35
  body?: z.ZodType<any, any>;
31
36
  /** Optional Zod object used to validate request headers. */
32
37
  headers?: z.ZodObject<any>;
33
- /** Optional compile-time-only JSON response type marker. */
34
- response?: Unchecked<any>;
38
+ /** Optional compile-time-only JSON or plugin response type marker. */
39
+ response?: RouteResponseSchema;
35
40
  };
36
41
  /** Any HTTP action schema Rouzer can execute. */
37
42
  export type RouteSchema = QueryRouteSchema | MutationRouteSchema;
package/docs/context.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Rouzer is for applications that want one TypeScript HTTP route tree to drive
4
4
  both the server and the client that calls it. A route tree combines URL
5
- patterns, named actions, HTTP method schemas, and optional compile-time response
6
- types.
5
+ patterns, named actions, HTTP method schemas, and optional compile-time JSON or
6
+ NDJSON response types.
7
7
 
8
8
  ## When to use Rouzer
9
9
 
@@ -18,8 +18,8 @@ Use Rouzer when:
18
18
  produced by a separate OpenAPI build step
19
19
 
20
20
  Rouzer is not a response validation library, an OpenAPI generator, or a complete
21
- server framework. It focuses on typed route contracts, validation, routing, and a
22
- small client wrapper.
21
+ server framework. It focuses on typed route contracts, request validation,
22
+ routing, and a small client wrapper.
23
23
 
24
24
  ## Core abstractions
25
25
 
@@ -79,9 +79,9 @@ them out of resource/base-path composition.
79
79
 
80
80
  Method schemas describe the request pieces Rouzer should validate:
81
81
 
82
- | Action helper | Request schemas | Notes |
83
- | ------------------------------------- | -------------------------------------- | ---------------- |
84
- | `http.get(...)` | `path`, `query`, `headers`, `response` | No request body. |
82
+ | Action helper | Request schemas | Notes |
83
+ | --------------------------------- | -------------------------------------- | ---------------- |
84
+ | `http.get(...)` | `path`, `query`, `headers`, `response` | No request body. |
85
85
  | `http.post/put/patch/delete(...)` | `path`, `body`, `headers`, `response` | No query schema. |
86
86
 
87
87
  If you omit a `path` schema, TypeScript infers path params from the pattern and
@@ -92,15 +92,39 @@ The HTTP action API models explicit operations. It does not expose the old
92
92
  method-map `ALL` fallback route shape; declare the concrete methods your client
93
93
  and server support.
94
94
 
95
- ### `$type<T>()`
95
+ ### `$type<T>()` and `ndjson.$type<T>()`
96
96
 
97
- `response: $type<T>()` is a TypeScript-only marker. It tells handlers and client
98
- action functions what response payload type to expect, but Rouzer does not
99
- validate response bodies at runtime.
97
+ `response: $type<T>()` is a TypeScript-only marker for JSON response payloads.
98
+ It tells handlers and client action functions what response payload type to
99
+ expect, but Rouzer does not validate response bodies at runtime.
100
+
101
+ `response: ndjson.$type<T>()` is a TypeScript-only marker for newline-delimited
102
+ JSON response streams from the `rouzer/ndjson` subpath. Register
103
+ `ndjson.routerPlugin` with `createRouter(...)` and `ndjson.clientPlugin` with
104
+ `createClient(...)` for routes that use this marker. Handlers return an
105
+ `Iterable<T>` or `AsyncIterable<T>`; Rouzer serializes each item as one JSON line
106
+ and sets the response content type to `application/x-ndjson; charset=utf-8`.
107
+ Client action functions resolve to an `AsyncIterable<T>` parsed from the
108
+ response body. Streamed items are parsed as JSON but are not validated against a
109
+ Zod schema.
100
110
 
101
111
  Actions without a `response` marker return a raw `Response` from client action
102
- functions. Actions with a `response` marker use `client.json(...)` under the hood
103
- and return parsed JSON typed as `T`.
112
+ functions. Actions with `response: $type<T>()` use `client.json(...)` under the
113
+ hood and return parsed JSON typed as `T`.
114
+
115
+ ### Response plugins
116
+
117
+ Response plugins add non-JSON response codecs without changing route matching or
118
+ request validation. A plugin package provides a compile-time response marker and
119
+ matching runtime plugins. For NDJSON, those are `ndjson.$type<T>()`,
120
+ `ndjson.routerPlugin`, and `ndjson.clientPlugin`.
121
+
122
+ The router plugin encodes non-`Response` handler results into an HTTP `Response`.
123
+ The client plugin decodes successful HTTP responses for generated client action
124
+ functions. Rouzer validates plugin registration when routes are attached to a
125
+ router or client, so routes that use an unregistered response marker fail fast
126
+ instead of falling back to JSON. Response plugins do not automatically validate
127
+ response payloads unless the plugin itself implements validation.
104
128
 
105
129
  ### Router
106
130
 
@@ -133,7 +157,10 @@ Handlers receive a context typed from middleware plus the action schema:
133
157
  - `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
134
158
  - mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
135
159
  - handlers may return a plain JSON-serializable value or a `Response`
160
+ - `ndjson.$type<T>()` handlers return an `Iterable<T>` or `AsyncIterable<T>`
161
+ unless they return a custom `Response`
136
162
  - plain values are returned with `Response.json(value)`
163
+ - NDJSON iterables are returned as `application/x-ndjson` streams
137
164
  - return a `Response` when you need custom status, headers, or body handling
138
165
 
139
166
  `basePath` is prepended to route tree paths, `debug` adds matched-route debug
@@ -148,6 +175,8 @@ requests with an `Origin` header.
148
175
  request factory contains the full path you want to call
149
176
  - `client.json(action.request(args))` for parsed JSON and default non-2xx
150
177
  throwing
178
+ - response plugin support for generated client action functions, such as
179
+ `ndjson.clientPlugin` for NDJSON response streams
151
180
  - a client tree that mirrors `routes`, with action functions such as
152
181
  `client.profiles.get(args)` when `routes` is supplied
153
182
 
@@ -167,14 +196,18 @@ runtimes.
167
196
  ## Lifecycle
168
197
 
169
198
  1. Define shared HTTP actions/resources with `rouzer/http` and Zod schemas.
170
- 2. Attach that route tree to a server with `createRouter().use(routes, handlers)`.
171
- 3. Create a client with the same route tree.
199
+ 2. Attach that route tree to a server with `createRouter().use(routes, handlers)`
200
+ or `createRouter({ plugins }).use(routes, handlers)` when response plugins
201
+ are needed.
202
+ 3. Create a client with the same route tree, plus matching client response
203
+ plugins when needed.
172
204
  4. Client action calls validate `path`, `query`, `body`, and `headers` before
173
205
  `fetch`.
174
206
  5. The router matches the request, validates the matched inputs, and calls the
175
207
  handler.
176
- 6. Plain handler results become JSON responses; explicit `Response` objects pass
177
- through unchanged.
208
+ 6. Plain handler results become JSON responses, plugin handler results become
209
+ plugin-encoded responses, and explicit `Response` objects pass through
210
+ unchanged.
178
211
 
179
212
  On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
180
213
  coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
@@ -214,6 +247,55 @@ const json = await client.json(
214
247
  )
215
248
  ```
216
249
 
250
+ Response plugins are applied by generated client action functions. For longhand
251
+ calls to plugin-backed routes, use `client.request(...)` for the raw `Response`
252
+ and call the plugin subpath's decoder yourself.
253
+
254
+ ### Stream newline-delimited JSON
255
+
256
+ Use `ndjson.$type<T>()` when a handler should produce a sequence of JSON values
257
+ without buffering the whole response:
258
+
259
+ ```ts
260
+ import { createClient, createRouter } from 'rouzer'
261
+ import * as http from 'rouzer/http'
262
+ import * as ndjson from 'rouzer/ndjson'
263
+
264
+ export const events = http.get('events', {
265
+ response: ndjson.$type<{ id: number; message: string }>(),
266
+ })
267
+ export const routes = { events }
268
+
269
+ createRouter({ plugins: [ndjson.routerPlugin] }).use(routes, {
270
+ async *events() {
271
+ yield { id: 1, message: 'ready' }
272
+ yield { id: 2, message: 'done' }
273
+ },
274
+ })
275
+
276
+ const client = createClient({
277
+ baseURL: 'https://example.com/api/',
278
+ routes,
279
+ plugins: [ndjson.clientPlugin],
280
+ })
281
+ for await (const event of await client.events()) {
282
+ console.log(event.message)
283
+ }
284
+ ```
285
+
286
+ A complete runnable version lives in
287
+ [`examples/ndjson-stream.ts`](../examples/ndjson-stream.ts).
288
+
289
+ Rouzer's decoder accepts `\n` and `\r\n`, handles UTF-8 chunk boundaries, and
290
+ throws a `SyntaxError` with a line number for malformed JSON. If a consumer stops
291
+ reading early, the response body is cancelled.
292
+
293
+ Rouzer does not convert handler or generator failures into extra NDJSON items. If
294
+ an async generator throws after the response starts, the response stream errors
295
+ and the client's `for await` loop throws. Model application-level stream errors
296
+ as part of your item type, for example `{ type: 'error'; message: string }`, when
297
+ clients should receive them as data.
298
+
217
299
  ### Group resource actions
218
300
 
219
301
  Use resources when the public API reads better as a tree or when actions share
@@ -239,17 +321,18 @@ custom headers. Return a plain value for the default `Response.json(value)` path
239
321
 
240
322
  ### Customize JSON errors
241
323
 
242
- By default, `client.json(...)` throws for non-2xx responses. If the response body
243
- is JSON, its properties are copied onto the thrown `Error`.
324
+ By default, `client.json(...)` and generated client action functions throw for
325
+ non-2xx responses. If the response body is JSON, its properties are copied onto
326
+ the thrown `Error`.
244
327
 
245
- `onJsonError` can override that behavior. Its return value is returned from
246
- `client.json(...)` as-is; Rouzer does not automatically parse a returned
247
- `Response` from `onJsonError`.
328
+ `onJsonError` can override that behavior. Its return value is returned from the
329
+ response helper as-is; Rouzer does not automatically parse a returned `Response`
330
+ from `onJsonError`.
248
331
 
249
- ### Update code written for v2.0.1
332
+ ### v2->v3 migration
250
333
 
251
334
  Rouzer now uses action/resource route trees for router registration and client
252
- shorthands. A v2.0.1 method-map route such as this:
335
+ shorthands. In the v2->v3 migration, a method-map route such as this:
253
336
 
254
337
  ```ts
255
338
  export const profileRoute = route('profiles/:id', {
@@ -307,6 +390,9 @@ await client.profiles.update({
307
390
  only when string params are sufficient.
308
391
  - Use `response: $type<T>()` for JSON endpoints that should have typed client
309
392
  action functions.
393
+ - Use `response: ndjson.$type<T>()` plus `ndjson.routerPlugin` and
394
+ `ndjson.clientPlugin` for response streams where each line is a JSON value and
395
+ the client should consume an `AsyncIterable<T>`.
310
396
  - Name actions after domain operations (`get`, `list`, `update`, `archive`) and
311
397
  let `http.get/post/put/patch/delete` own the transport method.
312
398
  - Set `content-type: application/json` yourself when your server or middleware
@@ -314,7 +400,12 @@ await client.profiles.update({
314
400
 
315
401
  ## Constraints and gotchas
316
402
 
317
- - `$type<T>()` is compile-time only and does not validate response payloads.
403
+ - `$type<T>()` and `ndjson.$type<T>()` are compile-time only and do not validate
404
+ response payloads or streamed items.
405
+ - NDJSON support is for response streams; request bodies still use the existing
406
+ JSON body schema path.
407
+ - Routes that use a response plugin fail fast if the matching client or router
408
+ plugin is not registered.
318
409
  - Pathname route patterns expect an absolute client `baseURL`.
319
410
  - Resource and action keys are API names only; paths come from the pattern
320
411
  strings passed to `http.resource(...)` and action helpers.
@@ -0,0 +1,68 @@
1
+ import type { HattipHandler } from '@hattip/core'
2
+ import { createClient, createRouter } from 'rouzer'
3
+ import * as http from 'rouzer/http'
4
+ import * as ndjson from 'rouzer/ndjson'
5
+
6
+ type Event = {
7
+ id: number
8
+ message: string
9
+ }
10
+
11
+ export const events = http.get('events', {
12
+ response: ndjson.$type<Event>(),
13
+ })
14
+
15
+ export const routes = { events }
16
+
17
+ /**
18
+ * Tiny Hattip adapter used only to keep this example self-contained. Real apps
19
+ * mount the handler with a Hattip adapter for their runtime.
20
+ */
21
+ function createLocalFetch(handler: HattipHandler): typeof fetch {
22
+ return async (input, init) => {
23
+ const request = new Request(input, init)
24
+ const response = await handler({
25
+ request,
26
+ ip: '127.0.0.1',
27
+ platform: undefined,
28
+ env() {
29
+ return undefined
30
+ },
31
+ passThrough() {},
32
+ waitUntil(promise) {
33
+ void promise
34
+ },
35
+ })
36
+
37
+ return response ?? new Response(null, { status: 404 })
38
+ }
39
+ }
40
+
41
+ async function collect<T>(source: AsyncIterable<T>) {
42
+ const values: T[] = []
43
+ for await (const value of source) {
44
+ values.push(value)
45
+ }
46
+ return values
47
+ }
48
+
49
+ export async function runNdjsonStreamExample() {
50
+ const handler = createRouter({
51
+ basePath: 'api/',
52
+ plugins: [ndjson.routerPlugin],
53
+ }).use(routes, {
54
+ async *events() {
55
+ yield { id: 1, message: 'ready' }
56
+ yield { id: 2, message: 'done' }
57
+ },
58
+ })
59
+
60
+ const client = createClient({
61
+ baseURL: 'https://example.test/api/',
62
+ routes,
63
+ plugins: [ndjson.clientPlugin],
64
+ fetch: createLocalFetch(handler),
65
+ })
66
+
67
+ return collect(await client.events())
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -10,6 +10,10 @@
10
10
  "./http": {
11
11
  "types": "./dist/http.d.ts",
12
12
  "import": "./dist/http.js"
13
+ },
14
+ "./ndjson": {
15
+ "types": "./dist/ndjson.d.ts",
16
+ "import": "./dist/ndjson.js"
13
17
  }
14
18
  },
15
19
  "peerDependencies": {