@agentmesh-p2p/sdk 0.0.0-stage → 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.
Files changed (68) hide show
  1. package/README.md +182 -2
  2. package/dist/auth.d.ts +66 -0
  3. package/dist/auth.js +164 -0
  4. package/dist/authorizer.d.ts +41 -0
  5. package/dist/authorizer.js +201 -0
  6. package/dist/base58.d.ts +2 -0
  7. package/dist/base58.js +57 -0
  8. package/dist/biscuit.d.ts +75 -0
  9. package/dist/biscuit.js +250 -0
  10. package/dist/bytes.d.ts +10 -0
  11. package/dist/bytes.js +51 -0
  12. package/dist/challenges.d.ts +16 -0
  13. package/dist/challenges.js +55 -0
  14. package/dist/conformance-join.d.ts +1 -0
  15. package/dist/conformance-join.js +257 -0
  16. package/dist/conformance.d.ts +1 -0
  17. package/dist/conformance.js +60 -0
  18. package/dist/controlplane.d.ts +123 -0
  19. package/dist/controlplane.js +368 -0
  20. package/dist/credential.d.ts +66 -0
  21. package/dist/credential.js +124 -0
  22. package/dist/discovery.d.ts +50 -0
  23. package/dist/discovery.js +150 -0
  24. package/dist/gen/agentmesh_pb.d.ts +2740 -0
  25. package/dist/gen/agentmesh_pb.js +591 -0
  26. package/dist/gen/datalog.d.ts +39 -0
  27. package/dist/gen/datalog.js +84 -0
  28. package/dist/host.d.ts +47 -0
  29. package/dist/host.js +109 -0
  30. package/dist/http1.d.ts +68 -0
  31. package/dist/http1.js +371 -0
  32. package/dist/identity.d.ts +31 -0
  33. package/dist/identity.js +125 -0
  34. package/dist/index.d.ts +18 -0
  35. package/dist/index.js +30 -0
  36. package/dist/libp2p-http-node.d.ts +17 -0
  37. package/dist/libp2p-http-node.js +116 -0
  38. package/dist/libp2p-http.d.ts +165 -0
  39. package/dist/libp2p-http.js +581 -0
  40. package/dist/mcp.d.ts +67 -0
  41. package/dist/mcp.js +157 -0
  42. package/dist/mesh.d.ts +151 -0
  43. package/dist/mesh.js +370 -0
  44. package/dist/platform/ingress.browser.d.ts +1 -0
  45. package/dist/platform/ingress.browser.js +16 -0
  46. package/dist/platform/ingress.d.ts +1 -0
  47. package/dist/platform/ingress.js +15 -0
  48. package/dist/platform/state.browser.d.ts +5 -0
  49. package/dist/platform/state.browser.js +47 -0
  50. package/dist/platform/state.d.ts +5 -0
  51. package/dist/platform/state.js +44 -0
  52. package/dist/platform/transports.browser.d.ts +3 -0
  53. package/dist/platform/transports.browser.js +27 -0
  54. package/dist/platform/transports.d.ts +3 -0
  55. package/dist/platform/transports.js +27 -0
  56. package/dist/platform/types.d.ts +7 -0
  57. package/dist/platform/types.js +14 -0
  58. package/dist/platform/wasm.browser.d.ts +2 -0
  59. package/dist/platform/wasm.browser.js +16 -0
  60. package/dist/platform/wasm.d.ts +2 -0
  61. package/dist/platform/wasm.js +56 -0
  62. package/dist/session.d.ts +316 -0
  63. package/dist/session.js +1269 -0
  64. package/dist/sync.d.ts +41 -0
  65. package/dist/sync.js +111 -0
  66. package/dist/tar.d.ts +25 -0
  67. package/dist/tar.js +406 -0
  68. package/package.json +74 -4
@@ -0,0 +1,84 @@
1
+ // Code generated by hack/gen-sdk-datalog from api/datalog.go; DO NOT EDIT.
2
+ /** The Datalog a provider evaluates against a caller's biscuit. */
3
+ export const BASELINE_DATALOG = {
4
+ "_comment": "Generated by hack/gen-sdk-datalog from api/datalog.go; do not edit.",
5
+ "policies": [
6
+ "allow if service($type, $name), granted_service_exact($type, $name)",
7
+ "allow if service($type, $name), granted_service_set($type, $set), $set.contains($name)",
8
+ "allow if service($type, $name), granted_service_prefix($type, $prefix), $name.starts_with($prefix)",
9
+ "allow if service($type, $name), granted_service_suffix($type, $suffix), $name.ends_with($suffix)",
10
+ "allow if service($type, $name), granted_service_all($type)",
11
+ "allow if service($type, $name), granted_service_all_types(true)"
12
+ ],
13
+ "publish_policies": [
14
+ "allow if publish($type, $name), granted_publish_exact($type, $name)",
15
+ "allow if publish($type, $name), granted_publish_set($type, $set), $set.contains($name)",
16
+ "allow if publish($type, $name), granted_publish_prefix($type, $prefix), $name.starts_with($prefix)",
17
+ "allow if publish($type, $name), granted_publish_suffix($type, $suffix), $name.ends_with($suffix)",
18
+ "allow if publish($type, $name), granted_publish_all($type)",
19
+ "allow if publish($type, $name), granted_publish_all_types(true)"
20
+ ],
21
+ "rules": [
22
+ "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_exact($fact, $val)",
23
+ "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_set($fact, $set), $set.contains($val)",
24
+ "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_prefix($fact, $prefix), $val.starts_with($prefix)",
25
+ "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_suffix($fact, $suffix), $val.ends_with($suffix)",
26
+ "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_all($fact)",
27
+ "allow_network_target($fact, $val) <- target_fact($fact, $val), granted_target_all_facts(true)"
28
+ ],
29
+ "http_rules": [
30
+ "http_method_ok($t, $k) <- method($m), granted_method($t, $k, $set), $set.contains($m)",
31
+ "http_method_ok($t, $k) <- method($m), !($m == \"CONNECT\"), granted_method_any($t, $k)",
32
+ "http_path_ok($t, $k) <- path($p), granted_path_exact($t, $k, $set), $set.contains($p)",
33
+ "http_path_ok($t, $k) <- path($p), granted_path_prefix($t, $k, $prefix), $p.starts_with($prefix)",
34
+ "http_path_ok($t, $k) <- path($p), $p.starts_with(\"/\"), granted_path_any($t, $k)",
35
+ "granted_service_exact($t, $n) <- service($t, $n), http_granted_service_exact($t, $n), http_method_ok($t, $n), http_path_ok($t, $n)",
36
+ "granted_service_suffix($t, $s) <- service($t, $n), http_granted_service_suffix($t, $s), $n.ends_with($s), http_method_ok($t, $s), http_path_ok($t, $s)",
37
+ "granted_service_prefix($t, $p) <- service($t, $n), http_granted_service_prefix($t, $p), $n.starts_with($p), http_method_ok($t, $p), http_path_ok($t, $p)",
38
+ "granted_service_all($t) <- service($t, $n), http_granted_service_all($t), http_method_ok($t, \"*\"), http_path_ok($t, \"*\")",
39
+ "granted_service_all_types(true) <- service($t, $n), http_granted_service_all_types(true), http_method_ok(\"*\", \"*\"), http_path_ok(\"*\", \"*\")"
40
+ ],
41
+ "target_fact_rules": [
42
+ "target_fact(\"email\", $val) <- email($val)",
43
+ "target_fact(\"group\", $val) <- group($val)",
44
+ "target_fact(\"idp_role\", $val) <- idp_role($val)",
45
+ "target_fact(\"node\", $val) <- node($val)",
46
+ "target_fact(\"user\", $val) <- user($val)"
47
+ ],
48
+ "replay_check": "check if client_peer_id($id), connection_peer_id($id)",
49
+ "target_check": "check if allow_network_target($fact, $val) or target_unrestricted(true)",
50
+ "time_check": "check if time($time), expiration($exp), $time <= $exp",
51
+ "allow_if_true": "allow if true",
52
+ "fact_node": "node",
53
+ "fact_client_peer_id": "client_peer_id",
54
+ "fact_actor_node": "actor_node",
55
+ "fact_service": "service",
56
+ "fact_connection_peer_id": "connection_peer_id",
57
+ "fact_method": "method",
58
+ "fact_path": "path",
59
+ "fact_time": "time",
60
+ "fact_role": "role",
61
+ "fact_target_fact": "target_fact",
62
+ "fact_publish": "publish",
63
+ "catalog_target": "mesh.catalog",
64
+ "fact_tar_block": "tar_block",
65
+ "tar_block_source_pattern": "^tar_block\\(\"([A-Za-z0-9_-]+)\"\\);?\\s*$",
66
+ "http_method_syntax": "^[A-Z][A-Z0-9-]{0,31}$",
67
+ "max_attenuation_blocks": 8,
68
+ "max_tar_bytes": 4096,
69
+ "max_rules_per_tar": 16,
70
+ "max_entries_per_tar_list": 64,
71
+ "max_tar_name_length": 128,
72
+ "max_tar_description_length": 256,
73
+ "max_tar_resource_length": 256,
74
+ "marker_term": "true",
75
+ "system_namespace": "mesh:system",
76
+ "binding_member_prefixes": [
77
+ "email",
78
+ "group",
79
+ "idp_role",
80
+ "node",
81
+ "user"
82
+ ],
83
+ "system_authenticated_member": "mesh:system:authenticated"
84
+ };
package/dist/host.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { type GossipSub } from "@libp2p/gossipsub";
2
+ import type { Libp2p } from "@libp2p/interface";
3
+ import type { Multiaddr } from "@multiformats/multiaddr";
4
+ import { type Libp2pOptions } from "libp2p";
5
+ import type { Identity } from "./identity.ts";
6
+ export interface MeshHostOptions {
7
+ /**
8
+ * Addresses to accept direct connections on, e.g. "/ip4/0.0.0.0/tcp/0"
9
+ * or "/ip4/0.0.0.0/tcp/0/ws". Empty by default: an agent is reached
10
+ * through a router's relay.
11
+ */
12
+ listenAddrs?: string[];
13
+ /**
14
+ * Peers the control plane has banned. Consulted for every dial and every
15
+ * inbound connection, as agentmesh-node's connection gater; the session keeps
16
+ * it current from /info and the gossip events.
17
+ */
18
+ banned?: {
19
+ has(peerId: string): boolean;
20
+ };
21
+ /** The resolver for `/dnsaddr` and `/dns*` addresses; the system's by default. */
22
+ dns?: Libp2pOptions["dns"];
23
+ }
24
+ /** The services a mesh host runs; `services.pubsub` carries the control plane's events. */
25
+ export type MeshHost = Libp2p<{
26
+ pubsub: GossipSub;
27
+ }>;
28
+ export declare function createMeshHost(identity: Identity, options?: MeshHostOptions): Promise<MeshHost>;
29
+ /** The relay listener a member holds, to ask for its reservation again. */
30
+ export interface RelayListener {
31
+ listen(addr: Multiaddr): Promise<void>;
32
+ }
33
+ /**
34
+ * Starts listening on a relay after the caller has authenticated with it,
35
+ * and returns the listener. js-libp2p reserves a relay slot when it starts
36
+ * listening on `<relay>/p2p-circuit`; a router refuses that until the peer
37
+ * has passed the auth handshake, so the listen cannot be part of the host's
38
+ * config. The transport manager is not on the public Libp2p interface.
39
+ *
40
+ * The reservation goes with the connection it was made on: a router that
41
+ * restarts or trims the connection drops it, and js-libp2p does not ask a
42
+ * configured relay again. Calling `listen` on the returned listener does,
43
+ * without registering a second listener.
44
+ *
45
+ * Upstream: libp2p/js-libp2p#3645 (listen after start), #3601 (re-reserve).
46
+ */
47
+ export declare function listenThroughRelay(node: Libp2p, relayAddr: Multiaddr): Promise<RelayListener>;
package/dist/host.js ADDED
@@ -0,0 +1,109 @@
1
+ // Copyright 2026 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ // The libp2p host a member joins the mesh with, configured the way
15
+ // agentmesh-node's is (internal/node/node.go): yamux the muxer, circuit relay v2
16
+ // for reachability through the routers. Transports and the security
17
+ // protocol come from the runtime (platform/transports.ts): on Node TCP and
18
+ // WebSocket with TLS, in a browser WebSocket with Noise.
19
+ import { yamux } from "@chainsafe/libp2p-yamux";
20
+ import { circuitRelayTransport } from "@libp2p/circuit-relay-v2";
21
+ import { privateKeyFromProtobuf } from "@libp2p/crypto/keys";
22
+ import { gossipsub } from "@libp2p/gossipsub";
23
+ import { identify, identifyPush } from "@libp2p/identify";
24
+ import { ping } from "@libp2p/ping";
25
+ import { createLibp2p } from "libp2p";
26
+ import { connectionEncrypters, transports } from "./platform/transports.js";
27
+ /** The floodsub protocol, the one a member speaks (see leafPubsub). */
28
+ const FLOODSUB_PROTOCOL = "/floodsub/1.0.0";
29
+ /**
30
+ * A member is a leaf of the gossip: it speaks floodsub only, so a router
31
+ * delivers every control plane event to it outright and it takes no part
32
+ * in the routers' gossipsub mesh, its heartbeats, grafts and peer exchange.
33
+ * The gossipsub implementation serves as the floodsub one, told to register
34
+ * that protocol alone; the message envelope is the same.
35
+ */
36
+ function leafPubsub() {
37
+ // StrictSign, as every Go component pins it: the pubsub envelope is
38
+ // signed by the author, and the event inside by the control plane.
39
+ const build = gossipsub({ globalSignaturePolicy: "StrictSign", allowPublishToZeroTopicPeers: true, emitSelf: false });
40
+ return (components) => {
41
+ const ps = build(components);
42
+ ps.protocols = [FLOODSUB_PROTOCOL];
43
+ return ps;
44
+ };
45
+ }
46
+ export async function createMeshHost(identity, options = {}) {
47
+ const banned = options.banned ?? { has: () => false };
48
+ const denyBanned = (peerId) => banned.has(peerId.toString());
49
+ return createLibp2p({
50
+ privateKey: privateKeyFromProtobuf(identity.toLibp2pPrivateKey()),
51
+ addresses: { listen: options.listenAddrs ?? [] },
52
+ ...(options.dns !== undefined ? { dns: options.dns } : {}),
53
+ transports: [...transports(), circuitRelayTransport()],
54
+ connectionEncrypters: connectionEncrypters(),
55
+ streamMuxers: [yamux()],
56
+ connectionManager: {
57
+ inboundConnectionThreshold: Infinity,
58
+ },
59
+ connectionGater: {
60
+ denyDialPeer: denyBanned,
61
+ denyInboundEncryptedConnection: denyBanned,
62
+ denyInboundRelayedConnection: (_relay, remotePeer) => denyBanned(remotePeer),
63
+ // The addresses dialled are the routers' as the control plane
64
+ // published them, and every connection is authenticated by peer ID
65
+ // whatever the address, so no address is refused for its shape. In a
66
+ // browser js-libp2p would otherwise skip loopback and plain ws://
67
+ // addresses, which is what agentmesh-one on the same machine advertises; a
68
+ // page on https cannot open ws:// anyway, the browser sees to that.
69
+ denyDialMultiaddr: () => false,
70
+ },
71
+ services: {
72
+ identify: identify(),
73
+ // A member's addresses change after it connects: the relay address
74
+ // arrives with the reservation, which follows the auth handshake.
75
+ // Identify alone tells a router the addresses at connect time; push
76
+ // tells the routers already connected, the reservation's own among
77
+ // them, where the member is reached now, as agentmesh-node does.
78
+ identifyPush: identifyPush(),
79
+ ping: ping(),
80
+ // The control plane's events reach members through the routers.
81
+ pubsub: leafPubsub(),
82
+ },
83
+ });
84
+ }
85
+ /**
86
+ * Starts listening on a relay after the caller has authenticated with it,
87
+ * and returns the listener. js-libp2p reserves a relay slot when it starts
88
+ * listening on `<relay>/p2p-circuit`; a router refuses that until the peer
89
+ * has passed the auth handshake, so the listen cannot be part of the host's
90
+ * config. The transport manager is not on the public Libp2p interface.
91
+ *
92
+ * The reservation goes with the connection it was made on: a router that
93
+ * restarts or trims the connection drops it, and js-libp2p does not ask a
94
+ * configured relay again. Calling `listen` on the returned listener does,
95
+ * without registering a second listener.
96
+ *
97
+ * Upstream: libp2p/js-libp2p#3645 (listen after start), #3601 (re-reserve).
98
+ */
99
+ export async function listenThroughRelay(node, relayAddr) {
100
+ const internals = node;
101
+ const manager = internals.components.transportManager;
102
+ const before = new Set(manager.getListeners());
103
+ await manager.listen([relayAddr.encapsulate("/p2p-circuit")]);
104
+ const listener = manager.getListeners().find((l) => !before.has(l));
105
+ if (listener === undefined) {
106
+ throw new Error("libp2p registered no listener for the relay address");
107
+ }
108
+ return listener;
109
+ }
@@ -0,0 +1,68 @@
1
+ import type { Stream } from "@libp2p/interface";
2
+ /** Longest request or response head (start line and headers) accepted. */
3
+ export declare const MAX_HEAD_BYTES: number;
4
+ /** Where bytes come from: a chunk, or null when no more will arrive. */
5
+ export interface ByteSource {
6
+ read(): Promise<Uint8Array | null>;
7
+ }
8
+ /** Reads a libp2p stream, holding the remote back while nobody is reading. */
9
+ export declare function streamSource(stream: Stream): ByteSource;
10
+ /** Sends to a libp2p stream, waiting for the remote when the send buffer is full. */
11
+ export declare function sendAll(stream: Stream, data: Uint8Array): Promise<void>;
12
+ export declare class HTTPParseError extends Error {
13
+ constructor(message: string);
14
+ }
15
+ /** Bytes from a source with lookahead: lines and exact counts. */
16
+ export declare class ByteReader {
17
+ #private;
18
+ constructor(source: ByteSource);
19
+ /** One CRLF-terminated line without its terminator, at most limit bytes; null at end of input before any byte. */
20
+ readLine(limit: number): Promise<string | null>;
21
+ /** Exactly n bytes, or throws at end of input. */
22
+ readExactly(n: number): Promise<Uint8Array>;
23
+ /** Whatever is available next, at most n bytes; null at end of input. */
24
+ readSome(n: number): Promise<Uint8Array | null>;
25
+ }
26
+ export interface RequestHead {
27
+ method: string;
28
+ target: string;
29
+ headers: Headers;
30
+ }
31
+ export interface ResponseHead {
32
+ status: number;
33
+ statusText: string;
34
+ headers: Headers;
35
+ }
36
+ /** The request line and headers, as a server reads them. */
37
+ export declare function readRequestHead(reader: ByteReader): Promise<RequestHead | null>;
38
+ /** The status line and headers, as a client reads them. */
39
+ export declare function readResponseHead(reader: ByteReader): Promise<ResponseHead>;
40
+ /** How the bytes after a head are delimited. */
41
+ export type BodyFraming = {
42
+ kind: "none";
43
+ } | {
44
+ kind: "length";
45
+ length: number;
46
+ } | {
47
+ kind: "chunked";
48
+ } | {
49
+ kind: "until-close";
50
+ };
51
+ /** A request body without Content-Length or Transfer-Encoding is empty (RFC 9112 section 6.3). */
52
+ export declare function requestBodyFraming(head: RequestHead): BodyFraming;
53
+ /** A response body ends where the sender says, or with the stream. */
54
+ export declare function responseBodyFraming(method: string, head: ResponseHead): BodyFraming;
55
+ /** The body bytes, as the framing delimits them, in the pieces they arrive. */
56
+ export declare function bodyChunks(reader: ByteReader, framing: BodyFraming): AsyncGenerator<Uint8Array>;
57
+ /** A whole body, refused past the limit. */
58
+ export declare function readBody(reader: ByteReader, framing: BodyFraming, limit: number): Promise<Uint8Array<ArrayBuffer>>;
59
+ /** The body as a web stream; onDone runs once it is drained, failed or cancelled. */
60
+ export declare function bodyStream(reader: ByteReader, framing: BodyFraming, onDone: (err?: Error) => void): ReadableStream<Uint8Array>;
61
+ /** A request head on the wire. */
62
+ export declare function encodeRequestHead(method: string, target: string, headers: Headers): Uint8Array;
63
+ /** A response head on the wire. */
64
+ export declare function encodeResponseHead(status: number, statusText: string, headers: Headers): Uint8Array;
65
+ /** One chunk of a chunked body. */
66
+ export declare function encodeChunk(data: Uint8Array): Uint8Array;
67
+ /** The end of a chunked body. */
68
+ export declare const LAST_CHUNK: Uint8Array<ArrayBuffer>;
package/dist/http1.js ADDED
@@ -0,0 +1,371 @@
1
+ // Copyright 2026 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ import { concatBytes } from "./bytes.js";
15
+ /** Longest request or response head (start line and headers) accepted. */
16
+ export const MAX_HEAD_BYTES = 64 * 1024;
17
+ const MAX_CHUNK_LINE_BYTES = 1024;
18
+ const CR = 0x0d;
19
+ const LF = 0x0a;
20
+ const encoder = new TextEncoder();
21
+ const decoder = new TextDecoder();
22
+ /** Reads a libp2p stream, holding the remote back while nobody is reading. */
23
+ export function streamSource(stream) {
24
+ const queue = [];
25
+ let ended = false;
26
+ let waiter;
27
+ const wake = () => {
28
+ waiter?.();
29
+ waiter = undefined;
30
+ };
31
+ stream.addEventListener("message", (evt) => {
32
+ queue.push(evt.data.subarray());
33
+ stream.pause();
34
+ wake();
35
+ });
36
+ const end = () => {
37
+ ended = true;
38
+ wake();
39
+ };
40
+ // 'end' fires once the read buffer has drained after the remote closed its
41
+ // writable end; 'remoteCloseWrite' can fire while data is still buffered.
42
+ stream.addEventListener("end", end);
43
+ stream.addEventListener("close", end);
44
+ if (stream.readableEnded) {
45
+ ended = true;
46
+ }
47
+ return {
48
+ async read() {
49
+ for (;;) {
50
+ const next = queue.shift();
51
+ if (next !== undefined) {
52
+ if (queue.length === 0 && !ended) {
53
+ stream.resume();
54
+ }
55
+ return next;
56
+ }
57
+ if (ended) {
58
+ return null;
59
+ }
60
+ stream.resume();
61
+ await new Promise((resolve) => {
62
+ waiter = resolve;
63
+ });
64
+ }
65
+ },
66
+ };
67
+ }
68
+ /** Sends to a libp2p stream, waiting for the remote when the send buffer is full. */
69
+ export async function sendAll(stream, data) {
70
+ if (data.length === 0) {
71
+ return;
72
+ }
73
+ if (!stream.send(data)) {
74
+ await stream.onDrain();
75
+ }
76
+ }
77
+ export class HTTPParseError extends Error {
78
+ constructor(message) {
79
+ super(message);
80
+ this.name = "HTTPParseError";
81
+ }
82
+ }
83
+ /** Bytes from a source with lookahead: lines and exact counts. */
84
+ export class ByteReader {
85
+ #source;
86
+ #buf = new Uint8Array(0);
87
+ #eof = false;
88
+ constructor(source) {
89
+ this.#source = source;
90
+ }
91
+ async #fill() {
92
+ if (this.#eof) {
93
+ return false;
94
+ }
95
+ const next = await this.#source.read();
96
+ if (next === null) {
97
+ this.#eof = true;
98
+ return false;
99
+ }
100
+ this.#buf = this.#buf.length === 0 ? next : concatBytes(this.#buf, next);
101
+ return true;
102
+ }
103
+ /** One CRLF-terminated line without its terminator, at most limit bytes; null at end of input before any byte. */
104
+ async readLine(limit) {
105
+ for (;;) {
106
+ const lf = this.#buf.indexOf(LF);
107
+ if (lf !== -1) {
108
+ const end = lf > 0 && this.#buf[lf - 1] === CR ? lf - 1 : lf;
109
+ if (end > limit) {
110
+ throw new HTTPParseError(`line exceeds ${limit} bytes`);
111
+ }
112
+ const line = decoder.decode(this.#buf.subarray(0, end));
113
+ this.#buf = this.#buf.subarray(lf + 1);
114
+ return line;
115
+ }
116
+ // Room for the CRLF of a line exactly at the limit.
117
+ if (this.#buf.length > limit + 2) {
118
+ throw new HTTPParseError(`line exceeds ${limit} bytes`);
119
+ }
120
+ if (!(await this.#fill())) {
121
+ if (this.#buf.length === 0) {
122
+ return null;
123
+ }
124
+ throw new HTTPParseError("unexpected end of input in a line");
125
+ }
126
+ }
127
+ }
128
+ /** Exactly n bytes, or throws at end of input. */
129
+ async readExactly(n) {
130
+ while (this.#buf.length < n) {
131
+ if (!(await this.#fill())) {
132
+ throw new HTTPParseError(`unexpected end of input after ${this.#buf.length} of ${n} bytes`);
133
+ }
134
+ }
135
+ const out = this.#buf.slice(0, n);
136
+ this.#buf = this.#buf.subarray(n);
137
+ return out;
138
+ }
139
+ /** Whatever is available next, at most n bytes; null at end of input. */
140
+ async readSome(n) {
141
+ if (this.#buf.length === 0 && !(await this.#fill())) {
142
+ return null;
143
+ }
144
+ const take = Math.min(n, this.#buf.length);
145
+ const out = this.#buf.slice(0, take);
146
+ this.#buf = this.#buf.subarray(take);
147
+ return out;
148
+ }
149
+ }
150
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
151
+ async function readHeaders(reader, budget) {
152
+ const headers = new Headers();
153
+ for (;;) {
154
+ const line = await reader.readLine(budget);
155
+ if (line === null) {
156
+ throw new HTTPParseError("unexpected end of input in headers");
157
+ }
158
+ if (line === "") {
159
+ return headers;
160
+ }
161
+ budget -= line.length + 2;
162
+ if (budget < 0) {
163
+ throw new HTTPParseError(`head exceeds ${MAX_HEAD_BYTES} bytes`);
164
+ }
165
+ const colon = line.indexOf(":");
166
+ if (colon <= 0 || line[0] === " " || line[0] === "\t") {
167
+ throw new HTTPParseError(`malformed header line ${JSON.stringify(line)}`);
168
+ }
169
+ const name = line.slice(0, colon);
170
+ if (!TOKEN.test(name)) {
171
+ throw new HTTPParseError(`malformed header name ${JSON.stringify(name)}`);
172
+ }
173
+ headers.append(name, line.slice(colon + 1).trim());
174
+ }
175
+ }
176
+ /** The request line and headers, as a server reads them. */
177
+ export async function readRequestHead(reader) {
178
+ const line = await reader.readLine(MAX_HEAD_BYTES);
179
+ if (line === null) {
180
+ return null;
181
+ }
182
+ const parts = line.split(" ");
183
+ if (parts.length !== 3 || !TOKEN.test(parts[0]) || parts[1] === "" || !/^HTTP\/1\.[01]$/.test(parts[2])) {
184
+ throw new HTTPParseError(`malformed request line ${JSON.stringify(line)}`);
185
+ }
186
+ const headers = await readHeaders(reader, MAX_HEAD_BYTES - line.length - 2);
187
+ return { method: parts[0], target: parts[1], headers };
188
+ }
189
+ /** The status line and headers, as a client reads them. */
190
+ export async function readResponseHead(reader) {
191
+ const line = await reader.readLine(MAX_HEAD_BYTES);
192
+ if (line === null) {
193
+ throw new HTTPParseError("no response");
194
+ }
195
+ const m = /^HTTP\/1\.[01] (\d{3})(?: (.*))?$/.exec(line);
196
+ if (m === null) {
197
+ throw new HTTPParseError(`malformed status line ${JSON.stringify(line)}`);
198
+ }
199
+ const headers = await readHeaders(reader, MAX_HEAD_BYTES - line.length - 2);
200
+ return { status: Number(m[1]), statusText: m[2] ?? "", headers };
201
+ }
202
+ function framingOf(headers, whenUnframed) {
203
+ const te = headers.get("transfer-encoding");
204
+ if (te !== null) {
205
+ const codings = te.split(",").map((c) => c.trim().toLowerCase());
206
+ if (codings.at(-1) !== "chunked" || codings.length !== 1) {
207
+ throw new HTTPParseError(`unsupported transfer-encoding ${JSON.stringify(te)}`);
208
+ }
209
+ return { kind: "chunked" };
210
+ }
211
+ const cl = headers.get("content-length");
212
+ if (cl !== null) {
213
+ const values = new Set(cl.split(",").map((v) => v.trim()));
214
+ if (values.size !== 1 || !/^\d{1,15}$/.test(cl.split(",")[0]?.trim() ?? "")) {
215
+ throw new HTTPParseError(`malformed content-length ${JSON.stringify(cl)}`);
216
+ }
217
+ const length = Number(values.values().next().value);
218
+ return length === 0 ? { kind: "none" } : { kind: "length", length };
219
+ }
220
+ return whenUnframed;
221
+ }
222
+ /** A request body without Content-Length or Transfer-Encoding is empty (RFC 9112 section 6.3). */
223
+ export function requestBodyFraming(head) {
224
+ return framingOf(head.headers, { kind: "none" });
225
+ }
226
+ /** A response body ends where the sender says, or with the stream. */
227
+ export function responseBodyFraming(method, head) {
228
+ if (method === "HEAD" || head.status === 204 || head.status === 304 || (head.status >= 100 && head.status < 200)) {
229
+ return { kind: "none" };
230
+ }
231
+ return framingOf(head.headers, { kind: "until-close" });
232
+ }
233
+ async function* chunkedBody(reader) {
234
+ for (;;) {
235
+ const line = await reader.readLine(MAX_CHUNK_LINE_BYTES);
236
+ if (line === null) {
237
+ throw new HTTPParseError("unexpected end of input in chunked body");
238
+ }
239
+ // The whole size token must be hex, else "5g" would read as 5 and the
240
+ // two ends would disagree on where the chunk ends. Eight digits bound
241
+ // the size to 4 GiB, well within what parseInt represents exactly.
242
+ const sizeText = line.split(";")[0].trim();
243
+ if (!/^[0-9A-Fa-f]{1,8}$/.test(sizeText)) {
244
+ throw new HTTPParseError(`malformed chunk size ${JSON.stringify(line)}`);
245
+ }
246
+ const size = parseInt(sizeText, 16);
247
+ if (size === 0) {
248
+ // Trailer fields, then the empty line that ends the message.
249
+ for (let t = await reader.readLine(MAX_HEAD_BYTES); t !== ""; t = await reader.readLine(MAX_HEAD_BYTES)) {
250
+ if (t === null) {
251
+ throw new HTTPParseError("unexpected end of input in trailers");
252
+ }
253
+ }
254
+ return;
255
+ }
256
+ let remaining = size;
257
+ while (remaining > 0) {
258
+ const part = await reader.readSome(remaining);
259
+ if (part === null) {
260
+ throw new HTTPParseError("unexpected end of input in a chunk");
261
+ }
262
+ remaining -= part.length;
263
+ yield part;
264
+ }
265
+ if ((await reader.readLine(2)) !== "") {
266
+ throw new HTTPParseError("chunk data not followed by CRLF");
267
+ }
268
+ }
269
+ }
270
+ /** The body bytes, as the framing delimits them, in the pieces they arrive. */
271
+ export async function* bodyChunks(reader, framing) {
272
+ switch (framing.kind) {
273
+ case "none":
274
+ return;
275
+ case "length": {
276
+ let remaining = framing.length;
277
+ while (remaining > 0) {
278
+ const part = await reader.readSome(remaining);
279
+ if (part === null) {
280
+ throw new HTTPParseError(`body ended ${remaining} bytes short of content-length`);
281
+ }
282
+ remaining -= part.length;
283
+ yield part;
284
+ }
285
+ return;
286
+ }
287
+ case "chunked":
288
+ yield* chunkedBody(reader);
289
+ return;
290
+ case "until-close":
291
+ for (let part = await reader.readSome(64 * 1024); part !== null; part = await reader.readSome(64 * 1024)) {
292
+ yield part;
293
+ }
294
+ return;
295
+ }
296
+ }
297
+ /** A whole body, refused past the limit. */
298
+ export async function readBody(reader, framing, limit) {
299
+ if (framing.kind === "length" && framing.length > limit) {
300
+ throw new HTTPParseError(`body of ${framing.length} bytes exceeds ${limit}`);
301
+ }
302
+ const parts = [];
303
+ let size = 0;
304
+ for await (const part of bodyChunks(reader, framing)) {
305
+ size += part.length;
306
+ if (size > limit) {
307
+ throw new HTTPParseError(`body exceeds ${limit} bytes`);
308
+ }
309
+ parts.push(part);
310
+ }
311
+ return concatBytes(...parts);
312
+ }
313
+ /** The body as a web stream; onDone runs once it is drained, failed or cancelled. */
314
+ export function bodyStream(reader, framing, onDone) {
315
+ const chunks = bodyChunks(reader, framing);
316
+ let done = false;
317
+ const finish = (err) => {
318
+ if (!done) {
319
+ done = true;
320
+ onDone(err);
321
+ }
322
+ };
323
+ return new ReadableStream({
324
+ async pull(controller) {
325
+ try {
326
+ const next = await chunks.next();
327
+ if (next.done) {
328
+ controller.close();
329
+ finish();
330
+ }
331
+ else {
332
+ controller.enqueue(next.value);
333
+ }
334
+ }
335
+ catch (err) {
336
+ const e = err instanceof Error ? err : new Error(String(err));
337
+ controller.error(e);
338
+ finish(e);
339
+ }
340
+ },
341
+ async cancel(reason) {
342
+ // The stream is torn down first, so a read the generator is blocked on ends.
343
+ finish(reason instanceof Error ? reason : new Error(String(reason)));
344
+ await chunks.return(undefined).catch(() => { });
345
+ },
346
+ });
347
+ }
348
+ function headerLines(headers) {
349
+ let out = "";
350
+ headers.forEach((value, name) => {
351
+ if (/[\r\n]/.test(value)) {
352
+ throw new HTTPParseError(`header ${name} contains a line break`);
353
+ }
354
+ out += `${name}: ${value}\r\n`;
355
+ });
356
+ return out;
357
+ }
358
+ /** A request head on the wire. */
359
+ export function encodeRequestHead(method, target, headers) {
360
+ return encoder.encode(`${method} ${target} HTTP/1.1\r\n${headerLines(headers)}\r\n`);
361
+ }
362
+ /** A response head on the wire. */
363
+ export function encodeResponseHead(status, statusText, headers) {
364
+ return encoder.encode(`HTTP/1.1 ${status} ${statusText.replace(/[\r\n]/g, " ")}\r\n${headerLines(headers)}\r\n`);
365
+ }
366
+ /** One chunk of a chunked body. */
367
+ export function encodeChunk(data) {
368
+ return concatBytes(encoder.encode(`${data.length.toString(16)}\r\n`), data, encoder.encode("\r\n"));
369
+ }
370
+ /** The end of a chunked body. */
371
+ export const LAST_CHUNK = encoder.encode("0\r\n\r\n");