@wenrwa/marketplace-cli 0.8.7 → 0.8.9
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/dist/__tests__/bridge-env-names.test.d.ts +1 -0
- package/dist/__tests__/bridge-env-names.test.js +243 -0
- package/dist/__tests__/bridge-preview-checkout.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-checkout.test.js +294 -0
- package/dist/__tests__/bridge-preview-forwarder.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-forwarder.test.js +465 -0
- package/dist/__tests__/bridge-preview-frames.test.d.ts +1 -0
- package/dist/__tests__/bridge-preview-frames.test.js +165 -0
- package/dist/__tests__/bridge-session-anchor-no-identity.test.d.ts +1 -0
- package/dist/__tests__/bridge-session-anchor-no-identity.test.js +74 -0
- package/dist/__tests__/bridge-session-anchor.test.js +10 -2
- package/dist/bridge/envNames.d.ts +26 -0
- package/dist/bridge/envNames.js +114 -0
- package/dist/bridge/previewCheckout.d.ts +136 -0
- package/dist/bridge/previewCheckout.js +677 -0
- package/dist/bridge/previewForwarder.d.ts +116 -0
- package/dist/bridge/previewForwarder.js +323 -0
- package/dist/bridge/previewFrames.d.ts +63 -0
- package/dist/bridge/previewFrames.js +59 -0
- package/dist/bridge/relayClient.d.ts +15 -0
- package/dist/bridge/relayClient.js +36 -0
- package/dist/bridge/server.d.ts +7 -0
- package/dist/bridge/server.js +40 -1
- package/dist/bridge/sessionAnchor.d.ts +9 -0
- package/dist/bridge/sessionAnchor.js +16 -1
- package/dist/commands/bridge.js +31 -0
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tp-06 (docs/prd/team-preview.md module 4, ADR-0014) — the Team preview
|
|
3
|
+
* forwarder: the tenant boundary between the shared preview gateway and the
|
|
4
|
+
* owner's machine.
|
|
5
|
+
*
|
|
6
|
+
* Members reach the owner's running `main` only through the preview gateway
|
|
7
|
+
* (a shared Fly app). The gateway dials this machine over Fly's private
|
|
8
|
+
* network (6PN), which is organisation-wide: every user's machine can reach
|
|
9
|
+
* every other user's machine over it. So this module:
|
|
10
|
+
*
|
|
11
|
+
* - listens on ONE port, on the machine's own 6PN address (never a
|
|
12
|
+
* wildcard), and
|
|
13
|
+
* - forwards a connection ONLY when it comes from the gateway's address
|
|
14
|
+
* (configured as an address or a `.internal` name, resolved and pinned),
|
|
15
|
+
* - and ONLY to `127.0.0.1:<preview port>` — the port the tp-05 preview
|
|
16
|
+
* checkout serves on. The target is fixed at construction; nothing a
|
|
17
|
+
* peer sends can choose another port.
|
|
18
|
+
*
|
|
19
|
+
* Every other connection is destroyed before a byte is read from it. HTTP and
|
|
20
|
+
* WebSocket upgrades both pass, because this is a plain TCP pipe.
|
|
21
|
+
*
|
|
22
|
+
* Modelled on bws-08's companionForwarder.ts, with the same crash discipline:
|
|
23
|
+
* nothing here may throw synchronously, emit an unhandled 'error', or reject
|
|
24
|
+
* unhandled into the bridge's crash handlers (installBridgeCrashHandlers,
|
|
25
|
+
* twr-05).
|
|
26
|
+
*
|
|
27
|
+
* TWO-LANE (ADR-0014): hosted only. The local bridge must never open this
|
|
28
|
+
* listener — a laptop has no 6PN address and opening it to outside traffic
|
|
29
|
+
* would expose the laptop. buildPreviewForwarder returns a no-op whenever
|
|
30
|
+
* there is no `fly-local-6pn` entry, and it is only ever called from the
|
|
31
|
+
* container (`--relay`) branch of commands/bridge.ts.
|
|
32
|
+
*/
|
|
33
|
+
import * as net from 'net';
|
|
34
|
+
/** The fixed port the preview app serves on, on 127.0.0.1 only (tp-05 starts
|
|
35
|
+
* the preview's `start` script with this PORT). Distinct from the usual dev
|
|
36
|
+
* ports so it never collides with the owner's own dev server. */
|
|
37
|
+
export declare const PREVIEW_PORT_DEFAULT = 4800;
|
|
38
|
+
/** The port the forwarder listens on, on the machine's 6PN address. The
|
|
39
|
+
* gateway (tp-08 `target`, tp-10) dials `[<6pn>]:<this>`. */
|
|
40
|
+
export declare const PREVIEW_FORWARDER_PORT_DEFAULT = 4801;
|
|
41
|
+
/** How often a `.internal` gateway name is re-resolved. Between refreshes the
|
|
42
|
+
* allowlist is pinned: no DNS lookup ever happens per connection. */
|
|
43
|
+
export declare const PREVIEW_GATEWAY_REFRESH_MS_DEFAULT: number;
|
|
44
|
+
export interface PreviewPorts {
|
|
45
|
+
previewPort: number;
|
|
46
|
+
forwarderPort: number;
|
|
47
|
+
}
|
|
48
|
+
/** Env knobs `HT_PREVIEW_PORT` / `HT_PREVIEW_FORWARDER_PORT`. The forwarder
|
|
49
|
+
* never shares the preview port: a preview app that (wrongly) binds a
|
|
50
|
+
* wildcard address would otherwise make the bind fail. */
|
|
51
|
+
export declare function resolvePreviewPorts(env?: Record<string, string | undefined>): PreviewPorts;
|
|
52
|
+
/**
|
|
53
|
+
* One canonical spelling per IP so the allowlist compare can't be dodged by
|
|
54
|
+
* formatting: IPv4 as dotted quad, IPv6 fully expanded to eight lower-case
|
|
55
|
+
* 4-digit groups. An IPv4-mapped IPv6 peer (`::ffff:1.2.3.4`) is unwrapped to
|
|
56
|
+
* its IPv4 form, and a zone id (`%eth0`) is dropped. Anything that isn't an
|
|
57
|
+
* IP (a host name, garbage) → null, which never matches.
|
|
58
|
+
*/
|
|
59
|
+
export declare function canonicalAddress(addr: string | null | undefined): string | null;
|
|
60
|
+
export type GatewayLookup = (name: string) => Promise<string[]>;
|
|
61
|
+
export interface PreviewForwarderDeps {
|
|
62
|
+
/** The machine's own 6PN address in production (`fly-local-6pn`); `::1`
|
|
63
|
+
* or `::` in tests. Never a wildcard in production. */
|
|
64
|
+
listenHost: string;
|
|
65
|
+
/** 0 picks an ephemeral port (tests only). */
|
|
66
|
+
listenPort: number;
|
|
67
|
+
/** The ONE port anything is ever forwarded to, always on 127.0.0.1. */
|
|
68
|
+
targetPort: number;
|
|
69
|
+
/** The gateway: IP addresses and/or names (a Fly `.internal` name). Names
|
|
70
|
+
* are resolved at start() and pinned; see refreshMs. */
|
|
71
|
+
allowed: string[];
|
|
72
|
+
lookup?: GatewayLookup;
|
|
73
|
+
/** Re-resolve names this often (a gateway machine replaced by a deploy gets
|
|
74
|
+
* a new 6PN address). A failed refresh keeps the pinned set. */
|
|
75
|
+
refreshMs?: number;
|
|
76
|
+
setIntervalFn?: (cb: () => void, ms: number) => unknown;
|
|
77
|
+
clearIntervalFn?: (handle: unknown) => void;
|
|
78
|
+
net?: Pick<typeof net, 'createServer' | 'createConnection'>;
|
|
79
|
+
/** Reads the peer address off an accepted socket. Injectable so loopback
|
|
80
|
+
* tests can play a 6PN peer. Default: `socket.remoteAddress`. */
|
|
81
|
+
remoteAddressOf?: (socket: net.Socket) => string | undefined;
|
|
82
|
+
onBindError?: (err: Error) => void;
|
|
83
|
+
onResolveError?: (name: string, err: Error) => void;
|
|
84
|
+
}
|
|
85
|
+
export interface PreviewForwarder {
|
|
86
|
+
/** Resolves the allowlist, then binds. Resolves once listening (or once a
|
|
87
|
+
* bind error was reported). Never rejects. */
|
|
88
|
+
start(): Promise<void>;
|
|
89
|
+
/** Closes the listener and stops refreshing. Safe to call more than once. */
|
|
90
|
+
stop(): void;
|
|
91
|
+
/** The bound port, or null when not listening. */
|
|
92
|
+
port(): number | null;
|
|
93
|
+
/** The pinned allowlist, canonical spellings. */
|
|
94
|
+
allowedAddresses(): string[];
|
|
95
|
+
}
|
|
96
|
+
export declare function createPreviewForwarder(deps: PreviewForwarderDeps): PreviewForwarder;
|
|
97
|
+
/** Same interface, zero behaviour — the local lane and unconfigured boxes. */
|
|
98
|
+
export declare function createNoopPreviewForwarder(): PreviewForwarder;
|
|
99
|
+
export interface BuildPreviewForwarderOpts {
|
|
100
|
+
env?: Record<string, string | undefined>;
|
|
101
|
+
/** Reads /etc/hosts. Throwing (no such file) means "not a Fly machine". */
|
|
102
|
+
readHosts: () => string;
|
|
103
|
+
net?: Pick<typeof net, 'createServer' | 'createConnection'>;
|
|
104
|
+
lookup?: GatewayLookup;
|
|
105
|
+
log?: (...args: unknown[]) => void;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The one place the lane decision is made. A real forwarder only when BOTH:
|
|
109
|
+
* - this is a Fly machine (`fly-local-6pn` in /etc/hosts) — the local
|
|
110
|
+
* bridge never has one, so it never binds (ADR-0014: Team preview is
|
|
111
|
+
* hosted-only; a laptop must not be opened to outside traffic), and
|
|
112
|
+
* - `HT_PREVIEW_GATEWAY` names the gateway (comma-separated addresses or
|
|
113
|
+
* `.internal` names). Unset = off: the feature stays inert until the
|
|
114
|
+
* gateway exists (tp-11) and the image is released (tp-12).
|
|
115
|
+
*/
|
|
116
|
+
export declare function buildPreviewForwarder(opts: BuildPreviewForwarderOpts): PreviewForwarder;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.PREVIEW_GATEWAY_REFRESH_MS_DEFAULT = exports.PREVIEW_FORWARDER_PORT_DEFAULT = exports.PREVIEW_PORT_DEFAULT = void 0;
|
|
37
|
+
exports.resolvePreviewPorts = resolvePreviewPorts;
|
|
38
|
+
exports.canonicalAddress = canonicalAddress;
|
|
39
|
+
exports.createPreviewForwarder = createPreviewForwarder;
|
|
40
|
+
exports.createNoopPreviewForwarder = createNoopPreviewForwarder;
|
|
41
|
+
exports.buildPreviewForwarder = buildPreviewForwarder;
|
|
42
|
+
/**
|
|
43
|
+
* tp-06 (docs/prd/team-preview.md module 4, ADR-0014) — the Team preview
|
|
44
|
+
* forwarder: the tenant boundary between the shared preview gateway and the
|
|
45
|
+
* owner's machine.
|
|
46
|
+
*
|
|
47
|
+
* Members reach the owner's running `main` only through the preview gateway
|
|
48
|
+
* (a shared Fly app). The gateway dials this machine over Fly's private
|
|
49
|
+
* network (6PN), which is organisation-wide: every user's machine can reach
|
|
50
|
+
* every other user's machine over it. So this module:
|
|
51
|
+
*
|
|
52
|
+
* - listens on ONE port, on the machine's own 6PN address (never a
|
|
53
|
+
* wildcard), and
|
|
54
|
+
* - forwards a connection ONLY when it comes from the gateway's address
|
|
55
|
+
* (configured as an address or a `.internal` name, resolved and pinned),
|
|
56
|
+
* - and ONLY to `127.0.0.1:<preview port>` — the port the tp-05 preview
|
|
57
|
+
* checkout serves on. The target is fixed at construction; nothing a
|
|
58
|
+
* peer sends can choose another port.
|
|
59
|
+
*
|
|
60
|
+
* Every other connection is destroyed before a byte is read from it. HTTP and
|
|
61
|
+
* WebSocket upgrades both pass, because this is a plain TCP pipe.
|
|
62
|
+
*
|
|
63
|
+
* Modelled on bws-08's companionForwarder.ts, with the same crash discipline:
|
|
64
|
+
* nothing here may throw synchronously, emit an unhandled 'error', or reject
|
|
65
|
+
* unhandled into the bridge's crash handlers (installBridgeCrashHandlers,
|
|
66
|
+
* twr-05).
|
|
67
|
+
*
|
|
68
|
+
* TWO-LANE (ADR-0014): hosted only. The local bridge must never open this
|
|
69
|
+
* listener — a laptop has no 6PN address and opening it to outside traffic
|
|
70
|
+
* would expose the laptop. buildPreviewForwarder returns a no-op whenever
|
|
71
|
+
* there is no `fly-local-6pn` entry, and it is only ever called from the
|
|
72
|
+
* container (`--relay`) branch of commands/bridge.ts.
|
|
73
|
+
*/
|
|
74
|
+
const net = __importStar(require("net"));
|
|
75
|
+
const dns = __importStar(require("dns"));
|
|
76
|
+
const companionForwarder_1 = require("./companionForwarder");
|
|
77
|
+
/** The fixed port the preview app serves on, on 127.0.0.1 only (tp-05 starts
|
|
78
|
+
* the preview's `start` script with this PORT). Distinct from the usual dev
|
|
79
|
+
* ports so it never collides with the owner's own dev server. */
|
|
80
|
+
exports.PREVIEW_PORT_DEFAULT = 4800;
|
|
81
|
+
/** The port the forwarder listens on, on the machine's 6PN address. The
|
|
82
|
+
* gateway (tp-08 `target`, tp-10) dials `[<6pn>]:<this>`. */
|
|
83
|
+
exports.PREVIEW_FORWARDER_PORT_DEFAULT = 4801;
|
|
84
|
+
/** How often a `.internal` gateway name is re-resolved. Between refreshes the
|
|
85
|
+
* allowlist is pinned: no DNS lookup ever happens per connection. */
|
|
86
|
+
exports.PREVIEW_GATEWAY_REFRESH_MS_DEFAULT = 5 * 60_000;
|
|
87
|
+
function parsePort(raw) {
|
|
88
|
+
if (!raw)
|
|
89
|
+
return null;
|
|
90
|
+
const n = Number(raw.trim());
|
|
91
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
|
|
92
|
+
}
|
|
93
|
+
/** Env knobs `HT_PREVIEW_PORT` / `HT_PREVIEW_FORWARDER_PORT`. The forwarder
|
|
94
|
+
* never shares the preview port: a preview app that (wrongly) binds a
|
|
95
|
+
* wildcard address would otherwise make the bind fail. */
|
|
96
|
+
function resolvePreviewPorts(env = process.env) {
|
|
97
|
+
const previewPort = parsePort(env.HT_PREVIEW_PORT) ?? exports.PREVIEW_PORT_DEFAULT;
|
|
98
|
+
let forwarderPort = parsePort(env.HT_PREVIEW_FORWARDER_PORT) ?? exports.PREVIEW_FORWARDER_PORT_DEFAULT;
|
|
99
|
+
if (forwarderPort === previewPort)
|
|
100
|
+
forwarderPort = exports.PREVIEW_FORWARDER_PORT_DEFAULT;
|
|
101
|
+
return { previewPort, forwarderPort };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* One canonical spelling per IP so the allowlist compare can't be dodged by
|
|
105
|
+
* formatting: IPv4 as dotted quad, IPv6 fully expanded to eight lower-case
|
|
106
|
+
* 4-digit groups. An IPv4-mapped IPv6 peer (`::ffff:1.2.3.4`) is unwrapped to
|
|
107
|
+
* its IPv4 form, and a zone id (`%eth0`) is dropped. Anything that isn't an
|
|
108
|
+
* IP (a host name, garbage) → null, which never matches.
|
|
109
|
+
*/
|
|
110
|
+
function canonicalAddress(addr) {
|
|
111
|
+
if (!addr)
|
|
112
|
+
return null;
|
|
113
|
+
let a = addr.trim().toLowerCase();
|
|
114
|
+
const zone = a.indexOf('%');
|
|
115
|
+
if (zone >= 0)
|
|
116
|
+
a = a.slice(0, zone);
|
|
117
|
+
if (a.startsWith('::ffff:') && net.isIPv4(a.slice(7)))
|
|
118
|
+
a = a.slice(7);
|
|
119
|
+
if (net.isIPv4(a))
|
|
120
|
+
return a;
|
|
121
|
+
if (!net.isIPv6(a))
|
|
122
|
+
return null;
|
|
123
|
+
// Turn an embedded IPv4 tail (e.g. `64:ff9b::1.2.3.4`) into two hex groups.
|
|
124
|
+
const lastColon = a.lastIndexOf(':');
|
|
125
|
+
const tail = a.slice(lastColon + 1);
|
|
126
|
+
if (net.isIPv4(tail)) {
|
|
127
|
+
const [b0, b1, b2, b3] = tail.split('.').map(Number);
|
|
128
|
+
a = `${a.slice(0, lastColon + 1)}${((b0 << 8) | b1).toString(16)}:${((b2 << 8) | b3).toString(16)}`;
|
|
129
|
+
}
|
|
130
|
+
const [head, rest] = a.includes('::') ? a.split('::') : [a, null];
|
|
131
|
+
const headGroups = head ? head.split(':') : [];
|
|
132
|
+
const restGroups = rest ? rest.split(':') : [];
|
|
133
|
+
const fill = rest === null ? [] : new Array(8 - headGroups.length - restGroups.length).fill('0');
|
|
134
|
+
const groups = [...headGroups, ...fill, ...restGroups];
|
|
135
|
+
if (groups.length !== 8)
|
|
136
|
+
return null;
|
|
137
|
+
return groups.map((g) => g.padStart(4, '0')).join(':');
|
|
138
|
+
}
|
|
139
|
+
const defaultLookup = async (name) => {
|
|
140
|
+
const results = await dns.promises.lookup(name, { all: true });
|
|
141
|
+
return results.map((r) => r.address);
|
|
142
|
+
};
|
|
143
|
+
function createPreviewForwarder(deps) {
|
|
144
|
+
const netImpl = deps.net ?? net;
|
|
145
|
+
const lookup = deps.lookup ?? defaultLookup;
|
|
146
|
+
const scheduleInterval = deps.setIntervalFn ?? ((cb, ms) => setInterval(cb, ms));
|
|
147
|
+
const cancelInterval = deps.clearIntervalFn ?? ((h) => clearInterval(h));
|
|
148
|
+
const remoteAddressOf = deps.remoteAddressOf ?? ((s) => s.remoteAddress);
|
|
149
|
+
// Fixed here, once: the only destination this module can ever dial.
|
|
150
|
+
const TARGET = Object.freeze({ host: '127.0.0.1', port: deps.targetPort });
|
|
151
|
+
const literal = new Set();
|
|
152
|
+
const names = [];
|
|
153
|
+
for (const entry of deps.allowed) {
|
|
154
|
+
const trimmed = entry.trim();
|
|
155
|
+
if (!trimmed)
|
|
156
|
+
continue;
|
|
157
|
+
const c = canonicalAddress(trimmed);
|
|
158
|
+
if (c)
|
|
159
|
+
literal.add(c);
|
|
160
|
+
else
|
|
161
|
+
names.push(trimmed);
|
|
162
|
+
}
|
|
163
|
+
// The pinned per-name resolutions. Empty until resolved: fail closed.
|
|
164
|
+
const resolved = new Map();
|
|
165
|
+
let allowSet = new Set(literal);
|
|
166
|
+
const rebuild = () => {
|
|
167
|
+
const next = new Set(literal);
|
|
168
|
+
for (const set of resolved.values())
|
|
169
|
+
for (const a of set)
|
|
170
|
+
next.add(a);
|
|
171
|
+
allowSet = next;
|
|
172
|
+
};
|
|
173
|
+
let server = null;
|
|
174
|
+
let refreshHandle = null;
|
|
175
|
+
let stopped = false;
|
|
176
|
+
const resolveNames = async () => {
|
|
177
|
+
await Promise.all(names.map(async (name) => {
|
|
178
|
+
try {
|
|
179
|
+
const addrs = (await lookup(name)).map(canonicalAddress).filter((a) => !!a);
|
|
180
|
+
if (addrs.length > 0)
|
|
181
|
+
resolved.set(name, new Set(addrs));
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
// Keep whatever was pinned before (nothing, on the first try).
|
|
185
|
+
deps.onResolveError?.(name, err instanceof Error ? err : new Error(String(err)));
|
|
186
|
+
}
|
|
187
|
+
}));
|
|
188
|
+
rebuild();
|
|
189
|
+
};
|
|
190
|
+
const onConnection = (socket) => {
|
|
191
|
+
const remote = canonicalAddress(remoteAddressOf(socket));
|
|
192
|
+
if (!remote || !allowSet.has(remote)) {
|
|
193
|
+
// THE tenant-boundary line. An accepted socket is paused, so destroying
|
|
194
|
+
// it here means a refused peer's bytes are never read.
|
|
195
|
+
socket.destroy();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const upstream = netImpl.createConnection({ host: TARGET.host, port: TARGET.port });
|
|
199
|
+
const teardown = () => {
|
|
200
|
+
socket.destroy();
|
|
201
|
+
upstream.destroy();
|
|
202
|
+
};
|
|
203
|
+
// Both legs need an 'error' listener: an unhandled one would throw into
|
|
204
|
+
// the bridge's crash handlers (a preview that's mid-restart is an
|
|
205
|
+
// ordinary ECONNREFUSED, not a crash).
|
|
206
|
+
socket.on('error', teardown);
|
|
207
|
+
upstream.on('error', teardown);
|
|
208
|
+
socket.pipe(upstream);
|
|
209
|
+
upstream.pipe(socket);
|
|
210
|
+
};
|
|
211
|
+
const bind = () => new Promise((resolve) => {
|
|
212
|
+
let s;
|
|
213
|
+
try {
|
|
214
|
+
s = netImpl.createServer(onConnection);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
deps.onBindError?.(err instanceof Error ? err : new Error(String(err)));
|
|
218
|
+
resolve();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
server = s;
|
|
222
|
+
s.on('error', (err) => {
|
|
223
|
+
// Bind failures (EADDRINUSE, EADDRNOTAVAIL) arrive here, async.
|
|
224
|
+
if (server === s)
|
|
225
|
+
server = null;
|
|
226
|
+
deps.onBindError?.(err);
|
|
227
|
+
resolve();
|
|
228
|
+
});
|
|
229
|
+
s.listen(deps.listenPort, deps.listenHost, () => resolve());
|
|
230
|
+
});
|
|
231
|
+
return {
|
|
232
|
+
async start() {
|
|
233
|
+
if (stopped || server)
|
|
234
|
+
return;
|
|
235
|
+
await resolveNames();
|
|
236
|
+
if (stopped)
|
|
237
|
+
return;
|
|
238
|
+
if (names.length > 0 && refreshHandle === null) {
|
|
239
|
+
refreshHandle = scheduleInterval(() => {
|
|
240
|
+
void resolveNames();
|
|
241
|
+
}, deps.refreshMs ?? exports.PREVIEW_GATEWAY_REFRESH_MS_DEFAULT);
|
|
242
|
+
refreshHandle?.unref?.();
|
|
243
|
+
}
|
|
244
|
+
await bind();
|
|
245
|
+
},
|
|
246
|
+
stop() {
|
|
247
|
+
stopped = true;
|
|
248
|
+
if (refreshHandle !== null) {
|
|
249
|
+
cancelInterval(refreshHandle);
|
|
250
|
+
refreshHandle = null;
|
|
251
|
+
}
|
|
252
|
+
const s = server;
|
|
253
|
+
server = null;
|
|
254
|
+
if (s) {
|
|
255
|
+
try {
|
|
256
|
+
s.close();
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
// already closed
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
port() {
|
|
264
|
+
const addr = server?.address();
|
|
265
|
+
return addr && typeof addr === 'object' ? addr.port : null;
|
|
266
|
+
},
|
|
267
|
+
allowedAddresses() {
|
|
268
|
+
return Array.from(allowSet);
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/** Same interface, zero behaviour — the local lane and unconfigured boxes. */
|
|
273
|
+
function createNoopPreviewForwarder() {
|
|
274
|
+
return {
|
|
275
|
+
start: async () => { },
|
|
276
|
+
stop: () => { },
|
|
277
|
+
port: () => null,
|
|
278
|
+
allowedAddresses: () => [],
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The one place the lane decision is made. A real forwarder only when BOTH:
|
|
283
|
+
* - this is a Fly machine (`fly-local-6pn` in /etc/hosts) — the local
|
|
284
|
+
* bridge never has one, so it never binds (ADR-0014: Team preview is
|
|
285
|
+
* hosted-only; a laptop must not be opened to outside traffic), and
|
|
286
|
+
* - `HT_PREVIEW_GATEWAY` names the gateway (comma-separated addresses or
|
|
287
|
+
* `.internal` names). Unset = off: the feature stays inert until the
|
|
288
|
+
* gateway exists (tp-11) and the image is released (tp-12).
|
|
289
|
+
*/
|
|
290
|
+
function buildPreviewForwarder(opts) {
|
|
291
|
+
const env = opts.env ?? process.env;
|
|
292
|
+
const log = opts.log ?? console.log;
|
|
293
|
+
let listenHost = null;
|
|
294
|
+
try {
|
|
295
|
+
listenHost = (0, companionForwarder_1.resolveFlyLocal6pnHost)(opts.readHosts());
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
listenHost = null;
|
|
299
|
+
}
|
|
300
|
+
if (!listenHost) {
|
|
301
|
+
log('[bridge] preview forwarder off: not a hosted machine (ADR-0014)');
|
|
302
|
+
return createNoopPreviewForwarder();
|
|
303
|
+
}
|
|
304
|
+
const allowed = (env.HT_PREVIEW_GATEWAY || '')
|
|
305
|
+
.split(',')
|
|
306
|
+
.map((s) => s.trim())
|
|
307
|
+
.filter(Boolean);
|
|
308
|
+
if (allowed.length === 0) {
|
|
309
|
+
log('[bridge] preview forwarder off: HT_PREVIEW_GATEWAY not set');
|
|
310
|
+
return createNoopPreviewForwarder();
|
|
311
|
+
}
|
|
312
|
+
const { previewPort, forwarderPort } = resolvePreviewPorts(env);
|
|
313
|
+
return createPreviewForwarder({
|
|
314
|
+
listenHost,
|
|
315
|
+
listenPort: forwarderPort,
|
|
316
|
+
targetPort: previewPort,
|
|
317
|
+
allowed,
|
|
318
|
+
lookup: opts.lookup,
|
|
319
|
+
net: opts.net,
|
|
320
|
+
onBindError: (err) => console.error(`[bridge] preview forwarder: failed to bind ${forwarderPort} on ${listenHost}:`, err.message),
|
|
321
|
+
onResolveError: (name, err) => console.error(`[bridge] preview forwarder: cannot resolve ${name}:`, err.message),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tp-05 — the Team preview relay frames (docs/prd/team-preview.md module 5).
|
|
3
|
+
*
|
|
4
|
+
* Server → bridge, each correlated by `requestId` (the `ensure-checkout`
|
|
5
|
+
* pattern). Workspace-addressed, open NO PTY, so — like `ensure-checkout` —
|
|
6
|
+
* they are absent from SESSION_ADDRESSED_FRAMES. None carries a branch: the
|
|
7
|
+
* preview is pinned to the remote's default branch (ADR-0014).
|
|
8
|
+
*
|
|
9
|
+
* preview-start { requestId, workspaceId, repoUrl?, gitCredential?, mode? }
|
|
10
|
+
* preview-update { requestId, workspaceId, repoUrl?, gitCredential?, mode? }
|
|
11
|
+
* preview-stop { requestId, workspaceId }
|
|
12
|
+
* preview-logs { requestId, workspaceId }
|
|
13
|
+
*
|
|
14
|
+
* Bridge → server:
|
|
15
|
+
* preview-result { requestId, workspaceId, phase, ok, status, … }
|
|
16
|
+
* start/update answer `phase: 'accepted'` at once (a build takes minutes;
|
|
17
|
+
* the backend must not hold a request open for it), then `phase: 'done'`
|
|
18
|
+
* with the outcome. stop answers `done` only.
|
|
19
|
+
* preview-logs-result { requestId, workspaceId, ok, log, truncated }
|
|
20
|
+
*
|
|
21
|
+
* LOCAL LANE — scoped out in writing (ADR-0014, two-lane rule). A laptop's
|
|
22
|
+
* bridge listens only on the laptop, laptops sleep, and reaching one from the
|
|
23
|
+
* gateway would mean opening it to the internet. So every preview frame that
|
|
24
|
+
* reaches a local bridge (its `--crew-relay` dial) gets the typed answer
|
|
25
|
+
* `{ error: 'unsupported', reason: 'local_lane' }` — never a silent drop the
|
|
26
|
+
* backend would have to time out on.
|
|
27
|
+
*/
|
|
28
|
+
import type { PreviewMode, PreviewRunner } from './previewCheckout';
|
|
29
|
+
export declare const PREVIEW_FRAME_TYPES: ReadonlySet<string>;
|
|
30
|
+
export type PreviewFrame = {
|
|
31
|
+
t: 'preview-start' | 'preview-update';
|
|
32
|
+
requestId: string;
|
|
33
|
+
workspaceId: string;
|
|
34
|
+
repoUrl?: string;
|
|
35
|
+
gitCredential?: {
|
|
36
|
+
token: string;
|
|
37
|
+
repoFullName?: string;
|
|
38
|
+
expiresAt?: string;
|
|
39
|
+
};
|
|
40
|
+
mode?: PreviewMode;
|
|
41
|
+
} | {
|
|
42
|
+
t: 'preview-stop' | 'preview-logs';
|
|
43
|
+
requestId: string;
|
|
44
|
+
workspaceId: string;
|
|
45
|
+
};
|
|
46
|
+
export type PreviewLane =
|
|
47
|
+
/** The hosted container's bridge with a runner wired. */
|
|
48
|
+
{
|
|
49
|
+
kind: 'hosted';
|
|
50
|
+
runner: PreviewRunner;
|
|
51
|
+
}
|
|
52
|
+
/** A laptop bridge (ADR-0014: out of scope). */
|
|
53
|
+
| {
|
|
54
|
+
kind: 'local';
|
|
55
|
+
}
|
|
56
|
+
/** A hosted bridge that has no runner (should not happen once wired). */
|
|
57
|
+
| {
|
|
58
|
+
kind: 'none';
|
|
59
|
+
};
|
|
60
|
+
type Send = (frame: Record<string, unknown>) => void;
|
|
61
|
+
/** Answer one preview frame. Never throws; every path sends a typed reply. */
|
|
62
|
+
export declare function handlePreviewFrame(frame: PreviewFrame, lane: PreviewLane, send: Send): void;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PREVIEW_FRAME_TYPES = void 0;
|
|
4
|
+
exports.handlePreviewFrame = handlePreviewFrame;
|
|
5
|
+
exports.PREVIEW_FRAME_TYPES = new Set([
|
|
6
|
+
'preview-start',
|
|
7
|
+
'preview-update',
|
|
8
|
+
'preview-stop',
|
|
9
|
+
'preview-logs',
|
|
10
|
+
]);
|
|
11
|
+
function replyType(t) {
|
|
12
|
+
return t === 'preview-logs' ? 'preview-logs-result' : 'preview-result';
|
|
13
|
+
}
|
|
14
|
+
function resultFrame(requestId, phase, r) {
|
|
15
|
+
const ok = r.status === 'running' || r.status === 'stopped' || r.status === 'building';
|
|
16
|
+
return { t: 'preview-result', requestId, phase, ok, ...r };
|
|
17
|
+
}
|
|
18
|
+
/** Answer one preview frame. Never throws; every path sends a typed reply. */
|
|
19
|
+
function handlePreviewFrame(frame, lane, send) {
|
|
20
|
+
const { requestId, workspaceId } = frame;
|
|
21
|
+
const t = replyType(frame.t);
|
|
22
|
+
if (lane.kind === 'local') {
|
|
23
|
+
// ADR-0014 — the local lane is out of scope; say so, typed.
|
|
24
|
+
send({ t, requestId, workspaceId, ok: false, ...(t === 'preview-result' ? { phase: 'done' } : {}), error: 'unsupported', reason: 'local_lane' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (lane.kind === 'none') {
|
|
28
|
+
send({ t, requestId, workspaceId, ok: false, ...(t === 'preview-result' ? { phase: 'done' } : {}), error: 'unsupported', reason: 'bridge-cannot-preview' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const { runner } = lane;
|
|
32
|
+
const failed = (err) => send({
|
|
33
|
+
t: 'preview-result', requestId, workspaceId, phase: 'done', ok: false, status: 'failed',
|
|
34
|
+
message: err instanceof Error ? err.message.slice(0, 200) : 'Preview failed.',
|
|
35
|
+
});
|
|
36
|
+
switch (frame.t) {
|
|
37
|
+
case 'preview-start':
|
|
38
|
+
case 'preview-update': {
|
|
39
|
+
const req = {
|
|
40
|
+
workspaceId,
|
|
41
|
+
repoUrl: frame.repoUrl,
|
|
42
|
+
gitCredential: frame.gitCredential,
|
|
43
|
+
mode: frame.mode === 'dev' ? 'dev' : frame.mode === 'built' ? 'built' : undefined,
|
|
44
|
+
};
|
|
45
|
+
send(resultFrame(requestId, 'accepted', { ...runner.status(workspaceId), status: 'building' }));
|
|
46
|
+
const op = frame.t === 'preview-start' ? runner.start(req) : runner.update(req);
|
|
47
|
+
op.then((r) => send(resultFrame(requestId, 'done', r)), failed);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
case 'preview-stop':
|
|
51
|
+
runner.stop({ workspaceId }).then((r) => send(resultFrame(requestId, 'done', r)), failed);
|
|
52
|
+
return;
|
|
53
|
+
case 'preview-logs': {
|
|
54
|
+
const { log, truncated } = runner.logs({ workspaceId });
|
|
55
|
+
send({ t: 'preview-logs-result', requestId, workspaceId, ok: true, log, truncated });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -10,6 +10,7 @@ import { GitRun } from './changesReporter';
|
|
|
10
10
|
import { buildChangesReport as buildChangesReportReal } from './changesReport';
|
|
11
11
|
import { CheckoutInventoryDeps } from './checkoutInventory';
|
|
12
12
|
import type { CheckoutResult } from './repoCheckout';
|
|
13
|
+
import type { PreviewRunner } from './previewCheckout';
|
|
13
14
|
/**
|
|
14
15
|
* fcct-27 — typed refusal when `resolveLaunch` names a binary this machine does
|
|
15
16
|
* not have. Typed (like CONTAINER_MEMORY_EXHAUSTED) so the conductor renders a
|
|
@@ -144,6 +145,14 @@ export interface RelayClientOpts {
|
|
|
144
145
|
repoUrl?: string;
|
|
145
146
|
gitCredential?: InjectedGitCredential;
|
|
146
147
|
}) => Promise<CheckoutResult>;
|
|
148
|
+
/**
|
|
149
|
+
* tp-05 — the Team preview runner (previewCheckout.ts). Wired on the HOSTED
|
|
150
|
+
* container bridge only. When `onlyRelayOwnedSessions` is set (a laptop's
|
|
151
|
+
* crew-relay dial) every preview frame is answered `unsupported /
|
|
152
|
+
* local_lane` regardless (ADR-0014 scopes the local lane out); absent on a
|
|
153
|
+
* hosted bridge → `unsupported / bridge-cannot-preview`.
|
|
154
|
+
*/
|
|
155
|
+
preview?: PreviewRunner;
|
|
147
156
|
/**
|
|
148
157
|
* fcct-27 — what a relay-opened session actually SPAWNS.
|
|
149
158
|
*
|
|
@@ -330,6 +339,12 @@ export interface RelayClientOpts {
|
|
|
330
339
|
ok: false;
|
|
331
340
|
reason: 'not-found' | 'invalid-agent-id' | 'too-large' | 'transcript-unavailable';
|
|
332
341
|
};
|
|
342
|
+
/**
|
|
343
|
+
* tp-02 — resolve a workspace id to the checkout root whose `.env` names
|
|
344
|
+
* the Keys list shows (bridge.ts: `<reposBaseDir>/<workspaceId>`). Absent →
|
|
345
|
+
* an `env-names` frame gets a typed refusal, never a hang.
|
|
346
|
+
*/
|
|
347
|
+
resolveEnvNamesRoot?: (workspaceId: string) => string | null | undefined;
|
|
333
348
|
/** Injectable for tests: defaults to `new WebSocket(url)` from `ws`. */
|
|
334
349
|
wsFactory?: (url: string) => WebSocketLike;
|
|
335
350
|
backoff?: BackoffOpts;
|
|
@@ -114,6 +114,9 @@ const diskPreflight_1 = require("./diskPreflight");
|
|
|
114
114
|
const checkoutParkAndRemove_1 = require("./checkoutParkAndRemove");
|
|
115
115
|
const attachmentWriter_1 = require("./attachmentWriter");
|
|
116
116
|
const envSaver_1 = require("./envSaver");
|
|
117
|
+
const envNames_1 = require("./envNames");
|
|
118
|
+
// tp-05 — Team preview frames (ADR-0014). See previewFrames.ts.
|
|
119
|
+
const previewFrames_1 = require("./previewFrames");
|
|
117
120
|
const PROTOCOL_VERSION = 1;
|
|
118
121
|
/**
|
|
119
122
|
* fcct-27 — typed refusal when `resolveLaunch` names a binary this machine does
|
|
@@ -1624,6 +1627,23 @@ function startRelayClient(opts) {
|
|
|
1624
1627
|
.catch(() => send(socket, { t: 'transcript-content', requestId, workspaceId, ok: false, reason: 'transcript-unavailable' }));
|
|
1625
1628
|
break;
|
|
1626
1629
|
}
|
|
1630
|
+
case 'env-names': {
|
|
1631
|
+
// tp-02: the NAMES in this workspace's `.env`, never a value —
|
|
1632
|
+
// envNames.ts parses on this machine and replies names only.
|
|
1633
|
+
// Never logged. requestId echoed so the backend can correlate.
|
|
1634
|
+
if (!opts.resolveEnvNamesRoot) {
|
|
1635
|
+
send(socket, {
|
|
1636
|
+
t: 'env-names-result',
|
|
1637
|
+
requestId: frame.requestId,
|
|
1638
|
+
workspaceId: frame.workspaceId,
|
|
1639
|
+
ok: false,
|
|
1640
|
+
reason: 'bridge-cannot-read-env-names',
|
|
1641
|
+
});
|
|
1642
|
+
break;
|
|
1643
|
+
}
|
|
1644
|
+
send(socket, (0, envNames_1.handleEnvNamesFrame)(frame, opts.resolveEnvNamesRoot));
|
|
1645
|
+
break;
|
|
1646
|
+
}
|
|
1627
1647
|
case 'browser-open': {
|
|
1628
1648
|
// cbs-02: a headless Chromium session, driven over CDP. This
|
|
1629
1649
|
// branch NEVER touches opts.sessions / SessionManager — a
|
|
@@ -1827,6 +1847,22 @@ function startRelayClient(opts) {
|
|
|
1827
1847
|
browserDrivers.get(frame.sessionId)?.resume().catch(() => { });
|
|
1828
1848
|
browserDrivers.get(frame.sessionId)?.reload().catch(() => { });
|
|
1829
1849
|
break;
|
|
1850
|
+
case 'preview-start':
|
|
1851
|
+
case 'preview-update':
|
|
1852
|
+
case 'preview-stop':
|
|
1853
|
+
case 'preview-logs':
|
|
1854
|
+
// tp-05 — ADR-0014: a laptop bridge (the crew-relay dial, the only
|
|
1855
|
+
// relay client with onlyRelayOwnedSessions) is the local lane, and
|
|
1856
|
+
// the local lane is out of scope — typed `unsupported`, never a drop.
|
|
1857
|
+
(0, previewFrames_1.handlePreviewFrame)(frame, opts.onlyRelayOwnedSessions
|
|
1858
|
+
? { kind: 'local' }
|
|
1859
|
+
: opts.preview
|
|
1860
|
+
? { kind: 'hosted', runner: opts.preview }
|
|
1861
|
+
: { kind: 'none' },
|
|
1862
|
+
// A build outlives a relay reconnect: answer on the LIVE socket,
|
|
1863
|
+
// not the one the request arrived on.
|
|
1864
|
+
(reply) => send(ws ?? socket, reply));
|
|
1865
|
+
break;
|
|
1830
1866
|
case 'companion-allow': {
|
|
1831
1867
|
// bws-08 — session-less tenant-boundary control frame (see
|
|
1832
1868
|
// companionFrames.ts's doc comment). Validated with the runtime
|
package/dist/bridge/server.d.ts
CHANGED
|
@@ -74,6 +74,13 @@ export interface BridgeServerOpts {
|
|
|
74
74
|
ok: false;
|
|
75
75
|
reason: 'not-found' | 'outside-root' | 'too-large' | 'binary' | 'conflict';
|
|
76
76
|
} | null>;
|
|
77
|
+
/**
|
|
78
|
+
* tp-02 — resolve a REGISTERED local workspace id to its project folder, for
|
|
79
|
+
* GET /local-env-names/:workspaceId (the Keys list: `.env` names, never
|
|
80
|
+
* values — envNames.ts). null → 404 (not registered on this device). Its
|
|
81
|
+
* presence is what /discovery advertises as the `env-names` capability.
|
|
82
|
+
*/
|
|
83
|
+
resolveEnvNamesRoot?: (workspaceId: string) => string | null | undefined;
|
|
77
84
|
/** lws-05: the device name to advertise on /discovery (arena renders "✓
|
|
78
85
|
* Bridge connected — <device>"). Absent/null when the device is not yet
|
|
79
86
|
* registered — the modal falls back to a generic connected label. */
|