@webstew/bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -0
- package/bin/webstew-bridge +2 -0
- package/dist/auth.d.ts +25 -0
- package/dist/auth.js +142 -0
- package/dist/claude-runner.d.ts +10 -0
- package/dist/claude-runner.js +532 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +162 -0
- package/dist/http.d.ts +14 -0
- package/dist/http.js +56 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +20 -0
- package/dist/protocol.d.ts +151 -0
- package/dist/protocol.js +50 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +113 -0
- package/package.json +27 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// @webstew/bridge CLI entry. Registered as `bin` so users run
|
|
4
|
+
//
|
|
5
|
+
// Shebang note: we point to `tsx` (TypeScript loader for Node) so the
|
|
6
|
+
// raw .ts files in src/ can be executed without a build step during
|
|
7
|
+
// monorepo dev + initial install. When we cut a real npm release we'll
|
|
8
|
+
// compile to dist/ and the published shebang will go back to plain
|
|
9
|
+
// `#!/usr/bin/env node`. `env -S` is needed on macOS so env passes the
|
|
10
|
+
// multi-arg command intact.
|
|
11
|
+
// `npx @webstew/bridge <subcommand>` (or `webstew-bridge` if installed
|
|
12
|
+
// globally) without a node-modules dance.
|
|
13
|
+
//
|
|
14
|
+
// Subcommands:
|
|
15
|
+
// connect <code> pair with a Webstew workspace, then start the bridge
|
|
16
|
+
// status print connection state from local config
|
|
17
|
+
// logout forget the saved pairing token
|
|
18
|
+
// --help, -h print usage
|
|
19
|
+
// --version, -v print bridge + protocol versions
|
|
20
|
+
//
|
|
21
|
+
// Persistent auth lives at ~/.webstew/bridge.json (0600 perms). See
|
|
22
|
+
// auth.ts. The CLI does NOT manage Claude Code's OAuth — that's owned
|
|
23
|
+
// by Claude Code itself; the bridge just spawns `claude --print …` and
|
|
24
|
+
// inherits whatever auth Claude Code is using.
|
|
25
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
|
+
};
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
30
|
+
const protocol_1 = require("./protocol");
|
|
31
|
+
const auth_1 = require("./auth");
|
|
32
|
+
const runtime_1 = require("./runtime");
|
|
33
|
+
const BRIDGE_VERSION = (() => {
|
|
34
|
+
try {
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
|
+
return require('../package.json').version;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return '0.0.0-dev';
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
const DEFAULT_SERVER_URL = process.env.WEBSTEW_SERVER_URL || 'https://webstew.net';
|
|
43
|
+
const HELP = `webstew-bridge — your kitchen line to Webstew. Cooks every order on
|
|
44
|
+
your installed Claude Code so your Pro/Max subscription picks up the tab.
|
|
45
|
+
|
|
46
|
+
Usage:
|
|
47
|
+
webstew-bridge connect Resume using saved session (after restarts).
|
|
48
|
+
webstew-bridge connect <code> Hire the chef. Get the code from
|
|
49
|
+
/integrations → "Connect Local Bridge".
|
|
50
|
+
webstew-bridge status Peek at the pass.
|
|
51
|
+
webstew-bridge logout Hang up the apron.
|
|
52
|
+
webstew-bridge --help This menu.
|
|
53
|
+
webstew-bridge --version Bridge + protocol versions.
|
|
54
|
+
|
|
55
|
+
Environment:
|
|
56
|
+
WEBSTEW_SERVER_URL Override the server URL (defaults to https://webstew.net).
|
|
57
|
+
Set to http://localhost:3000 to pair with a local dev workspace.
|
|
58
|
+
|
|
59
|
+
Keep this terminal open — that's the kitchen. Send orders from your
|
|
60
|
+
Webstew workspace and the chef cooks them on your subscription.
|
|
61
|
+
`;
|
|
62
|
+
function log(msg) {
|
|
63
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
64
|
+
process.stdout.write(`[${ts}] ${msg}\n`);
|
|
65
|
+
}
|
|
66
|
+
async function cmdConnect(code) {
|
|
67
|
+
// No code supplied — try to resume using stored token (survives dev-server restarts).
|
|
68
|
+
if (!code) {
|
|
69
|
+
const existing = (0, auth_1.loadAuth)();
|
|
70
|
+
if (!existing) {
|
|
71
|
+
process.stderr.write('error: no pairing code and no stored session.\n usage: webstew-bridge connect <code>\n Get a code from /integrations → "Connect Local Bridge".\n');
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
log(`resuming session bridgeId=${existing.bridgeId} — no new code needed`);
|
|
75
|
+
await (0, runtime_1.startBridge)({ ctx: { serverUrl: existing.serverUrl, pairingToken: existing.pairingToken }, log });
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
// Exchange the code for a long-lived pairingToken via /api/bridge/pair.
|
|
79
|
+
log(`pairing with ${DEFAULT_SERVER_URL}…`);
|
|
80
|
+
const body = {
|
|
81
|
+
code,
|
|
82
|
+
hostname: node_os_1.default.hostname() || 'unknown',
|
|
83
|
+
bridgeVersion: BRIDGE_VERSION,
|
|
84
|
+
protocolVersion: protocol_1.PROTOCOL_VERSION,
|
|
85
|
+
};
|
|
86
|
+
let res;
|
|
87
|
+
try {
|
|
88
|
+
res = await fetch(DEFAULT_SERVER_URL.replace(/\/$/, '') + protocol_1.BRIDGE_ROUTES.pairExchange, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify(body),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
process.stderr.write(`error: cannot reach ${DEFAULT_SERVER_URL}: ${e.message}\n`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
let detail = '';
|
|
100
|
+
try {
|
|
101
|
+
detail = (await res.json())?.error || '';
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
process.stderr.write(`error: pairing failed (HTTP ${res.status}): ${detail || 'no details'}\n`);
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
const out = (await res.json());
|
|
108
|
+
(0, auth_1.saveAuth)({
|
|
109
|
+
serverUrl: DEFAULT_SERVER_URL,
|
|
110
|
+
bridgeId: out.bridgeId,
|
|
111
|
+
pairingToken: out.pairingToken,
|
|
112
|
+
pairedAt: new Date().toISOString(),
|
|
113
|
+
});
|
|
114
|
+
log(`paired ✓ bridgeId=${out.bridgeId}`);
|
|
115
|
+
log(`chef hired — keep this terminal open, the kitchen needs the line`);
|
|
116
|
+
// Start the long-poll loop. Never returns.
|
|
117
|
+
await (0, runtime_1.startBridge)({
|
|
118
|
+
ctx: { serverUrl: DEFAULT_SERVER_URL, pairingToken: out.pairingToken },
|
|
119
|
+
log,
|
|
120
|
+
});
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
async function cmdStatus() {
|
|
124
|
+
const auth = (0, auth_1.loadAuth)();
|
|
125
|
+
if (!auth) {
|
|
126
|
+
process.stdout.write('not paired. Run: webstew-bridge connect <code>\n');
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
process.stdout.write(`paired with ${auth.serverUrl}\n` +
|
|
130
|
+
` bridgeId: ${auth.bridgeId}\n` +
|
|
131
|
+
` paired at: ${auth.pairedAt}\n` +
|
|
132
|
+
`\nrun \`webstew-bridge connect\` to resume (no code needed).\n`);
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
function cmdLogout() {
|
|
136
|
+
const removed = (0, auth_1.clearAuth)();
|
|
137
|
+
process.stdout.write(removed ? 'pairing token removed\n' : 'no pairing token to remove\n');
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
async function main(argv) {
|
|
141
|
+
const [, , sub, ...rest] = argv;
|
|
142
|
+
if (!sub || sub === '--help' || sub === '-h' || sub === 'help') {
|
|
143
|
+
process.stdout.write(HELP);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
if (sub === '--version' || sub === '-v' || sub === 'version') {
|
|
147
|
+
process.stdout.write(`@webstew/bridge ${BRIDGE_VERSION} (protocol ${protocol_1.PROTOCOL_VERSION})\n`);
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
if (sub === 'connect')
|
|
151
|
+
return cmdConnect(rest[0] || '');
|
|
152
|
+
if (sub === 'status')
|
|
153
|
+
return cmdStatus();
|
|
154
|
+
if (sub === 'logout')
|
|
155
|
+
return cmdLogout();
|
|
156
|
+
process.stderr.write(`error: unknown subcommand "${sub}"\n\n${HELP}`);
|
|
157
|
+
return 2;
|
|
158
|
+
}
|
|
159
|
+
main(process.argv).then((code) => process.exit(code), (err) => {
|
|
160
|
+
process.stderr.write(`bridge fatal: ${err?.stack || err}\n`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
});
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class HttpError extends Error {
|
|
2
|
+
status: number;
|
|
3
|
+
body?: unknown | undefined;
|
|
4
|
+
constructor(status: number, message: string, body?: unknown | undefined);
|
|
5
|
+
}
|
|
6
|
+
export interface HttpContext {
|
|
7
|
+
serverUrl: string;
|
|
8
|
+
pairingToken: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function pollOnce<T>(ctx: HttpContext, signal?: AbortSignal): Promise<T>;
|
|
11
|
+
export declare function postResponse(ctx: HttpContext, body: unknown): Promise<{
|
|
12
|
+
accepted: boolean;
|
|
13
|
+
}>;
|
|
14
|
+
export declare function postHeartbeat(ctx: HttpContext): Promise<void>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Thin fetch wrapper. Just enough to: (a) attach the bearer token, (b)
|
|
3
|
+
// retry transient network errors on poll/heartbeat without giving up,
|
|
4
|
+
// (c) surface server errors with readable messages. Uses Node 18+
|
|
5
|
+
// built-in fetch — no node-fetch dep.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.HttpError = void 0;
|
|
8
|
+
exports.pollOnce = pollOnce;
|
|
9
|
+
exports.postResponse = postResponse;
|
|
10
|
+
exports.postHeartbeat = postHeartbeat;
|
|
11
|
+
const protocol_1 = require("./protocol");
|
|
12
|
+
class HttpError extends Error {
|
|
13
|
+
status;
|
|
14
|
+
body;
|
|
15
|
+
constructor(status, message, body) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.body = body;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
exports.HttpError = HttpError;
|
|
22
|
+
async function call(ctx, method, pathStr, body, init) {
|
|
23
|
+
const url = ctx.serverUrl.replace(/\/$/, '') + pathStr;
|
|
24
|
+
const headers = {
|
|
25
|
+
Authorization: `Bearer ${ctx.pairingToken}`,
|
|
26
|
+
'x-webstew-bridge-protocol': protocol_1.PROTOCOL_VERSION,
|
|
27
|
+
};
|
|
28
|
+
if (body !== undefined)
|
|
29
|
+
headers['Content-Type'] = 'application/json';
|
|
30
|
+
const res = await fetch(url, {
|
|
31
|
+
method,
|
|
32
|
+
headers,
|
|
33
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
34
|
+
...init,
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = await res.json();
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
throw new HttpError(res.status, parsed?.error || `${method} ${pathStr} → HTTP ${res.status}`, parsed);
|
|
43
|
+
}
|
|
44
|
+
// Some endpoints (poll) may return null body via {empty:true}; parse
|
|
45
|
+
// JSON unconditionally — server always returns JSON.
|
|
46
|
+
return (await res.json());
|
|
47
|
+
}
|
|
48
|
+
async function pollOnce(ctx, signal) {
|
|
49
|
+
return call(ctx, 'GET', protocol_1.BRIDGE_ROUTES.poll, undefined, { signal });
|
|
50
|
+
}
|
|
51
|
+
async function postResponse(ctx, body) {
|
|
52
|
+
return call(ctx, 'POST', protocol_1.BRIDGE_ROUTES.respond, body);
|
|
53
|
+
}
|
|
54
|
+
async function postHeartbeat(ctx) {
|
|
55
|
+
await call(ctx, 'POST', protocol_1.BRIDGE_ROUTES.heartbeat, {});
|
|
56
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './protocol';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Public surface of @webstew/bridge. Webstew's app imports from here to
|
|
3
|
+
// get the protocol types; standalone bridge users get the same types if
|
|
4
|
+
// they want to script around the CLI.
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
17
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
__exportStar(require("./protocol"), exports);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export declare const PROTOCOL_VERSION = "1.0.0";
|
|
2
|
+
export declare const POLL_TIMEOUT_MS = 25000;
|
|
3
|
+
export declare const HEARTBEAT_INTERVAL_MS = 15000;
|
|
4
|
+
export declare const BRIDGE_OFFLINE_AFTER_MS = 60000;
|
|
5
|
+
export interface PairingCodeResponse {
|
|
6
|
+
/** Short human-friendly code the user pastes into the CLI. */
|
|
7
|
+
code: string;
|
|
8
|
+
/** Seconds until the code expires (typically 600). */
|
|
9
|
+
expiresInSec: number;
|
|
10
|
+
/** Origin the bridge should connect to (e.g. https://webstew.net). */
|
|
11
|
+
serverUrl: string;
|
|
12
|
+
}
|
|
13
|
+
export interface PairingExchangeRequest {
|
|
14
|
+
code: string;
|
|
15
|
+
/** Free-form label so users can tell bridges apart in the UI. */
|
|
16
|
+
hostname: string;
|
|
17
|
+
bridgeVersion: string;
|
|
18
|
+
protocolVersion: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PairingExchangeResponse {
|
|
21
|
+
/** Long-lived auth token. Bridge sends as `Authorization: Bearer …`. */
|
|
22
|
+
pairingToken: string;
|
|
23
|
+
/** Server-assigned ID for this bridge — surfaces in UI + logs. */
|
|
24
|
+
bridgeId: string;
|
|
25
|
+
}
|
|
26
|
+
export interface BridgeStatus {
|
|
27
|
+
connected: boolean;
|
|
28
|
+
/** ISO timestamp of the bridge's last poll/heartbeat. */
|
|
29
|
+
lastSeenAt?: string;
|
|
30
|
+
bridgeId?: string;
|
|
31
|
+
hostname?: string;
|
|
32
|
+
bridgeVersion?: string;
|
|
33
|
+
/** Number of in-flight agent requests currently queued or running. */
|
|
34
|
+
inFlightRequests: number;
|
|
35
|
+
}
|
|
36
|
+
export type BridgeRequest = {
|
|
37
|
+
kind: 'agent.run';
|
|
38
|
+
requestId: string;
|
|
39
|
+
payload: AgentRunRequest;
|
|
40
|
+
};
|
|
41
|
+
export interface AgentRunRequest {
|
|
42
|
+
prompt: string;
|
|
43
|
+
/** Project VFS snapshot — same shape the existing /api/builder/agent
|
|
44
|
+
* route accepts. Bridge replays edits against this map. */
|
|
45
|
+
files: Record<string, string>;
|
|
46
|
+
history?: Array<{
|
|
47
|
+
role: 'user' | 'assistant';
|
|
48
|
+
content: any;
|
|
49
|
+
}>;
|
|
50
|
+
/** 'claude-opus-4-7' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-…' —
|
|
51
|
+
* bridge maps to the local CLI's --model flag. Empty = bridge picks
|
|
52
|
+
* its default. */
|
|
53
|
+
model?: string;
|
|
54
|
+
target?: 'website' | 'nextjs' | 'react' | 'astro' | 'expo';
|
|
55
|
+
/** Webstew project id — bridge echoes back on file_update events so
|
|
56
|
+
* the server can persist the same way it does for direct Anthropic. */
|
|
57
|
+
projectId?: string;
|
|
58
|
+
/** Cap on tool-use iterations. Server passes the agent route's
|
|
59
|
+
* configured limit so bridge enforcement matches direct flow. */
|
|
60
|
+
maxIterations?: number;
|
|
61
|
+
}
|
|
62
|
+
export type BridgeResponse = {
|
|
63
|
+
requestId: string;
|
|
64
|
+
kind: 'text';
|
|
65
|
+
data: {
|
|
66
|
+
text: string;
|
|
67
|
+
};
|
|
68
|
+
} | {
|
|
69
|
+
requestId: string;
|
|
70
|
+
kind: 'tool_use';
|
|
71
|
+
data: {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
input: any;
|
|
75
|
+
};
|
|
76
|
+
} | {
|
|
77
|
+
requestId: string;
|
|
78
|
+
kind: 'tool_result';
|
|
79
|
+
data: {
|
|
80
|
+
tool_use_id: string;
|
|
81
|
+
ok: boolean;
|
|
82
|
+
content: string;
|
|
83
|
+
};
|
|
84
|
+
} | {
|
|
85
|
+
requestId: string;
|
|
86
|
+
kind: 'file_update';
|
|
87
|
+
data: {
|
|
88
|
+
path: string;
|
|
89
|
+
contents: string;
|
|
90
|
+
};
|
|
91
|
+
} | {
|
|
92
|
+
requestId: string;
|
|
93
|
+
kind: 'file_delete';
|
|
94
|
+
data: {
|
|
95
|
+
path: string;
|
|
96
|
+
};
|
|
97
|
+
} | {
|
|
98
|
+
requestId: string;
|
|
99
|
+
kind: 'done';
|
|
100
|
+
data: {
|
|
101
|
+
summary: string;
|
|
102
|
+
iterations: number;
|
|
103
|
+
};
|
|
104
|
+
} | {
|
|
105
|
+
requestId: string;
|
|
106
|
+
kind: 'error';
|
|
107
|
+
data: {
|
|
108
|
+
message: string;
|
|
109
|
+
};
|
|
110
|
+
} | {
|
|
111
|
+
requestId: string;
|
|
112
|
+
kind: 'workspace.switch_target';
|
|
113
|
+
data: {
|
|
114
|
+
target: string;
|
|
115
|
+
reason: string;
|
|
116
|
+
};
|
|
117
|
+
} | {
|
|
118
|
+
requestId: string;
|
|
119
|
+
kind: 'workspace.open_panel';
|
|
120
|
+
data: {
|
|
121
|
+
panel: string;
|
|
122
|
+
reason: string;
|
|
123
|
+
};
|
|
124
|
+
} | {
|
|
125
|
+
requestId: string;
|
|
126
|
+
kind: 'permission_request';
|
|
127
|
+
data: {
|
|
128
|
+
permissionId: string;
|
|
129
|
+
action: string;
|
|
130
|
+
title: string;
|
|
131
|
+
description: string;
|
|
132
|
+
approveLabel?: string;
|
|
133
|
+
denyLabel?: string;
|
|
134
|
+
meta?: Record<string, unknown>;
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
export declare const BRIDGE_ROUTES: {
|
|
138
|
+
readonly pairInit: "/api/bridge/pair-init";
|
|
139
|
+
readonly pairExchange: "/api/bridge/pair";
|
|
140
|
+
readonly poll: "/api/bridge/poll";
|
|
141
|
+
readonly respond: "/api/bridge/respond";
|
|
142
|
+
readonly heartbeat: "/api/bridge/heartbeat";
|
|
143
|
+
readonly status: "/api/bridge/status";
|
|
144
|
+
readonly disconnect: "/api/bridge/disconnect";
|
|
145
|
+
};
|
|
146
|
+
/** Polled when the queue is empty. Distinct from `null` so the bridge
|
|
147
|
+
* can re-poll immediately without ambiguity around timeouts. */
|
|
148
|
+
export interface EmptyPoll {
|
|
149
|
+
empty: true;
|
|
150
|
+
}
|
|
151
|
+
export type PollResponse = BridgeRequest | EmptyPoll;
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Wire protocol shared by the Webstew server and the @webstew/bridge CLI.
|
|
3
|
+
// Both sides import these types so any change to the contract is a
|
|
4
|
+
// compile-time break. Keep this file deps-free (pure types + tiny consts)
|
|
5
|
+
// so the bridge CLI bundle stays under ~50KB.
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle of one agent turn:
|
|
8
|
+
// 1. User submits chat in /workspace → POST /api/builder/agent on Webstew.
|
|
9
|
+
// 2. Webstew detects the user has an active bridge (per BridgeStatus) and
|
|
10
|
+
// enqueues a BridgeRequest in the per-user queue instead of calling
|
|
11
|
+
// Anthropic directly. The HTTP handler holds the response open (SSE).
|
|
12
|
+
// 3. The local bridge process is long-polling GET /api/bridge/poll; it
|
|
13
|
+
// receives the BridgeRequest within a few hundred ms.
|
|
14
|
+
// 4. Bridge spawns Claude Agent SDK (or `claude -p`) using the user's
|
|
15
|
+
// already-authenticated local OAuth — this is the whole point: their
|
|
16
|
+
// Pro/Max subscription, not API-rate billing.
|
|
17
|
+
// 5. As the agent streams, bridge POSTs each chunk to
|
|
18
|
+
// /api/bridge/respond as a BridgeResponse. Webstew forwards each
|
|
19
|
+
// chunk straight to the waiting SSE consumer (the workspace chat).
|
|
20
|
+
// 6. Bridge sends a `done` (or `error`) BridgeResponse to close.
|
|
21
|
+
//
|
|
22
|
+
// Versioning: this is v1. Breaking changes bump PROTOCOL_VERSION; the
|
|
23
|
+
// server rejects bridges on an older major version with a clear error
|
|
24
|
+
// telling the user to `npm i -g @webstew/bridge@latest`.
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.BRIDGE_ROUTES = exports.BRIDGE_OFFLINE_AFTER_MS = exports.HEARTBEAT_INTERVAL_MS = exports.POLL_TIMEOUT_MS = exports.PROTOCOL_VERSION = void 0;
|
|
27
|
+
exports.PROTOCOL_VERSION = '1.0.0';
|
|
28
|
+
// How long the bridge holds a poll open before returning empty so it can
|
|
29
|
+
// re-issue. Tuned for: (a) Render's default 30s edge timeout, (b) fast
|
|
30
|
+
// reconnect after server restarts. 25s gives 5s headroom under Render's
|
|
31
|
+
// limit. Bridge should re-poll immediately on empty return.
|
|
32
|
+
exports.POLL_TIMEOUT_MS = 25_000;
|
|
33
|
+
// How often the bridge sends a tiny heartbeat ping while idle, so the
|
|
34
|
+
// server knows the connection is live. Drives BridgeStatus.lastSeen.
|
|
35
|
+
exports.HEARTBEAT_INTERVAL_MS = 15_000;
|
|
36
|
+
// Server treats a bridge as offline if no poll/heartbeat in this long.
|
|
37
|
+
// Generous so brief network blips don't flap the UI status.
|
|
38
|
+
exports.BRIDGE_OFFLINE_AFTER_MS = 60_000;
|
|
39
|
+
// ── HTTP shapes ───────────────────────────────────────────────────────
|
|
40
|
+
// Concrete endpoint paths. Centralized so both sides import the same
|
|
41
|
+
// strings; typos become impossible.
|
|
42
|
+
exports.BRIDGE_ROUTES = {
|
|
43
|
+
pairInit: '/api/bridge/pair-init', // user → server, requires session
|
|
44
|
+
pairExchange: '/api/bridge/pair', // bridge → server, body: PairingExchangeRequest
|
|
45
|
+
poll: '/api/bridge/poll', // bridge → server, long-poll, returns BridgeRequest | null
|
|
46
|
+
respond: '/api/bridge/respond', // bridge → server, body: BridgeResponse
|
|
47
|
+
heartbeat: '/api/bridge/heartbeat', // bridge → server, idle keepalive
|
|
48
|
+
status: '/api/bridge/status', // UI → server, requires session
|
|
49
|
+
disconnect: '/api/bridge/disconnect', // UI → server, revokes token
|
|
50
|
+
};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Bridge runtime — the main loop after `connect` succeeds.
|
|
3
|
+
//
|
|
4
|
+
// Two concurrent activities:
|
|
5
|
+
// 1. Long-poll for work: hit /api/bridge/poll, on receiving a request
|
|
6
|
+
// dispatch it to the claude runner, stream events back via
|
|
7
|
+
// /api/bridge/respond. On empty, re-poll immediately.
|
|
8
|
+
// 2. Heartbeat: every HEARTBEAT_INTERVAL_MS while we're not in the
|
|
9
|
+
// middle of an active long-poll, POST /api/bridge/heartbeat to
|
|
10
|
+
// keep BridgeStatus.lastSeenAt fresh on the server.
|
|
11
|
+
//
|
|
12
|
+
// Concurrency: we process ONE request at a time per bridge (sequential
|
|
13
|
+
// poll → run → respond → poll). A Claude run can take 30-120s; running
|
|
14
|
+
// two in parallel would race the local Claude Code session. If a user
|
|
15
|
+
// needs parallel runs they can run multiple bridges.
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.startBridge = startBridge;
|
|
18
|
+
const http_1 = require("./http");
|
|
19
|
+
const protocol_1 = require("./protocol");
|
|
20
|
+
const claude_runner_1 = require("./claude-runner");
|
|
21
|
+
async function startBridge(opts) {
|
|
22
|
+
const { ctx, log } = opts;
|
|
23
|
+
log('kitchen open — waiting for orders 🥘');
|
|
24
|
+
// Heartbeat in a separate tick so it doesn't compete with polls.
|
|
25
|
+
// Errors are non-fatal — server's authenticate* already touches
|
|
26
|
+
// lastSeenAt on every poll, so missed heartbeats are cosmetic.
|
|
27
|
+
let lastHeartbeatAt = Date.now();
|
|
28
|
+
const heartbeatTick = setInterval(() => {
|
|
29
|
+
if (Date.now() - lastHeartbeatAt < protocol_1.HEARTBEAT_INTERVAL_MS)
|
|
30
|
+
return;
|
|
31
|
+
(0, http_1.postHeartbeat)(ctx).catch(() => { }).finally(() => {
|
|
32
|
+
lastHeartbeatAt = Date.now();
|
|
33
|
+
});
|
|
34
|
+
}, 5_000);
|
|
35
|
+
// Main long-poll loop. Never returns.
|
|
36
|
+
for (;;) {
|
|
37
|
+
let work;
|
|
38
|
+
try {
|
|
39
|
+
work = await (0, http_1.pollOnce)(ctx);
|
|
40
|
+
lastHeartbeatAt = Date.now(); // poll counts as activity
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e instanceof http_1.HttpError && e.status === 401) {
|
|
44
|
+
clearInterval(heartbeatTick);
|
|
45
|
+
log(`apron revoked (${e.message}). The kitchen disconnected this chef from the floor. Re-pair to get back on the line.`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
// Transient network blip — back off briefly and retry.
|
|
49
|
+
log(`waiter call dropped: ${e.message} — reaching back in 3s`);
|
|
50
|
+
await sleep(3_000);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if ('empty' in work && work.empty) {
|
|
54
|
+
// Server timed out the long-poll with no work. Re-poll
|
|
55
|
+
// immediately — this is the steady-state idle path.
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// We have an agent.run request. Send to claude.
|
|
59
|
+
const req = work;
|
|
60
|
+
log(`👨🍳 new order ${req.requestId.slice(0, 10)}… table=${req.payload.projectId || 'walk-in'}`);
|
|
61
|
+
const t0 = Date.now();
|
|
62
|
+
// Per-request AbortController — flips the moment the server-side
|
|
63
|
+
// response stream is gone (we get a 410 on POST /respond). The
|
|
64
|
+
// signal is passed into runClaudeOnce so it SIGTERMs the spawned
|
|
65
|
+
// claude child, stopping subscription token burn the moment the
|
|
66
|
+
// user clicks Stop in the workspace UI.
|
|
67
|
+
const runAbort = new AbortController();
|
|
68
|
+
try {
|
|
69
|
+
await (0, claude_runner_1.runClaudeOnce)({
|
|
70
|
+
request: req.payload,
|
|
71
|
+
requestId: req.requestId,
|
|
72
|
+
signal: runAbort.signal,
|
|
73
|
+
onEvent: (chunk) => (0, http_1.postResponse)(ctx, chunk).then(() => { }).catch((e) => {
|
|
74
|
+
// 410 = server has no open request anymore (timeout / user
|
|
75
|
+
// navigated away). Abort the running claude child so we
|
|
76
|
+
// stop burning subscription tokens, then reject with
|
|
77
|
+
// BridgeCancelled so the runtime loop logs it and moves on.
|
|
78
|
+
if (e instanceof http_1.HttpError && e.status === 410) {
|
|
79
|
+
try {
|
|
80
|
+
runAbort.abort();
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
return Promise.reject(new BridgeCancelled());
|
|
84
|
+
}
|
|
85
|
+
log(`respond failed: ${e.message}`);
|
|
86
|
+
return Promise.reject(e);
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
log(`🍽️ served ${req.requestId.slice(0, 10)}… ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
if (e instanceof BridgeCancelled) {
|
|
93
|
+
log(`🗑️ order tossed ${req.requestId.slice(0, 10)}… diner walked out`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const msg = e.message || String(e);
|
|
97
|
+
log(`🔥 burnt ${req.requestId.slice(0, 10)}… ${msg}`);
|
|
98
|
+
// Try to surface the error in the chat — but tolerate the case
|
|
99
|
+
// where the server already gave up.
|
|
100
|
+
await (0, http_1.postResponse)(ctx, {
|
|
101
|
+
requestId: req.requestId,
|
|
102
|
+
kind: 'error',
|
|
103
|
+
data: { message: `Bridge error: ${msg}` },
|
|
104
|
+
}).catch(() => { });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
class BridgeCancelled extends Error {
|
|
109
|
+
constructor() { super('cancelled'); }
|
|
110
|
+
}
|
|
111
|
+
function sleep(ms) {
|
|
112
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
113
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webstew/bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local bridge — run Webstew workspace AI against your Claude Code Pro/Max subscription instead of API credits.",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"webstew-bridge": "./bin/webstew-bridge"
|
|
9
|
+
},
|
|
10
|
+
"files": ["dist/", "bin/", "README.md"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc && node -e \"const fs=require('fs'),f='dist/cli.js';let c=fs.readFileSync(f,'utf8');c=c.replace(/^#!.*\\n/,'');fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);fs.chmodSync(f,0o755)\"",
|
|
13
|
+
"prepack": "npm run build",
|
|
14
|
+
"dev": "tsx src/cli.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"tsx": "^4.7.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^20.11.16",
|
|
21
|
+
"typescript": "^5.3.3"
|
|
22
|
+
},
|
|
23
|
+
"engines": { "node": ">=18" },
|
|
24
|
+
"repository": { "type": "git", "url": "https://github.com/SGK112/ai-website-builder" },
|
|
25
|
+
"keywords": ["webstew", "claude", "bridge", "ai", "website-builder"],
|
|
26
|
+
"license": "MIT"
|
|
27
|
+
}
|