@statewalker/httpeers-share 0.0.0-stage → 0.2.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
@@ -0,0 +1,229 @@
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
+
10
+ import { timingSafeEqual } from "node:crypto";
11
+ import { type FetchHandler, MARKER, urlUpstream } from "@statewalker/webrun-http-proxy";
12
+ import { checkTarget, isPrivateAddress } from "../targets.js";
13
+ import {
14
+ BRIDGE_COOKIES_KEPT_HEADER,
15
+ BRIDGE_GUEST_HEADER,
16
+ BRIDGE_MARKER,
17
+ BRIDGE_REDIRECT_LOCATION_HEADER,
18
+ BRIDGE_REDIRECT_STATUS_HEADER,
19
+ BRIDGE_SET_COOKIE_HEADER,
20
+ BRIDGE_TARGET_HEADER,
21
+ BRIDGE_TOKEN_HEADER,
22
+ } from "../upstreams.js";
23
+ import { type SocketGate, socketGate } from "./sockets.js";
24
+
25
+ export interface BridgeInit {
26
+ /** The Pairing token generated at launch; nothing is answered without it. */
27
+ token: string;
28
+ /** The PeerShare page's origin; the only one granted cross-origin access. */
29
+ allowedOrigin: string;
30
+ /** Injected by tests; defaults to the platform's. */
31
+ fetchImpl?: typeof fetch;
32
+ /** Every address a `.local` name resolves to. Injected by tests; defaults to the OS resolver. */
33
+ resolveHost?: (name: string) => Promise<string[]>;
34
+ /**
35
+ * Keep one cookie jar per Guest (rung 16, spike): a Service's `Set-Cookie`
36
+ * goes into the jar of the Guest named in `x-peershare-guest`, and that
37
+ * jar's cookies go on the Guest's next requests. Cookies never reach the
38
+ * page, which could not use them anyway.
39
+ */
40
+ cookieJars?: boolean;
41
+ }
42
+
43
+ /** Whether the Bridge may relay to `target` (rung 02 rules): `ok`, `forbidden` or `unreachable`. */
44
+ export async function reachable(
45
+ target: string,
46
+ resolveHost: (name: string) => Promise<string[]>,
47
+ ): Promise<"ok" | "forbidden" | "unreachable"> {
48
+ const verdict = checkTarget(target);
49
+ if (verdict === "forbidden") return "forbidden";
50
+ if (verdict === "allowed") return "ok";
51
+ const addresses = await resolveHost(verdict.resolve).catch(() => []);
52
+ if (addresses.length === 0) return "unreachable";
53
+ return addresses.every(isPrivateAddress) ? "ok" : "forbidden";
54
+ }
55
+
56
+ export async function systemResolve(name: string): Promise<string[]> {
57
+ const { lookup } = await import("node:dns/promises");
58
+ return (await lookup(name, { all: true })).map((a) => a.address);
59
+ }
60
+
61
+ /** The Bridge's HTTP handler, carrying its WebSocket gate for whichever server hosts it. */
62
+ export type BridgeHandler = FetchHandler & { sockets: SocketGate };
63
+
64
+ export function createBridge(init: BridgeInit): BridgeHandler {
65
+ const resolveHost = init.resolveHost ?? systemResolve;
66
+ const jars = new Map<string, Map<string, string>>();
67
+ const handler: FetchHandler = async (incoming) => {
68
+ const guest = init.cookieJars === true ? incoming.headers.get(BRIDGE_GUEST_HEADER) : null;
69
+ const jar =
70
+ guest == null ? undefined : (jars.get(guest) ?? jars.set(guest, new Map()).get(guest));
71
+ const request = jar == null ? incoming : withCookies(incoming, jar);
72
+ // A page on another site carries its own `Origin`. No `Origin` at all is
73
+ // a local process (curl, the Probe's CLI): the token alone admits it.
74
+ const origin = request.headers.get("origin");
75
+ if (origin != null && origin !== init.allowedOrigin) return refuse(403, "origin-forbidden");
76
+
77
+ // A preflight never carries custom headers, so it cannot carry the token:
78
+ // the origin check above is all that guards it, and it grants nothing
79
+ // but permission to send the real, token-bearing request.
80
+ if (
81
+ origin != null &&
82
+ request.method === "OPTIONS" &&
83
+ request.headers.has("access-control-request-method")
84
+ ) {
85
+ return preflight(request, origin);
86
+ }
87
+ if (!sameSecret(request.headers.get(BRIDGE_TOKEN_HEADER), init.token)) {
88
+ return refuse(401, "bad-token", origin);
89
+ }
90
+ // No target: the page is asking whether its Bridge is there and its
91
+ // token is right (the token check above already passed).
92
+ const target = request.headers.get(BRIDGE_TARGET_HEADER) || null;
93
+ if (target == null) {
94
+ const headers = new Headers({ "content-type": "application/json" });
95
+ if (origin != null) grantTo(headers, origin);
96
+ return new Response(JSON.stringify({ bridge: "peershare" }), { headers });
97
+ }
98
+ // The page passed both checks, so it may read WHY the target was refused.
99
+ const reach = await reachable(target, resolveHost);
100
+ if (reach !== "ok") return refuse(reach === "forbidden" ? 403 : 502, `target-${reach}`, origin);
101
+
102
+ // The same re-issuing the page itself does (rung 01): hop-by-hop headers
103
+ // dropped, body streamed, redirects reported. The Bridge's own headers
104
+ // are consumed by this hop.
105
+ const upstream = urlUpstream({
106
+ base: target,
107
+ stripRequestHeaders: [BRIDGE_TARGET_HEADER, BRIDGE_TOKEN_HEADER, BRIDGE_GUEST_HEADER],
108
+ ...(init.fetchImpl != null ? { fetchImpl: init.fetchImpl } : {}),
109
+ });
110
+ const response = await upstream(request);
111
+
112
+ // The proxy's own "unreachable" is about the SERVICE here. Passed on as
113
+ // is, the page's upstream (to which the Bridge is the upstream) would
114
+ // read it as "the Bridge is down" — so it is restated in the contract's
115
+ // words, and the proxy's marker removed.
116
+ if (response.headers.get(MARKER) === "upstream-unreachable") {
117
+ return refuse(502, "target-unreachable", origin);
118
+ }
119
+
120
+ const headers = new Headers(response.headers);
121
+ headers.set(BRIDGE_MARKER, "relayed");
122
+ const cookies = response.headers.getSetCookie();
123
+ if (cookies.length > 0) headers.set(BRIDGE_SET_COOKIE_HEADER, String(cookies.length));
124
+ if (jar != null) {
125
+ for (const cookie of cookies) keep(jar, cookie);
126
+ headers.delete("set-cookie");
127
+ if (cookies.length > 0) headers.set(BRIDGE_COOKIES_KEPT_HEADER, "kept");
128
+ }
129
+
130
+ const location = response.headers.get("location");
131
+ if (response.status >= 300 && response.status < 400 && location != null) {
132
+ headers.delete("location");
133
+ headers.set(BRIDGE_REDIRECT_STATUS_HEADER, String(response.status));
134
+ headers.set(BRIDGE_REDIRECT_LOCATION_HEADER, withinService(location, target));
135
+ if (origin != null) grantTo(headers, origin);
136
+ return new Response(null, { status: 200, headers });
137
+ }
138
+
139
+ if (origin != null) grantTo(headers, origin);
140
+ return new Response(response.body, { status: response.status, headers });
141
+ };
142
+ return Object.assign(handler, { sockets: socketGate(init, resolveHost) });
143
+ }
144
+
145
+ /**
146
+ * A location on the Service itself, as a path: the Guest sees the Service at
147
+ * the root of their own origin, so `http://127.0.0.1:5173/login` must become
148
+ * `/login`. Anywhere else stays absolute.
149
+ */
150
+ function withinService(location: string, target: string): string {
151
+ const resolved = new URL(location, target);
152
+ if (resolved.origin !== new URL(target).origin) return resolved.href;
153
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`;
154
+ }
155
+
156
+ function preflight(request: Request, origin: string): Response {
157
+ const headers = new Headers({
158
+ "access-control-allow-origin": origin,
159
+ vary: "Origin",
160
+ // Echoed: Guests' requests carry whatever headers the shared app uses.
161
+ "access-control-allow-methods": request.headers.get("access-control-request-method") ?? "GET",
162
+ "access-control-allow-headers": request.headers.get("access-control-request-headers") ?? "",
163
+ "access-control-max-age": "600",
164
+ });
165
+ // Chromium's Private Network Access preflight: a public page asking for loopback.
166
+ if (request.headers.get("access-control-request-private-network") === "true") {
167
+ headers.set("access-control-allow-private-network", "true");
168
+ }
169
+ return new Response(null, { status: 204, headers });
170
+ }
171
+
172
+ /**
173
+ * Cross-origin permission for exactly one origin. Whatever the Service itself
174
+ * sends (`*`, credentials) is replaced: the Service's CORS policy was written
175
+ * for its own pages, not for a relay that every website can try to reach.
176
+ */
177
+ function grantTo(headers: Headers, origin: string): void {
178
+ for (const name of [...headers.keys()]) {
179
+ if (name.startsWith("access-control-")) headers.delete(name);
180
+ }
181
+ headers.set("access-control-allow-origin", origin);
182
+ headers.append("vary", "Origin");
183
+ // Every response header, the marker included, readable by the page.
184
+ headers.set("access-control-expose-headers", [...headers.keys()].join(", "));
185
+ }
186
+
187
+ /**
188
+ * A refusal carries no CORS headers unless `grantTo` is given: a foreign page
189
+ * cannot even read why it was refused.
190
+ */
191
+ function refuse(status: number, reason: string, grantToOrigin?: string | null): Response {
192
+ const headers = new Headers({ [BRIDGE_MARKER]: reason });
193
+ if (grantToOrigin != null) grantTo(headers, grantToOrigin);
194
+ return new Response(null, { status, headers });
195
+ }
196
+
197
+ export function sameSecret(given: string | null, expected: string): boolean {
198
+ if (given == null) return false;
199
+ const a = new TextEncoder().encode(given);
200
+ const b = new TextEncoder().encode(expected);
201
+ return a.length === b.length && timingSafeEqual(a, b);
202
+ }
203
+
204
+ /** `request` with `jar`'s cookies as its `Cookie` header (the page cannot send one itself). */
205
+ function withCookies(request: Request, jar: Map<string, string>): Request {
206
+ if (jar.size === 0) return request;
207
+ const headers = new Headers(request.headers);
208
+ headers.set("cookie", [...jar].map(([name, value]) => `${name}=${value}`).join("; "));
209
+ // Rebuilt from its parts: the server's own request object (e.g.
210
+ // @hono/node-server's) need not be one the global Request can copy.
211
+ const body = request.method === "GET" || request.method === "HEAD" ? null : request.body;
212
+ return new Request(request.url, {
213
+ method: request.method,
214
+ headers,
215
+ body,
216
+ ...(body != null ? { duplex: "half" as const } : {}),
217
+ signal: request.signal,
218
+ });
219
+ }
220
+
221
+ /** A spike's cookie jar: name and value only; `Max-Age=0` deletes. Path, domain and expiry are ignored. */
222
+ function keep(jar: Map<string, string>, setCookie: string): void {
223
+ const [pair = "", ...attributes] = setCookie.split(";");
224
+ const at = pair.indexOf("=");
225
+ if (at <= 0) return;
226
+ const name = pair.slice(0, at).trim();
227
+ if (attributes.some((a) => /^\s*max-age\s*=\s*0\s*$/i.test(a))) jar.delete(name);
228
+ else jar.set(name, pair.slice(at + 1).trim());
229
+ }
@@ -0,0 +1,17 @@
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
+
6
+ export { type BridgeHandler, type BridgeInit, createBridge } from "./bridge.js";
7
+ export { type Listening, listenLoopback } from "./serve.js";
8
+ export {
9
+ acceptedProtocol,
10
+ connectApp,
11
+ type InboundSocket,
12
+ relaySocket,
13
+ SOCKET_PATH,
14
+ type SocketGate,
15
+ type SocketPlan,
16
+ TOKEN_PROTOCOL_PREFIX,
17
+ } from "./sockets.js";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The Bridge on a port, under Node. Under Bun, `main.ts` hands the same
3
+ * handler to `Bun.serve`.
4
+ */
5
+
6
+ import type { IncomingMessage } from "node:http";
7
+ import type { AddressInfo, Socket } from "node:net";
8
+ import { serve } from "@hono/node-server";
9
+ import type { FetchHandler } from "@statewalker/webrun-http-proxy";
10
+ import { WebSocketServer } from "ws";
11
+ import { acceptedProtocol, connectApp, relaySocket, type SocketGate } from "./sockets.js";
12
+
13
+ export interface Listening {
14
+ origin: string;
15
+ /** The interface actually bound. */
16
+ address: string;
17
+ close(): Promise<void>;
18
+ }
19
+
20
+ /**
21
+ * Binds 127.0.0.1 only: the Bridge is never reachable from the network. A
22
+ * handler carrying a socket gate (`createBridge`'s) also relays WebSockets.
23
+ */
24
+ export function listenLoopback(
25
+ handler: FetchHandler & { sockets?: SocketGate },
26
+ port = 0,
27
+ ): Promise<Listening> {
28
+ return new Promise((resolve) => {
29
+ // `overrideGlobalObjects: false`: by default @hono/node-server swaps the
30
+ // process's global Request and Response for its own, which breaks streaming
31
+ // request bodies for everything else in the process (found in M9: the mesh
32
+ // member's full-duplex fetch stalled while a Bridge ran in the same process).
33
+ const server = serve(
34
+ { fetch: handler, port, hostname: "127.0.0.1", overrideGlobalObjects: false },
35
+ (info: AddressInfo) => {
36
+ resolve({
37
+ origin: `http://127.0.0.1:${info.port}`,
38
+ address: info.address,
39
+ close: () =>
40
+ new Promise<void>((done) => {
41
+ for (const client of wss.clients) client.terminate();
42
+ if ("closeAllConnections" in server) server.closeAllConnections();
43
+ server.close(() => done());
44
+ }),
45
+ });
46
+ },
47
+ );
48
+ // The subprotocol to accept travels on the request itself, so concurrent
49
+ // upgrades cannot take each other's.
50
+ const accepts = new WeakMap<IncomingMessage, string>();
51
+ const wss = new WebSocketServer({
52
+ noServer: true,
53
+ handleProtocols: (_offered, req) => accepts.get(req) ?? false,
54
+ });
55
+ server.on("upgrade", async (req: IncomingMessage, socket: Socket, head: Buffer) => {
56
+ const gate = handler.sockets;
57
+ const headers = new Headers();
58
+ for (const [k, v] of Object.entries(req.headers))
59
+ if (typeof v === "string") headers.set(k, v);
60
+ const verdict =
61
+ gate == null
62
+ ? { ok: false as const, status: 400 }
63
+ : await gate(new URL(req.url ?? "/", "http://bridge"), headers);
64
+ if (!verdict.ok) {
65
+ socket.end(`HTTP/1.1 ${verdict.status} Refused\r\nconnection: close\r\n\r\n`);
66
+ return;
67
+ }
68
+ const plan = verdict.plan;
69
+ const app = await connectApp(plan).catch(() => null);
70
+ if (app == null) {
71
+ socket.end("HTTP/1.1 502 Refused\r\nconnection: close\r\n\r\n");
72
+ return;
73
+ }
74
+ const offered = (req.headers["sec-websocket-protocol"] ?? "").split(",").map((p) => p.trim());
75
+ accepts.set(req, acceptedProtocol(plan, app, offered));
76
+ wss.handleUpgrade(req, socket, head, (ws) => {
77
+ const relay = relaySocket(
78
+ { send: (data) => ws.send(data), close: (code, reason) => ws.close(code, reason) },
79
+ app,
80
+ );
81
+ ws.on("message", (data, isBinary) =>
82
+ relay.message(isBinary ? new Uint8Array(data as Buffer).buffer : String(data)),
83
+ );
84
+ ws.on("close", (code, reason) => relay.close(code, String(reason)));
85
+ });
86
+ });
87
+ });
88
+ }
@@ -0,0 +1,119 @@
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
+
10
+ import type { BridgeInit } from "./bridge.js";
11
+ import { reachable, sameSecret } from "./bridge.js";
12
+
13
+ export const SOCKET_PATH = "/.peershare/socket";
14
+ export const TOKEN_PROTOCOL_PREFIX = "peershare.";
15
+
16
+ export interface SocketPlan {
17
+ /** The app's WebSocket URL. */
18
+ target: string;
19
+ /** The subprotocols to offer the app. */
20
+ protocols: string[];
21
+ /** The one the Bridge accepts from the page (its token protocol). */
22
+ accept: string;
23
+ }
24
+
25
+ export type SocketGate = (
26
+ url: URL,
27
+ headers: Headers,
28
+ ) => Promise<{ ok: true; plan: SocketPlan } | { ok: false; status: 400 | 401 | 403 | 502 }>;
29
+
30
+ export function socketGate(
31
+ init: Pick<BridgeInit, "token" | "allowedOrigin">,
32
+ resolveHost: (name: string) => Promise<string[]>,
33
+ ): SocketGate {
34
+ return async (url, headers) => {
35
+ if (url.pathname !== SOCKET_PATH) return { ok: false, status: 400 };
36
+ const origin = headers.get("origin");
37
+ if (origin != null && origin !== init.allowedOrigin) return { ok: false, status: 403 };
38
+
39
+ const offered = (headers.get("sec-websocket-protocol") ?? "").split(",").map((p) => p.trim());
40
+ const accept = offered.find(
41
+ (p) =>
42
+ p.startsWith(TOKEN_PROTOCOL_PREFIX) &&
43
+ sameSecret(p.slice(TOKEN_PROTOCOL_PREFIX.length), init.token),
44
+ );
45
+ if (accept == null) return { ok: false, status: 401 };
46
+
47
+ const target = url.searchParams.get("target") ?? "";
48
+ let http: URL;
49
+ try {
50
+ http = new URL(target);
51
+ } catch {
52
+ return { ok: false, status: 400 };
53
+ }
54
+ const reach = await reachable(http.href.replace(/^ws/, "http"), resolveHost);
55
+ if (reach !== "ok") return { ok: false, status: reach === "forbidden" ? 403 : 502 };
56
+
57
+ const ws = new URL(http);
58
+ ws.protocol = http.protocol === "https:" || http.protocol === "wss:" ? "wss:" : "ws:";
59
+ const protocols = (url.searchParams.get("protocols") ?? "").split(",").filter(Boolean);
60
+ return { ok: true, plan: { target: ws.href, protocols, accept } };
61
+ };
62
+ }
63
+
64
+ /** The page's side of a relayed socket, whatever server accepted it. */
65
+ export interface InboundSocket {
66
+ send(data: string | ArrayBuffer): void;
67
+ close(code?: number, reason?: string): void;
68
+ }
69
+
70
+ /**
71
+ * Open the app's socket first: the page's upgrade is completed only once the
72
+ * app has accepted, so the page can be given the subprotocol the APP chose
73
+ * (a WebSocket's protocol is fixed by its handshake). Rejects if the app
74
+ * refuses or cannot be reached.
75
+ */
76
+ export function connectApp(plan: SocketPlan): Promise<WebSocket> {
77
+ return new Promise((resolve, reject) => {
78
+ const app = new WebSocket(plan.target, plan.protocols);
79
+ app.binaryType = "arraybuffer";
80
+ // An app often speaks first ("hello" on connect), before the page's side
81
+ // is upgraded and relaying: held here, delivered first by `relaySocket`.
82
+ const held: Array<string | ArrayBuffer> = [];
83
+ early.set(app, held);
84
+ app.onmessage = (e) => held.push(e.data as string | ArrayBuffer);
85
+ app.onopen = () => resolve(app);
86
+ app.onerror = () => reject(new Error(`the app's socket at ${plan.target} refused`));
87
+ });
88
+ }
89
+
90
+ /** The subprotocol to accept from the page: the app's choice when the page offered it, else the token's. */
91
+ export function acceptedProtocol(plan: SocketPlan, app: WebSocket, offered: string[]): string {
92
+ return app.protocol !== "" && offered.includes(app.protocol) ? app.protocol : plan.accept;
93
+ }
94
+
95
+ const early = new WeakMap<WebSocket, Array<string | ArrayBuffer>>();
96
+
97
+ /** Relay two open sockets, message for message, both ways; returns the inbound side's event sinks. */
98
+ export function relaySocket(
99
+ inbound: InboundSocket,
100
+ app: WebSocket,
101
+ ): { message(data: string | ArrayBuffer): void; close(code?: number, reason?: string): void } {
102
+ for (const data of early.get(app)?.splice(0) ?? []) inbound.send(data);
103
+ app.onmessage = (e) => inbound.send(e.data as string | ArrayBuffer);
104
+ app.onclose = (e) => inbound.close(closable(e.code), e.reason);
105
+ app.onerror = () => inbound.close(1011, "the app's socket failed");
106
+ return {
107
+ message(data) {
108
+ if (app.readyState === WebSocket.OPEN) app.send(data);
109
+ },
110
+ close(code, reason) {
111
+ if (app.readyState <= WebSocket.OPEN) app.close(closable(code), reason);
112
+ },
113
+ };
114
+ }
115
+
116
+ /** A code `close()` may be called with (1000, or 3000–4999); anything else becomes 1000. */
117
+ function closable(code?: number): number {
118
+ return code === 1000 || (code != null && code >= 3000 && code <= 4999) ? code : 1000;
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
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
+
10
+ export { decodeInvite, encodeInvite, type InviteLink } from "./invite-link.js";
11
+ export { type JoinOutcome, joinOutcome } from "./join-outcome.js";
12
+ export { isSharerOffline, revokeGuest } from "./lifecycle.js";
13
+ export {
14
+ checkServiceTarget,
15
+ type ServiceKind,
16
+ type TargetCheck,
17
+ type TargetRefusal,
18
+ } from "./policy.js";
19
+ export {
20
+ type Health,
21
+ type ProbeCause,
22
+ type ProbeReport,
23
+ type ProbeWarning,
24
+ probe,
25
+ SLOW_MS,
26
+ } from "./probe.js";
27
+ export {
28
+ type CatalogEntry,
29
+ SERVICE_MARKER,
30
+ type ServiceEntry,
31
+ serviceTable,
32
+ } from "./service-table.js";
33
+ export { decodeFrames, encodeFrame, SOCKET_HEADER, type SocketFrame } from "./socket-frames.js";
34
+ export { openTunnel, sharerSocket, TunnelSocket } from "./socket-tunnel.js";
35
+ export { stampGuest } from "./stamp-guest.js";
36
+ export { checkTarget, isPrivateAddress, type TargetVerdict } from "./targets.js";
37
+ export {
38
+ BRIDGE_COOKIES_KEPT_HEADER,
39
+ BRIDGE_GUEST_HEADER,
40
+ BRIDGE_MARKER,
41
+ BRIDGE_REDIRECT_LOCATION_HEADER,
42
+ BRIDGE_REDIRECT_STATUS_HEADER,
43
+ BRIDGE_SET_COOKIE_HEADER,
44
+ BRIDGE_TARGET_HEADER,
45
+ BRIDGE_TOKEN_HEADER,
46
+ createUpstream,
47
+ PROBE_GUEST,
48
+ type UpstreamConfig,
49
+ unreachable,
50
+ } from "./upstreams.js";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The Invite link: what the Sharer copies, and what the QR code holds.
3
+ *
4
+ * `https://tools.sandclaw.ai/peer-share/invite#join=<blob>&from=Dirk`
5
+ *
6
+ * The join blob is httpeers' own (`encodeJoinBlob`), so the existing join
7
+ * tooling can read it. But it travels in the FRAGMENT, not in httpeers'
8
+ * `?join=` query: a fragment never leaves the browser, so the page's server
9
+ * (and its logs, and any proxy) never sees an Invite, which is a bearer
10
+ * credential until it is spent.
11
+ */
12
+
13
+ import { decodeJoinBlob, encodeJoinBlob } from "@statewalker/httpeers-member";
14
+
15
+ export interface InviteLink {
16
+ relayAddrs: string[];
17
+ sharerPeerId: string;
18
+ invitationId: string;
19
+ /** Shown before joining: "Dirk invited you". */
20
+ sharerName: string;
21
+ }
22
+
23
+ export function encodeInvite(pageUrl: string, invite: InviteLink): string {
24
+ const url = new URL(pageUrl);
25
+ const fragment = new URLSearchParams({
26
+ join: encodeJoinBlob({
27
+ relayAddrs: invite.relayAddrs,
28
+ hubPeerId: invite.sharerPeerId,
29
+ invitationId: invite.invitationId,
30
+ }),
31
+ from: invite.sharerName,
32
+ });
33
+ url.hash = fragment.toString();
34
+ return url.toString();
35
+ }
36
+
37
+ export function decodeInvite(link: string): InviteLink {
38
+ const fragment = new URLSearchParams(new URL(link).hash.slice(1));
39
+ const join = fragment.get("join");
40
+ if (join == null) throw new Error("This is not a PeerShare invite link: it has no #join=… part.");
41
+ const blob = decodeJoinBlob(join);
42
+ return {
43
+ relayAddrs: blob.relayAddrs,
44
+ sharerPeerId: blob.hubPeerId,
45
+ invitationId: blob.invitationId,
46
+ sharerName: fragment.get("from") ?? "Someone",
47
+ };
48
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The Guest's side of a membership: join, come back, Close session.
3
+ */
4
+
5
+ export type JoinOutcome = "joined" | "invite-used" | "invite-expired" | "machine-offline";
6
+
7
+ /**
8
+ * What a failed join means to the Guest, for the Join screen.
9
+ *
10
+ * Measured against httpeers-member 0.1.6: a used and an expired Invite are
11
+ * both `MemberJoinError("invitation-refused")`, told apart only by the hub's
12
+ * answer quoted inside the message (`{"error":"invitation already-redeemed"}`
13
+ * / `{"error":"invitation expired"}`). An offline Sharer is a plain `Error`:
14
+ * the relay holds no reservation for it (`NO_RESERVATION`). Reading text is
15
+ * fragile; httpeers should carry the hub's reason as a field (see README).
16
+ */
17
+ export function joinOutcome(error: unknown): JoinOutcome {
18
+ const text = describe(error);
19
+ if (/already-redeemed/.test(text)) return "invite-used";
20
+ if (/invitation expired/.test(text)) return "invite-expired";
21
+ // Everything else is the Sharer not being reachable: offline, tab closed,
22
+ // or the relay unable to find it. The Guest's next step is the same: wait.
23
+ return "machine-offline";
24
+ }
25
+
26
+ function describe(error: unknown): string {
27
+ const parts: string[] = [];
28
+ for (let e: unknown = error; e != null; e = (e as { cause?: unknown }).cause) {
29
+ parts.push(e instanceof Error ? e.message : String(e));
30
+ if (parts.length > 5) break;
31
+ }
32
+ return parts.join(" | ");
33
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Stop, reload and Revoke, as the Sharer drives them and a Guest sees them.
3
+ */
4
+
5
+ import type { Hub } from "@statewalker/httpeers-hub";
6
+
7
+ /**
8
+ * Two writes, as the hub's own `DELETE /admin/members/{peerId}` does: the
9
+ * member record goes, AND a revocation is recorded, so the token the Guest
10
+ * still holds stops verifying on its next call instead of lasting until it
11
+ * expires.
12
+ */
13
+ export function revokeGuest(hub: Pick<Hub, "members" | "revocations">, guestPeerId: string): void {
14
+ hub.members.remove(guestPeerId);
15
+ hub.revocations.revoke(guestPeerId);
16
+ }
17
+
18
+ /** Whether a Guest's failed request means "the Sharer is offline" (Stop, closed tab). */
19
+ /**
20
+ * Measured: once the Sharer stops, a Guest's request comes back within tens
21
+ * of ms as `502 {"kind":"peer-unreachable"}` from the Guest's own member,
22
+ * never as a hang. The body is read from a clone, so the caller keeps it.
23
+ */
24
+ export async function isSharerOffline(response: Response): Promise<boolean> {
25
+ if (response.status !== 502) return false;
26
+ const body = (await response
27
+ .clone()
28
+ .json()
29
+ .catch(() => null)) as { kind?: unknown } | null;
30
+ return body?.kind === "peer-unreachable";
31
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What can be shared in v1, and how.
3
+ *
4
+ * - **Bridged**: a service on the Sharer's machine or LAN, through the CORS
5
+ * proxy on localhost (the Bridge). Its own target rules decide (rung 02).
6
+ * - **Direct**: a remote resource with a domain name, a certificate and its
7
+ * own CORS headers. So: `https`, and a host NAME, never an address, and not
8
+ * the Sharer's own machine or LAN (those go through the Bridge).
9
+ */
10
+
11
+ import { checkTarget } from "./targets.js";
12
+
13
+ export type ServiceKind = "bridged" | "direct";
14
+
15
+ export type TargetRefusal =
16
+ | "not-a-url"
17
+ | "not-http"
18
+ | "needs-https"
19
+ | "needs-domain-name"
20
+ | "local-needs-bridge"
21
+ | "remote-is-direct"
22
+ | "not-local";
23
+
24
+ export type TargetCheck = { ok: true } | { ok: false; reason: TargetRefusal };
25
+
26
+ export function checkServiceTarget(kind: ServiceKind, target: string): TargetCheck {
27
+ let url: URL;
28
+ try {
29
+ url = new URL(target);
30
+ } catch {
31
+ return refused("not-a-url");
32
+ }
33
+ if (url.protocol !== "http:" && url.protocol !== "https:") return refused("not-http");
34
+ const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
35
+ const isAddress = /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(":");
36
+ const isLocalName = host === "localhost" || host.endsWith(".local");
37
+
38
+ if (kind === "bridged") {
39
+ if (checkTarget(target) !== "forbidden") return { ok: true };
40
+ // A public https name is a remote resource: it is shared Direct.
41
+ return refused(url.protocol === "https:" && !isAddress ? "remote-is-direct" : "not-local");
42
+ }
43
+
44
+ if (isLocalName) return refused("local-needs-bridge");
45
+ if (isAddress) return refused("needs-domain-name");
46
+ if (url.protocol !== "https:") return refused("needs-https");
47
+ return { ok: true };
48
+ }
49
+
50
+ function refused(reason: TargetRefusal): TargetCheck {
51
+ return { ok: false, reason };
52
+ }