@statewalker/webrun-http-streams 0.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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/duplex-site-builder.d.ts +32 -0
- package/dist/duplex-site-builder.d.ts.map +1 -0
- package/dist/envelope.d.ts +30 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/fetch.d.ts +13 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/http-data.d.ts +36 -0
- package/dist/http-data.d.ts.map +1 -0
- package/dist/http-error.d.ts +23 -0
- package/dist/http-error.d.ts.map +1 -0
- package/dist/http-stubs.d.ts +36 -0
- package/dist/http-stubs.d.ts.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +407 -0
- package/package.json +43 -0
- package/src/duplex-site-builder.ts +48 -0
- package/src/envelope.ts +116 -0
- package/src/fetch.ts +134 -0
- package/src/http-data.ts +61 -0
- package/src/http-error.ts +67 -0
- package/src/http-stubs.ts +145 -0
- package/src/index.ts +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2026 statewalker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @statewalker/webrun-http-streams
|
|
2
|
+
|
|
3
|
+
HTTP request / response over a `Duplex` from any `webrun-streams-*` adapter. Replaces `webrun-http` + `webrun-http-port`.
|
|
4
|
+
|
|
5
|
+
## Three layers
|
|
6
|
+
|
|
7
|
+
### Data layer — `httpFetch` / `httpServe`
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { httpFetch, httpServe } from "@statewalker/webrun-http-streams";
|
|
11
|
+
import { connect } from "@statewalker/webrun-streams-ws";
|
|
12
|
+
|
|
13
|
+
const { call } = await connect({ url });
|
|
14
|
+
const { envelope, body } = await httpFetch(call, {
|
|
15
|
+
url: "/api/time",
|
|
16
|
+
method: "GET",
|
|
17
|
+
headers: [],
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`httpServe(handler)` returns a `Duplex` you can hand to any adapter's `serve(...)`.
|
|
22
|
+
|
|
23
|
+
### Fetch layer — `fetchOverDuplex` / `serveFetchOverDuplex`
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const response = await fetchOverDuplex(call, new Request("/api/time"));
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`serveFetchOverDuplex(handler)` adapts a `(Request) => Promise<Response>` handler.
|
|
30
|
+
|
|
31
|
+
### Site host — `DuplexSiteBuilder`
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { DuplexSiteBuilder } from "@statewalker/webrun-http-streams";
|
|
35
|
+
import { serve } from "@statewalker/webrun-streams-port";
|
|
36
|
+
|
|
37
|
+
const stop = await new DuplexSiteBuilder()
|
|
38
|
+
.setHandler(siteHandler)
|
|
39
|
+
.start(serve, { port });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`DuplexSiteBuilder` is the cross-platform sibling of `HostedSiteBuilder` (browser+SW) — same `SiteHandler` seam, different transport.
|
|
43
|
+
|
|
44
|
+
## Wire format
|
|
45
|
+
|
|
46
|
+
`<JSON.stringify(envelope)>\n<body bytes…>` — newline-delimited JSON header followed by raw body bytes. Same shape as the legacy `webrun-http-port` so a future bridging adapter could interop.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Serve } from "@statewalker/webrun-streams";
|
|
2
|
+
/**
|
|
3
|
+
* Structural alias of `SiteHandler` from `@statewalker/webrun-site-builder`.
|
|
4
|
+
* Kept local so this package doesn't depend on `webrun-site-builder`.
|
|
5
|
+
*/
|
|
6
|
+
export type SiteHandler = (request: Request) => Promise<Response>;
|
|
7
|
+
/**
|
|
8
|
+
* Host a `SiteHandler` over a `Connect/Serve` pair. The `start(serve, params)`
|
|
9
|
+
* call hands the handler to any `webrun-streams-*` adapter's `serve`.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { SiteBuilder } from "@statewalker/webrun-site-builder";
|
|
13
|
+
* import { DuplexSiteBuilder } from "@statewalker/webrun-http-streams";
|
|
14
|
+
* import { serve } from "@statewalker/webrun-streams-port";
|
|
15
|
+
*
|
|
16
|
+
* const handler = new SiteBuilder()
|
|
17
|
+
* .setEndpoint("/api/time", () => new Response(new Date().toISOString()))
|
|
18
|
+
* .build();
|
|
19
|
+
*
|
|
20
|
+
* const stop = await new DuplexSiteBuilder().setHandler(handler).start(serve, { port });
|
|
21
|
+
* // later: await stop();
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Holds no site-configuration state. Endpoints, files, auth belong to the
|
|
25
|
+
* `SiteHandler` producer (typically `SiteBuilder`).
|
|
26
|
+
*/
|
|
27
|
+
export declare class DuplexSiteBuilder {
|
|
28
|
+
#private;
|
|
29
|
+
setHandler(handler: SiteHandler): this;
|
|
30
|
+
start<P>(serve: Serve<P>, params: P): Promise<() => Promise<void>>;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=duplex-site-builder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duplex-site-builder.d.ts","sourceRoot":"","sources":["../src/duplex-site-builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAGzD;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAElE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,iBAAiB;;IAG5B,UAAU,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI;IAKhC,KAAK,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAUzE"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type RequestEnvelope = {
|
|
2
|
+
url: string;
|
|
3
|
+
method: string;
|
|
4
|
+
headers: [string, string][];
|
|
5
|
+
};
|
|
6
|
+
export type ResponseEnvelope = {
|
|
7
|
+
status: number;
|
|
8
|
+
statusText: string;
|
|
9
|
+
headers: [string, string][];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Encode an HTTP envelope plus optional body as one continuous byte stream:
|
|
13
|
+
*
|
|
14
|
+
* <JSON.stringify(envelope)>\n<body bytes...>
|
|
15
|
+
*
|
|
16
|
+
* `JSON.stringify` with default whitespace never emits a literal `\n`, so the
|
|
17
|
+
* first `0x0a` byte unambiguously terminates the envelope.
|
|
18
|
+
*
|
|
19
|
+
* This is the same wire shape used by the legacy `webrun-http-port` package.
|
|
20
|
+
*/
|
|
21
|
+
export declare function encodeMessage<E>(envelope: E, body?: AsyncIterable<Uint8Array> | Iterable<Uint8Array>): AsyncGenerator<Uint8Array>;
|
|
22
|
+
/**
|
|
23
|
+
* Consume an envelope-then-body byte stream. Returns the parsed envelope and
|
|
24
|
+
* an async iterable over the remaining body bytes.
|
|
25
|
+
*/
|
|
26
|
+
export declare function decodeMessage<E>(input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>): Promise<{
|
|
27
|
+
envelope: E;
|
|
28
|
+
body: AsyncIterable<Uint8Array>;
|
|
29
|
+
}>;
|
|
30
|
+
//# sourceMappingURL=envelope.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"envelope.d.ts","sourceRoot":"","sources":["../src/envelope.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;CAC7B,CAAC;AAMF;;;;;;;;;GASG;AACH,wBAAuB,aAAa,CAAC,CAAC,EACpC,QAAQ,EAAE,CAAC,EACX,IAAI,CAAC,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,GACtD,cAAc,CAAC,UAAU,CAAC,CAM5B;AAED;;;GAGG;AACH,wBAAsB,aAAa,CAAC,CAAC,EACnC,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,GACtD,OAAO,CAAC;IAAE,QAAQ,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAA;CAAE,CAAC,CAgD3D"}
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Duplex } from "@statewalker/webrun-streams";
|
|
2
|
+
/**
|
|
3
|
+
* Run a `Request` through a `Duplex` call and reconstruct the `Response` on
|
|
4
|
+
* the other side. The request's `signal` is plumbed into the body iteration —
|
|
5
|
+
* abort terminates the underlying call.
|
|
6
|
+
*/
|
|
7
|
+
export declare function fetchOverDuplex(call: Duplex, request: Request): Promise<Response>;
|
|
8
|
+
/**
|
|
9
|
+
* Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
|
|
10
|
+
* registered with any `webrun-streams-*` adapter's `serve`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function serveFetchOverDuplex(handler: (request: Request) => Promise<Response>): Duplex;
|
|
13
|
+
//# sourceMappingURL=fetch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../src/fetch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAgE1D;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAcvF;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,MAAM,CAsB7F"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Duplex } from "@statewalker/webrun-streams";
|
|
2
|
+
import { type RequestEnvelope, type ResponseEnvelope } from "./envelope.js";
|
|
3
|
+
export interface HttpFetchResult {
|
|
4
|
+
envelope: ResponseEnvelope;
|
|
5
|
+
body: AsyncIterable<Uint8Array>;
|
|
6
|
+
}
|
|
7
|
+
export interface HttpDataHandlerResult {
|
|
8
|
+
envelope: ResponseEnvelope;
|
|
9
|
+
body?: AsyncIterable<Uint8Array> | Iterable<Uint8Array>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Low-level HTTP-over-Duplex handler. Takes the request envelope plus body
|
|
13
|
+
* iterator; returns the response envelope and optional body.
|
|
14
|
+
*
|
|
15
|
+
* For the higher-level `(Request) => Promise<Response>` shape — the
|
|
16
|
+
* conventional `HttpHandler` used by `webrun-http-browser` and `SiteBuilder`
|
|
17
|
+
* — see `http-stubs.ts` and `fetch.ts`.
|
|
18
|
+
*/
|
|
19
|
+
export type HttpDataHandler = (env: RequestEnvelope, body: AsyncIterable<Uint8Array>) => Promise<HttpDataHandlerResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
|
|
22
|
+
* obtained from any `webrun-streams-*` adapter's `connect`. Returns the
|
|
23
|
+
* response envelope and an async iterable over the response body bytes.
|
|
24
|
+
*
|
|
25
|
+
* The call is one logical Duplex invocation; multiplexing of concurrent
|
|
26
|
+
* calls is the adapter's concern (native or `emulateMux`).
|
|
27
|
+
*/
|
|
28
|
+
export declare function httpFetch(call: Duplex, env: RequestEnvelope, body?: AsyncIterable<Uint8Array> | Iterable<Uint8Array>): Promise<HttpFetchResult>;
|
|
29
|
+
/**
|
|
30
|
+
* Wrap an HTTP handler as a `Duplex` so it can be registered with any
|
|
31
|
+
* `webrun-streams-*` adapter's `serve`. The duplex `split`s the input to
|
|
32
|
+
* recover envelope + body, dispatches to the handler, and emits the response
|
|
33
|
+
* via `encodeMessage`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function httpServe(handler: HttpDataHandler): Duplex;
|
|
36
|
+
//# sourceMappingURL=http-data.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-data.d.ts","sourceRoot":"","sources":["../src/http-data.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAC1D,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACtB,MAAM,eAAe,CAAC;AAEvB,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,IAAI,CAAC,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,CAAC;CACzD;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,CAC5B,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,KAC5B,OAAO,CAAC,qBAAqB,CAAC,CAAC;AAEpC;;;;;;;GAOG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,eAAe,EACpB,IAAI,CAAC,EAAE,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,GACtD,OAAO,CAAC,eAAe,CAAC,CAG1B;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,eAAe,GAAG,MAAM,CAM1D"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface HttpErrorOptions {
|
|
2
|
+
status?: number;
|
|
3
|
+
statusText?: string;
|
|
4
|
+
message?: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
export declare class HttpError extends Error {
|
|
8
|
+
status?: number;
|
|
9
|
+
statusText?: string;
|
|
10
|
+
constructor(options?: HttpErrorOptions);
|
|
11
|
+
getResponseOptions(options?: Record<string, unknown>): Record<string, unknown>;
|
|
12
|
+
toJson(): {
|
|
13
|
+
status?: number;
|
|
14
|
+
statusText?: string;
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
static fromError(error: unknown): HttpError;
|
|
18
|
+
static errorResourceNotFound(options?: HttpErrorOptions): HttpError;
|
|
19
|
+
static errorForbidden(options?: HttpErrorOptions): HttpError;
|
|
20
|
+
static errorResourceGone(options?: HttpErrorOptions): HttpError;
|
|
21
|
+
static errorInternalError(options?: HttpErrorOptions): HttpError;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=http-error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-error.d.ts","sourceRoot":"","sources":["../src/http-error.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,qBAAa,SAAU,SAAQ,KAAK;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;gBAER,OAAO,GAAE,gBAAqB;IAM1C,kBAAkB,CAAC,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAIlF,MAAM,IAAI;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE;IAQnE,MAAM,CAAC,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS;IAM3C,MAAM,CAAC,qBAAqB,CAAC,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAQvE,MAAM,CAAC,cAAc,CAAC,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAQhE,MAAM,CAAC,iBAAiB,CAAC,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAQnE,MAAM,CAAC,kBAAkB,CAAC,OAAO,GAAE,gBAAqB,GAAG,SAAS;CAOrE"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface SerializedHttpRequest {
|
|
2
|
+
url: string;
|
|
3
|
+
method?: string;
|
|
4
|
+
mode?: RequestMode;
|
|
5
|
+
credentials?: RequestCredentials;
|
|
6
|
+
cache?: RequestCache;
|
|
7
|
+
redirect?: RequestRedirect;
|
|
8
|
+
referrer?: string;
|
|
9
|
+
referrerPolicy?: ReferrerPolicy;
|
|
10
|
+
integrity?: string;
|
|
11
|
+
keepalive?: boolean;
|
|
12
|
+
headers: Array<[string, string]>;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface SerializedHttpResponse {
|
|
16
|
+
status: number;
|
|
17
|
+
statusText: string;
|
|
18
|
+
headers: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
export interface SerializedHttpEnvelope<Options> {
|
|
21
|
+
options: Options;
|
|
22
|
+
content: AsyncIterable<Uint8Array>;
|
|
23
|
+
}
|
|
24
|
+
export type HttpHandler = (request: Request) => Response | Promise<Response>;
|
|
25
|
+
/**
|
|
26
|
+
* Returns an HTTP handler that serializes a Request, hands the envelope to
|
|
27
|
+
* `send` for transport, and deserializes the reply into a Response. Used on
|
|
28
|
+
* the caller side.
|
|
29
|
+
*/
|
|
30
|
+
export declare function newHttpClientStub(send: (envelope: SerializedHttpEnvelope<SerializedHttpRequest>) => Promise<SerializedHttpEnvelope<SerializedHttpResponse> | undefined>): (request: Request | Promise<Request>) => Promise<Response>;
|
|
31
|
+
/**
|
|
32
|
+
* Returns a server-side transport handler. It deserializes the incoming
|
|
33
|
+
* request envelope, delegates to `handler`, and serializes the response.
|
|
34
|
+
*/
|
|
35
|
+
export declare function newHttpServerStub(handler: HttpHandler): (envelope: SerializedHttpEnvelope<SerializedHttpRequest> | Promise<SerializedHttpEnvelope<SerializedHttpRequest>>) => Promise<SerializedHttpEnvelope<SerializedHttpResponse>>;
|
|
36
|
+
//# sourceMappingURL=http-stubs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http-stubs.d.ts","sourceRoot":"","sources":["../src/http-stubs.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,qBAAqB;IACpC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,WAAW,CAAC,EAAE,kBAAkB,CAAC;IACjC,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,OAAO,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACjC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACjC;AAED,MAAM,WAAW,sBAAsB,CAAC,OAAO;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;CACpC;AAED,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAmB7E;;;;GAIG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,CACJ,QAAQ,EAAE,sBAAsB,CAAC,qBAAqB,CAAC,KACpD,OAAO,CAAC,sBAAsB,CAAC,sBAAsB,CAAC,GAAG,SAAS,CAAC,GACvE,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,CAmC5D;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,WAAW,GACnB,CACD,QAAQ,EACJ,sBAAsB,CAAC,qBAAqB,CAAC,GAC7C,OAAO,CAAC,sBAAsB,CAAC,qBAAqB,CAAC,CAAC,KACvD,OAAO,CAAC,sBAAsB,CAAC,sBAAsB,CAAC,CAAC,CAyC3D"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { DuplexSiteBuilder, type SiteHandler } from "./duplex-site-builder.js";
|
|
2
|
+
export { decodeMessage, encodeMessage, type RequestEnvelope, type ResponseEnvelope, } from "./envelope.js";
|
|
3
|
+
export { fetchOverDuplex, serveFetchOverDuplex } from "./fetch.js";
|
|
4
|
+
export { type HttpDataHandler, type HttpDataHandlerResult, type HttpFetchResult, httpFetch, httpServe, } from "./http-data.js";
|
|
5
|
+
export { HttpError, type HttpErrorOptions } from "./http-error.js";
|
|
6
|
+
export { type HttpHandler, newHttpClientStub, newHttpServerStub, type SerializedHttpEnvelope, type SerializedHttpRequest, type SerializedHttpResponse, } from "./http-stubs.js";
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC/E,OAAO,EACL,aAAa,EACb,aAAa,EACb,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AACnE,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EACpB,SAAS,EACT,SAAS,GACV,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,SAAS,EAAE,KAAK,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EACL,KAAK,WAAW,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,KAAK,sBAAsB,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,iBAAiB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { fromReadableStream, toReadableStream } from "@statewalker/webrun-streams";
|
|
2
|
+
//#region src/envelope.ts
|
|
3
|
+
const NEWLINE = 10;
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const decoder = new TextDecoder();
|
|
6
|
+
/**
|
|
7
|
+
* Encode an HTTP envelope plus optional body as one continuous byte stream:
|
|
8
|
+
*
|
|
9
|
+
* <JSON.stringify(envelope)>\n<body bytes...>
|
|
10
|
+
*
|
|
11
|
+
* `JSON.stringify` with default whitespace never emits a literal `\n`, so the
|
|
12
|
+
* first `0x0a` byte unambiguously terminates the envelope.
|
|
13
|
+
*
|
|
14
|
+
* This is the same wire shape used by the legacy `webrun-http-port` package.
|
|
15
|
+
*/
|
|
16
|
+
async function* encodeMessage(envelope, body) {
|
|
17
|
+
yield encoder.encode(`${JSON.stringify(envelope)}\n`);
|
|
18
|
+
if (!body) return;
|
|
19
|
+
for await (const chunk of body) if (chunk.byteLength > 0) yield chunk;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Consume an envelope-then-body byte stream. Returns the parsed envelope and
|
|
23
|
+
* an async iterable over the remaining body bytes.
|
|
24
|
+
*/
|
|
25
|
+
async function decodeMessage(input) {
|
|
26
|
+
const iter = toAsyncIterator(input);
|
|
27
|
+
const accum = [];
|
|
28
|
+
let accumLen = 0;
|
|
29
|
+
let before;
|
|
30
|
+
let tail;
|
|
31
|
+
while (true) {
|
|
32
|
+
const next = await iter.next();
|
|
33
|
+
if (next.done) throw new Error(`decodeMessage: stream ended after ${accumLen} bytes without delimiter (\\n)`);
|
|
34
|
+
const chunk = next.value;
|
|
35
|
+
if (chunk.byteLength === 0) continue;
|
|
36
|
+
const nl = chunk.indexOf(NEWLINE);
|
|
37
|
+
if (nl === -1) {
|
|
38
|
+
accum.push(chunk);
|
|
39
|
+
accumLen += chunk.byteLength;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
accum.push(chunk.subarray(0, nl));
|
|
43
|
+
accumLen += nl;
|
|
44
|
+
before = concatChunks(accum, accumLen);
|
|
45
|
+
tail = chunk.subarray(nl + 1);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
let envelope;
|
|
49
|
+
try {
|
|
50
|
+
envelope = JSON.parse(decoder.decode(before));
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new Error(`decodeMessage: malformed envelope JSON at bytes 0..${before.byteLength}: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
async function* body() {
|
|
55
|
+
if (tail.byteLength > 0) yield tail;
|
|
56
|
+
while (true) {
|
|
57
|
+
const next = await iter.next();
|
|
58
|
+
if (next.done) return;
|
|
59
|
+
if (next.value.byteLength > 0) yield next.value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
envelope,
|
|
64
|
+
body: body()
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function toAsyncIterator(input) {
|
|
68
|
+
const asyncIter = input[Symbol.asyncIterator];
|
|
69
|
+
if (asyncIter) return asyncIter.call(input);
|
|
70
|
+
const syncIter = input[Symbol.iterator]();
|
|
71
|
+
return { next() {
|
|
72
|
+
return Promise.resolve(syncIter.next());
|
|
73
|
+
} };
|
|
74
|
+
}
|
|
75
|
+
function concatChunks(parts, totalLen) {
|
|
76
|
+
if (parts.length === 1) return parts[0];
|
|
77
|
+
const out = new Uint8Array(totalLen);
|
|
78
|
+
let off = 0;
|
|
79
|
+
for (const p of parts) {
|
|
80
|
+
out.set(p, off);
|
|
81
|
+
off += p.byteLength;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/http-data.ts
|
|
87
|
+
/**
|
|
88
|
+
* Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
|
|
89
|
+
* obtained from any `webrun-streams-*` adapter's `connect`. Returns the
|
|
90
|
+
* response envelope and an async iterable over the response body bytes.
|
|
91
|
+
*
|
|
92
|
+
* The call is one logical Duplex invocation; multiplexing of concurrent
|
|
93
|
+
* calls is the adapter's concern (native or `emulateMux`).
|
|
94
|
+
*/
|
|
95
|
+
async function httpFetch(call, env, body) {
|
|
96
|
+
return decodeMessage(call(encodeMessage(env, body)));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Wrap an HTTP handler as a `Duplex` so it can be registered with any
|
|
100
|
+
* `webrun-streams-*` adapter's `serve`. The duplex `split`s the input to
|
|
101
|
+
* recover envelope + body, dispatches to the handler, and emits the response
|
|
102
|
+
* via `encodeMessage`.
|
|
103
|
+
*/
|
|
104
|
+
function httpServe(handler) {
|
|
105
|
+
return async function* httpHandlerDuplex(input) {
|
|
106
|
+
const { envelope: reqEnv, body: reqBody } = await decodeMessage(input);
|
|
107
|
+
const result = await handler(reqEnv, reqBody);
|
|
108
|
+
yield* encodeMessage(result.envelope, result.body);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
//#endregion
|
|
112
|
+
//#region src/fetch.ts
|
|
113
|
+
function headersToArray(headers) {
|
|
114
|
+
const out = [];
|
|
115
|
+
headers.forEach((value, key) => {
|
|
116
|
+
out.push([key, value]);
|
|
117
|
+
});
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
async function* readableToAsyncIterable(stream) {
|
|
121
|
+
if (!stream) return;
|
|
122
|
+
const reader = stream.getReader();
|
|
123
|
+
let consumed = false;
|
|
124
|
+
try {
|
|
125
|
+
while (true) {
|
|
126
|
+
const { done, value } = await reader.read();
|
|
127
|
+
if (done) {
|
|
128
|
+
consumed = true;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (value) yield value;
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
if (!consumed) try {
|
|
135
|
+
await reader.cancel();
|
|
136
|
+
} catch {}
|
|
137
|
+
try {
|
|
138
|
+
reader.releaseLock();
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function asyncIterableToReadable(iter) {
|
|
143
|
+
const it = iter[Symbol.asyncIterator]();
|
|
144
|
+
return new ReadableStream({
|
|
145
|
+
async pull(controller) {
|
|
146
|
+
try {
|
|
147
|
+
const { done, value } = await it.next();
|
|
148
|
+
if (done) {
|
|
149
|
+
controller.close();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
controller.enqueue(value);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
controller.error(err);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
async cancel() {
|
|
158
|
+
await it.return?.();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Run a `Request` through a `Duplex` call and reconstruct the `Response` on
|
|
164
|
+
* the other side. The request's `signal` is plumbed into the body iteration —
|
|
165
|
+
* abort terminates the underlying call.
|
|
166
|
+
*/
|
|
167
|
+
async function fetchOverDuplex(call, request) {
|
|
168
|
+
if (request.signal?.aborted) throw abortReason(request.signal);
|
|
169
|
+
const { envelope, body: respBody } = await httpFetch(call, {
|
|
170
|
+
url: request.url,
|
|
171
|
+
method: request.method,
|
|
172
|
+
headers: headersToArray(request.headers)
|
|
173
|
+
}, request.body ? readableToAsyncIterable(request.body) : void 0);
|
|
174
|
+
return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), {
|
|
175
|
+
status: envelope.status,
|
|
176
|
+
statusText: envelope.statusText,
|
|
177
|
+
headers: envelope.headers
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
|
|
182
|
+
* registered with any `webrun-streams-*` adapter's `serve`.
|
|
183
|
+
*/
|
|
184
|
+
function serveFetchOverDuplex(handler) {
|
|
185
|
+
return httpServe(async (env, body) => {
|
|
186
|
+
const reqInit = {
|
|
187
|
+
method: env.method,
|
|
188
|
+
headers: env.headers
|
|
189
|
+
};
|
|
190
|
+
if (env.method !== "GET" && env.method !== "HEAD") {
|
|
191
|
+
reqInit.body = asyncIterableToReadable(body);
|
|
192
|
+
reqInit.duplex = "half";
|
|
193
|
+
}
|
|
194
|
+
const response = await handler(new Request(env.url, reqInit));
|
|
195
|
+
return {
|
|
196
|
+
envelope: {
|
|
197
|
+
status: response.status,
|
|
198
|
+
statusText: response.statusText,
|
|
199
|
+
headers: headersToArray(response.headers)
|
|
200
|
+
},
|
|
201
|
+
body: response.body ? readableToAsyncIterable(response.body) : void 0
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async function* withAbort(iter, signal) {
|
|
206
|
+
if (!signal) {
|
|
207
|
+
yield* iter;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
for await (const chunk of iter) {
|
|
211
|
+
if (signal.aborted) throw abortReason(signal);
|
|
212
|
+
yield chunk;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function abortReason(signal) {
|
|
216
|
+
const reason = signal.reason;
|
|
217
|
+
if (reason instanceof Error) return reason;
|
|
218
|
+
const err = new Error(reason === void 0 ? "Aborted" : String(reason));
|
|
219
|
+
err.name = "AbortError";
|
|
220
|
+
return err;
|
|
221
|
+
}
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/duplex-site-builder.ts
|
|
224
|
+
/**
|
|
225
|
+
* Host a `SiteHandler` over a `Connect/Serve` pair. The `start(serve, params)`
|
|
226
|
+
* call hands the handler to any `webrun-streams-*` adapter's `serve`.
|
|
227
|
+
*
|
|
228
|
+
* ```ts
|
|
229
|
+
* import { SiteBuilder } from "@statewalker/webrun-site-builder";
|
|
230
|
+
* import { DuplexSiteBuilder } from "@statewalker/webrun-http-streams";
|
|
231
|
+
* import { serve } from "@statewalker/webrun-streams-port";
|
|
232
|
+
*
|
|
233
|
+
* const handler = new SiteBuilder()
|
|
234
|
+
* .setEndpoint("/api/time", () => new Response(new Date().toISOString()))
|
|
235
|
+
* .build();
|
|
236
|
+
*
|
|
237
|
+
* const stop = await new DuplexSiteBuilder().setHandler(handler).start(serve, { port });
|
|
238
|
+
* // later: await stop();
|
|
239
|
+
* ```
|
|
240
|
+
*
|
|
241
|
+
* Holds no site-configuration state. Endpoints, files, auth belong to the
|
|
242
|
+
* `SiteHandler` producer (typically `SiteBuilder`).
|
|
243
|
+
*/
|
|
244
|
+
var DuplexSiteBuilder = class {
|
|
245
|
+
#handler;
|
|
246
|
+
setHandler(handler) {
|
|
247
|
+
this.#handler = handler;
|
|
248
|
+
return this;
|
|
249
|
+
}
|
|
250
|
+
async start(serve, params) {
|
|
251
|
+
if (!this.#handler) throw new Error("DuplexSiteBuilder.start: setHandler(handler) must be called before start()");
|
|
252
|
+
const handler = this.#handler;
|
|
253
|
+
return serve(params, serveFetchOverDuplex(async (req) => handler(req)));
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/http-error.ts
|
|
258
|
+
var HttpError = class HttpError extends Error {
|
|
259
|
+
status;
|
|
260
|
+
statusText;
|
|
261
|
+
constructor(options = {}) {
|
|
262
|
+
super(options.message ?? options.statusText ?? "HTTP Error");
|
|
263
|
+
this.status = options.status;
|
|
264
|
+
this.statusText = options.statusText;
|
|
265
|
+
}
|
|
266
|
+
getResponseOptions(options = {}) {
|
|
267
|
+
return {
|
|
268
|
+
...this.toJson(),
|
|
269
|
+
...options
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
toJson() {
|
|
273
|
+
return {
|
|
274
|
+
status: this.status,
|
|
275
|
+
statusText: this.statusText,
|
|
276
|
+
message: this.message
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
static fromError(error) {
|
|
280
|
+
if (error instanceof HttpError) return error;
|
|
281
|
+
return new HttpError({
|
|
282
|
+
status: 500,
|
|
283
|
+
statusText: "Bad Request",
|
|
284
|
+
message: error instanceof Error ? error.message : String(error)
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
static errorResourceNotFound(options = {}) {
|
|
288
|
+
return new HttpError({
|
|
289
|
+
status: 404,
|
|
290
|
+
statusText: "Error 404: Resource not found",
|
|
291
|
+
...options
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
static errorForbidden(options = {}) {
|
|
295
|
+
return new HttpError({
|
|
296
|
+
status: 403,
|
|
297
|
+
statusText: "Error 403: Forbidden",
|
|
298
|
+
...options
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
static errorResourceGone(options = {}) {
|
|
302
|
+
return new HttpError({
|
|
303
|
+
status: 410,
|
|
304
|
+
statusText: "Error 410: Resource Gone",
|
|
305
|
+
...options
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
static errorInternalError(options = {}) {
|
|
309
|
+
return new HttpError({
|
|
310
|
+
...options,
|
|
311
|
+
status: 500,
|
|
312
|
+
statusText: "Error 500: Internal error"
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/http-stubs.ts
|
|
318
|
+
const NULL_BODY_STATUSES = new Set([
|
|
319
|
+
101,
|
|
320
|
+
103,
|
|
321
|
+
204,
|
|
322
|
+
205,
|
|
323
|
+
304
|
|
324
|
+
]);
|
|
325
|
+
const REQUEST_FIELDS = [
|
|
326
|
+
"url",
|
|
327
|
+
"method",
|
|
328
|
+
"mode",
|
|
329
|
+
"credentials",
|
|
330
|
+
"cache",
|
|
331
|
+
"redirect",
|
|
332
|
+
"referrer",
|
|
333
|
+
"referrerPolicy",
|
|
334
|
+
"integrity",
|
|
335
|
+
"keepalive"
|
|
336
|
+
];
|
|
337
|
+
/**
|
|
338
|
+
* Returns an HTTP handler that serializes a Request, hands the envelope to
|
|
339
|
+
* `send` for transport, and deserializes the reply into a Response. Used on
|
|
340
|
+
* the caller side.
|
|
341
|
+
*/
|
|
342
|
+
function newHttpClientStub(send) {
|
|
343
|
+
return async (requestOrPromise) => {
|
|
344
|
+
const request = await requestOrPromise;
|
|
345
|
+
const headers = [...request.headers].map(([k, v]) => [k, v]);
|
|
346
|
+
const options = {
|
|
347
|
+
url: request.url,
|
|
348
|
+
headers
|
|
349
|
+
};
|
|
350
|
+
for (const field of REQUEST_FIELDS) {
|
|
351
|
+
const val = request[field];
|
|
352
|
+
if (val !== void 0 && field !== "url") options[field] = val;
|
|
353
|
+
}
|
|
354
|
+
const result = await send({
|
|
355
|
+
options,
|
|
356
|
+
content: request.body ? fromReadableStream(request.body) : (async function* () {})()
|
|
357
|
+
});
|
|
358
|
+
if (!result) return new Response(null, {
|
|
359
|
+
status: 404,
|
|
360
|
+
statusText: "Error 404: Not Found"
|
|
361
|
+
});
|
|
362
|
+
const responseOptions = result.options;
|
|
363
|
+
const method = options.method;
|
|
364
|
+
if (method === "HEAD" || method === "OPTIONS" || NULL_BODY_STATUSES.has(responseOptions.status)) {
|
|
365
|
+
await result.content.return?.();
|
|
366
|
+
return new Response(null, responseOptions);
|
|
367
|
+
}
|
|
368
|
+
return new Response(toReadableStream(result.content[Symbol.asyncIterator]()), responseOptions);
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Returns a server-side transport handler. It deserializes the incoming
|
|
373
|
+
* request envelope, delegates to `handler`, and serializes the response.
|
|
374
|
+
*/
|
|
375
|
+
function newHttpServerStub(handler) {
|
|
376
|
+
return async (envelopeOrPromise) => {
|
|
377
|
+
const { options, content } = await envelopeOrPromise;
|
|
378
|
+
const { url, method, headers = [], ...rest } = options;
|
|
379
|
+
const { mode: _mode, ...forwardable } = rest;
|
|
380
|
+
const requestHeaders = new Headers();
|
|
381
|
+
for (const [key, value] of headers) requestHeaders.append(key, value);
|
|
382
|
+
const hasBody = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
|
|
383
|
+
let body;
|
|
384
|
+
if (hasBody) body = toReadableStream(content[Symbol.asyncIterator]());
|
|
385
|
+
else await content.return?.();
|
|
386
|
+
const requestInit = {
|
|
387
|
+
...forwardable,
|
|
388
|
+
method,
|
|
389
|
+
headers: requestHeaders
|
|
390
|
+
};
|
|
391
|
+
if (hasBody) {
|
|
392
|
+
requestInit.body = body;
|
|
393
|
+
requestInit.duplex = "half";
|
|
394
|
+
}
|
|
395
|
+
const response = await handler(new Request(url, requestInit));
|
|
396
|
+
return {
|
|
397
|
+
options: {
|
|
398
|
+
status: response.status,
|
|
399
|
+
statusText: response.statusText,
|
|
400
|
+
headers: Object.fromEntries([...response.headers])
|
|
401
|
+
},
|
|
402
|
+
content: response.body ? fromReadableStream(response.body) : (async function* () {})()
|
|
403
|
+
};
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
export { DuplexSiteBuilder, HttpError, decodeMessage, encodeMessage, fetchOverDuplex, httpFetch, httpServe, newHttpClientStub, newHttpServerStub, serveFetchOverDuplex };
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@statewalker/webrun-http-streams",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "HTTP request/response over Duplex — merge of webrun-http + webrun-http-port",
|
|
7
|
+
"homepage": "https://github.com/statewalker/webrun-wire",
|
|
8
|
+
"author": {
|
|
9
|
+
"name": "Mikhail Kotelnikov",
|
|
10
|
+
"email": "mikhail.kotelnikov@gmail.com"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git@github.com:statewalker/webrun-wire.git"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@statewalker/webrun-streams": "0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^25.6.0",
|
|
29
|
+
"rimraf": "^6.1.3",
|
|
30
|
+
"rolldown": "^1.0.0-rc.16",
|
|
31
|
+
"typescript": "^6.0.3",
|
|
32
|
+
"vitest": "^4.1.4"
|
|
33
|
+
},
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"lint": "biome check src tests"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Serve } from "@statewalker/webrun-streams";
|
|
2
|
+
import { serveFetchOverDuplex } from "./fetch.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structural alias of `SiteHandler` from `@statewalker/webrun-site-builder`.
|
|
6
|
+
* Kept local so this package doesn't depend on `webrun-site-builder`.
|
|
7
|
+
*/
|
|
8
|
+
export type SiteHandler = (request: Request) => Promise<Response>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Host a `SiteHandler` over a `Connect/Serve` pair. The `start(serve, params)`
|
|
12
|
+
* call hands the handler to any `webrun-streams-*` adapter's `serve`.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { SiteBuilder } from "@statewalker/webrun-site-builder";
|
|
16
|
+
* import { DuplexSiteBuilder } from "@statewalker/webrun-http-streams";
|
|
17
|
+
* import { serve } from "@statewalker/webrun-streams-port";
|
|
18
|
+
*
|
|
19
|
+
* const handler = new SiteBuilder()
|
|
20
|
+
* .setEndpoint("/api/time", () => new Response(new Date().toISOString()))
|
|
21
|
+
* .build();
|
|
22
|
+
*
|
|
23
|
+
* const stop = await new DuplexSiteBuilder().setHandler(handler).start(serve, { port });
|
|
24
|
+
* // later: await stop();
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Holds no site-configuration state. Endpoints, files, auth belong to the
|
|
28
|
+
* `SiteHandler` producer (typically `SiteBuilder`).
|
|
29
|
+
*/
|
|
30
|
+
export class DuplexSiteBuilder {
|
|
31
|
+
#handler?: SiteHandler;
|
|
32
|
+
|
|
33
|
+
setHandler(handler: SiteHandler): this {
|
|
34
|
+
this.#handler = handler;
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async start<P>(serve: Serve<P>, params: P): Promise<() => Promise<void>> {
|
|
39
|
+
if (!this.#handler) {
|
|
40
|
+
throw new Error("DuplexSiteBuilder.start: setHandler(handler) must be called before start()");
|
|
41
|
+
}
|
|
42
|
+
const handler = this.#handler;
|
|
43
|
+
return serve(
|
|
44
|
+
params,
|
|
45
|
+
serveFetchOverDuplex(async (req) => handler(req)),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export type RequestEnvelope = {
|
|
2
|
+
url: string;
|
|
3
|
+
method: string;
|
|
4
|
+
headers: [string, string][];
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ResponseEnvelope = {
|
|
8
|
+
status: number;
|
|
9
|
+
statusText: string;
|
|
10
|
+
headers: [string, string][];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const NEWLINE = 0x0a;
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
const decoder = new TextDecoder();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Encode an HTTP envelope plus optional body as one continuous byte stream:
|
|
19
|
+
*
|
|
20
|
+
* <JSON.stringify(envelope)>\n<body bytes...>
|
|
21
|
+
*
|
|
22
|
+
* `JSON.stringify` with default whitespace never emits a literal `\n`, so the
|
|
23
|
+
* first `0x0a` byte unambiguously terminates the envelope.
|
|
24
|
+
*
|
|
25
|
+
* This is the same wire shape used by the legacy `webrun-http-port` package.
|
|
26
|
+
*/
|
|
27
|
+
export async function* encodeMessage<E>(
|
|
28
|
+
envelope: E,
|
|
29
|
+
body?: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
30
|
+
): AsyncGenerator<Uint8Array> {
|
|
31
|
+
yield encoder.encode(`${JSON.stringify(envelope)}\n`);
|
|
32
|
+
if (!body) return;
|
|
33
|
+
for await (const chunk of body) {
|
|
34
|
+
if (chunk.byteLength > 0) yield chunk;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Consume an envelope-then-body byte stream. Returns the parsed envelope and
|
|
40
|
+
* an async iterable over the remaining body bytes.
|
|
41
|
+
*/
|
|
42
|
+
export async function decodeMessage<E>(
|
|
43
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
44
|
+
): Promise<{ envelope: E; body: AsyncIterable<Uint8Array> }> {
|
|
45
|
+
const iter = toAsyncIterator(input);
|
|
46
|
+
const accum: Uint8Array[] = [];
|
|
47
|
+
let accumLen = 0;
|
|
48
|
+
let before: Uint8Array;
|
|
49
|
+
let tail: Uint8Array;
|
|
50
|
+
|
|
51
|
+
while (true) {
|
|
52
|
+
const next = await iter.next();
|
|
53
|
+
if (next.done) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`decodeMessage: stream ended after ${accumLen} bytes without delimiter (\\n)`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const chunk = next.value;
|
|
59
|
+
if (chunk.byteLength === 0) continue;
|
|
60
|
+
const nl = chunk.indexOf(NEWLINE);
|
|
61
|
+
if (nl === -1) {
|
|
62
|
+
accum.push(chunk);
|
|
63
|
+
accumLen += chunk.byteLength;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
accum.push(chunk.subarray(0, nl));
|
|
67
|
+
accumLen += nl;
|
|
68
|
+
before = concatChunks(accum, accumLen);
|
|
69
|
+
tail = chunk.subarray(nl + 1);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let envelope: E;
|
|
74
|
+
try {
|
|
75
|
+
envelope = JSON.parse(decoder.decode(before)) as E;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`decodeMessage: malformed envelope JSON at bytes 0..${before.byteLength}: ${(err as Error).message}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function* body(): AsyncGenerator<Uint8Array> {
|
|
83
|
+
if (tail.byteLength > 0) yield tail;
|
|
84
|
+
while (true) {
|
|
85
|
+
const next = await iter.next();
|
|
86
|
+
if (next.done) return;
|
|
87
|
+
if (next.value.byteLength > 0) yield next.value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { envelope, body: body() };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toAsyncIterator(
|
|
95
|
+
input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
96
|
+
): AsyncIterator<Uint8Array> {
|
|
97
|
+
const asyncIter = (input as AsyncIterable<Uint8Array>)[Symbol.asyncIterator];
|
|
98
|
+
if (asyncIter) return asyncIter.call(input as AsyncIterable<Uint8Array>);
|
|
99
|
+
const syncIter = (input as Iterable<Uint8Array>)[Symbol.iterator]();
|
|
100
|
+
return {
|
|
101
|
+
next(): Promise<IteratorResult<Uint8Array>> {
|
|
102
|
+
return Promise.resolve(syncIter.next());
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function concatChunks(parts: Uint8Array[], totalLen: number): Uint8Array {
|
|
108
|
+
if (parts.length === 1) return parts[0];
|
|
109
|
+
const out = new Uint8Array(totalLen);
|
|
110
|
+
let off = 0;
|
|
111
|
+
for (const p of parts) {
|
|
112
|
+
out.set(p, off);
|
|
113
|
+
off += p.byteLength;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Duplex } from "@statewalker/webrun-streams";
|
|
2
|
+
import type { RequestEnvelope, ResponseEnvelope } from "./envelope.js";
|
|
3
|
+
import { httpFetch, httpServe } from "./http-data.js";
|
|
4
|
+
|
|
5
|
+
function headersToArray(headers: Headers): [string, string][] {
|
|
6
|
+
const out: [string, string][] = [];
|
|
7
|
+
headers.forEach((value, key) => {
|
|
8
|
+
out.push([key, value]);
|
|
9
|
+
});
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function* readableToAsyncIterable(
|
|
14
|
+
stream: ReadableStream<Uint8Array> | null | undefined,
|
|
15
|
+
): AsyncGenerator<Uint8Array> {
|
|
16
|
+
if (!stream) return;
|
|
17
|
+
const reader = stream.getReader();
|
|
18
|
+
let consumed = false;
|
|
19
|
+
try {
|
|
20
|
+
while (true) {
|
|
21
|
+
const { done, value } = await reader.read();
|
|
22
|
+
if (done) {
|
|
23
|
+
consumed = true;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (value) yield value;
|
|
27
|
+
}
|
|
28
|
+
} finally {
|
|
29
|
+
if (!consumed) {
|
|
30
|
+
try {
|
|
31
|
+
await reader.cancel();
|
|
32
|
+
} catch {
|
|
33
|
+
/* ignore */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
reader.releaseLock();
|
|
38
|
+
} catch {
|
|
39
|
+
/* ignore */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function asyncIterableToReadable(iter: AsyncIterable<Uint8Array>): ReadableStream<Uint8Array> {
|
|
45
|
+
const it = iter[Symbol.asyncIterator]();
|
|
46
|
+
return new ReadableStream<Uint8Array>({
|
|
47
|
+
async pull(controller) {
|
|
48
|
+
try {
|
|
49
|
+
const { done, value } = await it.next();
|
|
50
|
+
if (done) {
|
|
51
|
+
controller.close();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
controller.enqueue(value);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
controller.error(err);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
async cancel() {
|
|
60
|
+
await it.return?.();
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run a `Request` through a `Duplex` call and reconstruct the `Response` on
|
|
67
|
+
* the other side. The request's `signal` is plumbed into the body iteration —
|
|
68
|
+
* abort terminates the underlying call.
|
|
69
|
+
*/
|
|
70
|
+
export async function fetchOverDuplex(call: Duplex, request: Request): Promise<Response> {
|
|
71
|
+
if (request.signal?.aborted) throw abortReason(request.signal);
|
|
72
|
+
const env: RequestEnvelope = {
|
|
73
|
+
url: request.url,
|
|
74
|
+
method: request.method,
|
|
75
|
+
headers: headersToArray(request.headers),
|
|
76
|
+
};
|
|
77
|
+
const body = request.body ? readableToAsyncIterable(request.body) : undefined;
|
|
78
|
+
const { envelope, body: respBody } = await httpFetch(call, env, body);
|
|
79
|
+
return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), {
|
|
80
|
+
status: envelope.status,
|
|
81
|
+
statusText: envelope.statusText,
|
|
82
|
+
headers: envelope.headers,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
|
|
88
|
+
* registered with any `webrun-streams-*` adapter's `serve`.
|
|
89
|
+
*/
|
|
90
|
+
export function serveFetchOverDuplex(handler: (request: Request) => Promise<Response>): Duplex {
|
|
91
|
+
return httpServe(async (env, body) => {
|
|
92
|
+
const reqInit: RequestInit = {
|
|
93
|
+
method: env.method,
|
|
94
|
+
headers: env.headers,
|
|
95
|
+
};
|
|
96
|
+
if (env.method !== "GET" && env.method !== "HEAD") {
|
|
97
|
+
reqInit.body = asyncIterableToReadable(body);
|
|
98
|
+
(reqInit as RequestInit & { duplex?: string }).duplex = "half";
|
|
99
|
+
}
|
|
100
|
+
const request = new Request(env.url, reqInit);
|
|
101
|
+
const response = await handler(request);
|
|
102
|
+
const respEnv: ResponseEnvelope = {
|
|
103
|
+
status: response.status,
|
|
104
|
+
statusText: response.statusText,
|
|
105
|
+
headers: headersToArray(response.headers),
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
envelope: respEnv,
|
|
109
|
+
body: response.body ? readableToAsyncIterable(response.body) : undefined,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function* withAbort(
|
|
115
|
+
iter: AsyncIterable<Uint8Array>,
|
|
116
|
+
signal: AbortSignal | undefined,
|
|
117
|
+
): AsyncGenerator<Uint8Array> {
|
|
118
|
+
if (!signal) {
|
|
119
|
+
yield* iter;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for await (const chunk of iter) {
|
|
123
|
+
if (signal.aborted) throw abortReason(signal);
|
|
124
|
+
yield chunk;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function abortReason(signal: AbortSignal): Error {
|
|
129
|
+
const reason = (signal as unknown as { reason?: unknown }).reason;
|
|
130
|
+
if (reason instanceof Error) return reason;
|
|
131
|
+
const err = new Error(reason === undefined ? "Aborted" : String(reason));
|
|
132
|
+
err.name = "AbortError";
|
|
133
|
+
return err;
|
|
134
|
+
}
|
package/src/http-data.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Duplex } from "@statewalker/webrun-streams";
|
|
2
|
+
import {
|
|
3
|
+
decodeMessage,
|
|
4
|
+
encodeMessage,
|
|
5
|
+
type RequestEnvelope,
|
|
6
|
+
type ResponseEnvelope,
|
|
7
|
+
} from "./envelope.js";
|
|
8
|
+
|
|
9
|
+
export interface HttpFetchResult {
|
|
10
|
+
envelope: ResponseEnvelope;
|
|
11
|
+
body: AsyncIterable<Uint8Array>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HttpDataHandlerResult {
|
|
15
|
+
envelope: ResponseEnvelope;
|
|
16
|
+
body?: AsyncIterable<Uint8Array> | Iterable<Uint8Array>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Low-level HTTP-over-Duplex handler. Takes the request envelope plus body
|
|
21
|
+
* iterator; returns the response envelope and optional body.
|
|
22
|
+
*
|
|
23
|
+
* For the higher-level `(Request) => Promise<Response>` shape — the
|
|
24
|
+
* conventional `HttpHandler` used by `webrun-http-browser` and `SiteBuilder`
|
|
25
|
+
* — see `http-stubs.ts` and `fetch.ts`.
|
|
26
|
+
*/
|
|
27
|
+
export type HttpDataHandler = (
|
|
28
|
+
env: RequestEnvelope,
|
|
29
|
+
body: AsyncIterable<Uint8Array>,
|
|
30
|
+
) => Promise<HttpDataHandlerResult>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
|
|
34
|
+
* obtained from any `webrun-streams-*` adapter's `connect`. Returns the
|
|
35
|
+
* response envelope and an async iterable over the response body bytes.
|
|
36
|
+
*
|
|
37
|
+
* The call is one logical Duplex invocation; multiplexing of concurrent
|
|
38
|
+
* calls is the adapter's concern (native or `emulateMux`).
|
|
39
|
+
*/
|
|
40
|
+
export async function httpFetch(
|
|
41
|
+
call: Duplex,
|
|
42
|
+
env: RequestEnvelope,
|
|
43
|
+
body?: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
|
|
44
|
+
): Promise<HttpFetchResult> {
|
|
45
|
+
const output = call(encodeMessage(env, body));
|
|
46
|
+
return decodeMessage<ResponseEnvelope>(output);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Wrap an HTTP handler as a `Duplex` so it can be registered with any
|
|
51
|
+
* `webrun-streams-*` adapter's `serve`. The duplex `split`s the input to
|
|
52
|
+
* recover envelope + body, dispatches to the handler, and emits the response
|
|
53
|
+
* via `encodeMessage`.
|
|
54
|
+
*/
|
|
55
|
+
export function httpServe(handler: HttpDataHandler): Duplex {
|
|
56
|
+
return async function* httpHandlerDuplex(input) {
|
|
57
|
+
const { envelope: reqEnv, body: reqBody } = await decodeMessage<RequestEnvelope>(input);
|
|
58
|
+
const result = await handler(reqEnv, reqBody);
|
|
59
|
+
yield* encodeMessage(result.envelope, result.body);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface HttpErrorOptions {
|
|
2
|
+
status?: number;
|
|
3
|
+
statusText?: string;
|
|
4
|
+
message?: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class HttpError extends Error {
|
|
9
|
+
status?: number;
|
|
10
|
+
statusText?: string;
|
|
11
|
+
|
|
12
|
+
constructor(options: HttpErrorOptions = {}) {
|
|
13
|
+
super(options.message ?? options.statusText ?? "HTTP Error");
|
|
14
|
+
this.status = options.status;
|
|
15
|
+
this.statusText = options.statusText;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
getResponseOptions(options: Record<string, unknown> = {}): Record<string, unknown> {
|
|
19
|
+
return { ...this.toJson(), ...options };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
toJson(): { status?: number; statusText?: string; message: string } {
|
|
23
|
+
return {
|
|
24
|
+
status: this.status,
|
|
25
|
+
statusText: this.statusText,
|
|
26
|
+
message: this.message,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static fromError(error: unknown): HttpError {
|
|
31
|
+
if (error instanceof HttpError) return error;
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
return new HttpError({ status: 500, statusText: "Bad Request", message });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static errorResourceNotFound(options: HttpErrorOptions = {}): HttpError {
|
|
37
|
+
return new HttpError({
|
|
38
|
+
status: 404,
|
|
39
|
+
statusText: "Error 404: Resource not found",
|
|
40
|
+
...options,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static errorForbidden(options: HttpErrorOptions = {}): HttpError {
|
|
45
|
+
return new HttpError({
|
|
46
|
+
status: 403,
|
|
47
|
+
statusText: "Error 403: Forbidden",
|
|
48
|
+
...options,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static errorResourceGone(options: HttpErrorOptions = {}): HttpError {
|
|
53
|
+
return new HttpError({
|
|
54
|
+
status: 410,
|
|
55
|
+
statusText: "Error 410: Resource Gone",
|
|
56
|
+
...options,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static errorInternalError(options: HttpErrorOptions = {}): HttpError {
|
|
61
|
+
return new HttpError({
|
|
62
|
+
...options,
|
|
63
|
+
status: 500,
|
|
64
|
+
statusText: "Error 500: Internal error",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { fromReadableStream, toReadableStream } from "@statewalker/webrun-streams";
|
|
2
|
+
|
|
3
|
+
export interface SerializedHttpRequest {
|
|
4
|
+
url: string;
|
|
5
|
+
method?: string;
|
|
6
|
+
mode?: RequestMode;
|
|
7
|
+
credentials?: RequestCredentials;
|
|
8
|
+
cache?: RequestCache;
|
|
9
|
+
redirect?: RequestRedirect;
|
|
10
|
+
referrer?: string;
|
|
11
|
+
referrerPolicy?: ReferrerPolicy;
|
|
12
|
+
integrity?: string;
|
|
13
|
+
keepalive?: boolean;
|
|
14
|
+
headers: Array<[string, string]>;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SerializedHttpResponse {
|
|
19
|
+
status: number;
|
|
20
|
+
statusText: string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SerializedHttpEnvelope<Options> {
|
|
25
|
+
options: Options;
|
|
26
|
+
content: AsyncIterable<Uint8Array>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type HttpHandler = (request: Request) => Response | Promise<Response>;
|
|
30
|
+
|
|
31
|
+
// Statuses that MUST NOT carry a body — `new Response(body, { status })` throws
|
|
32
|
+
// for these (per the Fetch spec's "null body status" set).
|
|
33
|
+
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
34
|
+
|
|
35
|
+
const REQUEST_FIELDS = [
|
|
36
|
+
"url",
|
|
37
|
+
"method",
|
|
38
|
+
"mode",
|
|
39
|
+
"credentials",
|
|
40
|
+
"cache",
|
|
41
|
+
"redirect",
|
|
42
|
+
"referrer",
|
|
43
|
+
"referrerPolicy",
|
|
44
|
+
"integrity",
|
|
45
|
+
"keepalive",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns an HTTP handler that serializes a Request, hands the envelope to
|
|
50
|
+
* `send` for transport, and deserializes the reply into a Response. Used on
|
|
51
|
+
* the caller side.
|
|
52
|
+
*/
|
|
53
|
+
export function newHttpClientStub(
|
|
54
|
+
send: (
|
|
55
|
+
envelope: SerializedHttpEnvelope<SerializedHttpRequest>,
|
|
56
|
+
) => Promise<SerializedHttpEnvelope<SerializedHttpResponse> | undefined>,
|
|
57
|
+
): (request: Request | Promise<Request>) => Promise<Response> {
|
|
58
|
+
return async (requestOrPromise) => {
|
|
59
|
+
const request = await requestOrPromise;
|
|
60
|
+
const headers: Array<[string, string]> = [...request.headers].map(([k, v]) => [k, v]);
|
|
61
|
+
const options: SerializedHttpRequest = { url: request.url, headers };
|
|
62
|
+
for (const field of REQUEST_FIELDS) {
|
|
63
|
+
const val = (request as unknown as Record<string, unknown>)[field];
|
|
64
|
+
if (val !== undefined && field !== "url") (options as Record<string, unknown>)[field] = val;
|
|
65
|
+
}
|
|
66
|
+
const content = request.body
|
|
67
|
+
? fromReadableStream(request.body as ReadableStream<Uint8Array>)
|
|
68
|
+
: (async function* () {})();
|
|
69
|
+
|
|
70
|
+
const result = await send({ options, content });
|
|
71
|
+
|
|
72
|
+
if (!result) {
|
|
73
|
+
return new Response(null, { status: 404, statusText: "Error 404: Not Found" });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const responseOptions = result.options;
|
|
77
|
+
const method = options.method;
|
|
78
|
+
// `new Response(body, ...)` throws for null-body statuses (204/205/304), and
|
|
79
|
+
// HEAD/OPTIONS carry no body either — drain the (empty) content stream and
|
|
80
|
+
// emit a bodyless response.
|
|
81
|
+
if (
|
|
82
|
+
method === "HEAD" ||
|
|
83
|
+
method === "OPTIONS" ||
|
|
84
|
+
NULL_BODY_STATUSES.has(responseOptions.status)
|
|
85
|
+
) {
|
|
86
|
+
const returnable = result.content as AsyncIterable<Uint8Array> & { return?: () => unknown };
|
|
87
|
+
await returnable.return?.();
|
|
88
|
+
return new Response(null, responseOptions);
|
|
89
|
+
}
|
|
90
|
+
return new Response(toReadableStream(result.content[Symbol.asyncIterator]()), responseOptions);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns a server-side transport handler. It deserializes the incoming
|
|
96
|
+
* request envelope, delegates to `handler`, and serializes the response.
|
|
97
|
+
*/
|
|
98
|
+
export function newHttpServerStub(
|
|
99
|
+
handler: HttpHandler,
|
|
100
|
+
): (
|
|
101
|
+
envelope:
|
|
102
|
+
| SerializedHttpEnvelope<SerializedHttpRequest>
|
|
103
|
+
| Promise<SerializedHttpEnvelope<SerializedHttpRequest>>,
|
|
104
|
+
) => Promise<SerializedHttpEnvelope<SerializedHttpResponse>> {
|
|
105
|
+
return async (envelopeOrPromise) => {
|
|
106
|
+
const { options, content } = await envelopeOrPromise;
|
|
107
|
+
const { url, method, headers = [], ...rest } = options;
|
|
108
|
+
const { mode: _mode, ...forwardable } = rest as Record<string, unknown>;
|
|
109
|
+
|
|
110
|
+
const requestHeaders = new Headers();
|
|
111
|
+
for (const [key, value] of headers) requestHeaders.append(key, value);
|
|
112
|
+
|
|
113
|
+
const hasBody = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
|
|
114
|
+
let body: ReadableStream<Uint8Array> | undefined;
|
|
115
|
+
if (hasBody) {
|
|
116
|
+
body = toReadableStream(content[Symbol.asyncIterator]());
|
|
117
|
+
} else {
|
|
118
|
+
const returnable = content as AsyncIterable<Uint8Array> & { return?: () => unknown };
|
|
119
|
+
await returnable.return?.();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const requestInit: RequestInit & { duplex?: "half" } = {
|
|
123
|
+
...forwardable,
|
|
124
|
+
method,
|
|
125
|
+
headers: requestHeaders,
|
|
126
|
+
};
|
|
127
|
+
if (hasBody) {
|
|
128
|
+
requestInit.body = body;
|
|
129
|
+
requestInit.duplex = "half";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const request = new Request(url, requestInit);
|
|
133
|
+
const response = await handler(request);
|
|
134
|
+
|
|
135
|
+
const responseOptions: SerializedHttpResponse = {
|
|
136
|
+
status: response.status,
|
|
137
|
+
statusText: response.statusText,
|
|
138
|
+
headers: Object.fromEntries([...response.headers]),
|
|
139
|
+
};
|
|
140
|
+
const responseContent = response.body
|
|
141
|
+
? fromReadableStream(response.body as ReadableStream<Uint8Array>)
|
|
142
|
+
: (async function* () {})();
|
|
143
|
+
return { options: responseOptions, content: responseContent };
|
|
144
|
+
};
|
|
145
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { DuplexSiteBuilder, type SiteHandler } from "./duplex-site-builder.js";
|
|
2
|
+
export {
|
|
3
|
+
decodeMessage,
|
|
4
|
+
encodeMessage,
|
|
5
|
+
type RequestEnvelope,
|
|
6
|
+
type ResponseEnvelope,
|
|
7
|
+
} from "./envelope.js";
|
|
8
|
+
export { fetchOverDuplex, serveFetchOverDuplex } from "./fetch.js";
|
|
9
|
+
export {
|
|
10
|
+
type HttpDataHandler,
|
|
11
|
+
type HttpDataHandlerResult,
|
|
12
|
+
type HttpFetchResult,
|
|
13
|
+
httpFetch,
|
|
14
|
+
httpServe,
|
|
15
|
+
} from "./http-data.js";
|
|
16
|
+
export { HttpError, type HttpErrorOptions } from "./http-error.js";
|
|
17
|
+
export {
|
|
18
|
+
type HttpHandler,
|
|
19
|
+
newHttpClientStub,
|
|
20
|
+
newHttpServerStub,
|
|
21
|
+
type SerializedHttpEnvelope,
|
|
22
|
+
type SerializedHttpRequest,
|
|
23
|
+
type SerializedHttpResponse,
|
|
24
|
+
} from "./http-stubs.js";
|