@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.
- package/LICENSE +21 -0
- package/README.md +75 -2
- package/dist/bridge/bridge.d.ts +37 -0
- package/dist/bridge/bridge.d.ts.map +1 -0
- package/dist/bridge/bridge.js +195 -0
- package/dist/bridge/index.d.ts +8 -0
- package/dist/bridge/index.d.ts.map +1 -0
- package/dist/bridge/index.js +7 -0
- package/dist/bridge/serve.d.ts +20 -0
- package/dist/bridge/serve.d.ts.map +1 -0
- package/dist/bridge/serve.js +66 -0
- package/dist/bridge/sockets.d.ts +47 -0
- package/dist/bridge/sockets.d.ts.map +1 -0
- package/dist/bridge/sockets.js +86 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/invite-link.d.ts +21 -0
- package/dist/invite-link.d.ts.map +1 -0
- package/dist/invite-link.js +38 -0
- package/dist/join-outcome.d.ts +16 -0
- package/dist/join-outcome.d.ts.map +1 -0
- package/dist/join-outcome.js +32 -0
- package/dist/lifecycle.d.ts +19 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +28 -0
- package/dist/policy.d.ts +19 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +40 -0
- package/dist/probe.d.ts +20 -0
- package/dist/probe.d.ts.map +1 -0
- package/dist/probe.js +60 -0
- package/dist/service-table.d.ts +33 -0
- package/dist/service-table.d.ts.map +1 -0
- package/dist/service-table.js +59 -0
- package/dist/share-router.d.ts +15 -0
- package/dist/share-router.d.ts.map +1 -0
- package/dist/share-router.js +31 -0
- package/dist/socket-frames.d.ts +30 -0
- package/dist/socket-frames.d.ts.map +1 -0
- package/dist/socket-frames.js +81 -0
- package/dist/socket-tunnel.d.ts +46 -0
- package/dist/socket-tunnel.d.ts.map +1 -0
- package/dist/socket-tunnel.js +157 -0
- package/dist/stamp-guest.d.ts +7 -0
- package/dist/stamp-guest.d.ts.map +1 -0
- package/dist/stamp-guest.js +17 -0
- package/dist/targets.d.ts +18 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +48 -0
- package/dist/upstreams.d.ts +47 -0
- package/dist/upstreams.d.ts.map +1 -0
- package/dist/upstreams.js +141 -0
- package/package.json +59 -3
- package/src/bridge/bridge.ts +229 -0
- package/src/bridge/index.ts +17 -0
- package/src/bridge/serve.ts +88 -0
- package/src/bridge/sockets.ts +119 -0
- package/src/index.ts +50 -0
- package/src/invite-link.ts +48 -0
- package/src/join-outcome.ts +33 -0
- package/src/lifecycle.ts +31 -0
- package/src/policy.ts +52 -0
- package/src/probe.ts +86 -0
- package/src/service-table.ts +91 -0
- package/src/share-router.ts +43 -0
- package/src/socket-frames.ts +83 -0
- package/src/socket-tunnel.ts +167 -0
- package/src/stamp-guest.ts +18 -0
- package/src/targets.ts +51 -0
- package/src/upstreams.ts +149 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A WebSocket inside one request over the mesh (M9; rung 15 measured that a
|
|
3
|
+
* mesh request is full-duplex). The Guest's `openTunnel` sends frames in the
|
|
4
|
+
* request body and reads them from the response body; the Sharer's
|
|
5
|
+
* `sharerSocket` relays them to the Service's own socket.
|
|
6
|
+
*/
|
|
7
|
+
import { decodeFrames, encodeFrame, SOCKET_HEADER } from "./socket-frames.js";
|
|
8
|
+
/** The Sharer's side: answer a tunnel request by relaying to `open()`'s socket. */
|
|
9
|
+
export function sharerSocket(request, open) {
|
|
10
|
+
const ws = open();
|
|
11
|
+
ws.binaryType = "arraybuffer";
|
|
12
|
+
const held = [];
|
|
13
|
+
let out;
|
|
14
|
+
let done = false;
|
|
15
|
+
const emit = (frame) => {
|
|
16
|
+
if (!done)
|
|
17
|
+
out.enqueue(encodeFrame(frame));
|
|
18
|
+
};
|
|
19
|
+
const end = (code, reason) => {
|
|
20
|
+
emit({ type: "close", code, reason });
|
|
21
|
+
if (!done) {
|
|
22
|
+
done = true;
|
|
23
|
+
out.close();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const body = new ReadableStream({
|
|
27
|
+
start(controller) {
|
|
28
|
+
out = controller;
|
|
29
|
+
ws.onopen = () => {
|
|
30
|
+
emit({ type: "open", protocol: ws.protocol });
|
|
31
|
+
for (const data of held.splice(0))
|
|
32
|
+
ws.send(data);
|
|
33
|
+
};
|
|
34
|
+
ws.onmessage = (e) => emit(typeof e.data === "string"
|
|
35
|
+
? { type: "text", data: e.data }
|
|
36
|
+
: { type: "binary", data: new Uint8Array(e.data) });
|
|
37
|
+
ws.onclose = (e) => end(e.code, e.reason);
|
|
38
|
+
ws.onerror = () => end(1011, "the Service's socket failed");
|
|
39
|
+
},
|
|
40
|
+
cancel() {
|
|
41
|
+
done = true;
|
|
42
|
+
ws.close();
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
// The Guest's frames, as they arrive: the request is still open.
|
|
46
|
+
void (async () => {
|
|
47
|
+
if (request.body == null)
|
|
48
|
+
return;
|
|
49
|
+
for await (const frame of request.body.pipeThrough(decodeFrames())) {
|
|
50
|
+
if (frame.type === "close") {
|
|
51
|
+
ws.close(frame.code === 1000 || frame.code >= 3000 ? frame.code : 1000, frame.reason);
|
|
52
|
+
}
|
|
53
|
+
else if (frame.type === "text" || frame.type === "binary") {
|
|
54
|
+
const data = frame.type === "text" ? frame.data : frame.data.slice().buffer;
|
|
55
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
56
|
+
ws.send(data);
|
|
57
|
+
else
|
|
58
|
+
held.push(data);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// A client may close only with 1000 or 3000–4999 (1011 throws `InvalidAccessError`).
|
|
62
|
+
})().catch(() => ws.close(1000, "the Guest's stream ended abnormally"));
|
|
63
|
+
return new Response(body, { headers: { "content-type": "application/octet-stream" } });
|
|
64
|
+
}
|
|
65
|
+
/** What a Guest holds: shaped like a browser WebSocket, carried over `fetch`. */
|
|
66
|
+
export class TunnelSocket {
|
|
67
|
+
static CONNECTING = 0;
|
|
68
|
+
static OPEN = 1;
|
|
69
|
+
static CLOSING = 2;
|
|
70
|
+
static CLOSED = 3;
|
|
71
|
+
readyState = TunnelSocket.CONNECTING;
|
|
72
|
+
protocol = "";
|
|
73
|
+
onopen = null;
|
|
74
|
+
onmessage = null;
|
|
75
|
+
onclose = null;
|
|
76
|
+
onerror = null;
|
|
77
|
+
out;
|
|
78
|
+
sentClose = false;
|
|
79
|
+
constructor(fetch, path, protocols) {
|
|
80
|
+
const body = new ReadableStream({
|
|
81
|
+
start: (controller) => {
|
|
82
|
+
this.out = controller;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
// `path` may be a full URL (`http://local/peers/<sharer>/s/<id>/live`), so
|
|
86
|
+
// the caller's `fetch` gets the Request as built, streaming body and all.
|
|
87
|
+
void fetch(new Request(/^https?:/.test(path) ? path : `http://tunnel${path}`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { [SOCKET_HEADER]: protocols.join(",") },
|
|
90
|
+
body,
|
|
91
|
+
duplex: "half",
|
|
92
|
+
}))
|
|
93
|
+
.then((res) => this.read(res))
|
|
94
|
+
.catch(() => this.closed(1006, "the tunnel failed"));
|
|
95
|
+
}
|
|
96
|
+
send(data) {
|
|
97
|
+
if (this.readyState !== TunnelSocket.OPEN)
|
|
98
|
+
throw new Error("the socket is not open");
|
|
99
|
+
const frame = typeof data === "string"
|
|
100
|
+
? { type: "text", data }
|
|
101
|
+
: {
|
|
102
|
+
type: "binary",
|
|
103
|
+
data: data instanceof ArrayBuffer
|
|
104
|
+
? new Uint8Array(data)
|
|
105
|
+
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
|
106
|
+
};
|
|
107
|
+
this.out.enqueue(encodeFrame(frame));
|
|
108
|
+
}
|
|
109
|
+
close(code = 1000, reason = "") {
|
|
110
|
+
if (this.readyState >= TunnelSocket.CLOSING)
|
|
111
|
+
return;
|
|
112
|
+
this.readyState = TunnelSocket.CLOSING;
|
|
113
|
+
this.endRequest(code, reason);
|
|
114
|
+
}
|
|
115
|
+
endRequest(code, reason) {
|
|
116
|
+
if (this.sentClose)
|
|
117
|
+
return;
|
|
118
|
+
this.sentClose = true;
|
|
119
|
+
try {
|
|
120
|
+
this.out.enqueue(encodeFrame({ type: "close", code, reason }));
|
|
121
|
+
this.out.close();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* already closed */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async read(res) {
|
|
128
|
+
if (!res.ok || res.body == null) {
|
|
129
|
+
this.onerror?.({ type: "error" });
|
|
130
|
+
return this.closed(1011, `refused: ${res.status}`);
|
|
131
|
+
}
|
|
132
|
+
for await (const frame of res.body.pipeThrough(decodeFrames())) {
|
|
133
|
+
if (frame.type === "open") {
|
|
134
|
+
this.protocol = frame.protocol;
|
|
135
|
+
this.readyState = TunnelSocket.OPEN;
|
|
136
|
+
this.onopen?.({ type: "open" });
|
|
137
|
+
}
|
|
138
|
+
else if (frame.type === "text")
|
|
139
|
+
this.onmessage?.({ type: "message", data: frame.data });
|
|
140
|
+
else if (frame.type === "binary")
|
|
141
|
+
this.onmessage?.({ type: "message", data: frame.data.slice().buffer });
|
|
142
|
+
else
|
|
143
|
+
return this.closed(frame.code, frame.reason);
|
|
144
|
+
}
|
|
145
|
+
this.closed(1006, "the tunnel ended");
|
|
146
|
+
}
|
|
147
|
+
closed(code, reason) {
|
|
148
|
+
if (this.readyState === TunnelSocket.CLOSED)
|
|
149
|
+
return;
|
|
150
|
+
this.readyState = TunnelSocket.CLOSED;
|
|
151
|
+
this.endRequest(1000, "");
|
|
152
|
+
this.onclose?.({ type: "close", code, reason, wasClean: code === 1000 });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export function openTunnel(fetch, path, protocols) {
|
|
156
|
+
return new TunnelSocket(fetch, path, protocols);
|
|
157
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sharer's half of the cookie jars: name the Guest on every request to
|
|
3
|
+
* `/s`, from the peer id the mesh proved, never from what the Guest sent.
|
|
4
|
+
*/
|
|
5
|
+
import { type FetchHandler } from "@statewalker/httpeers-core";
|
|
6
|
+
export declare function stampGuest(handler: FetchHandler): FetchHandler;
|
|
7
|
+
//# sourceMappingURL=stamp-guest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stamp-guest.d.ts","sourceRoot":"","sources":["../src/stamp-guest.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAa,KAAK,YAAY,EAAc,MAAM,4BAA4B,CAAC;AAGtF,wBAAgB,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,CAS9D"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Sharer's half of the cookie jars: name the Guest on every request to
|
|
3
|
+
* `/s`, from the peer id the mesh proved, never from what the Guest sent.
|
|
4
|
+
*/
|
|
5
|
+
import { ANONYMOUS, lookupPeer } from "@statewalker/httpeers-core";
|
|
6
|
+
import { BRIDGE_GUEST_HEADER } from "./upstreams.js";
|
|
7
|
+
export function stampGuest(handler) {
|
|
8
|
+
return (request) => {
|
|
9
|
+
const peer = lookupPeer(request) ?? ANONYMOUS;
|
|
10
|
+
const headers = new Headers(request.headers);
|
|
11
|
+
// Overwritten, not merged: a Guest's own value would let it pick a jar.
|
|
12
|
+
headers.delete(BRIDGE_GUEST_HEADER);
|
|
13
|
+
if (peer !== ANONYMOUS)
|
|
14
|
+
headers.set(BRIDGE_GUEST_HEADER, peer);
|
|
15
|
+
return handler(new Request(request, { headers }));
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Bridge may relay: the Sharer's own machine, LAN and tailnet,
|
|
3
|
+
* nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Literal addresses are checked as they are. A DNS name is decided by whoever
|
|
6
|
+
* controls its zone at request time (DNS rebinding), so names are refused,
|
|
7
|
+
* with two exceptions: `localhost`, and mDNS `.local` names, which are
|
|
8
|
+
* resolved FIRST and allowed only if every address they resolve to is
|
|
9
|
+
* private. (Some company networks serve `.local` from ordinary DNS, so the
|
|
10
|
+
* name alone proves nothing.)
|
|
11
|
+
*/
|
|
12
|
+
export type TargetVerdict = "allowed" | "forbidden" | {
|
|
13
|
+
resolve: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function checkTarget(target: string): TargetVerdict;
|
|
16
|
+
/** Loopback, RFC 1918, carrier-grade NAT (Tailscale), IPv6 loopback and unique-local. */
|
|
17
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
18
|
+
//# sourceMappingURL=targets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"targets.d.ts","sourceRoot":"","sources":["../src/targets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,WAAW,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1E,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAczD;AAMD,yFAAyF;AACzF,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAezD"}
|
package/dist/targets.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Bridge may relay: the Sharer's own machine, LAN and tailnet,
|
|
3
|
+
* nothing else.
|
|
4
|
+
*
|
|
5
|
+
* Literal addresses are checked as they are. A DNS name is decided by whoever
|
|
6
|
+
* controls its zone at request time (DNS rebinding), so names are refused,
|
|
7
|
+
* with two exceptions: `localhost`, and mDNS `.local` names, which are
|
|
8
|
+
* resolved FIRST and allowed only if every address they resolve to is
|
|
9
|
+
* private. (Some company networks serve `.local` from ordinary DNS, so the
|
|
10
|
+
* name alone proves nothing.)
|
|
11
|
+
*/
|
|
12
|
+
export function checkTarget(target) {
|
|
13
|
+
let url;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(target);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return "forbidden";
|
|
19
|
+
}
|
|
20
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
21
|
+
return "forbidden";
|
|
22
|
+
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
23
|
+
if (host === "localhost")
|
|
24
|
+
return "allowed";
|
|
25
|
+
if (isAddress(host))
|
|
26
|
+
return isPrivateAddress(host) ? "allowed" : "forbidden";
|
|
27
|
+
if (/^[a-z0-9-]+(\.[a-z0-9-]+)*\.local$/.test(host))
|
|
28
|
+
return { resolve: host };
|
|
29
|
+
return "forbidden";
|
|
30
|
+
}
|
|
31
|
+
function isAddress(host) {
|
|
32
|
+
return /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(":");
|
|
33
|
+
}
|
|
34
|
+
/** Loopback, RFC 1918, carrier-grade NAT (Tailscale), IPv6 loopback and unique-local. */
|
|
35
|
+
export function isPrivateAddress(address) {
|
|
36
|
+
const v4 = address.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
37
|
+
if (v4 != null) {
|
|
38
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
39
|
+
return (a === 127 ||
|
|
40
|
+
a === 10 ||
|
|
41
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
42
|
+
(a === 192 && b === 168) ||
|
|
43
|
+
(a === 100 && b >= 64 && b <= 127));
|
|
44
|
+
}
|
|
45
|
+
// Link-local (fe80::/10) and IPv4-mapped addresses are refused.
|
|
46
|
+
const v6 = address.toLowerCase();
|
|
47
|
+
return v6 === "::1" || /^f[cd][0-9a-f]{0,2}:/.test(v6);
|
|
48
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream kinds: how the Sharer's tab reaches a Service's local URL.
|
|
3
|
+
*/
|
|
4
|
+
import { type Upstream } from "@statewalker/webrun-http-proxy";
|
|
5
|
+
/** Which local URL the Bridge relays to. Set by the Sharer, never by a Guest. */
|
|
6
|
+
export declare const BRIDGE_TARGET_HEADER = "x-peershare-target";
|
|
7
|
+
/** The Pairing token the Bridge printed at launch. */
|
|
8
|
+
export declare const BRIDGE_TOKEN_HEADER = "x-peershare-bridge-token";
|
|
9
|
+
/** Marks the Bridge's own responses, so they are never mistaken for the Service's. */
|
|
10
|
+
export declare const BRIDGE_MARKER = "x-peershare-bridge";
|
|
11
|
+
/**
|
|
12
|
+
* A Service's redirect, restated by the Bridge as a 200 with these two
|
|
13
|
+
* headers: a browser makes every `redirect: "manual"` 3xx opaque (status 0,
|
|
14
|
+
* no `Location`), so a real 3xx would reach the page with nothing in it.
|
|
15
|
+
*/
|
|
16
|
+
export declare const BRIDGE_REDIRECT_STATUS_HEADER = "x-peershare-redirect-status";
|
|
17
|
+
export declare const BRIDGE_REDIRECT_LOCATION_HEADER = "x-peershare-redirect-location";
|
|
18
|
+
/**
|
|
19
|
+
* How many cookies the Service tried to set. `Set-Cookie` is a forbidden
|
|
20
|
+
* response header: no page script can read it, so the Bridge says it instead.
|
|
21
|
+
*/
|
|
22
|
+
export declare const BRIDGE_SET_COOKIE_HEADER = "x-peershare-set-cookie";
|
|
23
|
+
/**
|
|
24
|
+
* Which Guest a request is for, as the Sharer states it from the mesh-proven
|
|
25
|
+
* peer id (rung 16); a Guest's own copy is overwritten. The Bridge keeps one
|
|
26
|
+
* cookie jar per value.
|
|
27
|
+
*/
|
|
28
|
+
export declare const BRIDGE_GUEST_HEADER = "x-peershare-guest";
|
|
29
|
+
/** Set by a Bridge with cookie jars when it kept the Service's cookies for that Guest. */
|
|
30
|
+
export declare const BRIDGE_COOKIES_KEPT_HEADER = "x-peershare-cookies";
|
|
31
|
+
/** The Guest id the Probe uses, so a Bridge with cookie jars treats it like any Guest. */
|
|
32
|
+
export declare const PROBE_GUEST = "peershare-probe";
|
|
33
|
+
export type UpstreamConfig = {
|
|
34
|
+
kind: "direct";
|
|
35
|
+
target: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "bridged";
|
|
38
|
+
target: string;
|
|
39
|
+
bridge: {
|
|
40
|
+
base: string;
|
|
41
|
+
token: string;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
export declare function createUpstream(config: UpstreamConfig): Upstream;
|
|
45
|
+
/** Which hop did not answer, read off an `Upstream`'s response; `undefined` when something did. */
|
|
46
|
+
export declare function unreachable(response: Response): "service" | "bridge" | undefined;
|
|
47
|
+
//# sourceMappingURL=upstreams.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upstreams.d.ts","sourceRoot":"","sources":["../src/upstreams.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,EAAU,KAAK,QAAQ,EAAe,MAAM,gCAAgC,CAAC;AAEpF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,uBAAuB,CAAC;AACzD,sDAAsD;AACtD,eAAO,MAAM,mBAAmB,6BAA6B,CAAC;AAC9D,sFAAsF;AACtF,eAAO,MAAM,aAAa,uBAAuB,CAAC;AAClD;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,gCAAgC,CAAC;AAC3E,eAAO,MAAM,+BAA+B,kCAAkC,CAAC;AAC/E;;;GAGG;AACH,eAAO,MAAM,wBAAwB,2BAA2B,CAAC;AACjE;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,sBAAsB,CAAC;AACvD,0FAA0F;AAC1F,eAAO,MAAM,0BAA0B,wBAAwB,CAAC;AAChE,0FAA0F;AAC1F,eAAO,MAAM,WAAW,oBAAoB,CAAC;AAE7C,MAAM,MAAM,cAAc,GACtB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAEjF,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,QAAQ,CAiD/D;AAuBD,mGAAmG;AACnG,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAMhF"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream kinds: how the Sharer's tab reaches a Service's local URL.
|
|
3
|
+
*/
|
|
4
|
+
import { MESH_CREDENTIAL_HEADERS } from "@statewalker/httpeers-core";
|
|
5
|
+
import { MARKER, urlUpstream } from "@statewalker/webrun-http-proxy";
|
|
6
|
+
/** Which local URL the Bridge relays to. Set by the Sharer, never by a Guest. */
|
|
7
|
+
export const BRIDGE_TARGET_HEADER = "x-peershare-target";
|
|
8
|
+
/** The Pairing token the Bridge printed at launch. */
|
|
9
|
+
export const BRIDGE_TOKEN_HEADER = "x-peershare-bridge-token";
|
|
10
|
+
/** Marks the Bridge's own responses, so they are never mistaken for the Service's. */
|
|
11
|
+
export const BRIDGE_MARKER = "x-peershare-bridge";
|
|
12
|
+
/**
|
|
13
|
+
* A Service's redirect, restated by the Bridge as a 200 with these two
|
|
14
|
+
* headers: a browser makes every `redirect: "manual"` 3xx opaque (status 0,
|
|
15
|
+
* no `Location`), so a real 3xx would reach the page with nothing in it.
|
|
16
|
+
*/
|
|
17
|
+
export const BRIDGE_REDIRECT_STATUS_HEADER = "x-peershare-redirect-status";
|
|
18
|
+
export const BRIDGE_REDIRECT_LOCATION_HEADER = "x-peershare-redirect-location";
|
|
19
|
+
/**
|
|
20
|
+
* How many cookies the Service tried to set. `Set-Cookie` is a forbidden
|
|
21
|
+
* response header: no page script can read it, so the Bridge says it instead.
|
|
22
|
+
*/
|
|
23
|
+
export const BRIDGE_SET_COOKIE_HEADER = "x-peershare-set-cookie";
|
|
24
|
+
/**
|
|
25
|
+
* Which Guest a request is for, as the Sharer states it from the mesh-proven
|
|
26
|
+
* peer id (rung 16); a Guest's own copy is overwritten. The Bridge keeps one
|
|
27
|
+
* cookie jar per value.
|
|
28
|
+
*/
|
|
29
|
+
export const BRIDGE_GUEST_HEADER = "x-peershare-guest";
|
|
30
|
+
/** Set by a Bridge with cookie jars when it kept the Service's cookies for that Guest. */
|
|
31
|
+
export const BRIDGE_COOKIES_KEPT_HEADER = "x-peershare-cookies";
|
|
32
|
+
/** The Guest id the Probe uses, so a Bridge with cookie jars treats it like any Guest. */
|
|
33
|
+
export const PROBE_GUEST = "peershare-probe";
|
|
34
|
+
export function createUpstream(config) {
|
|
35
|
+
// A Guest's membership token and peer id are consumed by the mesh hop:
|
|
36
|
+
// Dirk's app has no business seeing them, whichever way it is reached.
|
|
37
|
+
// A Guest's copy of the Bridge headers goes too: the target and token are
|
|
38
|
+
// the Sharer's to set, and Direct would otherwise hand them to the app.
|
|
39
|
+
const stripRequestHeaders = [
|
|
40
|
+
...MESH_CREDENTIAL_HEADERS,
|
|
41
|
+
BRIDGE_TARGET_HEADER,
|
|
42
|
+
BRIDGE_TOKEN_HEADER,
|
|
43
|
+
];
|
|
44
|
+
if (config.kind === "direct") {
|
|
45
|
+
return inGuestOrigin(urlUpstream({
|
|
46
|
+
base: config.target,
|
|
47
|
+
stripRequestHeaders,
|
|
48
|
+
fetchImpl: directFetch(needsLocalAddressSpace(config.target)),
|
|
49
|
+
}), config.target);
|
|
50
|
+
}
|
|
51
|
+
// Bridged is a URL upstream too: re-issued at the Bridge instead of the
|
|
52
|
+
// Service, with the real target and the Pairing token as headers.
|
|
53
|
+
const viaBridge = urlUpstream({
|
|
54
|
+
base: config.bridge.base,
|
|
55
|
+
stripRequestHeaders,
|
|
56
|
+
headers: {
|
|
57
|
+
[BRIDGE_TARGET_HEADER]: config.target,
|
|
58
|
+
[BRIDGE_TOKEN_HEADER]: config.bridge.token,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return inGuestOrigin(async (request) => {
|
|
62
|
+
const response = await viaBridge(request);
|
|
63
|
+
const redirect = response.headers.get(BRIDGE_REDIRECT_STATUS_HEADER);
|
|
64
|
+
if (redirect != null) {
|
|
65
|
+
// Back to a real redirect for the Guest (see BRIDGE_REDIRECT_STATUS_HEADER).
|
|
66
|
+
const headers = new Headers(response.headers);
|
|
67
|
+
headers.set("location", headers.get(BRIDGE_REDIRECT_LOCATION_HEADER) ?? "/");
|
|
68
|
+
headers.delete(BRIDGE_REDIRECT_STATUS_HEADER);
|
|
69
|
+
headers.delete(BRIDGE_REDIRECT_LOCATION_HEADER);
|
|
70
|
+
return new Response(null, { status: Number(redirect), headers });
|
|
71
|
+
}
|
|
72
|
+
// To `urlUpstream` the Bridge IS the upstream, so its "unreachable" means
|
|
73
|
+
// the Bridge is not running, not that the Service is down.
|
|
74
|
+
if (response.headers.get(MARKER) !== "upstream-unreachable")
|
|
75
|
+
return response;
|
|
76
|
+
const headers = new Headers(response.headers);
|
|
77
|
+
headers.delete(MARKER);
|
|
78
|
+
headers.set(BRIDGE_MARKER, "bridge-unreachable");
|
|
79
|
+
return new Response(response.body, { status: response.status, headers });
|
|
80
|
+
}, config.target);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A Guest sees the Service at the root of their own isolated origin (rung
|
|
84
|
+
* 08), so a redirect the Service writes absolutely to ITSELF
|
|
85
|
+
* (`https://wiki.example.com/login`, `http://127.0.0.1:5173/login`) must
|
|
86
|
+
* become a path, or the Guest's frame leaves that origin. Redirects anywhere
|
|
87
|
+
* else stay absolute.
|
|
88
|
+
*/
|
|
89
|
+
function inGuestOrigin(upstream, target) {
|
|
90
|
+
const own = new URL(target).origin;
|
|
91
|
+
return async (request) => {
|
|
92
|
+
const response = await upstream(request);
|
|
93
|
+
const location = response.headers.get("location");
|
|
94
|
+
if (response.status < 300 || response.status >= 400 || location == null)
|
|
95
|
+
return response;
|
|
96
|
+
const resolved = new URL(location, target);
|
|
97
|
+
if (resolved.origin !== own)
|
|
98
|
+
return response;
|
|
99
|
+
const headers = new Headers(response.headers);
|
|
100
|
+
headers.set("location", `${resolved.pathname}${resolved.search}${resolved.hash}`);
|
|
101
|
+
return new Response(response.body, { status: response.status, headers });
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Which hop did not answer, read off an `Upstream`'s response; `undefined` when something did. */
|
|
105
|
+
export function unreachable(response) {
|
|
106
|
+
const bridge = response.headers.get(BRIDGE_MARKER);
|
|
107
|
+
if (bridge === "bridge-unreachable")
|
|
108
|
+
return "bridge";
|
|
109
|
+
if (bridge === "target-unreachable")
|
|
110
|
+
return "service";
|
|
111
|
+
if (response.headers.get(MARKER) === "upstream-unreachable")
|
|
112
|
+
return "service";
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A plain-http Service on the LAN, called from the https PeerShare page, is
|
|
117
|
+
* mixed content and Chromium blocks it — unless the call says the target is
|
|
118
|
+
* on the local network, which also lets Local Network Access ask the Sharer
|
|
119
|
+
* (measured in rung 03). Loopback needs nothing: it is trusted already.
|
|
120
|
+
*/
|
|
121
|
+
function needsLocalAddressSpace(target) {
|
|
122
|
+
const url = new URL(target);
|
|
123
|
+
if (url.protocol !== "http:")
|
|
124
|
+
return false;
|
|
125
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
126
|
+
return !(host === "localhost" || host === "::1" || /^127\./.test(host));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* How Direct calls the Service. Redirects are FOLLOWED: in the Sharer's
|
|
130
|
+
* browser tab a `redirect: "manual"` response is opaque (status 0, no
|
|
131
|
+
* `Location`), so following is the only way to serve a site that redirects
|
|
132
|
+
* (rung 04). Same everywhere, so Node and the browser agree.
|
|
133
|
+
* `targetAddressSpace` is Chromium's; other runtimes ignore it.
|
|
134
|
+
*/
|
|
135
|
+
function directFetch(local) {
|
|
136
|
+
return (input, init) => fetch(input, {
|
|
137
|
+
...init,
|
|
138
|
+
redirect: "follow",
|
|
139
|
+
...(local ? { targetAddressSpace: "local" } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/httpeers-share",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Share HTTP services with the members of a mesh: upstream kinds, sharing policy, probe, service table, invite links, WebSockets over the mesh, and the Bridge.",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/statewalker/httpeers/issues"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/statewalker/httpeers/tree/main/packages/httpeers-share#readme",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/statewalker/httpeers.git",
|
|
14
|
+
"directory": "packages/httpeers-share"
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"source": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./bridge": {
|
|
25
|
+
"source": "./src/bridge/index.ts",
|
|
26
|
+
"types": "./dist/bridge/index.d.ts",
|
|
27
|
+
"import": "./dist/bridge/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@hono/node-server": "^2.1.3",
|
|
37
|
+
"@statewalker/webrun-http-proxy": "^0.2.1",
|
|
38
|
+
"hono": "^4.13.12",
|
|
39
|
+
"ws": "^8.22.0",
|
|
40
|
+
"@statewalker/httpeers-hub": "^0.1.4",
|
|
41
|
+
"@statewalker/httpeers-core": "^0.1.3",
|
|
42
|
+
"@statewalker/httpeers-member": "^0.3.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@biomejs/biome": "^2.5.15",
|
|
46
|
+
"@types/node": "^26.6.4",
|
|
47
|
+
"@types/ws": "^8.18.2",
|
|
48
|
+
"typescript": "^7.0.2",
|
|
49
|
+
"vitest": "^5.0.3",
|
|
50
|
+
"@statewalker/httpeers-qr": "^0.1.3"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsc -p tsconfig.build.json",
|
|
57
|
+
"test": "pnpm run build && pnpm run typecheck && vitest run",
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"lint": "biome check src tests",
|
|
60
|
+
"lint:check": "biome check src tests"
|
|
61
|
+
}
|
|
6
62
|
}
|