| @@ -1,622 +0,0 @@ |
| 1 | #!/usr/bin/env bun | | |
| 2 | | | |
| 3 | import fs from "node:fs/promises"; | | |
| 4 | import os from "node:os"; | | |
| 5 | import path from "node:path"; | | |
| 6 | import { createHash, generateKeyPairSync } from "node:crypto"; | | |
| 7 | import net from "node:net"; | | |
| 8 | | | |
| 9 | import { managedRuntimeVersions } from "../../packages/core/src/managed/runtime-info"; | | |
| 10 | | | |
| 11 | type ReleaseTarget = { | | |
| 12 | platform: "linux" | "darwin"; | | |
| 13 | arch: "x64" | "arm64"; | | |
| 14 | bunTarget: string; | | |
| 15 | }; | | |
| 16 | | | |
| 17 | const targets: ReleaseTarget[] = [ | | |
| 18 | { platform: "linux", arch: "x64", bunTarget: "bun-linux-x64" }, | | |
| 19 | { platform: "linux", arch: "arm64", bunTarget: "bun-linux-arm64" }, | | |
| 20 | { platform: "darwin", arch: "x64", bunTarget: "bun-darwin-x64" }, | | |
| 21 | { platform: "darwin", arch: "arm64", bunTarget: "bun-darwin-arm64" }, | | |
| 22 | ]; | | |
| 23 | | | |
| 24 | const convexBackendRepo = "get-convex/convex-backend"; | | |
| 25 | const defaultManagedSitePort = 5411; | | |
| 26 | | | |
| 27 | function hostPlatformArch(): { platform: "linux" | "darwin"; arch: "x64" | "arm64" } { | | |
| 28 | if (process.platform !== "linux" && process.platform !== "darwin") { | | |
| 29 | throw new Error(`Unsupported host platform for release build: ${process.platform}`); | | |
| 30 | } | | |
| 31 | if (process.arch !== "x64" && process.arch !== "arm64") { | | |
| 32 | throw new Error(`Unsupported host architecture for release build: ${process.arch}`); | | |
| 33 | } | | |
| 34 | return { platform: process.platform, arch: process.arch }; | | |
| 35 | } | | |
| 36 | | | |
| 37 | function archiveName(platform: ReleaseTarget["platform"], arch: ReleaseTarget["arch"]): string { | | |
| 38 | return `executor-${platform}-${arch}.tar.gz`; | | |
| 39 | } | | |
| 40 | | | |
| 41 | function webArchiveName(platform: ReleaseTarget["platform"], arch: ReleaseTarget["arch"]): string { | | |
| 42 | return `executor-web-${platform}-${arch}.tar.gz`; | | |
| 43 | } | | |
| 44 | | | |
| 45 | function runtimeArchiveName(platform: ReleaseTarget["platform"], arch: ReleaseTarget["arch"]): string { | | |
| 46 | return `executor-runtime-${platform}-${arch}.tar.gz`; | | |
| 47 | } | | |
| 48 | | | |
| 49 | function backendAssetName(target: ReleaseTarget): string { | | |
| 50 | if (target.platform === "linux" && target.arch === "x64") { | | |
| 51 | return "convex-local-backend-x86_64-unknown-linux-gnu.zip"; | | |
| 52 | } | | |
| 53 | if (target.platform === "linux" && target.arch === "arm64") { | | |
| 54 | return "convex-local-backend-aarch64-unknown-linux-gnu.zip"; | | |
| 55 | } | | |
| 56 | if (target.platform === "darwin" && target.arch === "x64") { | | |
| 57 | return "convex-local-backend-x86_64-apple-darwin.zip"; | | |
| 58 | } | | |
| 59 | return "convex-local-backend-aarch64-apple-darwin.zip"; | | |
| 60 | } | | |
| 61 | | | |
| 62 | function nodeArchiveName(target: ReleaseTarget): string { | | |
| 63 | return `node-v${managedRuntimeVersions.nodeVersion}-${target.platform}-${target.arch}.tar.gz`; | | |
| 64 | } | | |
| 65 | | | |
| 66 | function backendBinaryName(target: ReleaseTarget): string { | | |
| 67 | return target.platform === "linux" || target.platform === "darwin" | | |
| 68 | ? "convex-local-backend" | | |
| 69 | : "convex-local-backend.exe"; | | |
| 70 | } | | |
| 71 | | | |
| 72 | async function sha256(filePath: string): Promise<string> { | | |
| 73 | const bytes = await Bun.file(filePath).arrayBuffer(); | | |
| 74 | return createHash("sha256").update(Buffer.from(bytes)).digest("hex"); | | |
| 75 | } | | |
| 76 | | | |
| 77 | async function runCommand(command: string[], options: { cwd?: string; env?: Record<string, string | undefined> } = {}): Promise<void> { | | |
| 78 | const proc = Bun.spawn(command, { | | |
| 79 | cwd: options.cwd, | | |
| 80 | env: options.env, | | |
| 81 | stdin: "inherit", | | |
| 82 | stdout: "inherit", | | |
| 83 | stderr: "inherit", | | |
| 84 | }); | | |
| 85 | const code = await proc.exited; | | |
| 86 | if (code !== 0) { | | |
| 87 | throw new Error(`Command failed (${code}): ${command.join(" ")}`); | | |
| 88 | } | | |
| 89 | } | | |
| 90 | | | |
| 91 | async function runArchiveCommand(command: string[], cwd?: string): Promise<void> { | | |
| 92 | await runCommand(command, { cwd }); | | |
| 93 | } | | |
| 94 | | | |
| 95 | async function pathExists(filePath: string): Promise<boolean> { | | |
| 96 | try { | | |
| 97 | await fs.access(filePath); | | |
| 98 | return true; | | |
| 99 | } catch { | | |
| 100 | return false; | | |
| 101 | } | | |
| 102 | } | | |
| 103 | | | |
| 104 | async function downloadFile(url: string, destination: string): Promise<void> { | | |
| 105 | await runCommand(["curl", "-fsSL", url, "-o", destination]); | | |
| 106 | } | | |
| 107 | | | |
| 108 | async function extractZipArchive(archivePath: string, destinationDir: string): Promise<void> { | | |
| 109 | try { | | |
| 110 | await runCommand(["unzip", "-o", archivePath, "-d", destinationDir]); | | |
| 111 | return; | | |
| 112 | } catch { | | |
| 113 | const pythonScript = [ | | |
| 114 | "import sys, zipfile", | | |
| 115 | "zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])", | | |
| 116 | ].join(";"); | | |
| 117 | await runCommand(["python3", "-c", pythonScript, archivePath, destinationDir]); | | |
| 118 | } | | |
| 119 | } | | |
| 120 | | | |
| 121 | async function waitForHttp(url: string, timeoutMs: number): Promise<void> { | | |
| 122 | const startedAt = Date.now(); | | |
| 123 | while (Date.now() - startedAt < timeoutMs) { | | |
| 124 | try { | | |
| 125 | const response = await fetch(url); | | |
| 126 | if (response.ok) { | | |
| 127 | return; | | |
| 128 | } | | |
| 129 | } catch { | | |
| 130 | // still starting | | |
| 131 | } | | |
| 132 | await Bun.sleep(500); | | |
| 133 | } | | |
| 134 | throw new Error(`Timed out waiting for ${url}`); | | |
| 135 | } | | |
| 136 | | | |
| 137 | async function canBindPort(port: number): Promise<boolean> { | | |
| 138 | return await new Promise((resolve) => { | | |
| 139 | const server = net.createServer(); | | |
| 140 | server.once("error", () => { | | |
| 141 | resolve(false); | | |
| 142 | }); | | |
| 143 | server.listen(port, "127.0.0.1", () => { | | |
| 144 | server.close(() => resolve(true)); | | |
| 145 | }); | | |
| 146 | }); | | |
| 147 | } | | |
| 148 | | | |
| 149 | async function randomPortBase(): Promise<number> { | | |
| 150 | for (let attempt = 0; attempt < 100; attempt += 1) { | | |
| 151 | const base = 20000 + Math.floor(Math.random() * 20000); | | |
| 152 | if (await canBindPort(base) && await canBindPort(base + 1) && await canBindPort(base + 2)) { | | |
| 153 | return base; | | |
| 154 | } | | |
| 155 | } | | |
| 156 | | | |
| 157 | throw new Error("Unable to allocate free ports for runtime seeding"); | | |
| 158 | } | | |
| 159 | | | |
| 160 | type AnonymousAuthSeed = { | | |
| 161 | privateKeyPem: string; | | |
| 162 | publicKeyPem: string; | | |
| 163 | apiKeySecret: string; | | |
| 164 | }; | | |
| 165 | | | |
| 166 | type RuntimeConfig = { | | |
| 167 | instanceName: string; | | |
| 168 | instanceSecret: string; | | |
| 169 | hostInterface: string; | | |
| 170 | backendPort: number; | | |
| 171 | siteProxyPort: number; | | |
| 172 | }; | | |
| 173 | | | |
| 174 | type TanstackStartOutput = { | | |
| 175 | outputRoot: string; | | |
| 176 | serverEntry: string; | | |
| 177 | stagedServerEntry: string; | | |
| 178 | }; | | |
| 179 | | | |
| 180 | function normalizePemForEnv(value: string): string { | | |
| 181 | return value.replace(/\r\n/g, "\n").replace(/\n/g, "\\n").trim(); | | |
| 182 | } | | |
| 183 | | | |
| 184 | function createAnonymousAuthSeed(): AnonymousAuthSeed { | | |
| 185 | const keyPair = generateKeyPairSync("ec", { | | |
| 186 | namedCurve: "P-256", | | |
| 187 | privateKeyEncoding: { | | |
| 188 | type: "pkcs8", | | |
| 189 | format: "pem", | | |
| 190 | }, | | |
| 191 | publicKeyEncoding: { | | |
| 192 | type: "spki", | | |
| 193 | format: "pem", | | |
| 194 | }, | | |
| 195 | }); | | |
| 196 | | | |
| 197 | const privateKeyPem = normalizePemForEnv(keyPair.privateKey); | | |
| 198 | return { | | |
| 199 | privateKeyPem, | | |
| 200 | publicKeyPem: normalizePemForEnv(keyPair.publicKey), | | |
| 201 | apiKeySecret: privateKeyPem, | | |
| 202 | }; | | |
| 203 | } | | |
| 204 | | | |
| 205 | async function resolveTanstackStartOutput(webAppDir: string): Promise<TanstackStartOutput> { | | |
| 206 | const candidates: Array<{ outputRoot: string; serverEntry: string; stagedServerEntry: string }> = [ | | |
| 207 | { | | |
| 208 | outputRoot: path.join(webAppDir, ".output"), | | |
| 209 | serverEntry: path.join(".output", "server", "index.mjs"), | | |
| 210 | stagedServerEntry: path.join(".output", "server", "index.mjs"), | | |
| 211 | }, | | |
| 212 | { | | |
| 213 | outputRoot: path.join(webAppDir, "dist"), | | |
| 214 | serverEntry: path.join("dist", "server", "server.mjs"), | | |
| 215 | stagedServerEntry: path.join("dist", "server", "server.mjs"), | | |
| 216 | }, | | |
| 217 | ]; | | |
| 218 | | | |
| 219 | for (const candidate of candidates) { | | |
| 220 | const absoluteServerEntry = path.join(webAppDir, candidate.serverEntry); | | |
| 221 | if (await pathExists(absoluteServerEntry)) { | | |
| 222 | return { | | |
| 223 | outputRoot: candidate.outputRoot, | | |
| 224 | serverEntry: candidate.serverEntry, | | |
| 225 | stagedServerEntry: candidate.stagedServerEntry, | | |
| 226 | }; | | |
| 227 | } | | |
| 228 | } | | |
| 229 | | | |
| 230 | const checkedPaths = candidates | | |
| 231 | .map((candidate) => path.join(webAppDir, candidate.serverEntry)) | | |
| 232 | .join(", "); | | |
| 233 | | | |
| 234 | throw new Error(`Could not find TanStack Start server output. Checked: ${checkedPaths}`); | | |
| 235 | } | | |
| 236 | | | |
| 237 | async function readRuntimeConfig(runtimeDir: string): Promise<RuntimeConfig> { | | |
| 238 | const configPath = path.join(runtimeDir, "convex-backend.json"); | | |
| 239 | const raw = await fs.readFile(configPath, "utf8"); | | |
| 240 | const parsed = JSON.parse(raw) as Partial<RuntimeConfig>; | | |
| 241 | | | |
| 242 | if ( | | |
| 243 | typeof parsed.instanceName !== "string" | | |
| 244 | || typeof parsed.instanceSecret !== "string" | | |
| 245 | || typeof parsed.hostInterface !== "string" | | |
| 246 | || typeof parsed.backendPort !== "number" | | |
| 247 | || typeof parsed.siteProxyPort !== "number" | | |
| 248 | ) { | | |
| 249 | throw new Error(`Invalid runtime config at ${configPath}`); | | |
| 250 | } | | |
| 251 | | | |
| 252 | return { | | |
| 253 | instanceName: parsed.instanceName, | | |
| 254 | instanceSecret: parsed.instanceSecret, | | |
| 255 | hostInterface: parsed.hostInterface, | | |
| 256 | backendPort: parsed.backendPort, | | |
| 257 | siteProxyPort: parsed.siteProxyPort, | | |
| 258 | }; | | |
| 259 | } | | |
| 260 | | | |
| 261 | async function generateSelfHostedAdminKey(config: RuntimeConfig): Promise<string> { | | |
| 262 | const response = await fetch("https://api.convex.dev/api/local_deployment/generate_admin_key", { | | |
| 263 | method: "POST", | | |
| 264 | headers: { | | |
| 265 | "Content-Type": "application/json", | | |
| 266 | "Convex-Client": managedRuntimeVersions.convexClientHeader, | | |
| 267 | }, | | |
| 268 | body: JSON.stringify({ | | |
| 269 | instanceName: config.instanceName, | | |
| 270 | instanceSecret: config.instanceSecret, | | |
| 271 | }), | | |
| 272 | }); | | |
| 273 | | | |
| 274 | if (!response.ok) { | | |
| 275 | const text = await response.text(); | | |
| 276 | throw new Error(`Failed generating self-hosted admin key: ${text || response.statusText}`); | | |
| 277 | } | | |
| 278 | | | |
| 279 | const parsed = await response.json() as { adminKey?: string }; | | |
| 280 | if (!parsed.adminKey || parsed.adminKey.trim().length === 0) { | | |
| 281 | throw new Error("Self-hosted admin key API did not return adminKey"); | | |
| 282 | } | | |
| 283 | | | |
| 284 | return parsed.adminKey; | | |
| 285 | } | | |
| 286 | | | |
| 287 | async function assertSeededClientConfig(config: RuntimeConfig, expectedAnonymousIssuer: string): Promise<void> { | | |
| 288 | const response = await fetch(`http://${config.hostInterface}:${config.backendPort}/api/query`, { | | |
| 289 | method: "POST", | | |
| 290 | headers: { | | |
| 291 | "content-type": "application/json", | | |
| 292 | }, | | |
| 293 | body: JSON.stringify({ | | |
| 294 | path: "app:getClientConfig", | | |
| 295 | args: {}, | | |
| 296 | }), | | |
| 297 | }); | | |
| 298 | | | |
| 299 | if (!response.ok) { | | |
| 300 | const text = await response.text(); | | |
| 301 | throw new Error(`Failed querying app:getClientConfig during runtime seeding: ${text || response.statusText}`); | | |
| 302 | } | | |
| 303 | | | |
| 304 | const parsed = await response.json() as { | | |
| 305 | status?: string; | | |
| 306 | value?: { | | |
| 307 | anonymousAuthIssuer?: unknown; | | |
| 308 | }; | | |
| 309 | }; | | |
| 310 | | | |
| 311 | if (parsed.status !== "success") { | | |
| 312 | throw new Error(`Runtime seeding query returned unexpected status: ${JSON.stringify(parsed)}`); | | |
| 313 | } | | |
| 314 | | | |
| 315 | if (parsed.value?.anonymousAuthIssuer !== expectedAnonymousIssuer) { | | |
| 316 | throw new Error( | | |
| 317 | `Runtime seeding issuer mismatch. Expected ${expectedAnonymousIssuer}, got ${String(parsed.value?.anonymousAuthIssuer)}`, | | |
| 318 | ); | | |
| 319 | } | | |
| 320 | } | | |
| 321 | | | |
| 322 | async function seedManagedRuntimeState( | | |
| 323 | rootDir: string, | | |
| 324 | runtimeDir: string, | | |
| 325 | config: RuntimeConfig, | | |
| 326 | anonymousAuth: AnonymousAuthSeed, | | |
| 327 | ): Promise<void> { | | |
| 328 | const expectedAnonymousIssuer = `http://${config.hostInterface}:${defaultManagedSitePort}`; | | |
| 329 | const adminKey = await generateSelfHostedAdminKey(config); | | |
| 330 | const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "executor-seed-env-")); | | |
| 331 | const envFilePath = path.join(tempDir, "selfhost.env"); | | |
| 332 | const envContents = [ | | |
| 333 | `CONVEX_SELF_HOSTED_URL=http://${config.hostInterface}:${config.backendPort}`, | | |
| 334 | `CONVEX_SELF_HOSTED_ADMIN_KEY=${adminKey}`, | | |
| 335 | "WORKOS_CLIENT_ID=disabled", | | |
| 336 | "DANGEROUSLY_ALLOW_LOCAL_VM=1", | | |
| 337 | ].join("\n"); | | |
| 338 | | | |
| 339 | await fs.writeFile(envFilePath, `${envContents}\n`, "utf8"); | | |
| 340 | | | |
| 341 | const envEntries: Array<{ name: string; value: string }> = [ | | |
| 342 | { name: "WORKOS_CLIENT_ID", value: "disabled" }, | | |
| 343 | { name: "DANGEROUSLY_ALLOW_LOCAL_VM", value: "1" }, | | |
| 344 | { name: "ANONYMOUS_AUTH_ISSUER", value: expectedAnonymousIssuer }, | | |
| 345 | { name: "ANONYMOUS_AUTH_PRIVATE_KEY_PEM", value: anonymousAuth.privateKeyPem }, | | |
| 346 | { name: "ANONYMOUS_AUTH_PUBLIC_KEY_PEM", value: anonymousAuth.publicKeyPem }, | | |
| 347 | { name: "MCP_API_KEY_SECRET", value: anonymousAuth.apiKeySecret }, | | |
| 348 | ]; | | |
| 349 | | | |
| 350 | try { | | |
| 351 | for (const entry of envEntries) { | | |
| 352 | await runCommand(["bunx", "convex", "env", "set", `${entry.name}=${entry.value}`, "--env-file", envFilePath], { | | |
| 353 | cwd: rootDir, | | |
| 354 | }); | | |
| 355 | } | | |
| 356 | | | |
| 357 | await runCommand(["bunx", "convex", "deploy", "--yes", "--typecheck", "disable", "--codegen", "disable", "--env-file", envFilePath], { | | |
| 358 | cwd: rootDir, | | |
| 359 | }); | | |
| 360 | | | |
| 361 | await assertSeededClientConfig(config, expectedAnonymousIssuer); | | |
| 362 | } finally { | | |
| 363 | await fs.rm(tempDir, { recursive: true, force: true }); | | |
| 364 | } | | |
| 365 | | | |
| 366 | await fs.writeFile( | | |
| 367 | path.join(runtimeDir, "managed-anonymous-auth.json"), | | |
| 368 | `${JSON.stringify({ | | |
| 369 | ANONYMOUS_AUTH_PRIVATE_KEY_PEM: anonymousAuth.privateKeyPem, | | |
| 370 | ANONYMOUS_AUTH_PUBLIC_KEY_PEM: anonymousAuth.publicKeyPem, | | |
| 371 | MCP_API_KEY_SECRET: anonymousAuth.apiKeySecret, | | |
| 372 | }, null, 2)}\n`, | | |
| 373 | { encoding: "utf8", mode: 0o600 }, | | |
| 374 | ); | | |
| 375 | } | | |
| 376 | | | |
| 377 | async function seedRuntimeState(rootDir: string): Promise<{ tempRoot: string; runtimeDir: string }> { | | |
| 378 | const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "executor-runtime-seed-")); | | |
| 379 | const runtimeDir = path.join(tempRoot, "runtime"); | | |
| 380 | const basePort = await randomPortBase(); | | |
| 381 | const anonymousAuth = createAnonymousAuthSeed(); | | |
| 382 | const host = hostPlatformArch(); | | |
| 383 | const hostTarget: ReleaseTarget = { | | |
| 384 | platform: host.platform, | | |
| 385 | arch: host.arch, | | |
| 386 | bunTarget: host.platform === "linux" | | |
| 387 | ? (host.arch === "x64" ? "bun-linux-x64" : "bun-linux-arm64") | | |
| 388 | : (host.arch === "x64" ? "bun-darwin-x64" : "bun-darwin-arm64"), | | |
| 389 | }; | | |
| 390 | | | |
| 391 | await fs.mkdir(runtimeDir, { recursive: true }); | | |
| 392 | const hostNodeArchive = path.join(tempRoot, nodeArchiveName(hostTarget)); | | |
| 393 | const hostNodeUrl = `https://nodejs.org/dist/v${managedRuntimeVersions.nodeVersion}/${nodeArchiveName(hostTarget)}`; | | |
| 394 | await downloadFile(hostNodeUrl, hostNodeArchive); | | |
| 395 | await runArchiveCommand(["tar", "-xzf", hostNodeArchive, "-C", runtimeDir]); | | |
| 396 | | | |
| 397 | const env = { | | |
| 398 | ...process.env, | | |
| 399 | EXECUTOR_SKIP_RUNTIME_IMAGE: "1", | | |
| 400 | EXECUTOR_RUNTIME_DIR: runtimeDir, | | |
| 401 | EXECUTOR_PROJECT_DIR: rootDir, | | |
| 402 | EXECUTOR_BACKEND_PORT: String(basePort), | | |
| 403 | EXECUTOR_BACKEND_SITE_PORT: String(basePort + 1), | | |
| 404 | EXECUTOR_WEB_PORT: String(basePort + 2), | | |
| 405 | ANONYMOUS_AUTH_PRIVATE_KEY_PEM: anonymousAuth.privateKeyPem, | | |
| 406 | ANONYMOUS_AUTH_PUBLIC_KEY_PEM: anonymousAuth.publicKeyPem, | | |
| 407 | MCP_API_KEY_SECRET: anonymousAuth.apiKeySecret, | | |
| 408 | }; | | |
| 409 | | | |
| 410 | const backendProc = Bun.spawn(["bun", "run", "executor.ts", "up", "--disable-beacon"], { | | |
| 411 | cwd: rootDir, | | |
| 412 | env, | | |
| 413 | stdin: "ignore", | | |
| 414 | stdout: "ignore", | | |
| 415 | stderr: "ignore", | | |
| 416 | }); | | |
| 417 | | | |
| 418 | try { | | |
| 419 | await waitForHttp(`http://127.0.0.1:${basePort}/version`, 120_000); | | |
| 420 | const config = await readRuntimeConfig(runtimeDir); | | |
| 421 | await seedManagedRuntimeState(rootDir, runtimeDir, config, anonymousAuth); | | |
| 422 | } catch (error) { | | |
| 423 | backendProc.kill(); | | |
| 424 | await backendProc.exited; | | |
| 425 | throw error; | | |
| 426 | } | | |
| 427 | | | |
| 428 | backendProc.kill(); | | |
| 429 | await backendProc.exited; | | |
| 430 | | | |
| 431 | const convexDataDir = path.join(runtimeDir, "convex-data"); | | |
| 432 | if (!(await pathExists(convexDataDir))) { | | |
| 433 | throw new Error("Runtime seeding failed: convex-data directory missing"); | | |
| 434 | } | | |
| 435 | | | |
| 436 | return { tempRoot, runtimeDir }; | | |
| 437 | } | | |
| 438 | | | |
| 439 | async function buildWebArtifact(rootDir: string, releaseDir: string, checksums: string[]): Promise<void> { | | |
| 440 | const host = hostPlatformArch(); | | |
| 441 | const webAppDir = path.join(rootDir, "apps", "web"); | | |
| 442 | const webArtifactName = webArchiveName(host.platform, host.arch); | | |
| 443 | const archivePath = path.join(releaseDir, webArtifactName); | | |
| 444 | const webBuildEnv = { | | |
| 445 | ...process.env, | | |
| 446 | EXECUTOR_WEB_CONVEX_URL: "http://127.0.0.1:5410", | | |
| 447 | EXECUTOR_WEB_CONVEX_SITE_URL: "http://127.0.0.1:5411", | | |
| 448 | VITE_CONVEX_URL: "http://127.0.0.1:5410", | | |
| 449 | VITE_CONVEX_SITE_URL: "http://127.0.0.1:5411", | | |
| 450 | NODE_ENV: "production", | | |
| 451 | VITE_USER_NODE_ENV: "production", | | |
| 452 | }; | | |
| 453 | | | |
| 454 | await runCommand(["bunx", "vite", "build", "--mode", "production"], { | | |
| 455 | cwd: webAppDir, | | |
| 456 | env: webBuildEnv, | | |
| 457 | }); | | |
| 458 | | | |
| 459 | const tanstackStart = await resolveTanstackStartOutput(webAppDir); | | |
| 460 | | | |
| 461 | const outputRoot = tanstackStart.outputRoot; | | |
| 462 | const outputServerEntry = path.join(webAppDir, tanstackStart.serverEntry); | | |
| 463 | const stageRoot = path.join(releaseDir, `executor-web-${host.platform}-${host.arch}`); | | |
| 464 | const stagedAppRoot = path.join(stageRoot, "executor", "apps", "web"); | | |
| 465 | const stagedServerEntry = tanstackStart.stagedServerEntry; | | |
| 466 | const stagedOutputRoot = path.basename(outputRoot); | | |
| 467 | const stagedServerEntryWithLeadingSlash = `/${stagedServerEntry.split(path.sep).join("/")}`; | | |
| 468 | | | |
| 469 | if (!(await pathExists(outputServerEntry))) { | | |
| 470 | throw new Error(`Missing TanStack Start output at ${outputServerEntry}. Ensure vite build completed.`); | | |
| 471 | } | | |
| 472 | | | |
| 473 | await fs.rm(stageRoot, { recursive: true, force: true }); | | |
| 474 | await fs.mkdir(stageRoot, { recursive: true }); | | |
| 475 | await fs.mkdir(stagedAppRoot, { recursive: true }); | | |
| 476 | | | |
| 477 | await fs.cp(outputRoot, path.join(stagedAppRoot, stagedOutputRoot), { recursive: true }); | | |
| 478 | | | |
| 479 | const stagedServerEntryAbsolute = path.join(stagedAppRoot, stagedServerEntry); | | |
| 480 | if (!(await pathExists(stagedServerEntryAbsolute))) { | | |
| 481 | throw new Error(`Expected staged TanStack Start server entry at ${stagedServerEntryAbsolute}. Packaging failed.`); | | |
| 482 | } | | |
| 483 | | | |
| 484 | await Bun.write( | | |
| 485 | path.join(stageRoot, "server.js"), | | |
| 486 | `process.chdir(__dirname + '/executor/apps/web');\nimport(process.cwd() + '${stagedServerEntryWithLeadingSlash}');\n`, | | |
| 487 | ); | | |
| 488 | | | |
| 489 | await runArchiveCommand(["tar", "-czf", archivePath, "-C", stageRoot, "."]); | | |
| 490 | const digest = await sha256(archivePath); | | |
| 491 | checksums.push(`${digest} ${path.basename(archivePath)}`); | | |
| 492 | console.log(`built ${webArtifactName}`); | | |
| 493 | | | |
| 494 | for (const target of targets) { | | |
| 495 | if (target.platform === host.platform && target.arch === host.arch) { | | |
| 496 | continue; | | |
| 497 | } | | |
| 498 | | | |
| 499 | const targetArtifactName = webArchiveName(target.platform, target.arch); | | |
| 500 | const targetArchivePath = path.join(releaseDir, targetArtifactName); | | |
| 501 | await fs.copyFile(archivePath, targetArchivePath); | | |
| 502 | | | |
| 503 | const targetDigest = await sha256(targetArchivePath); | | |
| 504 | checksums.push(`${targetDigest} ${targetArtifactName}`); | | |
| 505 | console.log(`aliased ${targetArtifactName} from ${webArtifactName}`); | | |
| 506 | } | | |
| 507 | } | | |
| 508 | | | |
| 509 | async function buildRuntimeArtifact(rootDir: string, releaseDir: string, checksums: string[]): Promise<void> { | | |
| 510 | const seeded = await seedRuntimeState(rootDir); | | |
| 511 | const convexDataDir = path.join(seeded.runtimeDir, "convex-data"); | | |
| 512 | const managedAnonymousAuthFile = path.join(seeded.runtimeDir, "managed-anonymous-auth.json"); | | |
| 513 | | | |
| 514 | const tempDownloads = await fs.mkdtemp(path.join(os.tmpdir(), "executor-runtime-build-")); | | |
| 515 | | | |
| 516 | try { | | |
| 517 | for (const target of targets) { | | |
| 518 | const stageRoot = path.join(releaseDir, `executor-runtime-${target.platform}-${target.arch}`); | | |
| 519 | const artifactPath = path.join(releaseDir, runtimeArchiveName(target.platform, target.arch)); | | |
| 520 | | | |
| 521 | await fs.rm(stageRoot, { recursive: true, force: true }); | | |
| 522 | await fs.mkdir(stageRoot, { recursive: true }); | | |
| 523 | | | |
| 524 | await fs.cp(convexDataDir, path.join(stageRoot, "convex-data"), { recursive: true }); | | |
| 525 | if (await pathExists(managedAnonymousAuthFile)) { | | |
| 526 | await fs.copyFile(managedAnonymousAuthFile, path.join(stageRoot, "managed-anonymous-auth.json")); | | |
| 527 | } | | |
| 528 | | | |
| 529 | const backendArchive = path.join(tempDownloads, backendAssetName(target)); | | |
| 530 | const backendUrl = `https://github.com/${convexBackendRepo}/releases/latest/download/${backendAssetName(target)}`; | | |
| 531 | await downloadFile(backendUrl, backendArchive); | | |
| 532 | await fs.mkdir(path.join(stageRoot, "convex-backend"), { recursive: true }); | | |
| 533 | await extractZipArchive(backendArchive, path.join(stageRoot, "convex-backend")); | | |
| 534 | | | |
| 535 | const backendBinary = path.join(stageRoot, "convex-backend", backendBinaryName(target)); | | |
| 536 | if (!(await pathExists(backendBinary))) { | | |
| 537 | throw new Error(`Runtime artifact missing backend binary after extraction: ${backendBinary}`); | | |
| 538 | } | | |
| 539 | await fs.chmod(backendBinary, 0o755); | | |
| 540 | | | |
| 541 | const nodeArchive = path.join(tempDownloads, nodeArchiveName(target)); | | |
| 542 | const nodeUrl = `https://nodejs.org/dist/v${managedRuntimeVersions.nodeVersion}/${nodeArchiveName(target)}`; | | |
| 543 | await downloadFile(nodeUrl, nodeArchive); | | |
| 544 | await runArchiveCommand(["tar", "-xzf", nodeArchive, "-C", stageRoot]); | | |
| 545 | | | |
| 546 | const nodeDir = path.join(stageRoot, `node-v${managedRuntimeVersions.nodeVersion}-${target.platform}-${target.arch}`); | | |
| 547 | if (!(await pathExists(path.join(nodeDir, "bin", "node")))) { | | |
| 548 | throw new Error(`Runtime artifact missing extracted node runtime: ${nodeDir}`); | | |
| 549 | } | | |
| 550 | | | |
| 551 | const webArchive = path.join(releaseDir, webArchiveName(target.platform, target.arch)); | | |
| 552 | if (!(await pathExists(webArchive))) { | | |
| 553 | throw new Error(`Missing web artifact for runtime packaging: ${webArchive}`); | | |
| 554 | } | | |
| 555 | const webStage = path.join(tempDownloads, `web-${target.platform}-${target.arch}`); | | |
| 556 | await fs.rm(webStage, { recursive: true, force: true }); | | |
| 557 | await fs.mkdir(webStage, { recursive: true }); | | |
| 558 | await runArchiveCommand(["tar", "-xzf", webArchive, "-C", webStage]); | | |
| 559 | await fs.cp(webStage, path.join(stageRoot, "web"), { recursive: true }); | | |
| 560 | | | |
| 561 | if (!(await pathExists(path.join(stageRoot, "web", "server.js")))) { | | |
| 562 | throw new Error(`Runtime artifact missing web server entry for ${target.platform}-${target.arch}`); | | |
| 563 | } | | |
| 564 | | | |
| 565 | await runArchiveCommand(["tar", "-czf", artifactPath, "-C", stageRoot, "."]); | | |
| 566 | const digest = await sha256(artifactPath); | | |
| 567 | checksums.push(`${digest} ${path.basename(artifactPath)}`); | | |
| 568 | console.log(`built ${path.basename(artifactPath)}`); | | |
| 569 | } | | |
| 570 | } finally { | | |
| 571 | await fs.rm(tempDownloads, { recursive: true, force: true }); | | |
| 572 | await fs.rm(seeded.tempRoot, { recursive: true, force: true }); | | |
| 573 | } | | |
| 574 | } | | |
| 575 | | | |
| 576 | async function main(): Promise<void> { | | |
| 577 | const rootDir = path.resolve(import.meta.dir, "..", ".."); | | |
| 578 | const releaseDir = path.join(rootDir, "dist", "release"); | | |
| 579 | | | |
| 580 | await fs.rm(releaseDir, { recursive: true, force: true }); | | |
| 581 | await fs.mkdir(releaseDir, { recursive: true }); | | |
| 582 | | | |
| 583 | const checksums: string[] = []; | | |
| 584 | | | |
| 585 | for (const target of targets) { | | |
| 586 | const dirName = `executor-${target.platform}-${target.arch}`; | | |
| 587 | const bundleDir = path.join(releaseDir, dirName); | | |
| 588 | const binDir = path.join(bundleDir, "bin"); | | |
| 589 | const binName = "executor"; | | |
| 590 | const binPath = path.join(binDir, binName); | | |
| 591 | | | |
| 592 | await fs.mkdir(binDir, { recursive: true }); | | |
| 593 | | | |
| 594 | const build = await Bun.build({ | | |
| 595 | entrypoints: [path.join(rootDir, "executor.ts")], | | |
| 596 | compile: { | | |
| 597 | target: target.bunTarget as never, | | |
| 598 | outfile: binPath, | | |
| 599 | }, | | |
| 600 | }); | | |
| 601 | | | |
| 602 | if (!build.success) { | | |
| 603 | const logs = build.logs.map((log) => log.message).join("\n"); | | |
| 604 | throw new Error(`Failed to compile target ${target.bunTarget}\n${logs}`); | | |
| 605 | } | | |
| 606 | | | |
| 607 | const archivePath = path.join(releaseDir, archiveName(target.platform, target.arch)); | | |
| 608 | await runArchiveCommand(["tar", "-czf", archivePath, "-C", binDir, binName]); | | |
| 609 | | | |
| 610 | const digest = await sha256(archivePath); | | |
| 611 | checksums.push(`${digest} ${path.basename(archivePath)}`); | | |
| 612 | console.log(`built ${path.basename(archivePath)}`); | | |
| 613 | } | | |
| 614 | | | |
| 615 | await buildWebArtifact(rootDir, releaseDir, checksums); | | |
| 616 | await buildRuntimeArtifact(rootDir, releaseDir, checksums); | | |
| 617 | | | |
| 618 | await Bun.write(path.join(releaseDir, "checksums.txt"), `${checksums.join("\n")}\n`); | | |
| 619 | console.log(`wrote ${path.join("dist", "release", "checksums.txt")}`); | | |
| 620 | } | | |
| 621 | | | |
| 622 | await main(); | | |