@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.
- package/FAQ.md +87 -0
- package/LICENSE +17 -0
- package/README.md +84 -0
- package/dist/audit.d.ts +109 -0
- package/dist/audit.js +339 -0
- package/dist/browser-fetcher.d.ts +58 -0
- package/dist/browser-fetcher.js +132 -0
- package/dist/colors.d.ts +14 -0
- package/dist/colors.js +25 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.js +99 -0
- package/dist/dashboard.d.ts +67 -0
- package/dist/dashboard.js +394 -0
- package/dist/disclosure.d.ts +8 -0
- package/dist/disclosure.js +22 -0
- package/dist/domain.d.ts +9 -0
- package/dist/domain.js +20 -0
- package/dist/heartbeat.d.ts +48 -0
- package/dist/heartbeat.js +77 -0
- package/dist/keep-awake.d.ts +14 -0
- package/dist/keep-awake.js +38 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +480 -0
- package/dist/net-guard.d.ts +9 -0
- package/dist/net-guard.js +47 -0
- package/dist/relay-client.d.ts +66 -0
- package/dist/relay-client.js +154 -0
- package/dist/relay-tunnel.d.ts +62 -0
- package/dist/relay-tunnel.js +138 -0
- package/dist/runtime.d.ts +41 -0
- package/dist/runtime.js +51 -0
- package/package.json +42 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
function hostOf(url) {
|
|
2
|
+
try {
|
|
3
|
+
return new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
4
|
+
}
|
|
5
|
+
catch {
|
|
6
|
+
return "";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class BrowserFetcher {
|
|
10
|
+
opts;
|
|
11
|
+
browser = null;
|
|
12
|
+
session = null; // persistent profile (login domains)
|
|
13
|
+
served = 0;
|
|
14
|
+
constructor(opts) {
|
|
15
|
+
this.opts = opts;
|
|
16
|
+
}
|
|
17
|
+
async pw() {
|
|
18
|
+
return (await import("playwright").catch(() => {
|
|
19
|
+
throw new Error("Playwright missing. The relay bundles it; reinstall: npx @podbay/relay@latest");
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Launch, preferring the owner's installed Chrome (no download) and falling back to
|
|
24
|
+
* Playwright's bundled Chromium. A missing browser entirely is a clear, actionable
|
|
25
|
+
* error, not a stack trace.
|
|
26
|
+
*/
|
|
27
|
+
async launch(fn) {
|
|
28
|
+
const base = { headless: this.opts.headless ?? true, viewport: { width: 1440, height: 900 } };
|
|
29
|
+
try {
|
|
30
|
+
return await fn({ ...base, channel: this.opts.channel ?? "chrome" });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
try {
|
|
34
|
+
return await fn(base); // bundled chromium
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
throw new Error(`no browser found. Install one for the relay: npx playwright install chromium (${String(e).slice(0, 80)})`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async sessionCtx() {
|
|
42
|
+
if (this.session)
|
|
43
|
+
return this.session;
|
|
44
|
+
const pw = await this.pw();
|
|
45
|
+
this.session = (await this.launch((o) => pw.chromium.launchPersistentContext(this.opts.profileDir, o)));
|
|
46
|
+
return this.session;
|
|
47
|
+
}
|
|
48
|
+
async cleanCtx() {
|
|
49
|
+
if (!this.browser) {
|
|
50
|
+
const pw = await this.pw();
|
|
51
|
+
this.browser = (await this.launch((o) => pw.chromium.launch(o)));
|
|
52
|
+
}
|
|
53
|
+
return this.browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* WATCHDOG: a fetch that stalls (a site refusing a headless browser, a hung context,
|
|
57
|
+
* a wedged persistent profile) must NEVER hang the relay — that was the whole
|
|
58
|
+
* "relay running but nothing comes back" failure (live-caught on reddit, 2026-08-03).
|
|
59
|
+
* The nav timeout only bounds `goto`; launch, `content()`, and context creation could
|
|
60
|
+
* hang unbounded. So the whole fetch races a hard deadline, and on a blown deadline we
|
|
61
|
+
* TEAR DOWN the browser so the NEXT fetch relaunches clean rather than inheriting a
|
|
62
|
+
* wedged one.
|
|
63
|
+
*/
|
|
64
|
+
fetch = async (job) => {
|
|
65
|
+
const host = hostOf(job.url);
|
|
66
|
+
const useSession = this.opts.isSessionDomain(host);
|
|
67
|
+
const deadlineMs = this.opts.fetchDeadlineMs ?? 25_000;
|
|
68
|
+
let timer;
|
|
69
|
+
const deadline = new Promise((_, reject) => {
|
|
70
|
+
timer = setTimeout(() => reject(new Error(`relay fetch timed out after ${deadlineMs}ms`)), deadlineMs);
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
return await Promise.race([this.doFetch(job, useSession), deadline]);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
await this.resetBrowser(useSession); // a hung/errored browser must not poison the next fetch
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
if (timer)
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
async doFetch(job, useSession) {
|
|
85
|
+
const ctx = useSession ? await this.sessionCtx() : await this.cleanCtx();
|
|
86
|
+
const page = await ctx.newPage();
|
|
87
|
+
try {
|
|
88
|
+
const res = await page.goto(job.url, { waitUntil: "domcontentloaded", timeout: this.opts.navTimeoutMs ?? 20_000 });
|
|
89
|
+
await page.waitForTimeout(1500);
|
|
90
|
+
const out = { status: res?.status() ?? 0, body: await page.content(), finalUrl: page.url(), session: useSession };
|
|
91
|
+
this.served++;
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await page.close().catch(() => undefined);
|
|
96
|
+
// Ephemeral contexts are closed immediately (each is single-use); the persistent
|
|
97
|
+
// session stays warm. Recycle the shared clean browser after N pages.
|
|
98
|
+
if (!useSession)
|
|
99
|
+
await ctx.close().catch(() => undefined);
|
|
100
|
+
if (this.served >= (this.opts.recycleAfter ?? 200) && this.browser) {
|
|
101
|
+
const b = this.browser;
|
|
102
|
+
this.browser = null;
|
|
103
|
+
this.served = 0;
|
|
104
|
+
await b.close().catch(() => undefined);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Drop the wedged context so the next fetch launches a fresh one. */
|
|
109
|
+
async resetBrowser(useSession) {
|
|
110
|
+
if (useSession) {
|
|
111
|
+
const s = this.session;
|
|
112
|
+
this.session = null;
|
|
113
|
+
await s?.close?.().catch(() => undefined);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const b = this.browser;
|
|
117
|
+
this.browser = null;
|
|
118
|
+
this.served = 0;
|
|
119
|
+
await b?.close?.().catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Remove only one site's relay-profile cookies; clean contexts and siblings stay intact. */
|
|
123
|
+
async revokeDomain(domain) {
|
|
124
|
+
const escaped = domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
125
|
+
const ctx = await this.sessionCtx();
|
|
126
|
+
await ctx.clearCookies({ domain: new RegExp(`(^|\\.)${escaped}$`, "i") });
|
|
127
|
+
}
|
|
128
|
+
async close() {
|
|
129
|
+
await this.session?.close().catch(() => undefined);
|
|
130
|
+
await this.browser?.close?.().catch(() => undefined);
|
|
131
|
+
}
|
|
132
|
+
}
|
package/dist/colors.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const c: {
|
|
2
|
+
bold: (s: string) => string;
|
|
3
|
+
dim: (s: string) => string;
|
|
4
|
+
green: (s: string) => string;
|
|
5
|
+
cyan: (s: string) => string;
|
|
6
|
+
yellow: (s: string) => string;
|
|
7
|
+
red: (s: string) => string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Render aligned "command — description" rows: the command padded to a common width
|
|
11
|
+
* and coloured, the description dimmed, so a help block reads as a table instead of a
|
|
12
|
+
* wall of text.
|
|
13
|
+
*/
|
|
14
|
+
export declare function rows(pairs: [cmd: string, desc: string][], color?: (s: string) => string): string;
|
package/dist/colors.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ANSI styling for the CLI's human-facing output.
|
|
3
|
+
*
|
|
4
|
+
* Off automatically when stdout is not a TTY (piped/redirected) or when NO_COLOR is
|
|
5
|
+
* set — so `pb ... | cat` and CI logs stay clean, never littered with escape codes.
|
|
6
|
+
*/
|
|
7
|
+
const enabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
8
|
+
const wrap = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
9
|
+
export const c = {
|
|
10
|
+
bold: wrap("1"),
|
|
11
|
+
dim: wrap("2"),
|
|
12
|
+
green: wrap("32"),
|
|
13
|
+
cyan: wrap("36"),
|
|
14
|
+
yellow: wrap("33"),
|
|
15
|
+
red: wrap("31"),
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Render aligned "command — description" rows: the command padded to a common width
|
|
19
|
+
* and coloured, the description dimmed, so a help block reads as a table instead of a
|
|
20
|
+
* wall of text.
|
|
21
|
+
*/
|
|
22
|
+
export function rows(pairs, color = c.cyan) {
|
|
23
|
+
const w = Math.max(...pairs.map(([cmd]) => cmd.length));
|
|
24
|
+
return pairs.map(([cmd, desc]) => ` ${color(cmd.padEnd(w))} ${c.dim(desc)}`).join("\n");
|
|
25
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay's state on the owner's machine. Disk IS the shared state between the
|
|
3
|
+
* background daemon and the `login`/`dashboard`/`stop` commands — no IPC needed: login
|
|
4
|
+
* writes here and the daemon re-reads it per fetch; dashboard reads the audit log; stop
|
|
5
|
+
* reads the pidfile. Simpler and more robust than a socket.
|
|
6
|
+
*/
|
|
7
|
+
export interface RelayConfig {
|
|
8
|
+
gatewayUrl?: string;
|
|
9
|
+
/** Domains the owner explicitly signed into — these, and only these, are fetched
|
|
10
|
+
* with their session. Everything else is a clean, cookieless fetch. */
|
|
11
|
+
loginDomains: string[];
|
|
12
|
+
consentedAt?: string;
|
|
13
|
+
/** Durable reconnect token, handed to us at pairing. We reconnect with THIS (not the
|
|
14
|
+
* one-time code) so a gateway restart or network blip does not force re-pairing. */
|
|
15
|
+
reconnectToken?: string;
|
|
16
|
+
/** Explicit local denials. Clean public-web access remains open by default. */
|
|
17
|
+
pausedPodIds?: string[];
|
|
18
|
+
blockedDomains?: string[];
|
|
19
|
+
retentionDays?: 7 | 30 | 90;
|
|
20
|
+
/** Opt-in (`relay start --keep-awake`): prevent the Mac from idle-sleeping while the relay runs,
|
|
21
|
+
* for a host meant to stay online 24/7. Persisted so it survives restarts. Default off. */
|
|
22
|
+
keepAwake?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare const profileDir: () => string;
|
|
25
|
+
export declare const pidFile: () => string;
|
|
26
|
+
export declare const auditFile: () => string;
|
|
27
|
+
export declare const eventsDir: () => string;
|
|
28
|
+
export declare const dashboardRuntimeFile: () => string;
|
|
29
|
+
export declare function load(): RelayConfig;
|
|
30
|
+
export declare function save(cfg: RelayConfig): void;
|
|
31
|
+
export declare function addLoginDomain(domain: string): void;
|
|
32
|
+
export declare function setPodPaused(podId: string, paused: boolean): RelayConfig;
|
|
33
|
+
export declare function setDomainBlocked(domain: string, blocked: boolean): RelayConfig;
|
|
34
|
+
export declare function setRetentionDays(days: number): RelayConfig;
|
|
35
|
+
export declare function revokeLoginDomain(domain: string): RelayConfig;
|
|
36
|
+
export declare function isPodPaused(cfg: RelayConfig, podId?: string): boolean;
|
|
37
|
+
export declare function isDomainBlocked(cfg: RelayConfig, host: string): boolean;
|
|
38
|
+
export declare function resetProfile(): void;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const dir = () => process.env.PB_RELAY_HOME ?? path.join(homedir(), ".podway", "relay");
|
|
5
|
+
const file = () => path.join(dir(), "config.json");
|
|
6
|
+
export const profileDir = () => path.join(dir(), "profile");
|
|
7
|
+
export const pidFile = () => path.join(dir(), "relay.pid");
|
|
8
|
+
export const auditFile = () => path.join(dir(), "fetch-audit.jsonl");
|
|
9
|
+
export const eventsDir = () => path.join(dir(), "events");
|
|
10
|
+
export const dashboardRuntimeFile = () => path.join(dir(), "dashboard.json");
|
|
11
|
+
function defaults(value = {}) {
|
|
12
|
+
return {
|
|
13
|
+
...value,
|
|
14
|
+
loginDomains: Array.isArray(value.loginDomains) ? value.loginDomains : [],
|
|
15
|
+
pausedPodIds: Array.isArray(value.pausedPodIds) ? value.pausedPodIds : [],
|
|
16
|
+
blockedDomains: Array.isArray(value.blockedDomains) ? value.blockedDomains : [],
|
|
17
|
+
retentionDays: value.retentionDays === 7 || value.retentionDays === 90 ? value.retentionDays : 30,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function load() {
|
|
21
|
+
try {
|
|
22
|
+
return defaults(JSON.parse(readFileSync(file(), "utf8")));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return defaults();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function save(cfg) {
|
|
29
|
+
mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
30
|
+
writeFileSync(file(), JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
31
|
+
}
|
|
32
|
+
export function addLoginDomain(domain) {
|
|
33
|
+
const cfg = load();
|
|
34
|
+
if (!cfg.loginDomains.includes(domain))
|
|
35
|
+
cfg.loginDomains.push(domain);
|
|
36
|
+
save(cfg);
|
|
37
|
+
}
|
|
38
|
+
const normalizedDomain = (value) => value.trim().toLowerCase().replace(/^\.+|\.+$/g, "");
|
|
39
|
+
const normalizedPodId = (value) => value.trim().slice(0, 160);
|
|
40
|
+
export function setPodPaused(podId, paused) {
|
|
41
|
+
const cfg = load();
|
|
42
|
+
const id = normalizedPodId(podId);
|
|
43
|
+
if (!id)
|
|
44
|
+
return cfg;
|
|
45
|
+
const values = new Set(cfg.pausedPodIds ?? []);
|
|
46
|
+
if (paused)
|
|
47
|
+
values.add(id);
|
|
48
|
+
else
|
|
49
|
+
values.delete(id);
|
|
50
|
+
cfg.pausedPodIds = [...values].sort();
|
|
51
|
+
save(cfg);
|
|
52
|
+
return cfg;
|
|
53
|
+
}
|
|
54
|
+
export function setDomainBlocked(domain, blocked) {
|
|
55
|
+
const cfg = load();
|
|
56
|
+
const value = normalizedDomain(domain);
|
|
57
|
+
if (!value || /[\s/:@?#]/.test(value))
|
|
58
|
+
return cfg;
|
|
59
|
+
const values = new Set(cfg.blockedDomains ?? []);
|
|
60
|
+
if (blocked)
|
|
61
|
+
values.add(value);
|
|
62
|
+
else
|
|
63
|
+
values.delete(value);
|
|
64
|
+
cfg.blockedDomains = [...values].sort();
|
|
65
|
+
save(cfg);
|
|
66
|
+
return cfg;
|
|
67
|
+
}
|
|
68
|
+
export function setRetentionDays(days) {
|
|
69
|
+
if (days !== 7 && days !== 30 && days !== 90)
|
|
70
|
+
throw new Error("retention must be 7, 30, or 90 days");
|
|
71
|
+
const cfg = load();
|
|
72
|
+
cfg.retentionDays = days;
|
|
73
|
+
save(cfg);
|
|
74
|
+
return cfg;
|
|
75
|
+
}
|
|
76
|
+
export function revokeLoginDomain(domain) {
|
|
77
|
+
const value = normalizedDomain(domain);
|
|
78
|
+
const cfg = load();
|
|
79
|
+
cfg.loginDomains = cfg.loginDomains.filter((item) => item !== value);
|
|
80
|
+
save(cfg);
|
|
81
|
+
return cfg;
|
|
82
|
+
}
|
|
83
|
+
export function isPodPaused(cfg, podId) {
|
|
84
|
+
return !!podId && (cfg.pausedPodIds ?? []).includes(podId);
|
|
85
|
+
}
|
|
86
|
+
export function isDomainBlocked(cfg, host) {
|
|
87
|
+
return (cfg.blockedDomains ?? []).some((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
88
|
+
}
|
|
89
|
+
export function resetProfile() {
|
|
90
|
+
if (existsSync(profileDir()))
|
|
91
|
+
rmSync(profileDir(), { recursive: true, force: true });
|
|
92
|
+
const cfg = load();
|
|
93
|
+
cfg.loginDomains = [];
|
|
94
|
+
// Forget the reconnect token too — reset means fully un-pair; the next start needs a
|
|
95
|
+
// fresh code. (The gateway-side token expires on its own; a security revoke is a
|
|
96
|
+
// separate operation.)
|
|
97
|
+
delete cfg.reconnectToken;
|
|
98
|
+
save(cfg);
|
|
99
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { RelayEventStore } from "./audit.js";
|
|
2
|
+
import { type RelayConfig } from "./config.js";
|
|
3
|
+
import { RelayRuntime } from "./runtime.js";
|
|
4
|
+
export interface DashboardEvent {
|
|
5
|
+
id: string;
|
|
6
|
+
at: string;
|
|
7
|
+
podId?: string;
|
|
8
|
+
/** Gateway-resolved display name; the UI shows this in place of podId when present. */
|
|
9
|
+
podName?: string;
|
|
10
|
+
mode: "fetch" | "tunnel";
|
|
11
|
+
outcome: "ok" | "site-refused" | "owner-blocked" | "safety-blocked" | "rate-limited" | "network-error";
|
|
12
|
+
target: string;
|
|
13
|
+
host: string;
|
|
14
|
+
status?: number;
|
|
15
|
+
ms: number;
|
|
16
|
+
session: boolean;
|
|
17
|
+
bytesUp?: number;
|
|
18
|
+
bytesDown?: number;
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface DashboardData {
|
|
22
|
+
preview: boolean;
|
|
23
|
+
state: "connected" | "reconnecting" | "stopped";
|
|
24
|
+
stateSince: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
events: DashboardEvent[];
|
|
27
|
+
active: Array<{
|
|
28
|
+
id: string;
|
|
29
|
+
podId?: string;
|
|
30
|
+
podName?: string;
|
|
31
|
+
mode: "fetch" | "tunnel";
|
|
32
|
+
target: string;
|
|
33
|
+
startedAt: string;
|
|
34
|
+
bytesUp?: number;
|
|
35
|
+
bytesDown?: number;
|
|
36
|
+
}>;
|
|
37
|
+
signedInSites: string[];
|
|
38
|
+
blockedSites: string[];
|
|
39
|
+
pausedPods: string[];
|
|
40
|
+
retentionDays: 7 | 30 | 90;
|
|
41
|
+
storagePath: string;
|
|
42
|
+
storageBytes: number;
|
|
43
|
+
trend: number[];
|
|
44
|
+
}
|
|
45
|
+
export type DashboardFixture = "active" | "empty" | "healthy" | "failures" | "signed-in" | "legacy" | "stopped" | "large";
|
|
46
|
+
export declare function fixtureData(name: DashboardFixture, now?: number): DashboardData;
|
|
47
|
+
export interface DashboardActions {
|
|
48
|
+
stop?(): void | Promise<void>;
|
|
49
|
+
revokeSession?(domain: string): void | Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export interface DashboardOptions {
|
|
52
|
+
preview?: boolean;
|
|
53
|
+
fixture?: DashboardFixture;
|
|
54
|
+
readOnly?: boolean;
|
|
55
|
+
store?: RelayEventStore;
|
|
56
|
+
runtime?: RelayRuntime;
|
|
57
|
+
config?: () => RelayConfig;
|
|
58
|
+
actions?: DashboardActions;
|
|
59
|
+
routeToken?: string;
|
|
60
|
+
csrfToken?: string;
|
|
61
|
+
}
|
|
62
|
+
export declare function serveDashboard(port?: number, options?: DashboardOptions): Promise<{
|
|
63
|
+
url: string;
|
|
64
|
+
close: () => void;
|
|
65
|
+
routeToken: string;
|
|
66
|
+
csrfToken: string;
|
|
67
|
+
}>;
|