@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,21 @@
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
+ export interface InviteLink {
13
+ relayAddrs: string[];
14
+ sharerPeerId: string;
15
+ invitationId: string;
16
+ /** Shown before joining: "Dirk invited you". */
17
+ sharerName: string;
18
+ }
19
+ export declare function encodeInvite(pageUrl: string, invite: InviteLink): string;
20
+ export declare function decodeInvite(link: string): InviteLink;
21
+ //# sourceMappingURL=invite-link.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invite-link.d.ts","sourceRoot":"","sources":["../src/invite-link.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG,MAAM,CAYxE;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAWrD"}
@@ -0,0 +1,38 @@
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
+ import { decodeJoinBlob, encodeJoinBlob } from "@statewalker/httpeers-member";
13
+ export function encodeInvite(pageUrl, invite) {
14
+ const url = new URL(pageUrl);
15
+ const fragment = new URLSearchParams({
16
+ join: encodeJoinBlob({
17
+ relayAddrs: invite.relayAddrs,
18
+ hubPeerId: invite.sharerPeerId,
19
+ invitationId: invite.invitationId,
20
+ }),
21
+ from: invite.sharerName,
22
+ });
23
+ url.hash = fragment.toString();
24
+ return url.toString();
25
+ }
26
+ export function decodeInvite(link) {
27
+ const fragment = new URLSearchParams(new URL(link).hash.slice(1));
28
+ const join = fragment.get("join");
29
+ if (join == null)
30
+ throw new Error("This is not a PeerShare invite link: it has no #join=… part.");
31
+ const blob = decodeJoinBlob(join);
32
+ return {
33
+ relayAddrs: blob.relayAddrs,
34
+ sharerPeerId: blob.hubPeerId,
35
+ invitationId: blob.invitationId,
36
+ sharerName: fragment.get("from") ?? "Someone",
37
+ };
38
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The Guest's side of a membership: join, come back, Close session.
3
+ */
4
+ export type JoinOutcome = "joined" | "invite-used" | "invite-expired" | "machine-offline";
5
+ /**
6
+ * What a failed join means to the Guest, for the Join screen.
7
+ *
8
+ * Measured against httpeers-member 0.1.6: a used and an expired Invite are
9
+ * both `MemberJoinError("invitation-refused")`, told apart only by the hub's
10
+ * answer quoted inside the message (`{"error":"invitation already-redeemed"}`
11
+ * / `{"error":"invitation expired"}`). An offline Sharer is a plain `Error`:
12
+ * the relay holds no reservation for it (`NO_RESERVATION`). Reading text is
13
+ * fragile; httpeers should carry the hub's reason as a field (see README).
14
+ */
15
+ export declare function joinOutcome(error: unknown): JoinOutcome;
16
+ //# sourceMappingURL=join-outcome.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"join-outcome.d.ts","sourceRoot":"","sources":["../src/join-outcome.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,aAAa,GAAG,gBAAgB,GAAG,iBAAiB,CAAC;AAE1F;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,CAOvD"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The Guest's side of a membership: join, come back, Close session.
3
+ */
4
+ /**
5
+ * What a failed join means to the Guest, for the Join screen.
6
+ *
7
+ * Measured against httpeers-member 0.1.6: a used and an expired Invite are
8
+ * both `MemberJoinError("invitation-refused")`, told apart only by the hub's
9
+ * answer quoted inside the message (`{"error":"invitation already-redeemed"}`
10
+ * / `{"error":"invitation expired"}`). An offline Sharer is a plain `Error`:
11
+ * the relay holds no reservation for it (`NO_RESERVATION`). Reading text is
12
+ * fragile; httpeers should carry the hub's reason as a field (see README).
13
+ */
14
+ export function joinOutcome(error) {
15
+ const text = describe(error);
16
+ if (/already-redeemed/.test(text))
17
+ return "invite-used";
18
+ if (/invitation expired/.test(text))
19
+ return "invite-expired";
20
+ // Everything else is the Sharer not being reachable: offline, tab closed,
21
+ // or the relay unable to find it. The Guest's next step is the same: wait.
22
+ return "machine-offline";
23
+ }
24
+ function describe(error) {
25
+ const parts = [];
26
+ for (let e = error; e != null; e = e.cause) {
27
+ parts.push(e instanceof Error ? e.message : String(e));
28
+ if (parts.length > 5)
29
+ break;
30
+ }
31
+ return parts.join(" | ");
32
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Stop, reload and Revoke, as the Sharer drives them and a Guest sees them.
3
+ */
4
+ import type { Hub } from "@statewalker/httpeers-hub";
5
+ /**
6
+ * Two writes, as the hub's own `DELETE /admin/members/{peerId}` does: the
7
+ * member record goes, AND a revocation is recorded, so the token the Guest
8
+ * still holds stops verifying on its next call instead of lasting until it
9
+ * expires.
10
+ */
11
+ export declare function revokeGuest(hub: Pick<Hub, "members" | "revocations">, guestPeerId: string): void;
12
+ /** Whether a Guest's failed request means "the Sharer is offline" (Stop, closed tab). */
13
+ /**
14
+ * Measured: once the Sharer stops, a Guest's request comes back within tens
15
+ * of ms as `502 {"kind":"peer-unreachable"}` from the Guest's own member,
16
+ * never as a hang. The body is read from a clone, so the caller keeps it.
17
+ */
18
+ export declare function isSharerOffline(response: Response): Promise<boolean>;
19
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,2BAA2B,CAAC;AAErD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,SAAS,GAAG,aAAa,CAAC,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAGhG;AAED,yFAAyF;AACzF;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAO1E"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Stop, reload and Revoke, as the Sharer drives them and a Guest sees them.
3
+ */
4
+ /**
5
+ * Two writes, as the hub's own `DELETE /admin/members/{peerId}` does: the
6
+ * member record goes, AND a revocation is recorded, so the token the Guest
7
+ * still holds stops verifying on its next call instead of lasting until it
8
+ * expires.
9
+ */
10
+ export function revokeGuest(hub, guestPeerId) {
11
+ hub.members.remove(guestPeerId);
12
+ hub.revocations.revoke(guestPeerId);
13
+ }
14
+ /** Whether a Guest's failed request means "the Sharer is offline" (Stop, closed tab). */
15
+ /**
16
+ * Measured: once the Sharer stops, a Guest's request comes back within tens
17
+ * of ms as `502 {"kind":"peer-unreachable"}` from the Guest's own member,
18
+ * never as a hang. The body is read from a clone, so the caller keeps it.
19
+ */
20
+ export async function isSharerOffline(response) {
21
+ if (response.status !== 502)
22
+ return false;
23
+ const body = (await response
24
+ .clone()
25
+ .json()
26
+ .catch(() => null));
27
+ return body?.kind === "peer-unreachable";
28
+ }
@@ -0,0 +1,19 @@
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
+ export type ServiceKind = "bridged" | "direct";
11
+ export type TargetRefusal = "not-a-url" | "not-http" | "needs-https" | "needs-domain-name" | "local-needs-bridge" | "remote-is-direct" | "not-local";
12
+ export type TargetCheck = {
13
+ ok: true;
14
+ } | {
15
+ ok: false;
16
+ reason: TargetRefusal;
17
+ };
18
+ export declare function checkServiceTarget(kind: ServiceKind, target: string): TargetCheck;
19
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC;AAE/C,MAAM,MAAM,aAAa,GACrB,WAAW,GACX,UAAU,GACV,aAAa,GACb,mBAAmB,GACnB,oBAAoB,GACpB,kBAAkB,GAClB,WAAW,CAAC;AAEhB,MAAM,MAAM,WAAW,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,aAAa,CAAA;CAAE,CAAC;AAE9E,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,GAAG,WAAW,CAsBjF"}
package/dist/policy.js ADDED
@@ -0,0 +1,40 @@
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
+ import { checkTarget } from "./targets.js";
11
+ export function checkServiceTarget(kind, target) {
12
+ let url;
13
+ try {
14
+ url = new URL(target);
15
+ }
16
+ catch {
17
+ return refused("not-a-url");
18
+ }
19
+ if (url.protocol !== "http:" && url.protocol !== "https:")
20
+ return refused("not-http");
21
+ const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
22
+ const isAddress = /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(":");
23
+ const isLocalName = host === "localhost" || host.endsWith(".local");
24
+ if (kind === "bridged") {
25
+ if (checkTarget(target) !== "forbidden")
26
+ return { ok: true };
27
+ // A public https name is a remote resource: it is shared Direct.
28
+ return refused(url.protocol === "https:" && !isAddress ? "remote-is-direct" : "not-local");
29
+ }
30
+ if (isLocalName)
31
+ return refused("local-needs-bridge");
32
+ if (isAddress)
33
+ return refused("needs-domain-name");
34
+ if (url.protocol !== "https:")
35
+ return refused("needs-https");
36
+ return { ok: true };
37
+ }
38
+ function refused(reason) {
39
+ return { ok: false, reason };
40
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * "Test again": one request through the same Upstream Guests use.
3
+ */
4
+ import type { Upstream } from "@statewalker/webrun-http-proxy";
5
+ export type Health = "ok" | "slow" | "down";
6
+ export type ProbeCause = "service-down" | "bridge-down" | "cors-missing";
7
+ export type ProbeWarning = "ws-needed" | "cookies-set";
8
+ export interface ProbeReport {
9
+ health: Health;
10
+ cause?: ProbeCause;
11
+ warnings: ProbeWarning[];
12
+ ms: number;
13
+ }
14
+ /** What Guests notice: a page that takes longer than this feels broken. */
15
+ export declare const SLOW_MS = 1500;
16
+ export declare function probe(upstream: Upstream, opts?: {
17
+ slowMs?: number;
18
+ direct?: string;
19
+ }): Promise<ProbeReport>;
20
+ //# sourceMappingURL=probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAS/D,MAAM,MAAM,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAC5C,MAAM,MAAM,UAAU,GAAG,cAAc,GAAG,aAAa,GAAG,cAAc,CAAC;AACzE,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,aAAa,CAAC;AAEvD,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,EAAE,CAAC;IACzB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,2EAA2E;AAC3E,eAAO,MAAM,OAAO,OAAO,CAAC;AAE5B,wBAAsB,KAAK,CACzB,QAAQ,EAAE,QAAQ,EAClB,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9C,OAAO,CAAC,WAAW,CAAC,CAyBtB"}
package/dist/probe.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * "Test again": one request through the same Upstream Guests use.
3
+ */
4
+ import { BRIDGE_COOKIES_KEPT_HEADER, BRIDGE_GUEST_HEADER, BRIDGE_SET_COOKIE_HEADER, PROBE_GUEST, unreachable, } from "./upstreams.js";
5
+ /** What Guests notice: a page that takes longer than this feels broken. */
6
+ export const SLOW_MS = 1500;
7
+ export async function probe(upstream, opts = {}) {
8
+ const started = performance.now();
9
+ // As a Guest of its own: a Bridge with cookie jars treats the Probe like anyone else.
10
+ const response = await upstream(new Request("http://probe.peershare/", { headers: { [BRIDGE_GUEST_HEADER]: PROBE_GUEST } }));
11
+ const body = await response.text();
12
+ const ms = Math.round(performance.now() - started);
13
+ const down = unreachable(response);
14
+ if (down === "service" && opts.direct != null && (await answersWithoutCors(opts.direct))) {
15
+ return { health: "down", cause: "cors-missing", warnings: [], ms };
16
+ }
17
+ if (down != null)
18
+ return { health: "down", cause: `${down}-down`, warnings: [], ms };
19
+ // A 404 at `/` is an app that answers; a 5xx is one that does not work.
20
+ if (response.status >= 500)
21
+ return { health: "down", cause: "service-down", warnings: [], ms };
22
+ const health = ms >= (opts.slowMs ?? SLOW_MS) ? "slow" : "ok";
23
+ const warnings = [];
24
+ if (isHtml(response) && WEBSOCKET_CLIENTS.some((re) => re.test(body)))
25
+ warnings.push("ws-needed");
26
+ // Only the Bridge's marker: a page never sees `Set-Cookie` itself.
27
+ // A cookie login breaks for Guests only when nothing keeps the cookies for them.
28
+ const kept = response.headers.get(BRIDGE_COOKIES_KEPT_HEADER) === "kept";
29
+ if (response.headers.has(BRIDGE_SET_COOKIE_HEADER) && !kept)
30
+ warnings.push("cookies-set");
31
+ return { health, warnings, ms };
32
+ }
33
+ /**
34
+ * Pages whose scripts open a WebSocket: dev servers' hot-reload clients, and
35
+ * pages that do it themselves. Guests' traffic is HTTP only, so these break.
36
+ * Matched in script tags and code, not in prose.
37
+ */
38
+ const WEBSOCKET_CLIENTS = [
39
+ /<script[^>]+src="[^"]*\/@vite\/client"/,
40
+ /<script[^>]+src="[^"]*webpack-dev-server/,
41
+ /<script[^>]+src="[^"]*\/_next\/webpack-hmr/,
42
+ /new\s+WebSocket\s*\(/,
43
+ ];
44
+ function isHtml(response) {
45
+ return (response.headers.get("content-type") ?? "").includes("text/html");
46
+ }
47
+ /**
48
+ * In a browser a missing CORS header fails exactly like a dead server. A
49
+ * `no-cors` request is not subject to CORS: an (opaque) answer means the
50
+ * site is up, so what is missing is its permission for other origins.
51
+ */
52
+ async function answersWithoutCors(target) {
53
+ try {
54
+ await fetch(target, { mode: "no-cors" });
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The Sharer's Services as Guests see them: a live catalog at `/s/`, and
3
+ * each Service at `/s/<id>/…`.
4
+ *
5
+ * The hub's own advertisements are posted once, when it is created (there is
6
+ * no way to update them), so they cannot carry a Service that was added or
7
+ * paused later. One static advertisement says "Dirk shares services"; what
8
+ * they are, and which are paused, is read here, per request.
9
+ */
10
+ import type { FetchHandler, Upstream } from "@statewalker/webrun-http-proxy";
11
+ export interface ServiceEntry {
12
+ id: string;
13
+ name: string;
14
+ upstream: Upstream;
15
+ /** The Service's WebSocket at `path` (M9); without it, sockets are refused. */
16
+ socket?: (path: string, protocols: string[]) => WebSocket;
17
+ enabled: boolean;
18
+ isDefault: boolean;
19
+ }
20
+ export interface CatalogEntry {
21
+ id: string;
22
+ name: string;
23
+ isDefault: boolean;
24
+ paused: boolean;
25
+ }
26
+ /** Marks the table's own answers (paused, unknown), so they are never taken for a Service's. */
27
+ export declare const SERVICE_MARKER = "x-peershare-service";
28
+ export declare function serviceTable(services: () => readonly ServiceEntry[],
29
+ /** `sharerName` may be a function: read per request, so a name set later shows. */
30
+ opts?: {
31
+ sharerName?: string | (() => string);
32
+ }): FetchHandler;
33
+ //# sourceMappingURL=service-table.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-table.d.ts","sourceRoot":"","sources":["../src/service-table.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAK7E,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,QAAQ,CAAC;IACnB,+EAA+E;IAC/E,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,SAAS,CAAC;IAC1D,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,gGAAgG;AAChG,eAAO,MAAM,cAAc,wBAAwB,CAAC;AAEpD,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,MAAM,SAAS,YAAY,EAAE;AACvC,mFAAmF;AACnF,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,CAAC,CAAA;CAAO,GAClD,YAAY,CAuCd"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The Sharer's Services as Guests see them: a live catalog at `/s/`, and
3
+ * each Service at `/s/<id>/…`.
4
+ *
5
+ * The hub's own advertisements are posted once, when it is created (there is
6
+ * no way to update them), so they cannot carry a Service that was added or
7
+ * paused later. One static advertisement says "Dirk shares services"; what
8
+ * they are, and which are paused, is read here, per request.
9
+ */
10
+ import { shareRouter } from "./share-router.js";
11
+ import { SOCKET_HEADER } from "./socket-frames.js";
12
+ import { sharerSocket } from "./socket-tunnel.js";
13
+ /** Marks the table's own answers (paused, unknown), so they are never taken for a Service's. */
14
+ export const SERVICE_MARKER = "x-peershare-service";
15
+ export function serviceTable(services,
16
+ /** `sharerName` may be a function: read per request, so a name set later shows. */
17
+ opts = {}) {
18
+ const nameOf = typeof opts.sharerName === "function"
19
+ ? opts.sharerName
20
+ : () => opts.sharerName;
21
+ const router = shareRouter({ services: () => services().filter((s) => s.enabled) });
22
+ return async (request) => {
23
+ const sharer = nameOf() || "The Sharer";
24
+ const { pathname } = new URL(request.url);
25
+ if (pathname === "/s/" || pathname === "/s") {
26
+ const catalog = [...services()]
27
+ .sort((a, b) => Number(b.isDefault) - Number(a.isDefault))
28
+ .map((s) => ({ id: s.id, name: s.name, isDefault: s.isDefault, paused: !s.enabled }));
29
+ return Response.json(catalog);
30
+ }
31
+ const id = pathname.split("/")[2] ?? "";
32
+ const service = services().find((s) => s.id === id);
33
+ if (service == null)
34
+ return own(404, "unknown", `There is no service "${id}" here.`);
35
+ if (!service.enabled) {
36
+ return own(503, "paused", `${sharer} paused ${service.name}. It is back when ${sharer} turns it on again.`);
37
+ }
38
+ const protocols = request.headers.get(SOCKET_HEADER);
39
+ if (protocols != null) {
40
+ if (service.socket == null)
41
+ return own(501, "no-socket", `${service.name} cannot carry live connections.`);
42
+ const url = new URL(request.url);
43
+ const rest = url.pathname.slice(`/s/${id}`.length) || "/";
44
+ const open = service.socket;
45
+ return sharerSocket(request, () => open(`${rest}${url.search}`, protocols.split(",").filter(Boolean)));
46
+ }
47
+ return router(request);
48
+ };
49
+ }
50
+ function own(status, marker, message) {
51
+ const body = `<!doctype html><meta charset="utf-8"><title>${escapeHtml(message)}</title><p>${escapeHtml(message)}</p>`;
52
+ return new Response(body, {
53
+ status,
54
+ headers: { "content-type": "text/html; charset=utf-8", [SERVICE_MARKER]: marker },
55
+ });
56
+ }
57
+ function escapeHtml(text) {
58
+ return text.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
59
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * What the Sharer's member mounts for Guests: `/s/<serviceId>/<path>`.
3
+ *
4
+ * The router knows nothing about upstream kinds: it strips the mount, hands
5
+ * the rest of the path to the Service's `Upstream`, and that is all.
6
+ */
7
+ import type { FetchHandler, Upstream } from "@statewalker/webrun-http-proxy";
8
+ export interface SharedService {
9
+ id: string;
10
+ upstream: Upstream;
11
+ }
12
+ export declare function shareRouter(init: {
13
+ services: () => readonly SharedService[];
14
+ }): FetchHandler;
15
+ //# sourceMappingURL=share-router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"share-router.d.ts","sourceRoot":"","sources":["../src/share-router.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAG7E,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,QAAQ,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,SAAS,aAAa,EAAE,CAAA;CAAE,GAAG,YAAY,CA0B5F"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * What the Sharer's member mounts for Guests: `/s/<serviceId>/<path>`.
3
+ *
4
+ * The router knows nothing about upstream kinds: it strips the mount, hands
5
+ * the rest of the path to the Service's `Upstream`, and that is all.
6
+ */
7
+ import { bodyOf } from "@statewalker/httpeers-core";
8
+ import { Hono } from "hono";
9
+ export function shareRouter(init) {
10
+ const app = new Hono();
11
+ app.all("/s/:id/*", async (c) => {
12
+ const id = c.req.param("id");
13
+ // Read per request, so adding or pausing a Service needs no rebuild.
14
+ const service = init.services().find((s) => s.id === id);
15
+ if (service == null)
16
+ return c.notFound();
17
+ const url = new URL(c.req.url);
18
+ const rest = url.pathname.slice(`/s/${id}`.length) || "/";
19
+ // Through `bodyOf`, not `request.body`: Firefox has no `Request.body`,
20
+ // and a rewritten request silently lost its body there.
21
+ const body = await bodyOf(c.req.raw);
22
+ return service.upstream(new Request(new URL(`${rest}${url.search}`, url.origin), {
23
+ method: c.req.method,
24
+ headers: c.req.raw.headers,
25
+ body,
26
+ ...(body instanceof ReadableStream ? { duplex: "half" } : {}),
27
+ signal: c.req.raw.signal,
28
+ }));
29
+ });
30
+ return async (request) => app.fetch(request);
31
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * WebSocket messages inside one streaming HTTP request over the mesh (rung 15:
3
+ * a mesh request is full-duplex). Each frame: 1 byte type, 4 bytes length
4
+ * (big-endian), payload.
5
+ *
6
+ * text (1): UTF-8
7
+ * binary (2): bytes
8
+ * close (3): 2 bytes code, then the reason in UTF-8
9
+ * open (4): the subprotocol the app accepted, in UTF-8 (first frame back)
10
+ */
11
+ export type SocketFrame = {
12
+ type: "text";
13
+ data: string;
14
+ } | {
15
+ type: "binary";
16
+ data: Uint8Array;
17
+ } | {
18
+ type: "close";
19
+ code: number;
20
+ reason: string;
21
+ } | {
22
+ type: "open";
23
+ protocol: string;
24
+ };
25
+ /** Asks a Service's upstream for a socket instead of a response: the header names the subprotocols. */
26
+ export declare const SOCKET_HEADER = "x-peershare-websocket";
27
+ export declare function encodeFrame(frame: SocketFrame): Uint8Array;
28
+ /** Bytes, chunked any way, to frames. */
29
+ export declare function decodeFrames(): TransformStream<Uint8Array, SocketFrame>;
30
+ //# sourceMappingURL=socket-frames.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket-frames.d.ts","sourceRoot":"","sources":["../src/socket-frames.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,GACpC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvC,uGAAuG;AACvG,eAAO,MAAM,aAAa,0BAA0B,CAAC;AAQrD,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,UAAU,CA0B1D;AAED,yCAAyC;AACzC,wBAAgB,YAAY,IAAI,eAAe,CAAC,UAAU,EAAE,WAAW,CAAC,CA2BvE"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * WebSocket messages inside one streaming HTTP request over the mesh (rung 15:
3
+ * a mesh request is full-duplex). Each frame: 1 byte type, 4 bytes length
4
+ * (big-endian), payload.
5
+ *
6
+ * text (1): UTF-8
7
+ * binary (2): bytes
8
+ * close (3): 2 bytes code, then the reason in UTF-8
9
+ * open (4): the subprotocol the app accepted, in UTF-8 (first frame back)
10
+ */
11
+ /** Asks a Service's upstream for a socket instead of a response: the header names the subprotocols. */
12
+ export const SOCKET_HEADER = "x-peershare-websocket";
13
+ const TEXT = 1;
14
+ const BINARY = 2;
15
+ const CLOSE = 3;
16
+ const OPEN = 4;
17
+ const HEAD = 5;
18
+ export function encodeFrame(frame) {
19
+ const enc = new TextEncoder();
20
+ let type;
21
+ let payload;
22
+ if (frame.type === "text") {
23
+ type = TEXT;
24
+ payload = enc.encode(frame.data);
25
+ }
26
+ else if (frame.type === "binary") {
27
+ type = BINARY;
28
+ payload = frame.data;
29
+ }
30
+ else if (frame.type === "open") {
31
+ type = OPEN;
32
+ payload = enc.encode(frame.protocol);
33
+ }
34
+ else {
35
+ type = CLOSE;
36
+ const reason = enc.encode(frame.reason);
37
+ payload = new Uint8Array(2 + reason.length);
38
+ new DataView(payload.buffer).setUint16(0, frame.code);
39
+ payload.set(reason, 2);
40
+ }
41
+ const out = new Uint8Array(HEAD + payload.length);
42
+ const view = new DataView(out.buffer);
43
+ view.setUint8(0, type);
44
+ view.setUint32(1, payload.length);
45
+ out.set(payload, HEAD);
46
+ return out;
47
+ }
48
+ /** Bytes, chunked any way, to frames. */
49
+ export function decodeFrames() {
50
+ let buffer = new Uint8Array(0);
51
+ const dec = new TextDecoder();
52
+ return new TransformStream({
53
+ transform(chunk, controller) {
54
+ const joined = new Uint8Array(buffer.length + chunk.length);
55
+ joined.set(buffer);
56
+ joined.set(chunk, buffer.length);
57
+ buffer = joined;
58
+ while (buffer.length >= HEAD) {
59
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
60
+ const length = view.getUint32(1);
61
+ if (buffer.length < HEAD + length)
62
+ break;
63
+ const type = view.getUint8(0);
64
+ const payload = buffer.slice(HEAD, HEAD + length);
65
+ buffer = buffer.slice(HEAD + length);
66
+ if (type === TEXT)
67
+ controller.enqueue({ type: "text", data: dec.decode(payload) });
68
+ else if (type === BINARY)
69
+ controller.enqueue({ type: "binary", data: payload });
70
+ else if (type === CLOSE) {
71
+ const code = new DataView(payload.buffer, payload.byteOffset).getUint16(0);
72
+ controller.enqueue({ type: "close", code, reason: dec.decode(payload.slice(2)) });
73
+ }
74
+ else if (type === OPEN)
75
+ controller.enqueue({ type: "open", protocol: dec.decode(payload) });
76
+ else
77
+ controller.error(new Error(`unknown socket frame type ${type}`));
78
+ }
79
+ },
80
+ });
81
+ }
@@ -0,0 +1,46 @@
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 type { FetchHandler } from "@statewalker/webrun-http-proxy";
8
+ /** The Sharer's side: answer a tunnel request by relaying to `open()`'s socket. */
9
+ export declare function sharerSocket(request: Request, open: () => WebSocket): Response;
10
+ type Listener<E> = ((event: E) => void) | null;
11
+ /** What a Guest holds: shaped like a browser WebSocket, carried over `fetch`. */
12
+ export declare class TunnelSocket {
13
+ static readonly CONNECTING = 0;
14
+ static readonly OPEN = 1;
15
+ static readonly CLOSING = 2;
16
+ static readonly CLOSED = 3;
17
+ readyState: number;
18
+ protocol: string;
19
+ onopen: Listener<{
20
+ type: "open";
21
+ }>;
22
+ onmessage: Listener<{
23
+ type: "message";
24
+ data: string | ArrayBuffer;
25
+ }>;
26
+ onclose: Listener<{
27
+ type: "close";
28
+ code: number;
29
+ reason: string;
30
+ wasClean: boolean;
31
+ }>;
32
+ onerror: Listener<{
33
+ type: "error";
34
+ }>;
35
+ private out;
36
+ private sentClose;
37
+ constructor(fetch: FetchHandler, path: string, protocols: string[]);
38
+ send(data: string | ArrayBuffer | ArrayBufferView): void;
39
+ close(code?: number, reason?: string): void;
40
+ private endRequest;
41
+ private read;
42
+ private closed;
43
+ }
44
+ export declare function openTunnel(fetch: FetchHandler, path: string, protocols: string[]): TunnelSocket;
45
+ export {};
46
+ //# sourceMappingURL=socket-tunnel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"socket-tunnel.d.ts","sourceRoot":"","sources":["../src/socket-tunnel.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAGnE,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,SAAS,GAAG,QAAQ,CAsD9E;AAED,KAAK,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;AAE/C,iFAAiF;AACjF,qBAAa,YAAY;IACvB,MAAM,CAAC,QAAQ,CAAC,UAAU,KAAK;IAC/B,MAAM,CAAC,QAAQ,CAAC,IAAI,KAAK;IACzB,MAAM,CAAC,QAAQ,CAAC,OAAO,KAAK;IAC5B,MAAM,CAAC,QAAQ,CAAC,MAAM,KAAK;IAE3B,UAAU,SAA2B;IACrC,QAAQ,SAAM;IACd,MAAM,EAAE,QAAQ,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAQ;IAC1C,SAAS,EAAE,QAAQ,CAAC;QAAE,IAAI,EAAE,SAAS,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;KAAE,CAAC,CAAQ;IAC5E,OAAO,EAAE,QAAQ,CAAC;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC,CAAQ;IAC7F,OAAO,EAAE,QAAQ,CAAC;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAQ;IAE5C,OAAO,CAAC,GAAG,CAA+C;IAC1D,OAAO,CAAC,SAAS,CAAS;IAE1B,YAAY,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,EAkBjE;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,GAAG,eAAe,GAAG,IAAI,CAavD;IAED,KAAK,CAAC,IAAI,SAAO,EAAE,MAAM,SAAK,GAAG,IAAI,CAIpC;IAED,OAAO,CAAC,UAAU;YAWJ,IAAI;IAkBlB,OAAO,CAAC,MAAM;CAMf;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,YAAY,CAE/F"}