-
Notifications
You must be signed in to change notification settings - Fork 595
Expand file tree
/
Copy pathrun-dev-server.js
More file actions
331 lines (290 loc) · 13.5 KB
/
Copy pathrun-dev-server.js
File metadata and controls
331 lines (290 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
#!/usr/bin/env node
// Generates dev-only wrangler.dev.jsonc files for dynamic service bindings,
// then launches `wrangler dev` with all discovered workers.
//
// Flags:
// --use-workers-ai-binding Include the Workers AI binding in
// workshop-backend (requires Cloudflare login).
//
// Env:
// VITE_BACKEND_HOST=localhost:9000 Also pass --port 9000 to wrangler dev.
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { execFileSync, spawn } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "jsonc-parser";
import { getWranglerPortFromBackendHost } from "./scripts/dev-server-config.js";
const ROOT = dirname(fileURLToPath(import.meta.url));
const PACKAGES_DIR = join(ROOT, "packages");
const WORKSHOP_BACKEND_DIR = join(PACKAGES_DIR, "workshop-backend");
// Load a root `.dev.vars` file (KEY=VALUE lines) into process.env for local development. Existing
// shell environment values take precedence. This file is gitignored and may hold local secrets.
function loadDevVars() {
const path = join(ROOT, ".dev.vars");
if (!existsSync(path)) return;
for (const rawLine of readFileSync(path, "utf8").split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
// Strip surrounding single or double quotes.
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
}
loadDevVars();
const useWorkersAi = process.argv.includes("--use-workers-ai-binding");
// Generate the format blueprint module before Wrangler tries to bundle the backend. The output is
// gitignored, so it will not exist on a clean checkout.
execFileSync(
process.execPath,
[join(WORKSHOP_BACKEND_DIR, "scripts", "build-format-blueprints.mjs")],
{ stdio: "inherit", cwd: WORKSHOP_BACKEND_DIR },
);
// In `run-local` mode the backend serves the pre-built frontend bundle as static assets (there is no
// Vite dev server). In normal dev mode we leave assets unconfigured so the frontend is served by
// Vite on :3000 and no `vite build` is required to start the dev server.
const serveFrontendAssets = process.argv.includes("--serve-frontend-assets");
// ---------------------------------------------------------------------------
// Discover gatekeeper packages.
// ---------------------------------------------------------------------------
function findGatekeepers(parentDir) {
try {
return readdirSync(parentDir)
.filter(name => name.startsWith("gatekeeper-"))
.filter(name => {
try {
return statSync(join(parentDir, name, "wrangler.jsonc")).isFile();
} catch {
return false;
}
})
.map(name => ({ name, dir: join(parentDir, name) }));
} catch {
return [];
}
}
const gatekeepers = findGatekeepers(PACKAGES_DIR);
// The Context Library (packages/gatekeeper-context) is discovered by findGatekeepers and bound
// like any other gatekeeper (GATEKEEPER_CONTEXT -> GatekeeperVendor). Its describe() reports
// autoProvisionsAccount, so core auto-provisions one Context account per user. The only extra
// wiring it needs is a sharingDomain in its binding props (see below).
const CONTEXT_GATEKEEPER_NAME = "gatekeeper-context";
// Rebuild each gatekeeper's generated UI (src/generated/*) on source change so edits show up on
// reload; wrangler dev's `watch_dir: src` then re-bundles the worker.
const devWatchers = [];
let stoppingDevWatchers = false;
// Spawn a persistent watcher.
function spawnDevWatcher(label, command, args) {
const watcher = spawn(command, args, { stdio: "inherit", cwd: ROOT });
watcher.on("exit", (code, signal) => {
if (stoppingDevWatchers) return;
console.error(`${label} exited unexpectedly (code=${code}, signal=${signal}).`);
});
devWatchers.push(watcher);
}
for (const gk of gatekeepers) {
// Configurator UI (compiled by build-gatekeeper-configurator.mjs).
if (existsSync(join(gk.dir, "src", "configurator"))) {
const script = join(ROOT, "scripts", "build-gatekeeper-configurator.mjs");
execFileSync(process.execPath, [script, gk.dir, "--quiet"], { stdio: "inherit", cwd: ROOT });
spawnDevWatcher(
`configurator UI watcher for ${gk.name}`,
process.execPath,
[script, gk.dir, "--watch", "--quiet"],
);
}
// Single-file app UI (Vite bundle written to src/generated/app.txt by build-app.mjs).
if (existsSync(join(gk.dir, "build-app.mjs"))) {
const script = join(gk.dir, "build-app.mjs");
execFileSync(process.execPath, [script], { stdio: "inherit", cwd: gk.dir });
spawnDevWatcher(`app UI watcher for ${gk.name}`, process.execPath, [script, "--watch"]);
}
}
function stopDevWatchers() {
stoppingDevWatchers = true;
for (const watcher of devWatchers) watcher.kill();
}
process.on("exit", stopDevWatchers);
process.on("SIGINT", () => {
stopDevWatchers();
process.exit(130);
});
process.on("SIGTERM", () => {
stopDevWatchers();
process.exit(143);
});
// Helper: "gatekeeper-github" -> "GATEKEEPER_GITHUB"
function bindingName(gk) {
return gk.name.toUpperCase().replaceAll("-", "_");
}
// ---------------------------------------------------------------------------
// Generate wrangler.dev.jsonc (dev-router with gatekeeper service bindings).
// ---------------------------------------------------------------------------
{
const srcPath = join(ROOT, "wrangler.jsonc");
const config = parse(readFileSync(srcPath, "utf8"));
config.services = config.services || [];
for (const gk of gatekeepers) {
config.services.push({ binding: bindingName(gk), service: gk.name });
}
const outPath = join(ROOT, "wrangler.dev.jsonc");
writeFileSync(outPath, JSON.stringify(config, null, 2) + "\n");
console.log(`generated: ${outPath}`);
}
// ---------------------------------------------------------------------------
// Generate gatekeeper wrangler.dev.jsonc files. The checked-in wrangler.jsonc
// already points at the capnweb-validate output; dev needs an explicit cwd
// because this script starts a multi-config Wrangler process from the repo root.
// We also inject OAuth credentials shared with the sign-in flow, so a single
// OAuth app can drive both; gatekeepers without shared creds keep their raw config,
// and any creds already defined in the gatekeeper's own config still win.
// ---------------------------------------------------------------------------
// Maps a gatekeeper name to the shared env vars whose values seed its CLIENT_ID / CLIENT_SECRET.
const SHARED_GATEKEEPER_CREDS = {
"gatekeeper-github": { id: "GITHUB_CLIENT_ID", secret: "GITHUB_CLIENT_SECRET" },
"gatekeeper-google": { id: "GOOGLE_CLIENT_ID", secret: "GOOGLE_CLIENT_SECRET" },
"gatekeeper-cloudflare": { id: "CLOUDFLARE_OAUTH_CLIENT_ID", secret: "CLOUDFLARE_OAUTH_CLIENT_SECRET" },
"gatekeeper-supabase": { id: "SUPABASE_CLIENT_ID", secret: "SUPABASE_CLIENT_SECRET" },
"gatekeeper-notion": { id: "NOTION_CLIENT_ID", secret: "NOTION_CLIENT_SECRET" },
"gatekeeper-zoominfo": { id: "ZOOMINFO_CLIENT_ID", secret: "ZOOMINFO_CLIENT_SECRET" },
"gatekeeper-confluence": { id: "CONFLUENCE_CLIENT_ID", secret: "CONFLUENCE_CLIENT_SECRET" },
"gatekeeper-slack": { id: "SLACK_CLIENT_ID", secret: "SLACK_CLIENT_SECRET" },
};
// Deployment-configured vars a gatekeeper reads that its committed `wrangler.jsonc` deliberately
// leaves unset, passed through from the shell or the root `.dev.vars`.
//
// Without this the only way to point the portal connector somewhere for local testing is to edit a
// tracked file, and a URL committed there becomes the default for everyone who deploys this repo.
// `.dev.vars` is gitignored, so it cannot leave the machine. Secrets travel the same way
// `CLIENT_SECRET` already does, via SHARED_GATEKEEPER_CREDS above.
const PASSTHROUGH_GATEKEEPER_VARS = {
"gatekeeper-mcp-portal": [
"MCP_PORTAL_URL", "MCP_PORTAL_NAME", "MCP_PORTAL_AUTH", "MCP_PORTAL_TOKEN",
"MCP_PORTAL_TRUST_ANNOTATIONS", "MCP_ALLOW_INSECURE",
],
"gatekeeper-mcp": ["MCP_ALLOW_INSECURE"],
};
for (const gk of gatekeepers) {
const srcPath = join(gk.dir, "wrangler.jsonc");
const config = parse(readFileSync(srcPath, "utf8"));
config.build = { ...config.build, cwd: gk.dir };
const shared = SHARED_GATEKEEPER_CREDS[gk.name];
if (shared && process.env[shared.id] && process.env[shared.secret]) {
config.vars = config.vars || {};
if (config.vars.CLIENT_ID === undefined) config.vars.CLIENT_ID = process.env[shared.id];
if (config.vars.CLIENT_SECRET === undefined) config.vars.CLIENT_SECRET = process.env[shared.secret];
}
// The shell wins over the committed default, so `MCP_ALLOW_INSECURE=true` can override the
// `"false"` in wrangler.jsonc without editing it.
for (const name of PASSTHROUGH_GATEKEEPER_VARS[gk.name] ?? []) {
if (process.env[name] !== undefined) {
config.vars = config.vars || {};
config.vars[name] = process.env[name];
}
}
const outPath = join(gk.dir, "wrangler.dev.jsonc");
writeFileSync(outPath, JSON.stringify(config, null, 2) + "\n");
console.log(`generated: ${outPath}`);
}
// ---------------------------------------------------------------------------
// Generate packages/workshop-backend/wrangler.dev.jsonc (with gatekeeper
// service bindings using the GatekeeperVendor entrypoint).
// ---------------------------------------------------------------------------
{
const srcPath = join(ROOT, "packages", "workshop-backend", "wrangler.jsonc");
const config = parse(readFileSync(srcPath, "utf8"));
config.services = config.services || [];
// For local testing, create an account named "admin" to test admin features.
config.vars = config.vars || {};
config.vars.ADMINS = ["admin"];
// Pass through the optional OAuth sign-in / AI Gateway billing env vars from the shell
// environment, so you can run e.g.
// ENABLE_CLOUDFLARE_LIMITS=true DAILY_LLM_CALL_LIMIT=1 pnpm dev-server
// without editing any config files.
const OPTIONAL_FEATURE_VARS = [
"DISABLE_PASSWORD_AUTH", "AUTH_GATEKEEPERS", "ENABLE_CLOUDFLARE_LIMITS", "PUBLIC_BASE_URL",
"DAILY_LLM_CALL_LIMIT", "MINIMUM_CLOUDFLARE_BALANCE",
// Platform AI Gateway — makes the cross-provider model catalog available. The
// ACCOUNT_ID/API_TOKEN pair is required whenever CF_AI_GATEWAY is set (all inference goes
// over HTTPS with tokens).
"CF_AI_GATEWAY", "CF_AI_GATEWAY_PROVIDERS", "CF_AI_GATEWAY_ACCOUNT_ID",
"CF_AI_GATEWAY_API_TOKEN", "CF_AI_GATEWAY_WAI", "CF_AI_GATEWAY_WAI_DIRECT",
];
// OAuth app credentials (GOOGLE_/GITHUB_/CLOUDFLARE_OAUTH_*) are NOT passed to the backend anymore;
// they are injected into the gatekeeper Workers (see SHARED_GATEKEEPER_CREDS below).
for (const name of OPTIONAL_FEATURE_VARS) {
if (process.env[name] !== undefined) config.vars[name] = process.env[name];
}
for (const gk of gatekeepers) {
const binding = {
binding: bindingName(gk),
service: gk.name,
entrypoint: "GatekeeperVendor",
};
// The Context gatekeeper namespaces each workshop's data by a "sharingDomain" carried in its
// binding props (see packages/gatekeeper-context/src/domain.ts). Dev uses a single domain.
if (gk.name === CONTEXT_GATEKEEPER_NAME) {
binding.props = { sharingDomain: "dev" };
}
config.services.push(binding);
}
if (useWorkersAi) {
config.ai = { binding: "WORKERS_AI" };
}
// In run-local mode, serve the pre-built frontend bundle as static assets directly from the
// backend Worker (mirrors the production layout). The dev-router forwards all non-gatekeeper
// requests here; `run_worker_first` ensures the Worker handles the API routes while everything
// else falls back to the single-page app.
if (serveFrontendAssets) {
config.assets = {
directory: "../workshop-frontend/dist",
not_found_handling: "single-page-application",
run_worker_first: ["/api", "/api/*", "/blueprint-screenshot/*"],
};
}
config.build = { ...config.build, cwd: WORKSHOP_BACKEND_DIR };
const outPath = join(ROOT, "packages", "workshop-backend", "wrangler.dev.jsonc");
writeFileSync(outPath, JSON.stringify(config, null, 2) + "\n");
console.log(`generated: ${outPath}`);
}
// ---------------------------------------------------------------------------
// Build the wrangler dev command and exec it.
// ---------------------------------------------------------------------------
const configs = [
"wrangler.dev.jsonc",
join("packages", "workshop-backend", "wrangler.dev.jsonc"),
...gatekeepers.map(gk => join(gk.dir, "wrangler.dev.jsonc")),
];
const args = configs.flatMap(c => ["-c", c]);
const backendHost = process.env.VITE_BACKEND_HOST;
if (backendHost) {
let wranglerPort;
try {
wranglerPort = getWranglerPortFromBackendHost(backendHost);
} catch (err) {
console.error(err.message);
process.exit(1);
}
if (wranglerPort) {
args.push("--port", wranglerPort);
} else {
console.warn(
"VITE_BACKEND_HOST did not include a port, so run-dev-server.js could not derive " +
"a Wrangler --port override.");
}
}
console.log(`\nStarting: wrangler dev ${args.join(" ")}\n`);
try {
execFileSync("pnpm", ["exec", "wrangler", "dev", ...args],
{ stdio: "inherit", cwd: ROOT });
} catch (e) {
// wrangler was killed or exited with an error; the output was already shown
// via stdio: "inherit", so just propagate the exit code.
process.exit(e.status ?? 1);
}