@orkestrel/router 0.0.12 → 0.0.14
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 +18 -15
- package/dist/src/browser/index.d.ts +83 -66
- package/dist/src/browser/index.js +61 -52
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/core/index.cjs +214 -131
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +1050 -918
- package/dist/src/core/index.d.ts +1050 -918
- package/dist/src/core/index.js +213 -131
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +39 -26
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +38 -29
- package/dist/src/server/index.d.ts +38 -29
- package/dist/src/server/index.js +39 -26
- package/dist/src/server/index.js.map +1 -1
- package/package.json +13 -14
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { DispatcherInterface } from '@orkestrel/router';
|
|
2
|
-
import { IncomingMessage } from 'node:http';
|
|
3
|
-
import { ServerResponse } from 'node:http';
|
|
1
|
+
import type { DispatcherInterface } from '@orkestrel/router';
|
|
2
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
import type { ServerResponse } from 'node:http';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* server-adapter half of the
|
|
6
|
+
* Builds a fetch-standard `Request` from a `node:http` `IncomingMessage` — the
|
|
7
|
+
* server-adapter half of the fetch/node conversion seam.
|
|
8
8
|
*
|
|
9
9
|
* @remarks
|
|
10
10
|
* - `method` is carried over verbatim (defaulting to `GET` when absent).
|
|
@@ -29,7 +29,7 @@ import { ServerResponse } from 'node:http';
|
|
|
29
29
|
*
|
|
30
30
|
* @param message - The raw `node:http` request
|
|
31
31
|
* @param options - Optional `origin` override and paired `response` for
|
|
32
|
-
* response-side disconnect tracking (
|
|
32
|
+
* response-side disconnect tracking ({@link RequestOptions})
|
|
33
33
|
* @returns A fetch `Request` whose `signal` fires on an incomplete request, or
|
|
34
34
|
* on a response-side client disconnect when `options.response` is provided
|
|
35
35
|
*
|
|
@@ -47,20 +47,20 @@ import { ServerResponse } from 'node:http';
|
|
|
47
47
|
export declare function buildRequest(message: IncomingMessage, options?: RequestOptions): Request;
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
51
|
-
* the whole server face's entry point
|
|
52
|
-
* a fetch `Request`,
|
|
53
|
-
* `state`, and
|
|
50
|
+
* Creates a `node:http` request listener over a core {@link DispatcherInterface} —
|
|
51
|
+
* the whole server face's entry point: converts the incoming message to
|
|
52
|
+
* a fetch `Request`, hands it to the dispatcher with the consumer's per-request
|
|
53
|
+
* `state`, and writes the resulting `Response` back.
|
|
54
54
|
*
|
|
55
55
|
* @remarks
|
|
56
56
|
* A rejected `dispatcher.handle` (a route handler throw — the dispatcher
|
|
57
|
-
* never invents an error boundary
|
|
57
|
+
* never invents an error boundary) is this listener's transport-level
|
|
58
58
|
* LAST RESORT, distinct from an application error boundary: when nothing has
|
|
59
59
|
* been sent yet, it destroys the connection with a bare `500` head (never
|
|
60
|
-
* leaking a hanging socket);
|
|
60
|
+
* leaking a hanging socket); after headers are already sent, it destroys the
|
|
61
61
|
* connection outright. The router still owns no error POLICY — a consumer
|
|
62
62
|
* that wants mapped error responses installs its own boundary around
|
|
63
|
-
* `dispatcher.handle` (the future `@orkestrel/server` seam
|
|
63
|
+
* `dispatcher.handle` (the future `@orkestrel/server` seam).
|
|
64
64
|
*
|
|
65
65
|
* @typeParam TState - The consumer's opaque per-request state type
|
|
66
66
|
* @param dispatcher - The core dispatcher to run each converted request through
|
|
@@ -70,19 +70,28 @@ export declare function buildRequest(message: IncomingMessage, options?: Request
|
|
|
70
70
|
*
|
|
71
71
|
* @example
|
|
72
72
|
* ```ts
|
|
73
|
-
* import { createListener } from '@
|
|
74
|
-
* import { createDispatcher } from '@
|
|
73
|
+
* import { createListener } from '@orkestrel/router/server'
|
|
74
|
+
* import { createDispatcher } from '@orkestrel/router'
|
|
75
75
|
* import http from 'node:http'
|
|
76
76
|
*
|
|
77
|
-
* const dispatcher = createDispatcher()
|
|
78
|
-
* dispatcher.add({
|
|
79
|
-
*
|
|
77
|
+
* const dispatcher = createDispatcher<{ readonly requestId: string }>()
|
|
78
|
+
* dispatcher.add({
|
|
79
|
+
* method: 'GET',
|
|
80
|
+
* path: '/users/:id',
|
|
81
|
+
* handler: (_request, context) =>
|
|
82
|
+
* Response.json({ id: context.params.id, requestId: context.state.requestId }),
|
|
83
|
+
* })
|
|
84
|
+
*
|
|
85
|
+
* const server = http.createServer(
|
|
86
|
+
* createListener(dispatcher, () => ({ requestId: crypto.randomUUID() })),
|
|
87
|
+
* )
|
|
88
|
+
* server.listen(0)
|
|
80
89
|
* ```
|
|
81
90
|
*/
|
|
82
91
|
export declare function createListener<TState>(dispatcher: DispatcherInterface<TState>, state: StateFunction<TState>): ListenerFunction;
|
|
83
92
|
|
|
84
93
|
/**
|
|
85
|
-
*
|
|
94
|
+
* Handles one `node:http` request through a core dispatcher and writes its
|
|
86
95
|
* fetch-standard response.
|
|
87
96
|
*
|
|
88
97
|
* @remarks
|
|
@@ -108,13 +117,13 @@ export declare function createListener<TState>(dispatcher: DispatcherInterface<T
|
|
|
108
117
|
export declare function handleListenerRequest<TState>(dispatcher: DispatcherInterface<TState>, state: StateFunction<TState>, request: IncomingMessage, response: ServerResponse): Promise<void>;
|
|
109
118
|
|
|
110
119
|
/**
|
|
111
|
-
*
|
|
112
|
-
* total, never-throwing narrow
|
|
120
|
+
* Determines whether a `node:http` connection socket is TLS-encrypted — the
|
|
121
|
+
* total, never-throwing narrow `buildRequest` uses to pick the
|
|
113
122
|
* derived scheme (`https` vs `http`).
|
|
114
123
|
*
|
|
115
124
|
* @param socket - The connection value to test (typically `message.socket`)
|
|
116
|
-
* @returns
|
|
117
|
-
* `tls.TLSSocket`)
|
|
125
|
+
* @returns True if `socket` carries a truthy `encrypted` property (a
|
|
126
|
+
* `tls.TLSSocket`); false otherwise, including for `undefined`
|
|
118
127
|
*
|
|
119
128
|
* @example
|
|
120
129
|
* ```ts
|
|
@@ -129,7 +138,7 @@ export declare function isEncryptedSocket(socket: unknown): socket is {
|
|
|
129
138
|
};
|
|
130
139
|
|
|
131
140
|
/**
|
|
132
|
-
*
|
|
141
|
+
* Represents a `node:http` request handler — the function `createListener` returns,
|
|
133
142
|
* matching `http.createServer`'s handler signature.
|
|
134
143
|
*
|
|
135
144
|
* @remarks
|
|
@@ -140,7 +149,7 @@ export declare function isEncryptedSocket(socket: unknown): socket is {
|
|
|
140
149
|
export declare type ListenerFunction = (request: IncomingMessage, response: ServerResponse) => void;
|
|
141
150
|
|
|
142
151
|
/**
|
|
143
|
-
*
|
|
152
|
+
* Represents the options for `buildRequest` — URL origin and response-side disconnect tracking.
|
|
144
153
|
*
|
|
145
154
|
* @remarks
|
|
146
155
|
* - `origin` — an explicit scheme + host to build the request URL against
|
|
@@ -156,12 +165,12 @@ export declare interface RequestOptions {
|
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
/**
|
|
159
|
-
*
|
|
160
|
-
* the reverse half of the
|
|
168
|
+
* Writes a fetch-standard `Response` back to a `node:http` `ServerResponse` —
|
|
169
|
+
* the reverse half of the fetch/node conversion seam.
|
|
161
170
|
*
|
|
162
171
|
* @remarks
|
|
163
172
|
* Writes `status`/`statusText`, then every response header (`set-cookie`
|
|
164
|
-
* written
|
|
173
|
+
* written through {@link Headers.getSetCookie} so multiple cookies stay distinct
|
|
165
174
|
* instead of collapsing into one comma-joined header), then streams the web
|
|
166
175
|
* body to `target` chunk by chunk (`for await` over `response.body`), ending
|
|
167
176
|
* `target` when the stream completes. When a write reports backpressure, the
|
|
@@ -174,7 +183,7 @@ export declare interface RequestOptions {
|
|
|
174
183
|
*
|
|
175
184
|
* @param response - The fetch `Response` to write
|
|
176
185
|
* @param target - The `node:http` response to write it to
|
|
177
|
-
* @returns A promise that resolves
|
|
186
|
+
* @returns A promise that resolves after `target` has been ended (or the
|
|
178
187
|
* stream stopped because `target` was destroyed)
|
|
179
188
|
*
|
|
180
189
|
* @example
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { DispatcherInterface } from '@orkestrel/router';
|
|
2
|
-
import { IncomingMessage } from 'node:http';
|
|
3
|
-
import { ServerResponse } from 'node:http';
|
|
1
|
+
import type { DispatcherInterface } from '@orkestrel/router';
|
|
2
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
import type { ServerResponse } from 'node:http';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* server-adapter half of the
|
|
6
|
+
* Builds a fetch-standard `Request` from a `node:http` `IncomingMessage` — the
|
|
7
|
+
* server-adapter half of the fetch/node conversion seam.
|
|
8
8
|
*
|
|
9
9
|
* @remarks
|
|
10
10
|
* - `method` is carried over verbatim (defaulting to `GET` when absent).
|
|
@@ -29,7 +29,7 @@ import { ServerResponse } from 'node:http';
|
|
|
29
29
|
*
|
|
30
30
|
* @param message - The raw `node:http` request
|
|
31
31
|
* @param options - Optional `origin` override and paired `response` for
|
|
32
|
-
* response-side disconnect tracking (
|
|
32
|
+
* response-side disconnect tracking ({@link RequestOptions})
|
|
33
33
|
* @returns A fetch `Request` whose `signal` fires on an incomplete request, or
|
|
34
34
|
* on a response-side client disconnect when `options.response` is provided
|
|
35
35
|
*
|
|
@@ -47,20 +47,20 @@ import { ServerResponse } from 'node:http';
|
|
|
47
47
|
export declare function buildRequest(message: IncomingMessage, options?: RequestOptions): Request;
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
51
|
-
* the whole server face's entry point
|
|
52
|
-
* a fetch `Request`,
|
|
53
|
-
* `state`, and
|
|
50
|
+
* Creates a `node:http` request listener over a core {@link DispatcherInterface} —
|
|
51
|
+
* the whole server face's entry point: converts the incoming message to
|
|
52
|
+
* a fetch `Request`, hands it to the dispatcher with the consumer's per-request
|
|
53
|
+
* `state`, and writes the resulting `Response` back.
|
|
54
54
|
*
|
|
55
55
|
* @remarks
|
|
56
56
|
* A rejected `dispatcher.handle` (a route handler throw — the dispatcher
|
|
57
|
-
* never invents an error boundary
|
|
57
|
+
* never invents an error boundary) is this listener's transport-level
|
|
58
58
|
* LAST RESORT, distinct from an application error boundary: when nothing has
|
|
59
59
|
* been sent yet, it destroys the connection with a bare `500` head (never
|
|
60
|
-
* leaking a hanging socket);
|
|
60
|
+
* leaking a hanging socket); after headers are already sent, it destroys the
|
|
61
61
|
* connection outright. The router still owns no error POLICY — a consumer
|
|
62
62
|
* that wants mapped error responses installs its own boundary around
|
|
63
|
-
* `dispatcher.handle` (the future `@orkestrel/server` seam
|
|
63
|
+
* `dispatcher.handle` (the future `@orkestrel/server` seam).
|
|
64
64
|
*
|
|
65
65
|
* @typeParam TState - The consumer's opaque per-request state type
|
|
66
66
|
* @param dispatcher - The core dispatcher to run each converted request through
|
|
@@ -70,19 +70,28 @@ export declare function buildRequest(message: IncomingMessage, options?: Request
|
|
|
70
70
|
*
|
|
71
71
|
* @example
|
|
72
72
|
* ```ts
|
|
73
|
-
* import { createListener } from '@
|
|
74
|
-
* import { createDispatcher } from '@
|
|
73
|
+
* import { createListener } from '@orkestrel/router/server'
|
|
74
|
+
* import { createDispatcher } from '@orkestrel/router'
|
|
75
75
|
* import http from 'node:http'
|
|
76
76
|
*
|
|
77
|
-
* const dispatcher = createDispatcher()
|
|
78
|
-
* dispatcher.add({
|
|
79
|
-
*
|
|
77
|
+
* const dispatcher = createDispatcher<{ readonly requestId: string }>()
|
|
78
|
+
* dispatcher.add({
|
|
79
|
+
* method: 'GET',
|
|
80
|
+
* path: '/users/:id',
|
|
81
|
+
* handler: (_request, context) =>
|
|
82
|
+
* Response.json({ id: context.params.id, requestId: context.state.requestId }),
|
|
83
|
+
* })
|
|
84
|
+
*
|
|
85
|
+
* const server = http.createServer(
|
|
86
|
+
* createListener(dispatcher, () => ({ requestId: crypto.randomUUID() })),
|
|
87
|
+
* )
|
|
88
|
+
* server.listen(0)
|
|
80
89
|
* ```
|
|
81
90
|
*/
|
|
82
91
|
export declare function createListener<TState>(dispatcher: DispatcherInterface<TState>, state: StateFunction<TState>): ListenerFunction;
|
|
83
92
|
|
|
84
93
|
/**
|
|
85
|
-
*
|
|
94
|
+
* Handles one `node:http` request through a core dispatcher and writes its
|
|
86
95
|
* fetch-standard response.
|
|
87
96
|
*
|
|
88
97
|
* @remarks
|
|
@@ -108,13 +117,13 @@ export declare function createListener<TState>(dispatcher: DispatcherInterface<T
|
|
|
108
117
|
export declare function handleListenerRequest<TState>(dispatcher: DispatcherInterface<TState>, state: StateFunction<TState>, request: IncomingMessage, response: ServerResponse): Promise<void>;
|
|
109
118
|
|
|
110
119
|
/**
|
|
111
|
-
*
|
|
112
|
-
* total, never-throwing narrow
|
|
120
|
+
* Determines whether a `node:http` connection socket is TLS-encrypted — the
|
|
121
|
+
* total, never-throwing narrow `buildRequest` uses to pick the
|
|
113
122
|
* derived scheme (`https` vs `http`).
|
|
114
123
|
*
|
|
115
124
|
* @param socket - The connection value to test (typically `message.socket`)
|
|
116
|
-
* @returns
|
|
117
|
-
* `tls.TLSSocket`)
|
|
125
|
+
* @returns True if `socket` carries a truthy `encrypted` property (a
|
|
126
|
+
* `tls.TLSSocket`); false otherwise, including for `undefined`
|
|
118
127
|
*
|
|
119
128
|
* @example
|
|
120
129
|
* ```ts
|
|
@@ -129,7 +138,7 @@ export declare function isEncryptedSocket(socket: unknown): socket is {
|
|
|
129
138
|
};
|
|
130
139
|
|
|
131
140
|
/**
|
|
132
|
-
*
|
|
141
|
+
* Represents a `node:http` request handler — the function `createListener` returns,
|
|
133
142
|
* matching `http.createServer`'s handler signature.
|
|
134
143
|
*
|
|
135
144
|
* @remarks
|
|
@@ -140,7 +149,7 @@ export declare function isEncryptedSocket(socket: unknown): socket is {
|
|
|
140
149
|
export declare type ListenerFunction = (request: IncomingMessage, response: ServerResponse) => void;
|
|
141
150
|
|
|
142
151
|
/**
|
|
143
|
-
*
|
|
152
|
+
* Represents the options for `buildRequest` — URL origin and response-side disconnect tracking.
|
|
144
153
|
*
|
|
145
154
|
* @remarks
|
|
146
155
|
* - `origin` — an explicit scheme + host to build the request URL against
|
|
@@ -156,12 +165,12 @@ export declare interface RequestOptions {
|
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
/**
|
|
159
|
-
*
|
|
160
|
-
* the reverse half of the
|
|
168
|
+
* Writes a fetch-standard `Response` back to a `node:http` `ServerResponse` —
|
|
169
|
+
* the reverse half of the fetch/node conversion seam.
|
|
161
170
|
*
|
|
162
171
|
* @remarks
|
|
163
172
|
* Writes `status`/`statusText`, then every response header (`set-cookie`
|
|
164
|
-
* written
|
|
173
|
+
* written through {@link Headers.getSetCookie} so multiple cookies stay distinct
|
|
165
174
|
* instead of collapsing into one comma-joined header), then streams the web
|
|
166
175
|
* body to `target` chunk by chunk (`for await` over `response.body`), ending
|
|
167
176
|
* `target` when the stream completes. When a write reports backpressure, the
|
|
@@ -174,7 +183,7 @@ export declare interface RequestOptions {
|
|
|
174
183
|
*
|
|
175
184
|
* @param response - The fetch `Response` to write
|
|
176
185
|
* @param target - The `node:http` response to write it to
|
|
177
|
-
* @returns A promise that resolves
|
|
186
|
+
* @returns A promise that resolves after `target` has been ended (or the
|
|
178
187
|
* stream stopped because `target` was destroyed)
|
|
179
188
|
*
|
|
180
189
|
* @example
|
package/dist/src/server/index.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
+
import { isRecord } from "@orkestrel/contract";
|
|
1
2
|
import { once } from "node:events";
|
|
2
3
|
import { createAbort } from "@orkestrel/abort";
|
|
3
|
-
|
|
4
|
-
//#region src/server/helpers.ts
|
|
4
|
+
//#region src/server/validators.ts
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* total, never-throwing narrow
|
|
6
|
+
* Determines whether a `node:http` connection socket is TLS-encrypted — the
|
|
7
|
+
* total, never-throwing narrow `buildRequest` uses to pick the
|
|
8
8
|
* derived scheme (`https` vs `http`).
|
|
9
9
|
*
|
|
10
10
|
* @param socket - The connection value to test (typically `message.socket`)
|
|
11
|
-
* @returns
|
|
12
|
-
* `tls.TLSSocket`)
|
|
11
|
+
* @returns True if `socket` carries a truthy `encrypted` property (a
|
|
12
|
+
* `tls.TLSSocket`); false otherwise, including for `undefined`
|
|
13
13
|
*
|
|
14
14
|
* @example
|
|
15
15
|
* ```ts
|
|
@@ -22,9 +22,11 @@ import { isRecord } from "@orkestrel/contract";
|
|
|
22
22
|
function isEncryptedSocket(socket) {
|
|
23
23
|
return isRecord(socket) && socket.encrypted === true;
|
|
24
24
|
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/server/helpers.ts
|
|
25
27
|
/**
|
|
26
|
-
*
|
|
27
|
-
* server-adapter half of the
|
|
28
|
+
* Builds a fetch-standard `Request` from a `node:http` `IncomingMessage` — the
|
|
29
|
+
* server-adapter half of the fetch/node conversion seam.
|
|
28
30
|
*
|
|
29
31
|
* @remarks
|
|
30
32
|
* - `method` is carried over verbatim (defaulting to `GET` when absent).
|
|
@@ -49,7 +51,7 @@ function isEncryptedSocket(socket) {
|
|
|
49
51
|
*
|
|
50
52
|
* @param message - The raw `node:http` request
|
|
51
53
|
* @param options - Optional `origin` override and paired `response` for
|
|
52
|
-
* response-side disconnect tracking (
|
|
54
|
+
* response-side disconnect tracking ({@link RequestOptions})
|
|
53
55
|
* @returns A fetch `Request` whose `signal` fires on an incomplete request, or
|
|
54
56
|
* on a response-side client disconnect when `options.response` is provided
|
|
55
57
|
*
|
|
@@ -110,12 +112,12 @@ function buildRequest(message, options) {
|
|
|
110
112
|
return new Request(url, streamed);
|
|
111
113
|
}
|
|
112
114
|
/**
|
|
113
|
-
*
|
|
114
|
-
* the reverse half of the
|
|
115
|
+
* Writes a fetch-standard `Response` back to a `node:http` `ServerResponse` —
|
|
116
|
+
* the reverse half of the fetch/node conversion seam.
|
|
115
117
|
*
|
|
116
118
|
* @remarks
|
|
117
119
|
* Writes `status`/`statusText`, then every response header (`set-cookie`
|
|
118
|
-
* written
|
|
120
|
+
* written through {@link Headers.getSetCookie} so multiple cookies stay distinct
|
|
119
121
|
* instead of collapsing into one comma-joined header), then streams the web
|
|
120
122
|
* body to `target` chunk by chunk (`for await` over `response.body`), ending
|
|
121
123
|
* `target` when the stream completes. When a write reports backpressure, the
|
|
@@ -128,7 +130,7 @@ function buildRequest(message, options) {
|
|
|
128
130
|
*
|
|
129
131
|
* @param response - The fetch `Response` to write
|
|
130
132
|
* @param target - The `node:http` response to write it to
|
|
131
|
-
* @returns A promise that resolves
|
|
133
|
+
* @returns A promise that resolves after `target` has been ended (or the
|
|
132
134
|
* stream stopped because `target` was destroyed)
|
|
133
135
|
*
|
|
134
136
|
* @example
|
|
@@ -173,8 +175,10 @@ async function sendResponse(response, target) {
|
|
|
173
175
|
if (!target.destroyed) target.end();
|
|
174
176
|
}
|
|
175
177
|
}
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/server/handlers.ts
|
|
176
180
|
/**
|
|
177
|
-
*
|
|
181
|
+
* Handles one `node:http` request through a core dispatcher and writes its
|
|
178
182
|
* fetch-standard response.
|
|
179
183
|
*
|
|
180
184
|
* @remarks
|
|
@@ -209,20 +213,20 @@ async function handleListenerRequest(dispatcher, state, request, response) {
|
|
|
209
213
|
}
|
|
210
214
|
}
|
|
211
215
|
/**
|
|
212
|
-
*
|
|
213
|
-
* the whole server face's entry point
|
|
214
|
-
* a fetch `Request`,
|
|
215
|
-
* `state`, and
|
|
216
|
+
* Creates a `node:http` request listener over a core {@link DispatcherInterface} —
|
|
217
|
+
* the whole server face's entry point: converts the incoming message to
|
|
218
|
+
* a fetch `Request`, hands it to the dispatcher with the consumer's per-request
|
|
219
|
+
* `state`, and writes the resulting `Response` back.
|
|
216
220
|
*
|
|
217
221
|
* @remarks
|
|
218
222
|
* A rejected `dispatcher.handle` (a route handler throw — the dispatcher
|
|
219
|
-
* never invents an error boundary
|
|
223
|
+
* never invents an error boundary) is this listener's transport-level
|
|
220
224
|
* LAST RESORT, distinct from an application error boundary: when nothing has
|
|
221
225
|
* been sent yet, it destroys the connection with a bare `500` head (never
|
|
222
|
-
* leaking a hanging socket);
|
|
226
|
+
* leaking a hanging socket); after headers are already sent, it destroys the
|
|
223
227
|
* connection outright. The router still owns no error POLICY — a consumer
|
|
224
228
|
* that wants mapped error responses installs its own boundary around
|
|
225
|
-
* `dispatcher.handle` (the future `@orkestrel/server` seam
|
|
229
|
+
* `dispatcher.handle` (the future `@orkestrel/server` seam).
|
|
226
230
|
*
|
|
227
231
|
* @typeParam TState - The consumer's opaque per-request state type
|
|
228
232
|
* @param dispatcher - The core dispatcher to run each converted request through
|
|
@@ -232,13 +236,22 @@ async function handleListenerRequest(dispatcher, state, request, response) {
|
|
|
232
236
|
*
|
|
233
237
|
* @example
|
|
234
238
|
* ```ts
|
|
235
|
-
* import { createListener } from '@
|
|
236
|
-
* import { createDispatcher } from '@
|
|
239
|
+
* import { createListener } from '@orkestrel/router/server'
|
|
240
|
+
* import { createDispatcher } from '@orkestrel/router'
|
|
237
241
|
* import http from 'node:http'
|
|
238
242
|
*
|
|
239
|
-
* const dispatcher = createDispatcher()
|
|
240
|
-
* dispatcher.add({
|
|
241
|
-
*
|
|
243
|
+
* const dispatcher = createDispatcher<{ readonly requestId: string }>()
|
|
244
|
+
* dispatcher.add({
|
|
245
|
+
* method: 'GET',
|
|
246
|
+
* path: '/users/:id',
|
|
247
|
+
* handler: (_request, context) =>
|
|
248
|
+
* Response.json({ id: context.params.id, requestId: context.state.requestId }),
|
|
249
|
+
* })
|
|
250
|
+
*
|
|
251
|
+
* const server = http.createServer(
|
|
252
|
+
* createListener(dispatcher, () => ({ requestId: crypto.randomUUID() })),
|
|
253
|
+
* )
|
|
254
|
+
* server.listen(0)
|
|
242
255
|
* ```
|
|
243
256
|
*/
|
|
244
257
|
function createListener(dispatcher, state) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../../src/server/helpers.ts"],"sourcesContent":["// ============================================================================\n// Pure conversion + glue between `node:http` and the fetch vocabulary the\n// core `Dispatcher` speaks — no lifecycle, no listener ownership beyond the\n// handler function `createListener` returns (§5.3). Every function is\n// exported per AGENTS §5.\n// ============================================================================\n\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { DispatcherInterface } from '@src/core'\nimport type { ListenerFunction, RequestOptions, StateFunction } from './types.js'\nimport { once } from 'node:events'\nimport { createAbort } from '@orkestrel/abort'\nimport { isRecord } from '@orkestrel/contract'\n\n/**\n * Determine whether a `node:http` connection socket is TLS-encrypted — the\n * total, never-throwing narrow (AGENTS §14) `buildRequest` uses to pick the\n * derived scheme (`https` vs `http`).\n *\n * @param socket - The connection value to test (typically `message.socket`)\n * @returns `true` when `socket` carries a truthy `encrypted` property (a\n * `tls.TLSSocket`), `false` for anything else (including `undefined`)\n *\n * @example\n * ```ts\n * import { isEncryptedSocket } from '@src/server'\n *\n * isEncryptedSocket({ encrypted: true }) // true\n * isEncryptedSocket({}) // false\n * ```\n */\nexport function isEncryptedSocket(socket: unknown): socket is { readonly encrypted: true } {\n\treturn isRecord(socket) && socket.encrypted === true\n}\n\n/**\n * Build a fetch-standard `Request` from a `node:http` `IncomingMessage` — the\n * server-adapter half of the §5.3 conversion seam.\n *\n * @remarks\n * - `method` is carried over verbatim (defaulting to `GET` when absent).\n * - The URL is built against `options.origin` when given, otherwise a scheme\n * derived from the connection (`https` when {@link isEncryptedSocket}, else\n * `http`) plus the `Host` header (absent `Host` ⇒ `localhost`).\n * - Every request header is copied; multi-value headers are joined per fetch\n * semantics (`', '`-joined), except `set-cookie`, whose values are each\n * appended individually (fetch `Headers` preserves multiple `set-cookie`\n * entries distinctly).\n * - For a method that carries a body (anything but `GET`/`HEAD`), the message\n * is pumped chunk by chunk into a DOM-compatible `ReadableStream<Uint8Array>`\n * (reconciling the DOM + node type worlds under the root config), with\n * `duplex: 'half'` set as Node's fetch implementation requires for a\n * streamed request body.\n * - A fresh `@orkestrel/abort` handle backs `request.signal`. It aborts when\n * the request connection closes before the message finished\n * (`!message.complete`), or when the paired `options.response` closes before\n * its response finished (`!response.writableEnded`). A handler awaiting the\n * signal therefore observes either side of a client disconnect the\n * fetch-standard way, with zero router-specific API.\n *\n * @param message - The raw `node:http` request\n * @param options - Optional `origin` override and paired `response` for\n * response-side disconnect tracking (§5.3 {@link RequestOptions})\n * @returns A fetch `Request` whose `signal` fires on an incomplete request, or\n * on a response-side client disconnect when `options.response` is provided\n *\n * @example\n * ```ts\n * import { buildRequest } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer((incoming, response) => {\n * \tconst request = buildRequest(incoming, { response })\n * \tconsole.log(request.method, request.url)\n * })\n * ```\n */\nexport function buildRequest(message: IncomingMessage, options?: RequestOptions): Request {\n\tconst method = message.method ?? 'GET'\n\tconst host = message.headers.host ?? 'localhost'\n\tconst scheme = isEncryptedSocket(message.socket) ? 'https' : 'http'\n\tconst origin = options?.origin ?? `${scheme}://${host}`\n\tconst url = new URL(message.url ?? '/', origin)\n\n\tconst headers = new Headers()\n\tfor (const [name, value] of Object.entries(message.headers)) {\n\t\tif (value === undefined) continue\n\t\tif (name === 'set-cookie') {\n\t\t\tfor (const cookie of Array.isArray(value) ? value : [value]) headers.append(name, cookie)\n\t\t\tcontinue\n\t\t}\n\t\theaders.set(name, Array.isArray(value) ? value.join(', ') : value)\n\t}\n\n\tconst abort = createAbort()\n\tmessage.once('close', () => {\n\t\tif (!message.complete)\n\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before completion`))\n\t})\n\tconst response = options?.response\n\tif (response !== undefined)\n\t\tresponse.once('close', () => {\n\t\t\tif (!response.writableEnded)\n\t\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before response completed`))\n\t\t})\n\n\tconst carriesBody = method !== 'GET' && method !== 'HEAD'\n\tconst init: RequestInit = { method, headers, signal: abort.signal }\n\tif (!carriesBody) return new Request(url, init)\n\n\tconst body = new ReadableStream<Uint8Array>({\n\t\tasync start(controller) {\n\t\t\ttry {\n\t\t\t\tfor await (const chunk of message) controller.enqueue(chunk)\n\t\t\t\tcontroller.close()\n\t\t\t} catch (error) {\n\t\t\t\tcontroller.error(error)\n\t\t\t}\n\t\t},\n\t})\n\tconst streamed: RequestInit & { readonly duplex: 'half' } = { ...init, body, duplex: 'half' }\n\treturn new Request(url, streamed)\n}\n\n/**\n * Write a fetch-standard `Response` back to a `node:http` `ServerResponse` —\n * the reverse half of the §5.3 conversion seam.\n *\n * @remarks\n * Writes `status`/`statusText`, then every response header (`set-cookie`\n * written via {@link Headers.getSetCookie} so multiple cookies stay distinct\n * instead of collapsing into one comma-joined header), then streams the web\n * body to `target` chunk by chunk (`for await` over `response.body`), ending\n * `target` when the stream completes. When a write reports backpressure, the\n * body pump waits for `drain` before pulling again, unless the target closes,\n * errors, or is destroyed first. A `null` body ends `target` immediately with\n * no further writes. Total error posture: if `target` is destroyed mid-stream\n * (the client disconnected), the write loop stops and `target` is left as-is\n * rather than throwing an unhandled rejection — a destroyed target is not\n * this function's error to surface.\n *\n * @param response - The fetch `Response` to write\n * @param target - The `node:http` response to write it to\n * @returns A promise that resolves once `target` has been ended (or the\n * stream stopped because `target` was destroyed)\n *\n * @example\n * ```ts\n * import { sendResponse } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer(async (_incoming, target) => {\n * \tawait sendResponse(new Response('ok'), target)\n * })\n * ```\n */\nexport async function sendResponse(response: Response, target: ServerResponse): Promise<void> {\n\ttarget.statusCode = response.status\n\ttarget.statusMessage = response.statusText\n\tfor (const [name, value] of response.headers) {\n\t\tif (name === 'set-cookie') continue\n\t\ttarget.setHeader(name, value)\n\t}\n\tconst cookies = response.headers.getSetCookie()\n\tif (cookies.length > 0) target.setHeader('set-cookie', cookies)\n\n\tif (response.body === null) {\n\t\tif (!target.destroyed) target.end()\n\t\treturn\n\t}\n\ttry {\n\t\tfor await (const chunk of response.body) {\n\t\t\tif (target.destroyed) return\n\t\t\tif (!target.write(chunk)) {\n\t\t\t\tif (target.destroyed) return\n\t\t\t\tconst abort = new AbortController()\n\t\t\t\ttry {\n\t\t\t\t\tawait Promise.race([\n\t\t\t\t\t\tonce(target, 'drain', { signal: abort.signal }),\n\t\t\t\t\t\tonce(target, 'close', { signal: abort.signal }),\n\t\t\t\t\t])\n\t\t\t\t} finally {\n\t\t\t\t\tabort.abort()\n\t\t\t\t}\n\t\t\t\tif (target.destroyed) return\n\t\t\t}\n\t\t}\n\t\tif (!target.destroyed) target.end()\n\t} catch {\n\t\tif (!target.destroyed) target.end()\n\t}\n}\n\n/**\n * Handle one `node:http` request through a core dispatcher and write its\n * fetch-standard response.\n *\n * @remarks\n * This is the named asynchronous orchestration behind {@link createListener}.\n * A rejected dispatch is treated only as a transport-level last resort: write\n * a bare `500` before headers, or destroy a response whose headers have\n * already started.\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run\n * @param state - Derives the consumer state from the incoming message\n * @param request - The raw `node:http` request\n * @param response - The raw `node:http` response\n * @returns A promise that settles after the response is written or closed\n *\n * @example\n * ```ts\n * const server = http.createServer((request, response) => {\n * \tvoid handleListenerRequest(dispatcher, () => undefined, request, response)\n * })\n * ```\n */\nexport async function handleListenerRequest<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n\trequest: IncomingMessage,\n\tresponse: ServerResponse,\n): Promise<void> {\n\ttry {\n\t\tconst converted = buildRequest(request, { response })\n\t\tconst result = await dispatcher.handle(converted, state(request))\n\t\tawait sendResponse(result, response)\n\t} catch (error) {\n\t\tif (!response.headersSent && !response.destroyed) {\n\t\t\tresponse.writeHead(500)\n\t\t\tresponse.end()\n\t\t} else if (!response.destroyed) {\n\t\t\tresponse.destroy(error instanceof Error ? error : new Error(String(error)))\n\t\t}\n\t}\n}\n\n/**\n * Create a `node:http` request listener over a core {@link DispatcherInterface} —\n * the whole server face's entry point (§5.3): convert the incoming message to\n * a fetch `Request`, hand it to the dispatcher with the consumer's per-request\n * `state`, and write the resulting `Response` back.\n *\n * @remarks\n * A rejected `dispatcher.handle` (a route handler throw — the dispatcher\n * never invents an error boundary, §5.1) is this listener's transport-level\n * LAST RESORT, distinct from an application error boundary: when nothing has\n * been sent yet, it destroys the connection with a bare `500` head (never\n * leaking a hanging socket); once headers are already sent, it destroys the\n * connection outright. The router still owns no error POLICY — a consumer\n * that wants mapped error responses installs its own boundary around\n * `dispatcher.handle` (the future `@orkestrel/server` seam, §7).\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run each converted request through\n * @param state - Derives the consumer's per-request `state` from the raw message\n * @returns A `(request, response) => void` listener, passable directly to\n * `http.createServer`\n *\n * @example\n * ```ts\n * import { createListener } from '@src/server'\n * import { createDispatcher } from '@src/core'\n * import http from 'node:http'\n *\n * const dispatcher = createDispatcher()\n * dispatcher.add({ method: 'GET', path: '/health', handler: () => new Response('ok') })\n * http.createServer(createListener(dispatcher, () => undefined)).listen(0)\n * ```\n */\nexport function createListener<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n): ListenerFunction {\n\treturn (request, response) => {\n\t\tvoid handleListenerRequest(dispatcher, state, request, response)\n\t}\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../../src/server/validators.ts","../../../src/server/helpers.ts","../../../src/server/handlers.ts"],"sourcesContent":["// ============================================================================\n// Server guards — the centralized-file rule's home for the total `is*` narrows the\n// `node:http` conversion seam applies to raw connection values. Every\n// declaration here is total and `export`ed per the centralized-file rule.\n// ============================================================================\n\nimport { isRecord } from '@orkestrel/contract'\n\n/**\n * Determines whether a `node:http` connection socket is TLS-encrypted — the\n * total, never-throwing narrow `buildRequest` uses to pick the\n * derived scheme (`https` vs `http`).\n *\n * @param socket - The connection value to test (typically `message.socket`)\n * @returns True if `socket` carries a truthy `encrypted` property (a\n * `tls.TLSSocket`); false otherwise, including for `undefined`\n *\n * @example\n * ```ts\n * import { isEncryptedSocket } from '@src/server'\n *\n * isEncryptedSocket({ encrypted: true }) // true\n * isEncryptedSocket({}) // false\n * ```\n */\nexport function isEncryptedSocket(socket: unknown): socket is { readonly encrypted: true } {\n\treturn isRecord(socket) && socket.encrypted === true\n}\n","// ============================================================================\n// Pure conversion between `node:http` and the fetch vocabulary the core\n// `Dispatcher` speaks — no lifecycle and no listener ownership. Every\n// function is exported per the centralized-file rule.\n// ============================================================================\n\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { RequestOptions } from './types.js'\nimport { once } from 'node:events'\nimport { createAbort } from '@orkestrel/abort'\nimport { isEncryptedSocket } from './validators.js'\n\n/**\n * Builds a fetch-standard `Request` from a `node:http` `IncomingMessage` — the\n * server-adapter half of the fetch/node conversion seam.\n *\n * @remarks\n * - `method` is carried over verbatim (defaulting to `GET` when absent).\n * - The URL is built against `options.origin` when given, otherwise a scheme\n * derived from the connection (`https` when {@link isEncryptedSocket}, else\n * `http`) plus the `Host` header (absent `Host` ⇒ `localhost`).\n * - Every request header is copied; multi-value headers are joined per fetch\n * semantics (`', '`-joined), except `set-cookie`, whose values are each\n * appended individually (fetch `Headers` preserves multiple `set-cookie`\n * entries distinctly).\n * - For a method that carries a body (anything but `GET`/`HEAD`), the message\n * is pumped chunk by chunk into a DOM-compatible `ReadableStream<Uint8Array>`\n * (reconciling the DOM + node type worlds under the root config), with\n * `duplex: 'half'` set as Node's fetch implementation requires for a\n * streamed request body.\n * - A fresh `@orkestrel/abort` handle backs `request.signal`. It aborts when\n * the request connection closes before the message finished\n * (`!message.complete`), or when the paired `options.response` closes before\n * its response finished (`!response.writableEnded`). A handler awaiting the\n * signal therefore observes either side of a client disconnect the\n * fetch-standard way, with zero router-specific API.\n *\n * @param message - The raw `node:http` request\n * @param options - Optional `origin` override and paired `response` for\n * response-side disconnect tracking ({@link RequestOptions})\n * @returns A fetch `Request` whose `signal` fires on an incomplete request, or\n * on a response-side client disconnect when `options.response` is provided\n *\n * @example\n * ```ts\n * import { buildRequest } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer((incoming, response) => {\n * \tconst request = buildRequest(incoming, { response })\n * \tconsole.log(request.method, request.url)\n * })\n * ```\n */\nexport function buildRequest(message: IncomingMessage, options?: RequestOptions): Request {\n\tconst method = message.method ?? 'GET'\n\tconst host = message.headers.host ?? 'localhost'\n\tconst scheme = isEncryptedSocket(message.socket) ? 'https' : 'http'\n\tconst origin = options?.origin ?? `${scheme}://${host}`\n\tconst url = new URL(message.url ?? '/', origin)\n\n\tconst headers = new Headers()\n\tfor (const [name, value] of Object.entries(message.headers)) {\n\t\tif (value === undefined) continue\n\t\tif (name === 'set-cookie') {\n\t\t\tfor (const cookie of Array.isArray(value) ? value : [value]) headers.append(name, cookie)\n\t\t\tcontinue\n\t\t}\n\t\theaders.set(name, Array.isArray(value) ? value.join(', ') : value)\n\t}\n\n\tconst abort = createAbort()\n\tmessage.once('close', () => {\n\t\tif (!message.complete)\n\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before completion`))\n\t})\n\tconst response = options?.response\n\tif (response !== undefined)\n\t\tresponse.once('close', () => {\n\t\t\tif (!response.writableEnded)\n\t\t\t\tabort.abort(new Error(`request to ${url.pathname} disconnected before response completed`))\n\t\t})\n\n\tconst carriesBody = method !== 'GET' && method !== 'HEAD'\n\tconst init: RequestInit = { method, headers, signal: abort.signal }\n\tif (!carriesBody) return new Request(url, init)\n\n\tconst body = new ReadableStream<Uint8Array>({\n\t\tasync start(controller) {\n\t\t\ttry {\n\t\t\t\tfor await (const chunk of message) controller.enqueue(chunk)\n\t\t\t\tcontroller.close()\n\t\t\t} catch (error) {\n\t\t\t\tcontroller.error(error)\n\t\t\t}\n\t\t},\n\t})\n\tconst streamed: RequestInit & { readonly duplex: 'half' } = { ...init, body, duplex: 'half' }\n\treturn new Request(url, streamed)\n}\n\n/**\n * Writes a fetch-standard `Response` back to a `node:http` `ServerResponse` —\n * the reverse half of the fetch/node conversion seam.\n *\n * @remarks\n * Writes `status`/`statusText`, then every response header (`set-cookie`\n * written through {@link Headers.getSetCookie} so multiple cookies stay distinct\n * instead of collapsing into one comma-joined header), then streams the web\n * body to `target` chunk by chunk (`for await` over `response.body`), ending\n * `target` when the stream completes. When a write reports backpressure, the\n * body pump waits for `drain` before pulling again, unless the target closes,\n * errors, or is destroyed first. A `null` body ends `target` immediately with\n * no further writes. Total error posture: if `target` is destroyed mid-stream\n * (the client disconnected), the write loop stops and `target` is left as-is\n * rather than throwing an unhandled rejection — a destroyed target is not\n * this function's error to surface.\n *\n * @param response - The fetch `Response` to write\n * @param target - The `node:http` response to write it to\n * @returns A promise that resolves after `target` has been ended (or the\n * stream stopped because `target` was destroyed)\n *\n * @example\n * ```ts\n * import { sendResponse } from '@src/server'\n * import http from 'node:http'\n *\n * const server = http.createServer(async (_incoming, target) => {\n * \tawait sendResponse(new Response('ok'), target)\n * })\n * ```\n */\nexport async function sendResponse(response: Response, target: ServerResponse): Promise<void> {\n\ttarget.statusCode = response.status\n\ttarget.statusMessage = response.statusText\n\tfor (const [name, value] of response.headers) {\n\t\tif (name === 'set-cookie') continue\n\t\ttarget.setHeader(name, value)\n\t}\n\tconst cookies = response.headers.getSetCookie()\n\tif (cookies.length > 0) target.setHeader('set-cookie', cookies)\n\n\tif (response.body === null) {\n\t\tif (!target.destroyed) target.end()\n\t\treturn\n\t}\n\ttry {\n\t\tfor await (const chunk of response.body) {\n\t\t\tif (target.destroyed) return\n\t\t\tif (!target.write(chunk)) {\n\t\t\t\tif (target.destroyed) return\n\t\t\t\tconst abort = new AbortController()\n\t\t\t\ttry {\n\t\t\t\t\tawait Promise.race([\n\t\t\t\t\t\tonce(target, 'drain', { signal: abort.signal }),\n\t\t\t\t\t\tonce(target, 'close', { signal: abort.signal }),\n\t\t\t\t\t])\n\t\t\t\t} finally {\n\t\t\t\t\tabort.abort()\n\t\t\t\t}\n\t\t\t\tif (target.destroyed) return\n\t\t\t}\n\t\t}\n\t\tif (!target.destroyed) target.end()\n\t} catch {\n\t\tif (!target.destroyed) target.end()\n\t}\n}\n","// ============================================================================\n// Server request handlers — the centralized-file rule's home for the functions that\n// run one `node:http` exchange through a core `Dispatcher`, plus the listener\n// the whole server face hands to `http.createServer`. Every function\n// is exported per the centralized-file rule.\n// ============================================================================\n\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport type { DispatcherInterface } from '@src/core'\nimport type { ListenerFunction, StateFunction } from './types.js'\nimport { buildRequest, sendResponse } from './helpers.js'\n\n/**\n * Handles one `node:http` request through a core dispatcher and writes its\n * fetch-standard response.\n *\n * @remarks\n * This is the named asynchronous orchestration behind {@link createListener}.\n * A rejected dispatch is treated only as a transport-level last resort: write\n * a bare `500` before headers, or destroy a response whose headers have\n * already started.\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run\n * @param state - Derives the consumer state from the incoming message\n * @param request - The raw `node:http` request\n * @param response - The raw `node:http` response\n * @returns A promise that settles after the response is written or closed\n *\n * @example\n * ```ts\n * const server = http.createServer((request, response) => {\n * \tvoid handleListenerRequest(dispatcher, () => undefined, request, response)\n * })\n * ```\n */\nexport async function handleListenerRequest<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n\trequest: IncomingMessage,\n\tresponse: ServerResponse,\n): Promise<void> {\n\ttry {\n\t\tconst converted = buildRequest(request, { response })\n\t\tconst result = await dispatcher.handle(converted, state(request))\n\t\tawait sendResponse(result, response)\n\t} catch (error) {\n\t\tif (!response.headersSent && !response.destroyed) {\n\t\t\tresponse.writeHead(500)\n\t\t\tresponse.end()\n\t\t} else if (!response.destroyed) {\n\t\t\tresponse.destroy(error instanceof Error ? error : new Error(String(error)))\n\t\t}\n\t}\n}\n\n/**\n * Creates a `node:http` request listener over a core {@link DispatcherInterface} —\n * the whole server face's entry point: converts the incoming message to\n * a fetch `Request`, hands it to the dispatcher with the consumer's per-request\n * `state`, and writes the resulting `Response` back.\n *\n * @remarks\n * A rejected `dispatcher.handle` (a route handler throw — the dispatcher\n * never invents an error boundary) is this listener's transport-level\n * LAST RESORT, distinct from an application error boundary: when nothing has\n * been sent yet, it destroys the connection with a bare `500` head (never\n * leaking a hanging socket); after headers are already sent, it destroys the\n * connection outright. The router still owns no error POLICY — a consumer\n * that wants mapped error responses installs its own boundary around\n * `dispatcher.handle` (the future `@orkestrel/server` seam).\n *\n * @typeParam TState - The consumer's opaque per-request state type\n * @param dispatcher - The core dispatcher to run each converted request through\n * @param state - Derives the consumer's per-request `state` from the raw message\n * @returns A `(request, response) => void` listener, passable directly to\n * `http.createServer`\n *\n * @example\n * ```ts\n * import { createListener } from '@orkestrel/router/server'\n * import { createDispatcher } from '@orkestrel/router'\n * import http from 'node:http'\n *\n * const dispatcher = createDispatcher<{ readonly requestId: string }>()\n * dispatcher.add({\n * \tmethod: 'GET',\n * \tpath: '/users/:id',\n * \thandler: (_request, context) =>\n * \t\tResponse.json({ id: context.params.id, requestId: context.state.requestId }),\n * })\n *\n * const server = http.createServer(\n * \tcreateListener(dispatcher, () => ({ requestId: crypto.randomUUID() })),\n * )\n * server.listen(0)\n * ```\n */\nexport function createListener<TState>(\n\tdispatcher: DispatcherInterface<TState>,\n\tstate: StateFunction<TState>,\n): ListenerFunction {\n\treturn (request, response) => {\n\t\tvoid handleListenerRequest(dispatcher, state, request, response)\n\t}\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orkestrel/router",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "A typed request router for the @orkestrel line — server and browser environments. Part of the @orkestrel line.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"browser",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
62
62
|
"copy": "node -e \"const fs=require('node:fs'),p=require('node:path'),a=process.argv[1],b=process.argv[2];fs.mkdirSync(p.dirname(b),{recursive:true});fs.cpSync(a,b,{force:true});console.log('Copied: '+a+' to '+b)\"",
|
|
63
63
|
"scaffold": "scaffold",
|
|
64
|
-
"lint": "oxlint --config .oxlintrc.json --fix
|
|
64
|
+
"lint": "oxlint --config .oxlintrc.json --fix .",
|
|
65
65
|
"check": "tsc --noEmit --project tsconfig.json && npm run check:src",
|
|
66
66
|
"check:src": "npm run check:src:core && npm run check:src:browser && npm run check:src:server",
|
|
67
67
|
"check:src:core": "tsc --noEmit -p configs/src/tsconfig.core.json",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"test:src:server": "vitest run --config vite.config.ts --no-cache --reporter=dot --project src:server",
|
|
78
78
|
"test:policy": "vitest run --config vite.config.ts --no-cache --reporter=dot --project policy",
|
|
79
79
|
"test:config": "vitest run --config vite.config.ts --no-cache --reporter=dot --project config",
|
|
80
|
-
"test:guides": "
|
|
80
|
+
"test:guides": "node --experimental-strip-types tests/guides.test.ts",
|
|
81
81
|
"build": "npm run clean && npm run build:src",
|
|
82
82
|
"build:src": "npm run build:src:core && npm run build:src:browser && npm run build:src:server",
|
|
83
83
|
"build:src:core": "vite build --config configs/src/vite.core.config.ts && npm run copy dist/src/core/index.d.ts dist/src/core/index.d.cts",
|
|
@@ -91,24 +91,23 @@
|
|
|
91
91
|
"test:setup": "vitest run --config vite.config.ts --no-cache --reporter=dot --project setup"
|
|
92
92
|
},
|
|
93
93
|
"dependencies": {
|
|
94
|
-
"@orkestrel/abort": "^0.0.
|
|
95
|
-
"@orkestrel/contract": "^0.0.
|
|
96
|
-
"@orkestrel/emitter": "^0.0.
|
|
94
|
+
"@orkestrel/abort": "^0.0.10",
|
|
95
|
+
"@orkestrel/contract": "^0.0.17",
|
|
96
|
+
"@orkestrel/emitter": "^0.0.10"
|
|
97
97
|
},
|
|
98
98
|
"devDependencies": {
|
|
99
99
|
"@microsoft/api-extractor": "^7.59.0",
|
|
100
|
-
"@orkestrel/guide": "^0.0.
|
|
101
|
-
"@orkestrel/probe": "^0.0.
|
|
102
|
-
"@orkestrel/scaffold": "^0.0.
|
|
103
|
-
"@orkestrel/test": "^0.0.
|
|
104
|
-
"@types/node": "^26.4.
|
|
100
|
+
"@orkestrel/guide": "^0.0.17",
|
|
101
|
+
"@orkestrel/probe": "^0.0.12",
|
|
102
|
+
"@orkestrel/scaffold": "^0.0.63",
|
|
103
|
+
"@orkestrel/test": "^0.0.14",
|
|
104
|
+
"@types/node": "^26.4.1",
|
|
105
105
|
"@vitest/browser-playwright": "^4.1.11",
|
|
106
|
-
"oxfmt": "^0.
|
|
107
|
-
"oxlint": "^1.
|
|
106
|
+
"oxfmt": "^0.66.0",
|
|
107
|
+
"oxlint": "^1.81.0",
|
|
108
108
|
"playwright": "^1.62.1",
|
|
109
109
|
"typescript": "^6.0.3",
|
|
110
110
|
"vite": "^8.2.2",
|
|
111
|
-
"vite-plugin-dts": "^5.0.3",
|
|
112
111
|
"vitest": "^4.1.11"
|
|
113
112
|
},
|
|
114
113
|
"engines": {
|