rouzer 5.2.1 → 5.3.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
@@ -59,7 +59,7 @@ Import the primary API from the root package and declare routes through the HTTP
59
59
  subpath:
60
60
 
61
61
  ```ts
62
- import { $error, $type, chain, createClient, createRouter } from 'rouzer'
62
+ import { $error, $type, chain, createClient, createRouter, metadata } from 'rouzer'
63
63
  import * as http from 'rouzer/http'
64
64
  ```
65
65
 
@@ -181,6 +181,29 @@ await client.upload(file, { headers: { 'content-type': file.type } })
181
181
  Server handlers for raw-body routes read from `ctx.request` directly with Fetch
182
182
  APIs such as `arrayBuffer()`, `blob()`, `formData()`, or `text()`.
183
183
 
184
+ ### Route metadata
185
+
186
+ Use `metadata(...)` to attach optional runtime metadata to HTTP resources or
187
+ actions. Metadata does not affect routing, validation, client typing, or handler
188
+ behavior; it is preserved on route nodes for generated clients, CLIs, docs, and
189
+ route inspectors.
190
+
191
+ ```ts
192
+ export const sessions = http.resource('sessions', {
193
+ ...metadata({
194
+ description: 'Daemon-managed session control.',
195
+ }),
196
+ list: http.post('list', {
197
+ ...metadata({
198
+ description: 'Lists daemon-managed sessions and pagination state.',
199
+ }),
200
+ response: $type<SessionList>(),
201
+ }),
202
+ })
203
+ ```
204
+
205
+ The constructed nodes expose metadata as `node.metadata`.
206
+
184
207
  ### Client lifecycle hooks
185
208
 
186
209
  Pass `clientHook` to observe generated client action calls without wrapping the
package/dist/http.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
+ import { type RouteMetadata, type RouteMetadataMarker } from './metadata.js';
2
3
  import type { RawBodySchema, RouteSchema } from './types/schema.js';
3
4
  /** HTTP methods supported by Rouzer action declarations. */
4
5
  export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
@@ -17,6 +18,8 @@ export type HttpAction<P extends string = string, T extends RouteSchema = RouteS
17
18
  method: M;
18
19
  /** Request validation and optional response type schema. */
19
20
  schema: T;
21
+ /** Optional runtime metadata for generated tooling. */
22
+ metadata?: RouteMetadata;
20
23
  };
21
24
  /**
22
25
  * Path-scoped namespace in an HTTP route tree.
@@ -31,6 +34,8 @@ export type HttpResource<P extends string = string, TChildren extends HttpRouteT
31
34
  path: RoutePattern<P>;
32
35
  /** Child resources and actions exposed below this resource. */
33
36
  children: TChildren;
37
+ /** Optional runtime metadata for generated tooling. */
38
+ metadata?: RouteMetadata;
34
39
  };
35
40
  /** Node type accepted inside an HTTP route tree. */
36
41
  export type HttpNode = HttpAction | HttpResource;
@@ -38,6 +43,8 @@ export type HttpNode = HttpAction | HttpResource;
38
43
  export type HttpRouteTree = {
39
44
  [key: string]: HttpNode;
40
45
  };
46
+ type RouteDeclaration<T extends object> = T & Partial<RouteMetadataMarker>;
47
+ type StringKeys<T> = Pick<T, Extract<keyof T, string>>;
41
48
  /**
42
49
  * Declare an HTTP resource namespace.
43
50
  *
@@ -45,22 +52,22 @@ export type HttpRouteTree = {
45
52
  * property names are API names only; they do not affect the URL unless the child
46
53
  * is another resource or an action with an explicit path.
47
54
  */
48
- export declare function resource<const P extends string, const TChildren extends HttpRouteTree>(path: P, children: TChildren): HttpResource<P, TChildren>;
55
+ export declare function resource<const P extends string, const TChildren extends HttpRouteTree>(path: P, children: RouteDeclaration<TChildren>): HttpResource<P, StringKeys<TChildren>>;
49
56
  /** Declare a GET action, optionally with an action-local path segment. */
50
- export declare function get<const P extends string, const T extends RouteSchema>(path: P, schema: T): HttpAction<P, T, 'GET'>;
51
- export declare function get<const T extends RouteSchema>(schema: T): HttpAction<'', T, 'GET'>;
57
+ export declare function get<const P extends string, const T extends RouteSchema>(path: P, schema: RouteDeclaration<T>): HttpAction<P, T, 'GET'>;
58
+ export declare function get<const T extends RouteSchema>(schema: RouteDeclaration<T>): HttpAction<'', T, 'GET'>;
52
59
  /** Declare a POST action, optionally with an action-local path segment. */
53
- export declare function post<const P extends string, const T extends RouteSchema>(path: P, schema: T): HttpAction<P, T, 'POST'>;
54
- export declare function post<const T extends RouteSchema>(schema: T): HttpAction<'', T, 'POST'>;
60
+ export declare function post<const P extends string, const T extends RouteSchema>(path: P, schema: RouteDeclaration<T>): HttpAction<P, T, 'POST'>;
61
+ export declare function post<const T extends RouteSchema>(schema: RouteDeclaration<T>): HttpAction<'', T, 'POST'>;
55
62
  /** Declare a PUT action, optionally with an action-local path segment. */
56
- export declare function put<const P extends string, const T extends RouteSchema>(path: P, schema: T): HttpAction<P, T, 'PUT'>;
57
- export declare function put<const T extends RouteSchema>(schema: T): HttpAction<'', T, 'PUT'>;
63
+ export declare function put<const P extends string, const T extends RouteSchema>(path: P, schema: RouteDeclaration<T>): HttpAction<P, T, 'PUT'>;
64
+ export declare function put<const T extends RouteSchema>(schema: RouteDeclaration<T>): HttpAction<'', T, 'PUT'>;
58
65
  /** Declare a PATCH action, optionally with an action-local path segment. */
59
- export declare function patch<const P extends string, const T extends RouteSchema>(path: P, schema: T): HttpAction<P, T, 'PATCH'>;
60
- export declare function patch<const T extends RouteSchema>(schema: T): HttpAction<'', T, 'PATCH'>;
66
+ export declare function patch<const P extends string, const T extends RouteSchema>(path: P, schema: RouteDeclaration<T>): HttpAction<P, T, 'PATCH'>;
67
+ export declare function patch<const T extends RouteSchema>(schema: RouteDeclaration<T>): HttpAction<'', T, 'PATCH'>;
61
68
  /** Declare a DELETE action, optionally with an action-local path segment. */
62
- declare function deleteAction<const P extends string, const T extends RouteSchema>(path: P, schema: T): HttpAction<P, T, 'DELETE'>;
63
- declare function deleteAction<const T extends RouteSchema>(schema: T): HttpAction<'', T, 'DELETE'>;
69
+ declare function deleteAction<const P extends string, const T extends RouteSchema>(path: P, schema: RouteDeclaration<T>): HttpAction<P, T, 'DELETE'>;
70
+ declare function deleteAction<const T extends RouteSchema>(schema: RouteDeclaration<T>): HttpAction<'', T, 'DELETE'>;
64
71
  export { deleteAction as delete };
65
72
  /**
66
73
  * Declare a request body that is passed through to `fetch` without JSON encoding.
package/dist/http.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { RoutePattern } from '@remix-run/route-pattern';
2
+ import { getRouteMetadata, stripRouteMetadata, } from './metadata.js';
2
3
  /**
3
4
  * Declare an HTTP resource namespace.
4
5
  *
@@ -7,10 +8,12 @@ import { RoutePattern } from '@remix-run/route-pattern';
7
8
  * is another resource or an action with an explicit path.
8
9
  */
9
10
  export function resource(path, children) {
11
+ const metadata = getRouteMetadata(children);
10
12
  return {
11
13
  kind: 'resource',
12
14
  path: RoutePattern.parse(path),
13
- children,
15
+ children: stripRouteMetadata(children),
16
+ metadata,
14
17
  };
15
18
  }
16
19
  export function get(pathOrSchema, schema) {
@@ -47,5 +50,12 @@ function action(method, pathOrSchema, schema) {
47
50
  ? RoutePattern.parse(pathOrSchema)
48
51
  : undefined;
49
52
  schema ??= typeof pathOrSchema === 'string' ? {} : pathOrSchema;
50
- return { kind: 'action', path, method, schema };
53
+ const metadata = getRouteMetadata(schema);
54
+ return {
55
+ kind: 'action',
56
+ path,
57
+ method,
58
+ schema: stripRouteMetadata(schema),
59
+ metadata,
60
+ };
51
61
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './client/index.js';
2
+ export { metadata, type RouteMetadata } from './metadata.js';
2
3
  export * from './response.js';
3
4
  export * from './type.js';
4
5
  export * from './server/router.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './client/index.js';
2
+ export { metadata } from './metadata.js';
2
3
  export * from './response.js';
3
4
  export * from './type.js';
4
5
  export * from './server/router.js';
@@ -0,0 +1,16 @@
1
+ declare const routeMetadataKey: unique symbol;
2
+ /** Runtime metadata attached to Rouzer route nodes. */
3
+ export type RouteMetadata = {
4
+ /** Short label for generated indexes, clients, CLIs, or docs. */
5
+ summary?: string;
6
+ /** Human-readable route description for generated tooling. */
7
+ description?: string;
8
+ };
9
+ export type RouteMetadataMarker = {
10
+ readonly [routeMetadataKey]: RouteMetadata;
11
+ };
12
+ /** Attach runtime metadata to a route declaration. */
13
+ export declare function metadata(value: RouteMetadata): RouteMetadataMarker;
14
+ export declare function getRouteMetadata(value: unknown): RouteMetadata | undefined;
15
+ export declare function stripRouteMetadata<T extends object>(value: T): Omit<T, typeof routeMetadataKey>;
16
+ export {};
@@ -0,0 +1,14 @@
1
+ const routeMetadataKey = Symbol('rouzer.metadata');
2
+ /** Attach runtime metadata to a route declaration. */
3
+ export function metadata(value) {
4
+ return { [routeMetadataKey]: value };
5
+ }
6
+ export function getRouteMetadata(value) {
7
+ return typeof value === 'object' && value !== null
8
+ ? value[routeMetadataKey]
9
+ : undefined;
10
+ }
11
+ export function stripRouteMetadata(value) {
12
+ const { [routeMetadataKey]: _metadata, ...rest } = value;
13
+ return rest;
14
+ }
package/dist/ndjson.d.ts CHANGED
@@ -57,5 +57,5 @@ export declare function decodeNdjson<T = unknown>(stream: ReadableStream<Uint8Ar
57
57
  * `content-type: application/x-ndjson; charset=utf-8` unless the caller supplies
58
58
  * a content type in `init.headers`.
59
59
  */
60
- export declare function ndjsonResponse<T>(source: NdjsonSource<T>, init?: ResponseInit & NdjsonEncodeOptions): Response;
60
+ export declare function ndjsonResponse<T>(source: NdjsonSource<T>, { signal, ...init }?: ResponseInit & NdjsonEncodeOptions): Response;
61
61
  export {};
package/dist/ndjson.js CHANGED
@@ -52,107 +52,184 @@ export const routerPlugin = {
52
52
  * closed.
53
53
  */
54
54
  export function encodeNdjson(source, options = {}) {
55
- const iterator = getAsyncIterator(source);
56
- const encoder = new TextEncoder();
57
- const { signal } = options;
58
- let cancelled = false;
59
- let cleanup;
60
- let abortHandler;
61
- function removeAbortHandler() {
62
- if (signal && abortHandler) {
63
- signal.removeEventListener('abort', abortHandler);
64
- abortHandler = undefined;
65
- }
66
- }
67
- function cancelIterator(reason) {
68
- cancelled = true;
69
- removeAbortHandler();
70
- cleanup ??= Promise.resolve(iterator.return?.(reason)).then(() => { });
71
- return cleanup;
55
+ return new ReadableStream(new NdjsonEncoder(source, options));
56
+ }
57
+ /**
58
+ * Decode a newline-delimited JSON byte stream.
59
+ *
60
+ * @remarks UTF-8 chunks may split JSON lines. Both `\n` and `\r\n` line endings
61
+ * are accepted, and a final line does not need a trailing newline. Malformed
62
+ * lines throw a `SyntaxError` that includes the 1-based line number.
63
+ */
64
+ export function decodeNdjson(stream) {
65
+ return new NdjsonDecoder(stream);
66
+ }
67
+ class NdjsonEncoder {
68
+ options;
69
+ iterator;
70
+ encoder = new TextEncoder();
71
+ cancelled = false;
72
+ cleanup;
73
+ abortHandler;
74
+ constructor(source, options) {
75
+ this.options = options;
76
+ this.iterator = getAsyncIterator(source);
72
77
  }
73
- return new ReadableStream({
74
- start(controller) {
75
- if (!signal) {
76
- return;
77
- }
78
- abortHandler = () => {
79
- void cancelIterator(signal.reason).catch(() => { });
78
+ start(controller) {
79
+ const { signal } = this.options;
80
+ if (signal) {
81
+ this.abortHandler = () => {
82
+ void this.cancel(signal.reason).catch(() => { });
80
83
  try {
81
84
  controller.close();
82
85
  }
83
86
  catch { }
84
87
  };
85
88
  if (signal.aborted) {
86
- abortHandler();
87
- return;
89
+ this.abortHandler();
88
90
  }
89
- signal.addEventListener('abort', abortHandler, { once: true });
90
- },
91
- async pull(controller) {
92
- if (cancelled) {
93
- controller.close();
94
- return;
91
+ else {
92
+ signal.addEventListener('abort', this.abortHandler, { once: true });
95
93
  }
96
- const { done, value } = await iterator.next();
97
- if (cancelled) {
98
- return;
94
+ }
95
+ }
96
+ async pull(controller) {
97
+ if (this.cancelled) {
98
+ controller.close();
99
+ return;
100
+ }
101
+ const { done, value } = await this.iterator.next();
102
+ if (this.cancelled) {
103
+ return;
104
+ }
105
+ if (done) {
106
+ this.removeAbortHandler();
107
+ controller.close();
108
+ return;
109
+ }
110
+ const line = JSON.stringify(value);
111
+ if (line === undefined) {
112
+ throw new TypeError('NDJSON items must serialize to a JSON text; received undefined');
113
+ }
114
+ controller.enqueue(this.encoder.encode(`${line}\n`));
115
+ }
116
+ async cancel(reason) {
117
+ if (!this.cancelled) {
118
+ this.cancelled = true;
119
+ this.removeAbortHandler();
120
+ this.cleanup ??= Promise.resolve(this.iterator.return?.(reason)).then(() => { });
121
+ }
122
+ await this.cleanup;
123
+ }
124
+ removeAbortHandler() {
125
+ const { signal } = this.options;
126
+ if (signal && this.abortHandler) {
127
+ signal.removeEventListener('abort', this.abortHandler);
128
+ this.abortHandler = undefined;
129
+ }
130
+ }
131
+ }
132
+ class NdjsonDecoder {
133
+ reader;
134
+ decoder = new TextDecoder();
135
+ buffer = '';
136
+ lineNumber = 0;
137
+ closed = false;
138
+ doneReading = false;
139
+ readerReleased = false;
140
+ constructor(stream) {
141
+ this.reader = stream.getReader();
142
+ }
143
+ [Symbol.asyncIterator]() {
144
+ return new NdjsonAsyncIterator(this);
145
+ }
146
+ releaseReader() {
147
+ if (!this.readerReleased) {
148
+ this.readerReleased = true;
149
+ this.reader.releaseLock();
150
+ }
151
+ }
152
+ async cancelReader(reason) {
153
+ if (!this.doneReading) {
154
+ await this.reader.cancel(reason).catch(() => { });
155
+ }
156
+ this.releaseReader();
157
+ }
158
+ async parseNextLine(line) {
159
+ try {
160
+ this.lineNumber += 1;
161
+ return {
162
+ done: false,
163
+ value: parseNdjsonLine(stripCarriageReturn(line), this.lineNumber),
164
+ };
165
+ }
166
+ catch (error) {
167
+ this.closed = true;
168
+ await this.cancelReader(error);
169
+ throw error;
170
+ }
171
+ }
172
+ }
173
+ class NdjsonAsyncIterator {
174
+ decoder;
175
+ closed = false;
176
+ constructor(decoder) {
177
+ this.decoder = decoder;
178
+ }
179
+ async next() {
180
+ if (this.closed || this.decoder.closed) {
181
+ return { done: true, value: undefined };
182
+ }
183
+ while (true) {
184
+ const newlineIndex = this.decoder.buffer.indexOf('\n');
185
+ if (newlineIndex !== -1) {
186
+ const line = this.decoder.buffer.slice(0, newlineIndex);
187
+ this.decoder.buffer = this.decoder.buffer.slice(newlineIndex + 1);
188
+ return this.decoder.parseNextLine(line);
99
189
  }
100
- if (done) {
101
- removeAbortHandler();
102
- controller.close();
103
- return;
190
+ if (this.decoder.doneReading) {
191
+ this.close();
192
+ this.decoder.releaseReader();
193
+ if (this.decoder.buffer.length > 0) {
194
+ const line = this.decoder.buffer;
195
+ this.decoder.buffer = '';
196
+ return this.decoder.parseNextLine(line);
197
+ }
198
+ return { done: true, value: undefined };
104
199
  }
105
- const line = JSON.stringify(value);
106
- if (line === undefined) {
107
- throw new TypeError('NDJSON items must serialize to a JSON text; received undefined');
200
+ let chunk;
201
+ try {
202
+ chunk = await this.decoder.reader.read();
108
203
  }
109
- controller.enqueue(encoder.encode(`${line}\n`));
110
- },
111
- async cancel(reason) {
112
- await cancelIterator(reason);
113
- },
114
- });
115
- }
116
- /**
117
- * Decode a newline-delimited JSON byte stream.
118
- *
119
- * @remarks UTF-8 chunks may split JSON lines. Both `\n` and `\r\n` line endings
120
- * are accepted, and a final line does not need a trailing newline. Malformed
121
- * lines throw a `SyntaxError` that includes the 1-based line number.
122
- */
123
- export async function* decodeNdjson(stream) {
124
- const reader = stream.getReader();
125
- const decoder = new TextDecoder();
126
- let buffer = '';
127
- let lineNumber = 0;
128
- let doneReading = false;
129
- try {
130
- while (true) {
131
- const { done, value } = await reader.read();
132
- if (done) {
133
- buffer += decoder.decode();
134
- doneReading = true;
135
- break;
204
+ catch (error) {
205
+ this.close();
206
+ this.decoder.releaseReader();
207
+ throw error;
208
+ }
209
+ if (this.closed || this.decoder.closed) {
210
+ return { done: true, value: undefined };
136
211
  }
137
- buffer += decoder.decode(value, { stream: true });
138
- let newlineIndex;
139
- while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
140
- const line = stripCarriageReturn(buffer.slice(0, newlineIndex));
141
- buffer = buffer.slice(newlineIndex + 1);
142
- lineNumber += 1;
143
- yield parseNdjsonLine(line, lineNumber);
212
+ if (chunk.done) {
213
+ this.decoder.buffer += this.decoder.decoder.decode();
214
+ this.decoder.doneReading = true;
215
+ }
216
+ else {
217
+ this.decoder.buffer += this.decoder.decoder.decode(chunk.value, {
218
+ stream: true,
219
+ });
144
220
  }
145
- }
146
- if (buffer.length > 0) {
147
- lineNumber += 1;
148
- yield parseNdjsonLine(stripCarriageReturn(buffer), lineNumber);
149
221
  }
150
222
  }
151
- finally {
152
- if (!doneReading) {
153
- await reader.cancel().catch(() => { });
223
+ async return(reason) {
224
+ if (!this.closed) {
225
+ this.close();
226
+ await this.decoder.cancelReader(reason);
154
227
  }
155
- reader.releaseLock();
228
+ return { done: true, value: undefined };
229
+ }
230
+ close() {
231
+ this.closed = true;
232
+ this.decoder.closed = true;
156
233
  }
157
234
  }
158
235
  /**
@@ -162,32 +239,28 @@ export async function* decodeNdjson(stream) {
162
239
  * `content-type: application/x-ndjson; charset=utf-8` unless the caller supplies
163
240
  * a content type in `init.headers`.
164
241
  */
165
- export function ndjsonResponse(source, init = {}) {
166
- const { signal, ...responseInit } = init;
242
+ export function ndjsonResponse(source, { signal, ...init } = {}) {
167
243
  const headers = new Headers(init.headers);
168
244
  if (!headers.has('content-type')) {
169
245
  headers.set('content-type', 'application/x-ndjson; charset=utf-8');
170
246
  }
171
247
  return new Response(encodeNdjson(source, { signal }), {
172
- ...responseInit,
248
+ ...init,
173
249
  headers,
174
250
  });
175
251
  }
176
252
  function getAsyncIterator(source) {
177
- const asyncIterator = source[Symbol.asyncIterator]?.();
178
- if (asyncIterator) {
179
- return asyncIterator;
253
+ if (Symbol.asyncIterator in source) {
254
+ return source[Symbol.asyncIterator]();
180
255
  }
181
- const iterator = source[Symbol.iterator]?.();
182
- if (iterator) {
256
+ if (Symbol.iterator in source) {
257
+ const iterator = source[Symbol.iterator]();
183
258
  return {
184
- next() {
185
- return Promise.resolve(iterator.next());
186
- },
187
- async return() {
188
- iterator.return?.();
189
- return { done: true, value: undefined };
190
- },
259
+ next: async (value) => iterator.next(value),
260
+ return: iterator.return
261
+ ? async (value) => iterator.return(value)
262
+ : undefined,
263
+ throw: iterator.throw ? async (error) => iterator.throw(error) : undefined,
191
264
  };
192
265
  }
193
266
  throw new TypeError('NDJSON source must be iterable');
package/docs/context.md CHANGED
@@ -72,6 +72,29 @@ paths are joined, so the examples above expose `profiles/:id`, `profiles/:id`,
72
72
  and `profiles/:id/posts`. Path params from parent resources are accumulated into
73
73
  child action types.
74
74
 
75
+ Use the root `metadata(...)` helper to attach optional runtime metadata to
76
+ resources or actions without changing route matching, validation, client typing,
77
+ or handler behavior:
78
+
79
+ ```ts
80
+ import { metadata } from 'rouzer'
81
+
82
+ export const sessions = http.resource('sessions', {
83
+ ...metadata({
84
+ description: 'Daemon-managed session control.',
85
+ }),
86
+ list: http.post('list', {
87
+ ...metadata({
88
+ description: 'Lists daemon-managed sessions and pagination state.',
89
+ }),
90
+ response: $type<SessionList>(),
91
+ }),
92
+ })
93
+ ```
94
+
95
+ Constructed route nodes expose metadata through `node.metadata` for generated
96
+ clients, CLIs, docs, and route inspectors.
97
+
75
98
  Patterns are parsed by `@remix-run/route-pattern` v0.21. Params can be inferred
76
99
  from patterns such as `hello/:name`, `v:major.:minor`,
77
100
  `api(/v:major(.:minor))`, `assets/*path`, and `search?q`. Full URL patterns such
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rouzer",
3
- "version": "5.2.1",
3
+ "version": "5.3.0",
4
4
  "packageManager": "pnpm@11.5.1",
5
5
  "type": "module",
6
6
  "exports": {