@podway/relay 0.2.3

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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * VENDORED from `@podbay/shared/src/net-guard.ts` — keep the two IDENTICAL in behaviour.
3
+ *
4
+ * Why a copy: `pb` is published standalone to npm and builds with plain tsc, so it can
5
+ * carry no workspace dependency. Why it is safe: `test/net-guard-parity.test.ts` runs
6
+ * BOTH implementations over the same table and fails if they ever disagree, so a drift
7
+ * in this security control breaks the build rather than opening a quiet hole.
8
+ */
9
+ export function isDisallowedTarget(host) {
10
+ const h = host.toLowerCase().replace(/^\[|\]$/g, "");
11
+ if (h === "localhost" || h.endsWith(".localhost"))
12
+ return true;
13
+ // IPv4 literal
14
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
15
+ if (v4) {
16
+ const o = v4.slice(1).map(Number);
17
+ if (o.some((n) => n > 255))
18
+ return true; // malformed → refuse
19
+ const [a, b] = o;
20
+ if (a === 127 || a === 10 || a === 0)
21
+ return true; // loopback / private / this-host
22
+ if (a === 172 && b >= 16 && b <= 31)
23
+ return true; // 172.16/12
24
+ if (a === 192 && b === 168)
25
+ return true; // 192.168/16
26
+ if (a === 169 && b === 254)
27
+ return true; // link-local
28
+ if (a === 100 && b >= 64 && b <= 127)
29
+ return true; // CGNAT 100.64/10
30
+ if (a >= 224)
31
+ return true; // multicast / reserved
32
+ return false;
33
+ }
34
+ // IPv6 literal: refuse loopback, unspecified, link-local (fe80::/10), unique-local (fc00::/7)
35
+ if (h.includes(":")) {
36
+ if (h === "::1" || h === "::")
37
+ return true;
38
+ if (h.startsWith("fe8") || h.startsWith("fe9") || h.startsWith("fea") || h.startsWith("feb"))
39
+ return true;
40
+ if (h.startsWith("fc") || h.startsWith("fd"))
41
+ return true;
42
+ if (h.startsWith("::ffff:"))
43
+ return isDisallowedTarget(h.slice(7)); // v4-mapped
44
+ return false;
45
+ }
46
+ return false; // a domain name — allowed here; owner re-checks post-DNS
47
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The relay's protocol brain: what to do with each fetch the gateway sends.
3
+ *
4
+ * The design (decided 2026-07-31): once the owner starts the relay, the pod may fetch
5
+ * the public web through it freely — no per-request approval, because nobody watches a
6
+ * terminal to click "allow". Oversight is after the fact: every fetch is audited, and
7
+ * the owner monitors via `relay dashboard` and the admin dashboard.
8
+ *
9
+ * Two guards remain, and both protect the OWNER, not us:
10
+ * 1. The relay refuses a URL that is not a plain web address or resolves to a bare IP
11
+ * or private host. A prompt-injected pod must not be able to make the owner's
12
+ * machine hit their own LAN (192.168.x, 10.x, localhost) — an SSRF the owner cannot
13
+ * see to consent to.
14
+ * 2. WHICH sessions are used is decided by the fetcher: a domain the owner explicitly
15
+ * signed into (`relay login`) is fetched AS them; every other domain is fetched
16
+ * from a CLEAN context with no cookies. So "fetch as me" is opt-in per site, and a
17
+ * compromised pod cannot read accounts the owner never lent it.
18
+ *
19
+ * Pure over injected effects (fetcher, audit) so the decisions test without a browser.
20
+ */
21
+ import { type RelayEventV2, type RelaySource } from "./audit.js";
22
+ import type { RelayRuntime } from "./runtime.js";
23
+ export interface RelaySocket {
24
+ send(json: string): void;
25
+ }
26
+ export interface FetchJob {
27
+ id: string;
28
+ url: string;
29
+ source?: RelaySource;
30
+ }
31
+ export interface FetchOutput {
32
+ status: number;
33
+ body: string;
34
+ finalUrl?: string;
35
+ error?: string;
36
+ /** Whether the fetch used the owner's signed-in session (a login domain) or a clean
37
+ * context. Recorded in the audit so the owner can see it. */
38
+ session?: boolean;
39
+ }
40
+ export type Fetcher = (job: FetchJob) => Promise<FetchOutput>;
41
+ export type AuditFn = (entry: RelayEventV2) => void;
42
+ /** Host of a URL, or null if it is not a public web host the relay may fetch. */
43
+ export declare function publicHostOf(url: string): string | null;
44
+ interface Opts {
45
+ maxConcurrent?: number;
46
+ queueMax?: number;
47
+ audit?: AuditFn;
48
+ runtime?: RelayRuntime;
49
+ /** Return a refusal reason before any browser work, or undefined to allow. */
50
+ deny?: (source: RelaySource | undefined, host: string) => string | undefined;
51
+ now?: () => number;
52
+ }
53
+ export declare class RelayClient {
54
+ private readonly socket;
55
+ private readonly fetcher;
56
+ private readonly opts;
57
+ private active;
58
+ private readonly queue;
59
+ constructor(socket: RelaySocket, fetcher: Fetcher, opts?: Opts);
60
+ private reply;
61
+ onMessage(raw: string): Promise<void>;
62
+ private now;
63
+ private audit;
64
+ private run;
65
+ }
66
+ export {};
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The relay's protocol brain: what to do with each fetch the gateway sends.
3
+ *
4
+ * The design (decided 2026-07-31): once the owner starts the relay, the pod may fetch
5
+ * the public web through it freely — no per-request approval, because nobody watches a
6
+ * terminal to click "allow". Oversight is after the fact: every fetch is audited, and
7
+ * the owner monitors via `relay dashboard` and the admin dashboard.
8
+ *
9
+ * Two guards remain, and both protect the OWNER, not us:
10
+ * 1. The relay refuses a URL that is not a plain web address or resolves to a bare IP
11
+ * or private host. A prompt-injected pod must not be able to make the owner's
12
+ * machine hit their own LAN (192.168.x, 10.x, localhost) — an SSRF the owner cannot
13
+ * see to consent to.
14
+ * 2. WHICH sessions are used is decided by the fetcher: a domain the owner explicitly
15
+ * signed into (`relay login`) is fetched AS them; every other domain is fetched
16
+ * from a CLEAN context with no cookies. So "fetch as me" is opt-in per site, and a
17
+ * compromised pod cannot read accounts the owner never lent it.
18
+ *
19
+ * Pure over injected effects (fetcher, audit) so the decisions test without a browser.
20
+ */
21
+ import { fetchOutcome, safeFetchTarget, } from "./audit.js";
22
+ /** Host of a URL, or null if it is not a public web host the relay may fetch. */
23
+ export function publicHostOf(url) {
24
+ let u;
25
+ try {
26
+ u = new URL(url);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ if (u.protocol !== "http:" && u.protocol !== "https:")
32
+ return null;
33
+ const h = u.hostname.toLowerCase().replace(/^www\./, "").replace(/\.$/, "");
34
+ if (!h.includes(".") || h.includes(":"))
35
+ return null;
36
+ // No bare IPs, and no private / loopback ranges — this is the SSRF guard.
37
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) {
38
+ if (/^(10|127)\./.test(h) || /^192\.168\./.test(h) || /^172\.(1[6-9]|2\d|3[01])\./.test(h) || /^169\.254\./.test(h) || h === "0.0.0.0") {
39
+ return null;
40
+ }
41
+ return null; // a bare public IP is still refused — the relay lends NAMED sites
42
+ }
43
+ if (h === "localhost" || h.endsWith(".local") || h.endsWith(".internal") || h.endsWith(".lan"))
44
+ return null;
45
+ return h;
46
+ }
47
+ export class RelayClient {
48
+ socket;
49
+ fetcher;
50
+ opts;
51
+ active = 0;
52
+ queue = [];
53
+ constructor(socket, fetcher, opts = {}) {
54
+ this.socket = socket;
55
+ this.fetcher = fetcher;
56
+ this.opts = opts;
57
+ }
58
+ reply(id, out) {
59
+ this.socket.send(JSON.stringify({ type: "fetch-result", id, ...out }));
60
+ }
61
+ async onMessage(raw) {
62
+ let msg;
63
+ try {
64
+ msg = JSON.parse(raw);
65
+ }
66
+ catch {
67
+ return;
68
+ }
69
+ if (msg.type !== "fetch" || !msg.id || !msg.url)
70
+ return;
71
+ const safe = safeFetchTarget(msg.url);
72
+ const host = publicHostOf(msg.url);
73
+ if (!safe || !host) {
74
+ // The one thing we refuse rather than serve: a non-public / private target.
75
+ const auditSafe = safe ?? safeFetchTarget("https://invalid.invalid/");
76
+ this.audit({ id: msg.id, url: auditSafe.target, source: msg.source }, auditSafe, "safety-blocked", 0, false, "refused: not a public web URL");
77
+ this.reply(msg.id, { status: 0, body: "", error: "the relay only fetches public web addresses" });
78
+ return;
79
+ }
80
+ const denial = this.opts.deny?.(msg.source, host);
81
+ if (denial) {
82
+ this.audit({ id: msg.id, url: safe.target, source: msg.source }, safe, "owner-blocked", 0, false, denial);
83
+ this.reply(msg.id, { status: 0, body: "", error: denial });
84
+ return;
85
+ }
86
+ if (this.active >= (this.opts.maxConcurrent ?? 3)) {
87
+ if (this.queue.length >= (this.opts.queueMax ?? 200)) {
88
+ this.reply(msg.id, { status: 0, body: "", error: "relay busy — queue full" });
89
+ return;
90
+ }
91
+ this.queue.push({ id: msg.id, url: msg.url, source: msg.source });
92
+ return;
93
+ }
94
+ void this.run({ id: msg.id, url: msg.url, source: msg.source }, host, safe);
95
+ }
96
+ now() { return (this.opts.now ?? Date.now)(); }
97
+ audit(job, safe, outcome, started, session, reason, status, finalTarget) {
98
+ const finished = this.now();
99
+ this.opts.audit?.({
100
+ v: 2,
101
+ id: `fetch-${job.id}`,
102
+ startedAt: new Date(started || finished).toISOString(),
103
+ finishedAt: new Date(finished).toISOString(),
104
+ durationMs: started ? Math.max(0, finished - started) : 0,
105
+ source: job.source,
106
+ mode: "fetch",
107
+ outcome,
108
+ target: safe.target,
109
+ ...(finalTarget ? { finalTarget } : {}),
110
+ host: safe.host,
111
+ ...(status !== undefined ? { httpStatus: status } : {}),
112
+ session,
113
+ ...(reason ? { reason } : {}),
114
+ });
115
+ }
116
+ async run(job, host, safe = safeFetchTarget(job.url)) {
117
+ const denial = this.opts.deny?.(job.source, host);
118
+ if (denial) {
119
+ this.audit(job, safe, "owner-blocked", 0, false, denial);
120
+ this.reply(job.id, { status: 0, body: "", error: denial });
121
+ return;
122
+ }
123
+ this.active++;
124
+ const started = this.now();
125
+ this.opts.runtime?.begin({ id: job.id, source: job.source, mode: "fetch", target: safe.target, startedAt: new Date(started).toISOString() });
126
+ try {
127
+ const out = await this.fetcher(job);
128
+ // A redirect that landed on a private target is the same SSRF, after the fact.
129
+ if (out.finalUrl && !publicHostOf(out.finalUrl)) {
130
+ this.reply(job.id, { status: 0, body: "", error: "redirected to a non-public address" });
131
+ this.audit(job, safe, "safety-blocked", started, !!out.session, "redirect to private");
132
+ return;
133
+ }
134
+ this.reply(job.id, out);
135
+ const final = out.finalUrl ? safeFetchTarget(out.finalUrl)?.target : undefined;
136
+ this.audit(job, safe, fetchOutcome(out.status, out.error), started, !!out.session, out.error, out.status, final);
137
+ }
138
+ catch (e) {
139
+ this.reply(job.id, { status: 0, body: "", error: String(e) });
140
+ this.audit(job, safe, "network-error", started, false, String(e), 0);
141
+ }
142
+ finally {
143
+ this.opts.runtime?.finish(job.id);
144
+ this.active--;
145
+ const next = this.queue.shift();
146
+ if (next) {
147
+ const h = publicHostOf(next.url);
148
+ const nextSafe = safeFetchTarget(next.url);
149
+ if (h && nextSafe)
150
+ void this.run(next, h, nextSafe);
151
+ }
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,62 @@
1
+ import { type RelayEventV2, type RelaySource } from "./audit.js";
2
+ import type { RelayRuntime } from "./runtime.js";
3
+ /**
4
+ * The owner's half of the egress tunnel: a pod asked (via the gateway) for a connection
5
+ * to `host:port`; we make it FROM THIS MACHINE and stream the bytes back.
6
+ *
7
+ * The guard that only this end can enforce: **re-check after DNS**. The pod and gateway
8
+ * can only refuse address *literals* — a hostname that resolves to 127.0.0.1 or
9
+ * 192.168.x (a rebinding trick) gets past them. Here the socket is connected, so the
10
+ * resolved peer address is known: if it turns out to be private, the connection is
11
+ * dropped before a single byte flows. That is the difference between checking a string
12
+ * and checking where the packets actually went.
13
+ *
14
+ * Everything IO is injected (dial, audit, clock) so the routing and the guard are tested
15
+ * without opening a socket.
16
+ */
17
+ export interface TunnelSink {
18
+ send(json: string): void;
19
+ }
20
+ /** The minimum of a net.Socket this module uses — so tests can supply a fake. */
21
+ export interface DialedSocket {
22
+ on(event: "data", cb: (chunk: Buffer) => void): unknown;
23
+ on(event: "close" | "end" | "error", cb: () => void): unknown;
24
+ write(chunk: Buffer): unknown;
25
+ end(): unknown;
26
+ destroy(): unknown;
27
+ remoteAddress?: string;
28
+ }
29
+ export type Dialer = (host: string, port: number, onConnect: (s: DialedSocket) => void, onError: () => void) => DialedSocket;
30
+ export interface TunnelAudit {
31
+ (event: RelayEventV2): void;
32
+ }
33
+ export declare class RelayTunnel {
34
+ private readonly sink;
35
+ private readonly opts;
36
+ private readonly streams;
37
+ constructor(sink: TunnelSink, opts?: {
38
+ dial?: Dialer;
39
+ audit?: TunnelAudit;
40
+ runtime?: RelayRuntime;
41
+ deny?: (source: RelaySource | undefined, host: string) => string | undefined;
42
+ now?: () => number;
43
+ });
44
+ get openCount(): number;
45
+ /** Handle one gateway frame. Returns false if it wasn't a tunnel frame. */
46
+ handle(msg: {
47
+ type?: string;
48
+ id?: string;
49
+ host?: string;
50
+ port?: number;
51
+ b64?: string;
52
+ source?: RelaySource;
53
+ }): boolean;
54
+ private now;
55
+ private audit;
56
+ private refuse;
57
+ private open;
58
+ /** Close a stream. `fromGateway` means the pod hung up, so we don't echo a close back. */
59
+ private dropStream;
60
+ /** Tear everything down (relay stopping / socket lost). */
61
+ closeAll(): void;
62
+ }
@@ -0,0 +1,138 @@
1
+ import { connect } from "node:net";
2
+ import { isDisallowedTarget } from "./net-guard.js";
3
+ import { safeTunnelTarget } from "./audit.js";
4
+ const defaultDial = (host, port, onConnect, onError) => {
5
+ const s = connect({ host, port });
6
+ s.once("connect", () => onConnect(s));
7
+ s.once("error", onError);
8
+ return s;
9
+ };
10
+ export class RelayTunnel {
11
+ sink;
12
+ opts;
13
+ streams = new Map();
14
+ constructor(sink, opts = {}) {
15
+ this.sink = sink;
16
+ this.opts = opts;
17
+ }
18
+ get openCount() {
19
+ return this.streams.size;
20
+ }
21
+ /** Handle one gateway frame. Returns false if it wasn't a tunnel frame. */
22
+ handle(msg) {
23
+ if (!msg.type?.startsWith("tunnel-"))
24
+ return false;
25
+ const id = msg.id;
26
+ if (!id)
27
+ return true;
28
+ switch (msg.type) {
29
+ case "tunnel-open":
30
+ this.open(id, String(msg.host ?? ""), Number(msg.port ?? 0), msg.source);
31
+ return true;
32
+ case "tunnel-data": {
33
+ const st = this.streams.get(id);
34
+ if (st && msg.b64) {
35
+ const chunk = Buffer.from(msg.b64, "base64");
36
+ st.up += chunk.length;
37
+ this.opts.runtime?.addBytes(id, "up", chunk.length);
38
+ st.sock.write(chunk);
39
+ }
40
+ return true;
41
+ }
42
+ case "tunnel-close":
43
+ this.dropStream(id, true);
44
+ return true;
45
+ default:
46
+ return true;
47
+ }
48
+ }
49
+ now() { return (this.opts.now ?? Date.now)(); }
50
+ audit(id, host, port, source, started, outcome, reason, bytesUp = 0, bytesDown = 0) {
51
+ const safe = safeTunnelTarget(host, port) ?? safeTunnelTarget("invalid.invalid", Number.isInteger(port) && port > 0 && port <= 65535 ? port : 1);
52
+ const finished = this.now();
53
+ this.opts.audit?.({
54
+ v: 2,
55
+ id: `tunnel-${id}`,
56
+ startedAt: new Date(started || finished).toISOString(),
57
+ finishedAt: new Date(finished).toISOString(),
58
+ durationMs: started ? Math.max(0, finished - started) : 0,
59
+ source,
60
+ mode: "tunnel",
61
+ outcome,
62
+ target: safe.target,
63
+ host: safe.host,
64
+ session: false,
65
+ bytesUp,
66
+ bytesDown,
67
+ ...(reason ? { reason } : {}),
68
+ });
69
+ }
70
+ refuse(id, host, port, source, outcome, reason, started = 0) {
71
+ this.opts.runtime?.finish(id);
72
+ this.audit(id, host, port, source, started, outcome, reason);
73
+ this.sink.send(JSON.stringify({ type: "tunnel-refused", id, reason }));
74
+ }
75
+ open(id, host, port, source) {
76
+ if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
77
+ return this.refuse(id, host, port, source, "safety-blocked", "bad target");
78
+ }
79
+ const denial = this.opts.deny?.(source, host.toLowerCase().replace(/\.$/, ""));
80
+ if (denial)
81
+ return this.refuse(id, host, port, source, "owner-blocked", denial);
82
+ // Literal check (cheap, and catches the obvious attempt) …
83
+ if (isDisallowedTarget(host))
84
+ return this.refuse(id, host, port, source, "safety-blocked", "target not allowed");
85
+ const started = this.now();
86
+ const safe = safeTunnelTarget(host, port);
87
+ this.opts.runtime?.begin({ id, source, mode: "tunnel", target: safe.target, startedAt: new Date(started).toISOString() });
88
+ const dial = this.opts.dial ?? defaultDial;
89
+ const sock = dial(host, port, (s) => {
90
+ // … and the check only this end can do: where did it ACTUALLY connect? A
91
+ // hostname that resolves into the owner's LAN dies here, before any bytes.
92
+ // Fail CLOSED: if we can't read where it actually connected (falsy remoteAddress), we
93
+ // can't prove it isn't the owner's LAN — so refuse, don't wave it through.
94
+ const peer = s.remoteAddress;
95
+ if (!peer || isDisallowedTarget(peer)) {
96
+ s.destroy();
97
+ this.streams.delete(id);
98
+ return this.refuse(id, host, port, source, "safety-blocked", "resolved to a private address", started);
99
+ }
100
+ this.sink.send(JSON.stringify({ type: "tunnel-ready", id }));
101
+ }, () => {
102
+ this.streams.delete(id);
103
+ this.refuse(id, host, port, source, "network-error", "could not connect", started);
104
+ });
105
+ this.streams.set(id, { sock, host, port, up: 0, down: 0, source, started });
106
+ sock.on("data", (chunk) => {
107
+ const st = this.streams.get(id);
108
+ if (st)
109
+ st.down += chunk.length;
110
+ this.opts.runtime?.addBytes(id, "down", chunk.length);
111
+ this.sink.send(JSON.stringify({ type: "tunnel-data", id, b64: chunk.toString("base64") }));
112
+ });
113
+ sock.on("close", () => this.dropStream(id, false));
114
+ sock.on("error", () => this.dropStream(id, false, "network-error", "remote connection error"));
115
+ }
116
+ /** Close a stream. `fromGateway` means the pod hung up, so we don't echo a close back. */
117
+ dropStream(id, fromGateway, outcome = "ok", reason) {
118
+ const st = this.streams.get(id);
119
+ if (!st)
120
+ return;
121
+ this.streams.delete(id);
122
+ this.opts.runtime?.finish(id);
123
+ try {
124
+ st.sock.end();
125
+ }
126
+ catch {
127
+ /* already gone */
128
+ }
129
+ this.audit(id, st.host, st.port, st.source, st.started, outcome, reason, st.up, st.down);
130
+ if (!fromGateway)
131
+ this.sink.send(JSON.stringify({ type: "tunnel-close", id }));
132
+ }
133
+ /** Tear everything down (relay stopping / socket lost). */
134
+ closeAll() {
135
+ for (const id of [...this.streams.keys()])
136
+ this.dropStream(id, true);
137
+ }
138
+ }
@@ -0,0 +1,41 @@
1
+ import type { RelayMode, RelaySource } from "./audit.js";
2
+ export type GatewayState = "connected" | "reconnecting" | "unavailable";
3
+ export interface ActiveRelayWork {
4
+ id: string;
5
+ source?: RelaySource;
6
+ mode: RelayMode;
7
+ target: string;
8
+ startedAt: string;
9
+ bytesUp?: number;
10
+ bytesDown?: number;
11
+ }
12
+ export interface RelayRuntimeSnapshot {
13
+ daemon: "running" | "stopped";
14
+ gateway: GatewayState;
15
+ stateSince: string;
16
+ updatedAt: string;
17
+ version: string;
18
+ active: ActiveRelayWork[];
19
+ }
20
+ /** In-memory truth owned by the daemon; the dashboard reads snapshots, never state files. */
21
+ export declare class RelayRuntime {
22
+ private readonly options;
23
+ private gateway;
24
+ private stateSince;
25
+ private readonly active;
26
+ constructor(options?: {
27
+ now?: () => number;
28
+ version?: string;
29
+ daemon?: "running" | "stopped";
30
+ });
31
+ private now;
32
+ private iso;
33
+ setGateway(state: GatewayState): void;
34
+ begin(work: Omit<ActiveRelayWork, "startedAt"> & {
35
+ startedAt?: string;
36
+ }): void;
37
+ addBytes(id: string, direction: "up" | "down", count: number): void;
38
+ finish(id: string): ActiveRelayWork | undefined;
39
+ clearActive(): void;
40
+ snapshot(): RelayRuntimeSnapshot;
41
+ }
@@ -0,0 +1,51 @@
1
+ /** In-memory truth owned by the daemon; the dashboard reads snapshots, never state files. */
2
+ export class RelayRuntime {
3
+ options;
4
+ gateway = "reconnecting";
5
+ stateSince;
6
+ active = new Map();
7
+ constructor(options = {}) {
8
+ this.options = options;
9
+ this.stateSince = this.iso();
10
+ }
11
+ now() { return (this.options.now ?? Date.now)(); }
12
+ iso() { return new Date(this.now()).toISOString(); }
13
+ setGateway(state) {
14
+ if (state === this.gateway)
15
+ return;
16
+ this.gateway = state;
17
+ this.stateSince = this.iso();
18
+ }
19
+ begin(work) {
20
+ this.active.set(work.id, {
21
+ ...work,
22
+ startedAt: work.startedAt ?? this.iso(),
23
+ ...(work.mode === "tunnel" ? { bytesUp: work.bytesUp ?? 0, bytesDown: work.bytesDown ?? 0 } : {}),
24
+ });
25
+ }
26
+ addBytes(id, direction, count) {
27
+ const work = this.active.get(id);
28
+ if (!work || work.mode !== "tunnel")
29
+ return;
30
+ if (direction === "up")
31
+ work.bytesUp = (work.bytesUp ?? 0) + Math.max(0, count);
32
+ else
33
+ work.bytesDown = (work.bytesDown ?? 0) + Math.max(0, count);
34
+ }
35
+ finish(id) {
36
+ const work = this.active.get(id);
37
+ this.active.delete(id);
38
+ return work;
39
+ }
40
+ clearActive() { this.active.clear(); }
41
+ snapshot() {
42
+ return {
43
+ daemon: this.options.daemon ?? "running",
44
+ gateway: this.options.daemon === "stopped" ? "unavailable" : this.gateway,
45
+ stateSince: this.stateSince,
46
+ updatedAt: this.iso(),
47
+ version: this.options.version ?? "unknown",
48
+ active: [...this.active.values()].map((work) => ({ ...work, source: work.source ? { ...work.source } : undefined })),
49
+ };
50
+ }
51
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@podway/relay",
3
+ "version": "0.2.3",
4
+ "description": "Podbay relay — runs a fetch relay on your machine so a pod can read pages from your network, using sessions you sign into. Install: npm i -g @podway/relay, then `relay start`.",
5
+ "type": "module",
6
+ "bin": {
7
+ "relay": "./dist/main.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "FAQ.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "test": "vitest run"
22
+ },
23
+ "dependencies": {
24
+ "ws": "^8.18.0",
25
+ "playwright": "^1.49.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.10.7",
29
+ "@types/ws": "^8.5.13",
30
+ "typescript": "^5.7.3",
31
+ "vitest": "^2.1.9"
32
+ },
33
+ "license": "Apache-2.0",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/podway-cloud/relay.git"
37
+ },
38
+ "homepage": "https://github.com/podway-cloud/relay#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/podway-cloud/relay/issues"
41
+ }
42
+ }