@statewalker/httpeers-share 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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -2
  3. package/dist/bridge/bridge.d.ts +37 -0
  4. package/dist/bridge/bridge.d.ts.map +1 -0
  5. package/dist/bridge/bridge.js +195 -0
  6. package/dist/bridge/index.d.ts +8 -0
  7. package/dist/bridge/index.d.ts.map +1 -0
  8. package/dist/bridge/index.js +7 -0
  9. package/dist/bridge/serve.d.ts +20 -0
  10. package/dist/bridge/serve.d.ts.map +1 -0
  11. package/dist/bridge/serve.js +66 -0
  12. package/dist/bridge/sockets.d.ts +47 -0
  13. package/dist/bridge/sockets.d.ts.map +1 -0
  14. package/dist/bridge/sockets.js +86 -0
  15. package/dist/index.d.ts +20 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +19 -0
  18. package/dist/invite-link.d.ts +21 -0
  19. package/dist/invite-link.d.ts.map +1 -0
  20. package/dist/invite-link.js +38 -0
  21. package/dist/join-outcome.d.ts +16 -0
  22. package/dist/join-outcome.d.ts.map +1 -0
  23. package/dist/join-outcome.js +32 -0
  24. package/dist/lifecycle.d.ts +19 -0
  25. package/dist/lifecycle.d.ts.map +1 -0
  26. package/dist/lifecycle.js +28 -0
  27. package/dist/policy.d.ts +19 -0
  28. package/dist/policy.d.ts.map +1 -0
  29. package/dist/policy.js +40 -0
  30. package/dist/probe.d.ts +20 -0
  31. package/dist/probe.d.ts.map +1 -0
  32. package/dist/probe.js +60 -0
  33. package/dist/service-table.d.ts +33 -0
  34. package/dist/service-table.d.ts.map +1 -0
  35. package/dist/service-table.js +59 -0
  36. package/dist/share-router.d.ts +15 -0
  37. package/dist/share-router.d.ts.map +1 -0
  38. package/dist/share-router.js +31 -0
  39. package/dist/socket-frames.d.ts +30 -0
  40. package/dist/socket-frames.d.ts.map +1 -0
  41. package/dist/socket-frames.js +81 -0
  42. package/dist/socket-tunnel.d.ts +46 -0
  43. package/dist/socket-tunnel.d.ts.map +1 -0
  44. package/dist/socket-tunnel.js +157 -0
  45. package/dist/stamp-guest.d.ts +7 -0
  46. package/dist/stamp-guest.d.ts.map +1 -0
  47. package/dist/stamp-guest.js +17 -0
  48. package/dist/targets.d.ts +18 -0
  49. package/dist/targets.d.ts.map +1 -0
  50. package/dist/targets.js +48 -0
  51. package/dist/upstreams.d.ts +47 -0
  52. package/dist/upstreams.d.ts.map +1 -0
  53. package/dist/upstreams.js +141 -0
  54. package/package.json +59 -3
  55. package/src/bridge/bridge.ts +229 -0
  56. package/src/bridge/index.ts +17 -0
  57. package/src/bridge/serve.ts +88 -0
  58. package/src/bridge/sockets.ts +119 -0
  59. package/src/index.ts +50 -0
  60. package/src/invite-link.ts +48 -0
  61. package/src/join-outcome.ts +33 -0
  62. package/src/lifecycle.ts +31 -0
  63. package/src/policy.ts +52 -0
  64. package/src/probe.ts +86 -0
  65. package/src/service-table.ts +91 -0
  66. package/src/share-router.ts +43 -0
  67. package/src/socket-frames.ts +83 -0
  68. package/src/socket-tunnel.ts +167 -0
  69. package/src/stamp-guest.ts +18 -0
  70. package/src/targets.ts +51 -0
  71. package/src/upstreams.ts +149 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Kotelnikov
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 CHANGED
@@ -1,3 +1,76 @@
1
- # Temporary Holding Version
1
+ # @statewalker/httpeers-share
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ## What it is
4
+
5
+ Sharing an HTTP service with the members of an httpeers mesh. It covers:
6
+ - how the Sharer reaches the service (upstream kinds);
7
+ - what may be shared (policy);
8
+ - whether it works for Guests (probe);
9
+ - how Guests see it (service table);
10
+ - how they are invited (invite links);
11
+ - what a failed join means (join outcome).
12
+
13
+ `./bridge` is the PeerShare Bridge, for Node and Bun only.
14
+
15
+ ## Why it exists
16
+
17
+ PeerShare (a SandClaw app: share a local HTTP service with invited Guests) needs the same
18
+ rules in the Sharer's browser tab, in the Bridge and in tests. Everything here was proven in
19
+ a prototype rung first (`statewalker-sandbox`, `apps/peershare-protos`, whose rung READMEs
20
+ say why each rule is the way it is) and moved here with its tests.
21
+
22
+ ## How to use
23
+
24
+ ```sh
25
+ pnpm add @statewalker/httpeers-share
26
+ ```
27
+
28
+ | Import | Gives | Runs in | Rung |
29
+ |---|---|---|---|
30
+ | `.` | `createUpstream`, `unreachable`, the `BRIDGE_*` header contract | anywhere | 01 |
31
+ | `.` | `checkServiceTarget` (v1 policy), `checkTarget`, `isPrivateAddress` | anywhere | 04, 02 |
32
+ | `.` | `probe` | anywhere | 04 |
33
+ | `.` | `serviceTable` (catalog at `/s/`, Pause, routing) | anywhere | 07 |
34
+ | `.` | `encodeInvite`, `decodeInvite` | anywhere | 06 |
35
+ | `.` | `joinOutcome`, `revokeGuest`, `isSharerOffline`, `stampGuest` | anywhere | 09, 10, 16 |
36
+ | `.` | `openTunnel`, `sharerSocket`, `encodeFrame`/`decodeFrames`: a WebSocket inside one full-duplex mesh request | anywhere | 15 |
37
+ | `./bridge` | `createBridge` (HTTP and its WebSocket gate), `listenLoopback` (Node), `connectApp`/`relaySocket` (for a Bun host) | Node, Bun | 02, 03, 16 |
38
+
39
+ ## Examples
40
+
41
+ ```ts
42
+ import { createUpstream, serviceTable, checkServiceTarget } from "@statewalker/httpeers-share";
43
+
44
+ checkServiceTarget("direct", "http://api.example.com"); // { ok: false, reason: "needs-https" }
45
+
46
+ const upstream = createUpstream({
47
+ kind: "bridged",
48
+ target: "http://localhost:5173",
49
+ bridge: { base: "http://127.0.0.1:9090", token },
50
+ });
51
+ const mount = serviceTable(
52
+ () => [{ id: "foo", name: "fooService", upstream, enabled: true, isDefault: true }],
53
+ { sharerName: "Dirk" },
54
+ ); // the Sharer's `/s` mount
55
+ ```
56
+
57
+ ## Internals
58
+
59
+ - The root entry point is isomorphic. `tests/boundary.test.ts` fails if any file under `src/`
60
+ (other than `src/bridge/`) imports a `node:` module.
61
+ - Tests run against real ports and the real Bridge. `tests/fixtures.ts` includes a stand-in
62
+ Bridge, kept on purpose: it proves the upstream depends only on the wire contract.
63
+ - `service-table`, `join-outcome` and `stamp-guest` were tested over the mesh in the rungs.
64
+ Their unit tests here characterise code that moved, and passed on their first run.
65
+
66
+ - `./bridge` serves with `@hono/node-server`'s `overrideGlobalObjects: false`. By default that
67
+ server replaces the process's global `Request`/`Response`, which stalls streaming fetches
68
+ (the mesh member's full-duplex requests) everywhere else in the process.
69
+
70
+ ```sh
71
+ pnpm --filter @statewalker/httpeers-share test
72
+ ```
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The Bridge: a loopback relay from the PeerShare page to the Sharer's local
3
+ * Services, speaking rung 01's wire contract.
4
+ *
5
+ * A loopback port that answers cross-origin requests is reachable by EVERY
6
+ * website the Sharer visits. Each rule below closes one way in; the tests in
7
+ * `../tests/` name them.
8
+ */
9
+ import { type FetchHandler } from "@statewalker/webrun-http-proxy";
10
+ import { type SocketGate } from "./sockets.js";
11
+ export interface BridgeInit {
12
+ /** The Pairing token generated at launch; nothing is answered without it. */
13
+ token: string;
14
+ /** The PeerShare page's origin; the only one granted cross-origin access. */
15
+ allowedOrigin: string;
16
+ /** Injected by tests; defaults to the platform's. */
17
+ fetchImpl?: typeof fetch;
18
+ /** Every address a `.local` name resolves to. Injected by tests; defaults to the OS resolver. */
19
+ resolveHost?: (name: string) => Promise<string[]>;
20
+ /**
21
+ * Keep one cookie jar per Guest (rung 16, spike): a Service's `Set-Cookie`
22
+ * goes into the jar of the Guest named in `x-peershare-guest`, and that
23
+ * jar's cookies go on the Guest's next requests. Cookies never reach the
24
+ * page, which could not use them anyway.
25
+ */
26
+ cookieJars?: boolean;
27
+ }
28
+ /** Whether the Bridge may relay to `target` (rung 02 rules): `ok`, `forbidden` or `unreachable`. */
29
+ export declare function reachable(target: string, resolveHost: (name: string) => Promise<string[]>): Promise<"ok" | "forbidden" | "unreachable">;
30
+ export declare function systemResolve(name: string): Promise<string[]>;
31
+ /** The Bridge's HTTP handler, carrying its WebSocket gate for whichever server hosts it. */
32
+ export type BridgeHandler = FetchHandler & {
33
+ sockets: SocketGate;
34
+ };
35
+ export declare function createBridge(init: BridgeInit): BridgeHandler;
36
+ export declare function sameSecret(given: string | null, expected: string): boolean;
37
+ //# sourceMappingURL=bridge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge.d.ts","sourceRoot":"","sources":["../../src/bridge/bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,KAAK,YAAY,EAAuB,MAAM,gCAAgC,CAAC;AAYxF,OAAO,EAAE,KAAK,UAAU,EAAc,MAAM,cAAc,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,6EAA6E;IAC7E,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,aAAa,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,iGAAiG;IACjG,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAClD;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,oGAAoG;AACpG,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,GAC/C,OAAO,CAAC,IAAI,GAAG,WAAW,GAAG,aAAa,CAAC,CAO7C;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAGnE;AAED,4FAA4F;AAC5F,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG;IAAE,OAAO,EAAE,UAAU,CAAA;CAAE,CAAC;AAEnE,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,aAAa,CA+E5D;AAsDD,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAK1E"}
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The Bridge: a loopback relay from the PeerShare page to the Sharer's local
3
+ * Services, speaking rung 01's wire contract.
4
+ *
5
+ * A loopback port that answers cross-origin requests is reachable by EVERY
6
+ * website the Sharer visits. Each rule below closes one way in; the tests in
7
+ * `../tests/` name them.
8
+ */
9
+ import { timingSafeEqual } from "node:crypto";
10
+ import { MARKER, urlUpstream } from "@statewalker/webrun-http-proxy";
11
+ import { checkTarget, isPrivateAddress } from "../targets.js";
12
+ import { BRIDGE_COOKIES_KEPT_HEADER, BRIDGE_GUEST_HEADER, BRIDGE_MARKER, BRIDGE_REDIRECT_LOCATION_HEADER, BRIDGE_REDIRECT_STATUS_HEADER, BRIDGE_SET_COOKIE_HEADER, BRIDGE_TARGET_HEADER, BRIDGE_TOKEN_HEADER, } from "../upstreams.js";
13
+ import { socketGate } from "./sockets.js";
14
+ /** Whether the Bridge may relay to `target` (rung 02 rules): `ok`, `forbidden` or `unreachable`. */
15
+ export async function reachable(target, resolveHost) {
16
+ const verdict = checkTarget(target);
17
+ if (verdict === "forbidden")
18
+ return "forbidden";
19
+ if (verdict === "allowed")
20
+ return "ok";
21
+ const addresses = await resolveHost(verdict.resolve).catch(() => []);
22
+ if (addresses.length === 0)
23
+ return "unreachable";
24
+ return addresses.every(isPrivateAddress) ? "ok" : "forbidden";
25
+ }
26
+ export async function systemResolve(name) {
27
+ const { lookup } = await import("node:dns/promises");
28
+ return (await lookup(name, { all: true })).map((a) => a.address);
29
+ }
30
+ export function createBridge(init) {
31
+ const resolveHost = init.resolveHost ?? systemResolve;
32
+ const jars = new Map();
33
+ const handler = async (incoming) => {
34
+ const guest = init.cookieJars === true ? incoming.headers.get(BRIDGE_GUEST_HEADER) : null;
35
+ const jar = guest == null ? undefined : (jars.get(guest) ?? jars.set(guest, new Map()).get(guest));
36
+ const request = jar == null ? incoming : withCookies(incoming, jar);
37
+ // A page on another site carries its own `Origin`. No `Origin` at all is
38
+ // a local process (curl, the Probe's CLI): the token alone admits it.
39
+ const origin = request.headers.get("origin");
40
+ if (origin != null && origin !== init.allowedOrigin)
41
+ return refuse(403, "origin-forbidden");
42
+ // A preflight never carries custom headers, so it cannot carry the token:
43
+ // the origin check above is all that guards it, and it grants nothing
44
+ // but permission to send the real, token-bearing request.
45
+ if (origin != null &&
46
+ request.method === "OPTIONS" &&
47
+ request.headers.has("access-control-request-method")) {
48
+ return preflight(request, origin);
49
+ }
50
+ if (!sameSecret(request.headers.get(BRIDGE_TOKEN_HEADER), init.token)) {
51
+ return refuse(401, "bad-token", origin);
52
+ }
53
+ // No target: the page is asking whether its Bridge is there and its
54
+ // token is right (the token check above already passed).
55
+ const target = request.headers.get(BRIDGE_TARGET_HEADER) || null;
56
+ if (target == null) {
57
+ const headers = new Headers({ "content-type": "application/json" });
58
+ if (origin != null)
59
+ grantTo(headers, origin);
60
+ return new Response(JSON.stringify({ bridge: "peershare" }), { headers });
61
+ }
62
+ // The page passed both checks, so it may read WHY the target was refused.
63
+ const reach = await reachable(target, resolveHost);
64
+ if (reach !== "ok")
65
+ return refuse(reach === "forbidden" ? 403 : 502, `target-${reach}`, origin);
66
+ // The same re-issuing the page itself does (rung 01): hop-by-hop headers
67
+ // dropped, body streamed, redirects reported. The Bridge's own headers
68
+ // are consumed by this hop.
69
+ const upstream = urlUpstream({
70
+ base: target,
71
+ stripRequestHeaders: [BRIDGE_TARGET_HEADER, BRIDGE_TOKEN_HEADER, BRIDGE_GUEST_HEADER],
72
+ ...(init.fetchImpl != null ? { fetchImpl: init.fetchImpl } : {}),
73
+ });
74
+ const response = await upstream(request);
75
+ // The proxy's own "unreachable" is about the SERVICE here. Passed on as
76
+ // is, the page's upstream (to which the Bridge is the upstream) would
77
+ // read it as "the Bridge is down" — so it is restated in the contract's
78
+ // words, and the proxy's marker removed.
79
+ if (response.headers.get(MARKER) === "upstream-unreachable") {
80
+ return refuse(502, "target-unreachable", origin);
81
+ }
82
+ const headers = new Headers(response.headers);
83
+ headers.set(BRIDGE_MARKER, "relayed");
84
+ const cookies = response.headers.getSetCookie();
85
+ if (cookies.length > 0)
86
+ headers.set(BRIDGE_SET_COOKIE_HEADER, String(cookies.length));
87
+ if (jar != null) {
88
+ for (const cookie of cookies)
89
+ keep(jar, cookie);
90
+ headers.delete("set-cookie");
91
+ if (cookies.length > 0)
92
+ headers.set(BRIDGE_COOKIES_KEPT_HEADER, "kept");
93
+ }
94
+ const location = response.headers.get("location");
95
+ if (response.status >= 300 && response.status < 400 && location != null) {
96
+ headers.delete("location");
97
+ headers.set(BRIDGE_REDIRECT_STATUS_HEADER, String(response.status));
98
+ headers.set(BRIDGE_REDIRECT_LOCATION_HEADER, withinService(location, target));
99
+ if (origin != null)
100
+ grantTo(headers, origin);
101
+ return new Response(null, { status: 200, headers });
102
+ }
103
+ if (origin != null)
104
+ grantTo(headers, origin);
105
+ return new Response(response.body, { status: response.status, headers });
106
+ };
107
+ return Object.assign(handler, { sockets: socketGate(init, resolveHost) });
108
+ }
109
+ /**
110
+ * A location on the Service itself, as a path: the Guest sees the Service at
111
+ * the root of their own origin, so `http://127.0.0.1:5173/login` must become
112
+ * `/login`. Anywhere else stays absolute.
113
+ */
114
+ function withinService(location, target) {
115
+ const resolved = new URL(location, target);
116
+ if (resolved.origin !== new URL(target).origin)
117
+ return resolved.href;
118
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`;
119
+ }
120
+ function preflight(request, origin) {
121
+ const headers = new Headers({
122
+ "access-control-allow-origin": origin,
123
+ vary: "Origin",
124
+ // Echoed: Guests' requests carry whatever headers the shared app uses.
125
+ "access-control-allow-methods": request.headers.get("access-control-request-method") ?? "GET",
126
+ "access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "",
127
+ "access-control-max-age": "600",
128
+ });
129
+ // Chromium's Private Network Access preflight: a public page asking for loopback.
130
+ if (request.headers.get("access-control-request-private-network") === "true") {
131
+ headers.set("access-control-allow-private-network", "true");
132
+ }
133
+ return new Response(null, { status: 204, headers });
134
+ }
135
+ /**
136
+ * Cross-origin permission for exactly one origin. Whatever the Service itself
137
+ * sends (`*`, credentials) is replaced: the Service's CORS policy was written
138
+ * for its own pages, not for a relay that every website can try to reach.
139
+ */
140
+ function grantTo(headers, origin) {
141
+ for (const name of [...headers.keys()]) {
142
+ if (name.startsWith("access-control-"))
143
+ headers.delete(name);
144
+ }
145
+ headers.set("access-control-allow-origin", origin);
146
+ headers.append("vary", "Origin");
147
+ // Every response header, the marker included, readable by the page.
148
+ headers.set("access-control-expose-headers", [...headers.keys()].join(", "));
149
+ }
150
+ /**
151
+ * A refusal carries no CORS headers unless `grantTo` is given: a foreign page
152
+ * cannot even read why it was refused.
153
+ */
154
+ function refuse(status, reason, grantToOrigin) {
155
+ const headers = new Headers({ [BRIDGE_MARKER]: reason });
156
+ if (grantToOrigin != null)
157
+ grantTo(headers, grantToOrigin);
158
+ return new Response(null, { status, headers });
159
+ }
160
+ export function sameSecret(given, expected) {
161
+ if (given == null)
162
+ return false;
163
+ const a = new TextEncoder().encode(given);
164
+ const b = new TextEncoder().encode(expected);
165
+ return a.length === b.length && timingSafeEqual(a, b);
166
+ }
167
+ /** `request` with `jar`'s cookies as its `Cookie` header (the page cannot send one itself). */
168
+ function withCookies(request, jar) {
169
+ if (jar.size === 0)
170
+ return request;
171
+ const headers = new Headers(request.headers);
172
+ headers.set("cookie", [...jar].map(([name, value]) => `${name}=${value}`).join("; "));
173
+ // Rebuilt from its parts: the server's own request object (e.g.
174
+ // @hono/node-server's) need not be one the global Request can copy.
175
+ const body = request.method === "GET" || request.method === "HEAD" ? null : request.body;
176
+ return new Request(request.url, {
177
+ method: request.method,
178
+ headers,
179
+ body,
180
+ ...(body != null ? { duplex: "half" } : {}),
181
+ signal: request.signal,
182
+ });
183
+ }
184
+ /** A spike's cookie jar: name and value only; `Max-Age=0` deletes. Path, domain and expiry are ignored. */
185
+ function keep(jar, setCookie) {
186
+ const [pair = "", ...attributes] = setCookie.split(";");
187
+ const at = pair.indexOf("=");
188
+ if (at <= 0)
189
+ return;
190
+ const name = pair.slice(0, at).trim();
191
+ if (attributes.some((a) => /^\s*max-age\s*=\s*0\s*$/i.test(a)))
192
+ jar.delete(name);
193
+ else
194
+ jar.set(name, pair.slice(at + 1).trim());
195
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The Bridge (Node and Bun only): a locked-down loopback relay from the
3
+ * PeerShare page to the Sharer's local Services, with per-Guest cookie jars.
4
+ */
5
+ export { type BridgeHandler, type BridgeInit, createBridge } from "./bridge.js";
6
+ export { type Listening, listenLoopback } from "./serve.js";
7
+ export { acceptedProtocol, connectApp, type InboundSocket, relaySocket, SOCKET_PATH, type SocketGate, type SocketPlan, TOKEN_PROTOCOL_PREFIX, } from "./sockets.js";
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bridge/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChF,OAAO,EAAE,KAAK,SAAS,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5D,OAAO,EACL,gBAAgB,EAChB,UAAU,EACV,KAAK,aAAa,EAClB,WAAW,EACX,WAAW,EACX,KAAK,UAAU,EACf,KAAK,UAAU,EACf,qBAAqB,GACtB,MAAM,cAAc,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The Bridge (Node and Bun only): a locked-down loopback relay from the
3
+ * PeerShare page to the Sharer's local Services, with per-Guest cookie jars.
4
+ */
5
+ export { createBridge } from "./bridge.js";
6
+ export { listenLoopback } from "./serve.js";
7
+ export { acceptedProtocol, connectApp, relaySocket, SOCKET_PATH, TOKEN_PROTOCOL_PREFIX, } from "./sockets.js";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The Bridge on a port, under Node. Under Bun, `main.ts` hands the same
3
+ * handler to `Bun.serve`.
4
+ */
5
+ import type { FetchHandler } from "@statewalker/webrun-http-proxy";
6
+ import { type SocketGate } from "./sockets.js";
7
+ export interface Listening {
8
+ origin: string;
9
+ /** The interface actually bound. */
10
+ address: string;
11
+ close(): Promise<void>;
12
+ }
13
+ /**
14
+ * Binds 127.0.0.1 only: the Bridge is never reachable from the network. A
15
+ * handler carrying a socket gate (`createBridge`'s) also relays WebSockets.
16
+ */
17
+ export declare function listenLoopback(handler: FetchHandler & {
18
+ sockets?: SocketGate;
19
+ }, port?: number): Promise<Listening>;
20
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/bridge/serve.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAEnE,OAAO,EAA6C,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAE1F,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,YAAY,GAAG;IAAE,OAAO,CAAC,EAAE,UAAU,CAAA;CAAE,EAChD,IAAI,SAAI,GACP,OAAO,CAAC,SAAS,CAAC,CA6DpB"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The Bridge on a port, under Node. Under Bun, `main.ts` hands the same
3
+ * handler to `Bun.serve`.
4
+ */
5
+ import { serve } from "@hono/node-server";
6
+ import { WebSocketServer } from "ws";
7
+ import { acceptedProtocol, connectApp, relaySocket } from "./sockets.js";
8
+ /**
9
+ * Binds 127.0.0.1 only: the Bridge is never reachable from the network. A
10
+ * handler carrying a socket gate (`createBridge`'s) also relays WebSockets.
11
+ */
12
+ export function listenLoopback(handler, port = 0) {
13
+ return new Promise((resolve) => {
14
+ // `overrideGlobalObjects: false`: by default @hono/node-server swaps the
15
+ // process's global Request and Response for its own, which breaks streaming
16
+ // request bodies for everything else in the process (found in M9: the mesh
17
+ // member's full-duplex fetch stalled while a Bridge ran in the same process).
18
+ const server = serve({ fetch: handler, port, hostname: "127.0.0.1", overrideGlobalObjects: false }, (info) => {
19
+ resolve({
20
+ origin: `http://127.0.0.1:${info.port}`,
21
+ address: info.address,
22
+ close: () => new Promise((done) => {
23
+ for (const client of wss.clients)
24
+ client.terminate();
25
+ if ("closeAllConnections" in server)
26
+ server.closeAllConnections();
27
+ server.close(() => done());
28
+ }),
29
+ });
30
+ });
31
+ // The subprotocol to accept travels on the request itself, so concurrent
32
+ // upgrades cannot take each other's.
33
+ const accepts = new WeakMap();
34
+ const wss = new WebSocketServer({
35
+ noServer: true,
36
+ handleProtocols: (_offered, req) => accepts.get(req) ?? false,
37
+ });
38
+ server.on("upgrade", async (req, socket, head) => {
39
+ const gate = handler.sockets;
40
+ const headers = new Headers();
41
+ for (const [k, v] of Object.entries(req.headers))
42
+ if (typeof v === "string")
43
+ headers.set(k, v);
44
+ const verdict = gate == null
45
+ ? { ok: false, status: 400 }
46
+ : await gate(new URL(req.url ?? "/", "http://bridge"), headers);
47
+ if (!verdict.ok) {
48
+ socket.end(`HTTP/1.1 ${verdict.status} Refused\r\nconnection: close\r\n\r\n`);
49
+ return;
50
+ }
51
+ const plan = verdict.plan;
52
+ const app = await connectApp(plan).catch(() => null);
53
+ if (app == null) {
54
+ socket.end("HTTP/1.1 502 Refused\r\nconnection: close\r\n\r\n");
55
+ return;
56
+ }
57
+ const offered = (req.headers["sec-websocket-protocol"] ?? "").split(",").map((p) => p.trim());
58
+ accepts.set(req, acceptedProtocol(plan, app, offered));
59
+ wss.handleUpgrade(req, socket, head, (ws) => {
60
+ const relay = relaySocket({ send: (data) => ws.send(data), close: (code, reason) => ws.close(code, reason) }, app);
61
+ ws.on("message", (data, isBinary) => relay.message(isBinary ? new Uint8Array(data).buffer : String(data)));
62
+ ws.on("close", (code, reason) => relay.close(code, String(reason)));
63
+ });
64
+ });
65
+ });
66
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The Bridge's WebSockets (M9): `/.peershare/socket?target=<url>&protocols=a,b`.
3
+ *
4
+ * Guarded exactly like HTTP (rung 02): the PeerShare origin only, the
5
+ * Pairing token (as the subprotocol `peershare.<token>`, because a browser
6
+ * cannot set headers on a WebSocket), and the same target rules. Then one
7
+ * socket to the app is relayed message for message, both ways.
8
+ */
9
+ import type { BridgeInit } from "./bridge.js";
10
+ export declare const SOCKET_PATH = "/.peershare/socket";
11
+ export declare const TOKEN_PROTOCOL_PREFIX = "peershare.";
12
+ export interface SocketPlan {
13
+ /** The app's WebSocket URL. */
14
+ target: string;
15
+ /** The subprotocols to offer the app. */
16
+ protocols: string[];
17
+ /** The one the Bridge accepts from the page (its token protocol). */
18
+ accept: string;
19
+ }
20
+ export type SocketGate = (url: URL, headers: Headers) => Promise<{
21
+ ok: true;
22
+ plan: SocketPlan;
23
+ } | {
24
+ ok: false;
25
+ status: 400 | 401 | 403 | 502;
26
+ }>;
27
+ export declare function socketGate(init: Pick<BridgeInit, "token" | "allowedOrigin">, resolveHost: (name: string) => Promise<string[]>): SocketGate;
28
+ /** The page's side of a relayed socket, whatever server accepted it. */
29
+ export interface InboundSocket {
30
+ send(data: string | ArrayBuffer): void;
31
+ close(code?: number, reason?: string): void;
32
+ }
33
+ /**
34
+ * Open the app's socket first: the page's upgrade is completed only once the
35
+ * app has accepted, so the page can be given the subprotocol the APP chose
36
+ * (a WebSocket's protocol is fixed by its handshake). Rejects if the app
37
+ * refuses or cannot be reached.
38
+ */
39
+ export declare function connectApp(plan: SocketPlan): Promise<WebSocket>;
40
+ /** The subprotocol to accept from the page: the app's choice when the page offered it, else the token's. */
41
+ export declare function acceptedProtocol(plan: SocketPlan, app: WebSocket, offered: string[]): string;
42
+ /** Relay two open sockets, message for message, both ways; returns the inbound side's event sinks. */
43
+ export declare function relaySocket(inbound: InboundSocket, app: WebSocket): {
44
+ message(data: string | ArrayBuffer): void;
45
+ close(code?: number, reason?: string): void;
46
+ };
47
+ //# sourceMappingURL=sockets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sockets.d.ts","sourceRoot":"","sources":["../../src/bridge/sockets.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG9C,eAAO,MAAM,WAAW,uBAAuB,CAAC;AAChD,eAAO,MAAM,qBAAqB,eAAe,CAAC;AAElD,MAAM,WAAW,UAAU;IACzB,+BAA+B;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,yCAAyC;IACzC,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,UAAU,GAAG,CACvB,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,OAAO,KACb,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAA;CAAE,CAAC,CAAC;AAE5F,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,eAAe,CAAC,EACjD,WAAW,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,GAC/C,UAAU,CA6BZ;AAED,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;IACvC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7C;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAY/D;AAED,4GAA4G;AAC5G,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,MAAM,CAE5F;AAID,sGAAsG;AACtG,wBAAgB,WAAW,CACzB,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,SAAS,GACb;IAAE,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAAC;IAAC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAa5F"}
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The Bridge's WebSockets (M9): `/.peershare/socket?target=<url>&protocols=a,b`.
3
+ *
4
+ * Guarded exactly like HTTP (rung 02): the PeerShare origin only, the
5
+ * Pairing token (as the subprotocol `peershare.<token>`, because a browser
6
+ * cannot set headers on a WebSocket), and the same target rules. Then one
7
+ * socket to the app is relayed message for message, both ways.
8
+ */
9
+ import { reachable, sameSecret } from "./bridge.js";
10
+ export const SOCKET_PATH = "/.peershare/socket";
11
+ export const TOKEN_PROTOCOL_PREFIX = "peershare.";
12
+ export function socketGate(init, resolveHost) {
13
+ return async (url, headers) => {
14
+ if (url.pathname !== SOCKET_PATH)
15
+ return { ok: false, status: 400 };
16
+ const origin = headers.get("origin");
17
+ if (origin != null && origin !== init.allowedOrigin)
18
+ return { ok: false, status: 403 };
19
+ const offered = (headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim());
20
+ const accept = offered.find((p) => p.startsWith(TOKEN_PROTOCOL_PREFIX) &&
21
+ sameSecret(p.slice(TOKEN_PROTOCOL_PREFIX.length), init.token));
22
+ if (accept == null)
23
+ return { ok: false, status: 401 };
24
+ const target = url.searchParams.get("target") ?? "";
25
+ let http;
26
+ try {
27
+ http = new URL(target);
28
+ }
29
+ catch {
30
+ return { ok: false, status: 400 };
31
+ }
32
+ const reach = await reachable(http.href.replace(/^ws/, "http"), resolveHost);
33
+ if (reach !== "ok")
34
+ return { ok: false, status: reach === "forbidden" ? 403 : 502 };
35
+ const ws = new URL(http);
36
+ ws.protocol = http.protocol === "https:" || http.protocol === "wss:" ? "wss:" : "ws:";
37
+ const protocols = (url.searchParams.get("protocols") ?? "").split(",").filter(Boolean);
38
+ return { ok: true, plan: { target: ws.href, protocols, accept } };
39
+ };
40
+ }
41
+ /**
42
+ * Open the app's socket first: the page's upgrade is completed only once the
43
+ * app has accepted, so the page can be given the subprotocol the APP chose
44
+ * (a WebSocket's protocol is fixed by its handshake). Rejects if the app
45
+ * refuses or cannot be reached.
46
+ */
47
+ export function connectApp(plan) {
48
+ return new Promise((resolve, reject) => {
49
+ const app = new WebSocket(plan.target, plan.protocols);
50
+ app.binaryType = "arraybuffer";
51
+ // An app often speaks first ("hello" on connect), before the page's side
52
+ // is upgraded and relaying: held here, delivered first by `relaySocket`.
53
+ const held = [];
54
+ early.set(app, held);
55
+ app.onmessage = (e) => held.push(e.data);
56
+ app.onopen = () => resolve(app);
57
+ app.onerror = () => reject(new Error(`the app's socket at ${plan.target} refused`));
58
+ });
59
+ }
60
+ /** The subprotocol to accept from the page: the app's choice when the page offered it, else the token's. */
61
+ export function acceptedProtocol(plan, app, offered) {
62
+ return app.protocol !== "" && offered.includes(app.protocol) ? app.protocol : plan.accept;
63
+ }
64
+ const early = new WeakMap();
65
+ /** Relay two open sockets, message for message, both ways; returns the inbound side's event sinks. */
66
+ export function relaySocket(inbound, app) {
67
+ for (const data of early.get(app)?.splice(0) ?? [])
68
+ inbound.send(data);
69
+ app.onmessage = (e) => inbound.send(e.data);
70
+ app.onclose = (e) => inbound.close(closable(e.code), e.reason);
71
+ app.onerror = () => inbound.close(1011, "the app's socket failed");
72
+ return {
73
+ message(data) {
74
+ if (app.readyState === WebSocket.OPEN)
75
+ app.send(data);
76
+ },
77
+ close(code, reason) {
78
+ if (app.readyState <= WebSocket.OPEN)
79
+ app.close(closable(code), reason);
80
+ },
81
+ };
82
+ }
83
+ /** A code `close()` may be called with (1000, or 3000–4999); anything else becomes 1000. */
84
+ function closable(code) {
85
+ return code === 1000 || (code != null && code >= 3000 && code <= 4999) ? code : 1000;
86
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Sharing an HTTP service with the members of an httpeers mesh: how the
3
+ * Sharer reaches it (upstream kinds), what may be shared (policy), whether it
4
+ * works for Guests (probe), how Guests see it (service table), how they are
5
+ * invited (invite link), and what a failed join means (join outcome).
6
+ *
7
+ * Isomorphic and UI-free: no DOM, no `node:` module.
8
+ */
9
+ export { decodeInvite, encodeInvite, type InviteLink } from "./invite-link.js";
10
+ export { type JoinOutcome, joinOutcome } from "./join-outcome.js";
11
+ export { isSharerOffline, revokeGuest } from "./lifecycle.js";
12
+ export { checkServiceTarget, type ServiceKind, type TargetCheck, type TargetRefusal, } from "./policy.js";
13
+ export { type Health, type ProbeCause, type ProbeReport, type ProbeWarning, probe, SLOW_MS, } from "./probe.js";
14
+ export { type CatalogEntry, SERVICE_MARKER, type ServiceEntry, serviceTable, } from "./service-table.js";
15
+ export { decodeFrames, encodeFrame, SOCKET_HEADER, type SocketFrame } from "./socket-frames.js";
16
+ export { openTunnel, sharerSocket, TunnelSocket } from "./socket-tunnel.js";
17
+ export { stampGuest } from "./stamp-guest.js";
18
+ export { checkTarget, isPrivateAddress, type TargetVerdict } from "./targets.js";
19
+ export { BRIDGE_COOKIES_KEPT_HEADER, BRIDGE_GUEST_HEADER, BRIDGE_MARKER, BRIDGE_REDIRECT_LOCATION_HEADER, BRIDGE_REDIRECT_STATUS_HEADER, BRIDGE_SET_COOKIE_HEADER, BRIDGE_TARGET_HEADER, BRIDGE_TOKEN_HEADER, createUpstream, PROBE_GUEST, type UpstreamConfig, unreachable, } from "./upstreams.js";
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC/E,OAAO,EAAE,KAAK,WAAW,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EACL,kBAAkB,EAClB,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,aAAa,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,KAAK,MAAM,EACX,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,EACL,OAAO,GACR,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,KAAK,YAAY,EACjB,cAAc,EACd,KAAK,YAAY,EACjB,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAChG,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AACjF,OAAO,EACL,0BAA0B,EAC1B,mBAAmB,EACnB,aAAa,EACb,+BAA+B,EAC/B,6BAA6B,EAC7B,wBAAwB,EACxB,oBAAoB,EACpB,mBAAmB,EACnB,cAAc,EACd,WAAW,EACX,KAAK,cAAc,EACnB,WAAW,GACZ,MAAM,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Sharing an HTTP service with the members of an httpeers mesh: how the
3
+ * Sharer reaches it (upstream kinds), what may be shared (policy), whether it
4
+ * works for Guests (probe), how Guests see it (service table), how they are
5
+ * invited (invite link), and what a failed join means (join outcome).
6
+ *
7
+ * Isomorphic and UI-free: no DOM, no `node:` module.
8
+ */
9
+ export { decodeInvite, encodeInvite } from "./invite-link.js";
10
+ export { joinOutcome } from "./join-outcome.js";
11
+ export { isSharerOffline, revokeGuest } from "./lifecycle.js";
12
+ export { checkServiceTarget, } from "./policy.js";
13
+ export { probe, SLOW_MS, } from "./probe.js";
14
+ export { SERVICE_MARKER, serviceTable, } from "./service-table.js";
15
+ export { decodeFrames, encodeFrame, SOCKET_HEADER } from "./socket-frames.js";
16
+ export { openTunnel, sharerSocket, TunnelSocket } from "./socket-tunnel.js";
17
+ export { stampGuest } from "./stamp-guest.js";
18
+ export { checkTarget, isPrivateAddress } from "./targets.js";
19
+ export { BRIDGE_COOKIES_KEPT_HEADER, BRIDGE_GUEST_HEADER, BRIDGE_MARKER, BRIDGE_REDIRECT_LOCATION_HEADER, BRIDGE_REDIRECT_STATUS_HEADER, BRIDGE_SET_COOKIE_HEADER, BRIDGE_TARGET_HEADER, BRIDGE_TOKEN_HEADER, createUpstream, PROBE_GUEST, unreachable, } from "./upstreams.js";