From 55f72ea4a21127f227c9baa7f1e18170f535cd48 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 13:33:41 -0700 Subject: [PATCH 01/21] Add preliminary Traefik setup --- .gitignore | 1 + docker-compose.traefik.yml | 13 ++ packages/host/config/environment.js | 56 +++++-- packages/host/package.json | 3 +- packages/host/scripts/serve-dist.js | 140 +++++++++++++++++ .../realm-server/lib/dev-service-registry.ts | 145 ++++++++++++++++++ packages/realm-server/main.ts | 47 ++++-- packages/realm-server/prerender/config.ts | 6 +- .../realm-server/prerender/manager-server.ts | 4 + .../prerender/prerender-server.ts | 4 + .../realm-server/prerender/prerenderer.ts | 6 +- packages/realm-server/scripts/start-all.sh | 29 +++- .../realm-server/scripts/start-development.sh | 50 ++++-- packages/realm-server/scripts/start-icons.sh | 43 +++++- .../scripts/start-prerender-dev.sh | 14 +- .../scripts/start-prerender-manager-dev.sh | 9 +- .../scripts/start-worker-development.sh | 32 ++-- packages/realm-server/server.ts | 4 +- packages/realm-server/worker-manager.ts | 14 ++ scripts/ensure-branch-db.sh | 27 ++++ scripts/start-traefik.sh | 16 ++ traefik/dynamic/.gitkeep | 0 traefik/traefik.yml | 12 ++ 23 files changed, 615 insertions(+), 60 deletions(-) create mode 100644 docker-compose.traefik.yml create mode 100644 packages/host/scripts/serve-dist.js create mode 100644 packages/realm-server/lib/dev-service-registry.ts create mode 100755 scripts/ensure-branch-db.sh create mode 100755 scripts/start-traefik.sh create mode 100644 traefik/dynamic/.gitkeep create mode 100644 traefik/traefik.yml diff --git a/.gitignore b/.gitignore index ad89a2142f5..70f6926c7eb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ node_modules schema_tmp.sql test-results/.last-run.json .claude/settings.local.json +traefik/dynamic/*.yml diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml new file mode 100644 index 00000000000..280eff3d05d --- /dev/null +++ b/docker-compose.traefik.yml @@ -0,0 +1,13 @@ +services: + traefik: + image: traefik:v3.2 + container_name: boxel-traefik + ports: + - "80:80" + - "8080:8080" + volumes: + - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro + - ./traefik/dynamic:/etc/traefik/dynamic:ro + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index 9a059702873..a77d4e8113e 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -8,7 +8,45 @@ const DEFAULT_FILE_SIZE_LIMIT_BYTES = 5 * 1024 * 1024; // 5MB let sqlSchema = fs.readFileSync(getLatestSchemaFile(), 'utf8'); +// Branch-mode: when BOXEL_BRANCH is set, derive default URLs from Traefik hostnames +function branchSlug() { + let raw = process.env.BOXEL_BRANCH || ''; + return raw + .toLowerCase() + .replace(/\//g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function branchDefaults() { + if (!process.env.BOXEL_BRANCH) { + return { + realmServerURL: 'http://localhost:4201/', + realmHost: 'localhost:4201', + iconsURL: 'http://localhost:4206', + baseRealmURL: 'http://localhost:4201/base/', + catalogRealmURL: 'http://localhost:4201/catalog/', + skillsRealmURL: 'http://localhost:4201/skills/', + defaultSystemCardBase: 'http://localhost:4201', + }; + } + let slug = branchSlug(); + let realmHost = `realm.${slug}.localdev.boxel.ai`; + return { + realmServerURL: `http://${realmHost}/`, + realmHost, + iconsURL: `http://icons.${slug}.localdev.boxel.ai`, + baseRealmURL: `http://${realmHost}/base/`, + catalogRealmURL: `http://${realmHost}/catalog/`, + skillsRealmURL: `http://${realmHost}/skills/`, + defaultSystemCardBase: `http://${realmHost}`, + }; +} + module.exports = function (environment) { + let defaults = branchDefaults(); + const ENV = { modulePrefix: '@cardstack/host', environment, @@ -48,23 +86,23 @@ module.exports = function (environment) { fileSizeLimitBytes: Number( process.env.FILE_SIZE_LIMIT_BYTES ?? DEFAULT_FILE_SIZE_LIMIT_BYTES, ), - iconsURL: process.env.ICONS_URL || 'http://localhost:4206', + iconsURL: process.env.ICONS_URL || defaults.iconsURL, publishedRealmBoxelSpaceDomain: - process.env.PUBLISHED_REALM_BOXEL_SPACE_DOMAIN || 'localhost:4201', + process.env.PUBLISHED_REALM_BOXEL_SPACE_DOMAIN || defaults.realmHost, publishedRealmBoxelSiteDomain: - process.env.PUBLISHED_REALM_BOXEL_SITE_DOMAIN || 'localhost:4201', + process.env.PUBLISHED_REALM_BOXEL_SITE_DOMAIN || defaults.realmHost, // the fields below may be rewritten by the realm server hostsOwnAssets: true, - realmServerURL: process.env.REALM_SERVER_DOMAIN || 'http://localhost:4201/', + realmServerURL: + process.env.REALM_SERVER_DOMAIN || defaults.realmServerURL, resolvedBaseRealmURL: - process.env.RESOLVED_BASE_REALM_URL || 'http://localhost:4201/base/', + process.env.RESOLVED_BASE_REALM_URL || defaults.baseRealmURL, resolvedCatalogRealmURL: process.env.SKIP_CATALOG ? undefined - : process.env.RESOLVED_CATALOG_REALM_URL || - 'http://localhost:4201/catalog/', + : process.env.RESOLVED_CATALOG_REALM_URL || defaults.catalogRealmURL, resolvedSkillsRealmURL: - process.env.RESOLVED_SKILLS_REALM_URL || 'http://localhost:4201/skills/', + process.env.RESOLVED_SKILLS_REALM_URL || defaults.skillsRealmURL, featureFlags: { SHOW_ASK_AI: process.env.SHOW_ASK_AI === 'true' || false, }, @@ -79,7 +117,7 @@ module.exports = function (environment) { ENV.defaultSystemCardId = process.env.DEFAULT_SYSTEM_CARD_ID; if (!ENV.defaultSystemCardId && !process.env.SKIP_CATALOG) { ENV.defaultSystemCardId = - 'http://localhost:4201/catalog/SystemCard/default'; + defaults.defaultSystemCardBase + '/catalog/SystemCard/default'; } } diff --git a/packages/host/package.json b/packages/host/package.json index c280d2bcc2f..10e2c9b572c 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -23,7 +23,8 @@ "lint:glint": "glint", "ensure-boxel-ui": "../boxel-ui/addon/bin/conditional-build.sh", "start": "pnpm ensure-boxel-ui && NODE_OPTIONS='--max-old-space-size=8192' ember serve", - "serve:dist": "serve --config ../tests/serve.json --single --cors --no-request-logging --no-etag --listen 4200 dist", + "serve:dist": "node scripts/serve-dist.js", + "serve:dist:legacy": "serve --config ../tests/serve.json --single --cors --no-request-logging --no-etag --listen 4200 dist", "start:build": "NODE_OPTIONS='--max-old-space-size=8192' ember build --watch", "test": "concurrently \"pnpm:lint\" \"pnpm:test:*\" --names \"lint,test:\"", "test-with-percy": "percy exec --parallel -- pnpm test:wait-for-servers", diff --git a/packages/host/scripts/serve-dist.js b/packages/host/scripts/serve-dist.js new file mode 100644 index 00000000000..97db946158c --- /dev/null +++ b/packages/host/scripts/serve-dist.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +/** + * Wrapper around `serve` that supports dynamic port allocation in branch mode. + * When BOXEL_BRANCH is set, picks a free port, starts `serve`, then registers + * with Traefik so that `host..localdev.boxel.ai` routes to this instance. + * When BOXEL_BRANCH is not set, behaves identically to the old serve:dist command. + */ + +const { spawn } = require('child_process'); +const path = require('path'); + +const BOXEL_BRANCH = process.env.BOXEL_BRANCH; + +function runServe(port) { + const child = spawn( + 'npx', + [ + 'serve', + '--config', '../tests/serve.json', + '--single', + '--cors', + '--no-request-logging', + '--no-etag', + '--listen', String(port), + 'dist', + ], + { stdio: 'inherit', cwd: path.join(__dirname, '..'), shell: true }, + ); + child.on('exit', (code) => process.exit(code || 0)); + return child; +} + +if (!BOXEL_BRANCH) { + // Legacy mode: hardcoded port 4200 + runServe(4200); +} else { + // Branch mode: dynamic port + Traefik registration + const net = require('net'); + const fs = require('fs'); + + function sanitizeSlug(raw) { + return raw + .toLowerCase() + .replace(/\//g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + } + + /** + * Merge a router+service entry into the branch's Traefik YAML config. + * Uses the realm-server's yaml dependency (resolved via pnpm workspace). + * Falls back to writing a standalone YAML file if yaml isn't available. + */ + function registerWithTraefik(slug, hostname, port) { + const dynamicDir = path.resolve( + __dirname, '..', '..', '..', 'traefik', 'dynamic', + ); + const configPath = path.join(dynamicDir, `${slug}.yml`); + const routerKey = `host-${slug}`; + + let yaml; + try { + // Try to resolve yaml from realm-server's dependencies + const realmServerDir = path.resolve(__dirname, '..', '..', 'realm-server'); + yaml = require(require.resolve('yaml', { paths: [realmServerDir] })); + } catch { + // yaml not resolvable — write minimal YAML by hand + const entry = [ + 'http:', + ' routers:', + ` ${routerKey}:`, + ` rule: "Host(\\`${hostname}\\`)"`, + ` service: ${routerKey}`, + ' entryPoints:', + ' - web', + ' services:', + ` ${routerKey}:`, + ' loadBalancer:', + ' servers:', + ` - url: "http://host.docker.internal:${port}"`, + '', + ].join('\n'); + const tmpPath = configPath + '.tmp'; + fs.writeFileSync(tmpPath, entry, 'utf-8'); + fs.renameSync(tmpPath, configPath); + return; + } + + let config = {}; + try { + config = yaml.parse(fs.readFileSync(configPath, 'utf-8')) || {}; + } catch { + // file may not exist yet + } + if (!config.http) config.http = {}; + if (!config.http.routers) config.http.routers = {}; + if (!config.http.services) config.http.services = {}; + + config.http.routers[routerKey] = { + rule: `Host(\`${hostname}\`)`, + service: routerKey, + entryPoints: ['web'], + }; + config.http.services[routerKey] = { + loadBalancer: { + servers: [{ url: `http://host.docker.internal:${port}` }], + }, + }; + + const tmpPath = configPath + '.tmp'; + fs.writeFileSync(tmpPath, yaml.stringify(config), 'utf-8'); + fs.renameSync(tmpPath, configPath); + } + + const slug = sanitizeSlug(BOXEL_BRANCH); + const hostname = `host.${slug}.localdev.boxel.ai`; + + // Find a free port + const srv = net.createServer(); + srv.listen(0, () => { + const port = srv.address().port; + srv.close(() => { + console.log(`[branch-mode] Starting host app on dynamic port ${port}`); + console.log(`[branch-mode] Will be accessible at http://${hostname}`); + + runServe(port); + + try { + registerWithTraefik(slug, hostname, port); + console.log( + `[branch-mode] Registered host at ${hostname} -> localhost:${port}`, + ); + } catch (e) { + console.error('[branch-mode] Failed to register with Traefik:', e.message); + } + }); + }); +} diff --git a/packages/realm-server/lib/dev-service-registry.ts b/packages/realm-server/lib/dev-service-registry.ts new file mode 100644 index 00000000000..4630a7c272c --- /dev/null +++ b/packages/realm-server/lib/dev-service-registry.ts @@ -0,0 +1,145 @@ +import { execSync } from 'child_process'; +import { writeFileSync, renameSync, unlinkSync, readFileSync } from 'fs'; +import { join, resolve } from 'path'; +import { logger } from '@cardstack/runtime-common'; +import type { Server } from 'http'; +import type { AddressInfo } from 'net'; +import yaml from 'yaml'; + +const log = logger('dev-service-registry'); + +const DOMAIN = 'localdev.boxel.ai'; + +// Resolve traefik/dynamic dir relative to repo root +function traefikDynamicDir(): string { + // Walk up from packages/realm-server to repo root + return resolve(__dirname, '..', '..', '..', 'traefik', 'dynamic'); +} + +export function getBranchSlug(): string { + if (process.env.BOXEL_BRANCH) { + return sanitizeSlug(process.env.BOXEL_BRANCH); + } + try { + let branch = execSync('git branch --show-current', { + encoding: 'utf-8', + }).trim(); + return sanitizeSlug(branch); + } catch { + return 'default'; + } +} + +function sanitizeSlug(raw: string): string { + return raw + .toLowerCase() + .replace(/\//g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +export function serviceHostname( + serviceName: string, + branch?: string, +): string { + let slug = branch ?? getBranchSlug(); + return `${serviceName}.${slug}.${DOMAIN}`; +} + +export function serviceURL(serviceName: string, branch?: string): string { + return `http://${serviceHostname(serviceName, branch)}`; +} + +export function isBranchMode(): boolean { + return !!process.env.BOXEL_BRANCH; +} + +/** + * Register a running HTTP server with Traefik by writing/merging a dynamic YAML config. + * The config file is `traefik/dynamic/.yml` and contains routers + services + * for all services in this branch. + */ +export function registerService( + server: Server, + serviceName: string, + branch?: string, +): void { + let slug = branch ?? getBranchSlug(); + let addr = server.address() as AddressInfo; + if (!addr || typeof addr === 'string') { + log.error( + `Cannot register service ${serviceName}: server not bound to a port`, + ); + return; + } + let actualPort = addr.port; + log.info( + `Registering service ${serviceName} (port ${actualPort}) for branch ${slug}`, + ); + + let configPath = join(traefikDynamicDir(), `${slug}.yml`); + let config = loadExistingConfig(configPath); + + let routerKey = `${serviceName}-${slug}`; + let serviceKey = `${serviceName}-${slug}`; + let hostname = serviceHostname(serviceName, slug); + + if (!config.http) { + config.http = {}; + } + if (!config.http.routers) { + config.http.routers = {}; + } + if (!config.http.services) { + config.http.services = {}; + } + + config.http.routers[routerKey] = { + rule: `Host(\`${hostname}\`)`, + service: serviceKey, + entryPoints: ['web'], + }; + + config.http.services[serviceKey] = { + loadBalancer: { + servers: [{ url: `http://host.docker.internal:${actualPort}` }], + }, + }; + + atomicWrite(configPath, yaml.stringify(config)); + log.info( + `Registered ${serviceName} at ${hostname} -> localhost:${actualPort}`, + ); +} + +/** + * Remove the branch's Traefik dynamic config file on shutdown. + */ +export function deregisterBranch(branch?: string): void { + let slug = branch ?? getBranchSlug(); + let configPath = join(traefikDynamicDir(), `${slug}.yml`); + try { + unlinkSync(configPath); + log.info(`Deregistered branch ${slug} from Traefik`); + } catch (e: any) { + if (e.code !== 'ENOENT') { + log.error(`Failed to deregister branch ${slug}: ${e.message}`); + } + } +} + +function loadExistingConfig(configPath: string): any { + try { + let content = readFileSync(configPath, 'utf-8'); + return yaml.parse(content) || {}; + } catch { + return {}; + } +} + +function atomicWrite(filePath: string, content: string): void { + let tmpPath = `${filePath}.tmp`; + writeFileSync(tmpPath, content, 'utf-8'); + renameSync(tmpPath, filePath); +} diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index e5de6457d4d..a94f049786e 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -24,6 +24,13 @@ import * as ContentTagGlobal from 'content-tag'; import 'decorator-transforms/globals'; import { createRemotePrerenderer } from './prerender/remote-prerenderer'; import { buildCreatePrerenderAuth } from './prerender/auth'; +import { + isBranchMode, + getBranchSlug, + serviceURL, + registerService, + deregisterBranch, +} from './lib/dev-service-registry'; (globalThis as any).ContentTagGlobal = ContentTagGlobal; @@ -87,8 +94,12 @@ let { port, matrixURL, realmsRootPath, - serverURL = `http://localhost:${port}`, - distURL = process.env.HOST_URL ?? 'http://localhost:4200', + serverURL = isBranchMode() + ? serviceURL('realm') + : `http://localhost:${port}`, + distURL = isBranchMode() + ? serviceURL('host') + : process.env.HOST_URL ?? 'http://localhost:4200', path: paths, fromUrl: fromUrls, toUrl: toUrls, @@ -96,6 +107,7 @@ let { useRegistrationSecretFunction, migrateDB, workerManagerPort, + workerManagerUrl, prerendererUrl, } = yargs(process.argv.slice(2)) .usage('Start realm server') @@ -159,6 +171,11 @@ let { 'The port the worker manager is running on. used to wait for the workers to be ready', type: 'number', }, + workerManagerUrl: { + description: + 'The full URL of the worker manager. Used in branch mode instead of workerManagerPort.', + type: 'string', + }, prerendererUrl: { demandOption: true, description: 'URL of the prerender server to invoke', @@ -243,8 +260,10 @@ const getIndexHTML = async () => { let dbAdapter = new PgAdapter({ autoMigrate }); let queue = new PgQueuePublisher(dbAdapter); - if (workerManagerPort != null) { - await waitForWorkerManager(workerManagerPort); + if (workerManagerUrl) { + await waitForWorkerManager(workerManagerUrl); + } else if (workerManagerPort != null) { + await waitForWorkerManager(`http://localhost:${workerManagerPort}`); } let matrixClient = new MatrixClient({ @@ -322,11 +341,14 @@ const getIndexHTML = async () => { // Domains to use for when users publish their realms. // PUBLISHED_REALM_BOXEL_SPACE_DOMAIN is used to form urls like "mike.boxel.space/game-mechanics" // PUBLISHED_REALM_BOXEL_SITE_DOMAIN is used to form urls like "mike.boxel.site" + let defaultPublishedDomain = isBranchMode() + ? `realm.${getBranchSlug()}.localdev.boxel.ai` + : 'localhost:4201'; let domainsForPublishedRealms = { boxelSpace: - process.env.PUBLISHED_REALM_BOXEL_SPACE_DOMAIN || 'localhost:4201', + process.env.PUBLISHED_REALM_BOXEL_SPACE_DOMAIN || defaultPublishedDomain, boxelSite: - process.env.PUBLISHED_REALM_BOXEL_SITE_DOMAIN || 'localhost:4201', + process.env.PUBLISHED_REALM_BOXEL_SITE_DOMAIN || defaultPublishedDomain, }; let server = new RealmServer({ @@ -355,9 +377,15 @@ const getIndexHTML = async () => { }); let httpServer = server.listen(port); + if (isBranchMode()) { + registerService(httpServer, 'realm'); + } process.on('message', (message) => { if (message === 'stop') { console.log(`stopping realm server on port ${port}...`); + if (isBranchMode()) { + deregisterBranch(); + } httpServer.closeAllConnections(); httpServer.close(() => { queue.destroy(); // warning this is async @@ -426,12 +454,13 @@ const getIndexHTML = async () => { process.exit(-3); }); -async function waitForWorkerManager(port: number) { +async function waitForWorkerManager(url: string) { let isReady = false; let timeout = Date.now() + 30_000; + let normalizedUrl = url.replace(/\/$/, '') + '/'; do { try { - let response = await fetch(`http://localhost:${port}/`); + let response = await fetch(normalizedUrl); if (response.ok) { let json = await response.json(); isReady = json.ready; @@ -446,7 +475,7 @@ async function waitForWorkerManager(port: number) { } while (!isReady && Date.now() < timeout); if (!isReady) { throw new Error( - `timed out waiting for worker manager to be ready on port ${port}`, + `timed out waiting for worker manager to be ready at ${url}`, ); } log.info('workers are ready'); diff --git a/packages/realm-server/prerender/config.ts b/packages/realm-server/prerender/config.ts index 82baa48d220..18398b7dd31 100644 --- a/packages/realm-server/prerender/config.ts +++ b/packages/realm-server/prerender/config.ts @@ -1,4 +1,8 @@ -export const defaultPrerenderManagerURL = 'http://localhost:4222'; +import { isBranchMode, serviceURL } from '../lib/dev-service-registry'; + +export const defaultPrerenderManagerURL = isBranchMode() + ? serviceURL('prerender-mgr') + : 'http://localhost:4222'; export function resolvePrerenderManagerURL(): string { let base = process.env.PRERENDER_MANAGER_URL ?? defaultPrerenderManagerURL; diff --git a/packages/realm-server/prerender/manager-server.ts b/packages/realm-server/prerender/manager-server.ts index 19bf09557e7..4f1dd9d366f 100644 --- a/packages/realm-server/prerender/manager-server.ts +++ b/packages/realm-server/prerender/manager-server.ts @@ -5,6 +5,7 @@ import type { Server } from 'http'; import { createServer } from 'http'; import yargs from 'yargs'; import { buildPrerenderManagerApp } from './manager-app'; +import { isBranchMode, registerService } from '../lib/dev-service-registry'; let log = logger('prerender-manager'); @@ -37,6 +38,9 @@ let { app } = buildPrerenderManagerApp({ }); let _webServerInstance: Server | undefined; _webServerInstance = createServer(app.callback()).listen(port); +if (isBranchMode() && _webServerInstance) { + registerService(_webServerInstance, 'prerender-mgr'); +} log.info(`prerender manager HTTP listening on port ${port}`); function shutdown(signal: NodeJS.Signals) { diff --git a/packages/realm-server/prerender/prerender-server.ts b/packages/realm-server/prerender/prerender-server.ts index 959fcb349db..6482295547c 100644 --- a/packages/realm-server/prerender/prerender-server.ts +++ b/packages/realm-server/prerender/prerender-server.ts @@ -4,6 +4,7 @@ import { logger } from '@cardstack/runtime-common'; import yargs from 'yargs'; import type { Server } from 'http'; import { createPrerenderHttpServer } from './prerender-app'; +import { isBranchMode, registerService } from '../lib/dev-service-registry'; let log = logger('prerender-server'); @@ -28,6 +29,9 @@ webServerInstance = createPrerenderHttpServer({ silent, port, }).listen(port); +if (isBranchMode() && webServerInstance) { + registerService(webServerInstance, 'prerender'); +} log.info(`prerender server HTTP listening on port ${port}`); function shutdown() { diff --git a/packages/realm-server/prerender/prerenderer.ts b/packages/realm-server/prerender/prerenderer.ts index ff7cca14b79..7bd2bde12df 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -11,9 +11,13 @@ import { import { BrowserManager } from './browser-manager'; import { PagePool } from './page-pool'; import { RenderRunner } from './render-runner'; +import { isBranchMode, serviceURL } from '../lib/dev-service-registry'; const log = logger('prerenderer'); -const boxelHostURL = process.env.BOXEL_HOST_URL ?? 'http://localhost:4200'; +const defaultHostURL = isBranchMode() + ? serviceURL('host') + : 'http://localhost:4200'; +const boxelHostURL = process.env.BOXEL_HOST_URL ?? defaultHostURL; const DEFAULT_REALM_IDLE_EVICT_MS = 12 * 60 * 60 * 1000; class AsyncSemaphore { diff --git a/packages/realm-server/scripts/start-all.sh b/packages/realm-server/scripts/start-all.sh index f78d27f6b8c..07a27d60690 100755 --- a/packages/realm-server/scripts/start-all.sh +++ b/packages/realm-server/scripts/start-all.sh @@ -1,11 +1,27 @@ #! /bin/sh -BASE_REALM="http-get://localhost:4201/base/" -CATALOG_REALM="http-get://localhost:4201/catalog/" -SKILLS_REALM="http-get://localhost:4201/skills/" -BOXEL_HOMEPAGE_REALM="http-get://localhost:4201/boxel-homepage/" -EXPERIMENTS_REALM="http-get://localhost:4201/experiments/" -NODE_TEST_REALM="http-get://localhost:4202/node-test/" +# Branch-mode: when BOXEL_BRANCH is set, use Traefik hostnames for readiness checks +if [ -n "$BOXEL_BRANCH" ]; then + BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + REALM_HOST="realm.${BRANCH_SLUG}.localdev.boxel.ai" + ICONS_HOST="icons.${BRANCH_SLUG}.localdev.boxel.ai" + + BASE_REALM="http-get://${REALM_HOST}/base/" + CATALOG_REALM="http-get://${REALM_HOST}/catalog/" + SKILLS_REALM="http-get://${REALM_HOST}/skills/" + BOXEL_HOMEPAGE_REALM="http-get://${REALM_HOST}/boxel-homepage/" + EXPERIMENTS_REALM="http-get://${REALM_HOST}/experiments/" + NODE_TEST_REALM="http-get://localhost:4202/node-test/" + ICONS_URL="http://${ICONS_HOST}" +else + BASE_REALM="http-get://localhost:4201/base/" + CATALOG_REALM="http-get://localhost:4201/catalog/" + SKILLS_REALM="http-get://localhost:4201/skills/" + BOXEL_HOMEPAGE_REALM="http-get://localhost:4201/boxel-homepage/" + EXPERIMENTS_REALM="http-get://localhost:4201/experiments/" + NODE_TEST_REALM="http-get://localhost:4202/node-test/" + ICONS_URL="http://localhost:4206" +fi READY_PATH="_readiness-check?acceptHeader=application%2Fvnd.api%2Bjson" @@ -18,7 +34,6 @@ NODE_TEST_REALM_READY="$NODE_TEST_REALM$READY_PATH" SYNAPSE_URL="http://localhost:8008" SMTP_4_DEV_URL="http://localhost:5001" -ICONS_URL="http://localhost:4206" WAIT_ON_TIMEOUT=2000000 NODE_NO_WARNINGS=1 start-server-and-test \ 'run-p -ln start:pg start:matrix start:smtp start:prerender-dev start:prerender-manager-dev start:worker-development start:development' \ diff --git a/packages/realm-server/scripts/start-development.sh b/packages/realm-server/scripts/start-development.sh index c503b3992e9..6c47d694a54 100755 --- a/packages/realm-server/scripts/start-development.sh +++ b/packages/realm-server/scripts/start-development.sh @@ -29,18 +29,33 @@ START_CATALOG=$(if [ -z "$SKIP_CATALOG" ]; then echo "true"; else echo ""; fi) START_BOXEL_HOMEPAGE=$(if [ -z "$SKIP_BOXEL_HOMEPAGE" ]; then echo "true"; else echo ""; fi) START_SUBMISSION=$(if [ -z "$SKIP_SUBMISSION" ]; then echo "true"; else echo ""; fi) -DEFAULT_CATALOG_REALM_URL='http://localhost:4201/catalog/' +# Branch-mode configuration: when BOXEL_BRANCH is set, use dynamic ports and +# Traefik routing instead of hardcoded ports. +if [ -n "$BOXEL_BRANCH" ]; then + BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + REALM_BASE_URL="http://realm.${BRANCH_SLUG}.localdev.boxel.ai" + REALM_PORT=0 + REALMS_ROOT="./realms/${BRANCH_SLUG}" + PGDATABASE_VAL="boxel_${BRANCH_SLUG}" +else + REALM_BASE_URL="http://localhost:4201" + REALM_PORT=4201 + REALMS_ROOT="./realms/localhost_4201" + PGDATABASE_VAL="boxel" +fi + +DEFAULT_CATALOG_REALM_URL="${REALM_BASE_URL}/catalog/" CATALOG_REALM_URL="${RESOLVED_CATALOG_REALM_URL:-$DEFAULT_CATALOG_REALM_URL}" -DEFAULT_BOXEL_HOMEPAGE_REALM_URL='http://localhost:4201/boxel-homepage/' +DEFAULT_BOXEL_HOMEPAGE_REALM_URL="${REALM_BASE_URL}/boxel-homepage/" BOXEL_HOMEPAGE_REALM_URL="${RESOLVED_BOXEL_HOMEPAGE_REALM_URL:-$DEFAULT_BOXEL_HOMEPAGE_REALM_URL}" -DEFAULT_SUBMISSION_REALM_URL='http://localhost:4201/submissions/' +DEFAULT_SUBMISSION_REALM_URL="${REALM_BASE_URL}/submissions/" SUBMISSION_REALM_URL="${RESOLVED_SUBMISSION_REALM_URL:-$DEFAULT_SUBMISSION_REALM_URL}" # This can be overridden from the environment to point to a different catalog # and is used in start-services-for-host-tests.sh to point to a trimmed down # version of the catalog-realm for faster startup. CATALOG_REALM_PATH="${CATALOG_REALM_PATH:-../catalog-realm}" -SUBMISSION_REALM_PATH="${SUBMISSION_REALM_PATH:-./realms/localhost_4201/submissions}" +SUBMISSION_REALM_PATH="${SUBMISSION_REALM_PATH:-${REALMS_ROOT}/submissions}" if [ -n "$USE_EXTERNAL_CATALOG" ]; then pnpm --dir=../catalog catalog:setup @@ -53,13 +68,20 @@ if [ -n "$START_SUBMISSION" ]; then fi -PRERENDER_URL="${PRERENDER_URL:-http://localhost:4221}" +# In branch mode, override prerender URL and worker manager arg to use Traefik hostnames +if [ -n "$BOXEL_BRANCH" ]; then + PRERENDER_URL="${PRERENDER_URL:-http://prerender.${BRANCH_SLUG}.localdev.boxel.ai}" + WORKER_MANAGER_ARG="--workerManagerUrl=http://worker.${BRANCH_SLUG}.localdev.boxel.ai" +else + PRERENDER_URL="${PRERENDER_URL:-http://localhost:4221}" + WORKER_MANAGER_ARG="$1" +fi LOW_CREDIT_THRESHOLD="${LOW_CREDIT_THRESHOLD:-2000}" \ NODE_ENV=development \ NODE_NO_WARNINGS=1 \ PGPORT=5435 \ - PGDATABASE=boxel \ + PGDATABASE="${PGDATABASE_VAL}" \ LOG_LEVELS='*=info' \ REALM_SERVER_SECRET_SEED="mum's the word" \ REALM_SECRET_SEED="shhh! it's a secret" \ @@ -69,17 +91,17 @@ LOW_CREDIT_THRESHOLD="${LOW_CREDIT_THRESHOLD:-2000}" \ ENABLE_FILE_WATCHER=true \ ts-node \ --transpileOnly main \ - --port=4201 \ + --port="${REALM_PORT}" \ --matrixURL='http://localhost:8008' \ - --realmsRootPath='./realms/localhost_4201' \ + --realmsRootPath="${REALMS_ROOT}" \ --prerendererUrl="${PRERENDER_URL}" \ --migrateDB \ - $1 \ + $WORKER_MANAGER_ARG \ \ --path='../base' \ --username='base_realm' \ --fromUrl='https://cardstack.com/base/' \ - --toUrl='http://localhost:4201/base/' \ + --toUrl="${REALM_BASE_URL}/base/" \ \ ${START_CATALOG:+--path="${CATALOG_REALM_PATH}"} \ ${START_CATALOG:+--username='catalog_realm'} \ @@ -88,8 +110,8 @@ LOW_CREDIT_THRESHOLD="${LOW_CREDIT_THRESHOLD:-2000}" \ \ --path='../skills-realm/contents' \ --username='skills_realm' \ - --fromUrl='http://localhost:4201/skills/' \ - --toUrl='http://localhost:4201/skills/' \ + --fromUrl="${REALM_BASE_URL}/skills/" \ + --toUrl="${REALM_BASE_URL}/skills/" \ \ ${START_SUBMISSION:+--path="${SUBMISSION_REALM_PATH}"} \ ${START_SUBMISSION:+--username='submission_realm'} \ @@ -103,5 +125,5 @@ LOW_CREDIT_THRESHOLD="${LOW_CREDIT_THRESHOLD:-2000}" \ \ ${START_EXPERIMENTS:+--path='../experiments-realm'} \ ${START_EXPERIMENTS:+--username='experiments_realm'} \ - ${START_EXPERIMENTS:+--fromUrl='http://localhost:4201/experiments/'} \ - ${START_EXPERIMENTS:+--toUrl='http://localhost:4201/experiments/'} + ${START_EXPERIMENTS:+--fromUrl="${REALM_BASE_URL}/experiments/"} \ + ${START_EXPERIMENTS:+--toUrl="${REALM_BASE_URL}/experiments/"} diff --git a/packages/realm-server/scripts/start-icons.sh b/packages/realm-server/scripts/start-icons.sh index a6870e86917..b38fa195353 100644 --- a/packages/realm-server/scripts/start-icons.sh +++ b/packages/realm-server/scripts/start-icons.sh @@ -1,8 +1,41 @@ #! /bin/sh -if curl --fail --silent --show-error http://localhost:4206 >/dev/null 2>&1; then - echo "icons server already running on http://localhost:4206, skipping startup" - exit 0 -fi +if [ -n "$BOXEL_BRANCH" ]; then + # In branch mode, use port 0 (dynamic) and register with Traefik. + # http-server doesn't support port 0, so we pick a free port ourselves. + ICONS_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()' 2>/dev/null || node -e 'const s=require("net").createServer();s.listen(0,()=>{console.log(s.address().port);s.close();})') + echo "Starting icons server on dynamic port ${ICONS_PORT}" + cd "$(dirname "$0")/../../boxel-icons" && npx http-server --cors=Origin,X-Requested-With,Content-Type,Accept,Range,Authorization,X-Boxel-Assume-User --port "${ICONS_PORT}" dist & + ICONS_PID=$! + + # Register icons service with Traefik via a small node script + BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + node -e " + const yaml = require('yaml'); + const fs = require('fs'); + const path = require('path'); + const dir = path.resolve(__dirname, '..', '..', 'traefik', 'dynamic'); + const slug = '${BRANCH_SLUG}'; + const configPath = path.join(dir, slug + '.yml'); + let config = {}; + try { config = yaml.parse(fs.readFileSync(configPath, 'utf-8')) || {}; } catch {} + if (!config.http) config.http = {}; + if (!config.http.routers) config.http.routers = {}; + if (!config.http.services) config.http.services = {}; + config.http.routers['icons-' + slug] = { rule: 'Host(\`icons.${BRANCH_SLUG}.localdev.boxel.ai\`)', service: 'icons-' + slug, entryPoints: ['web'] }; + config.http.services['icons-' + slug] = { loadBalancer: { servers: [{ url: 'http://host.docker.internal:${ICONS_PORT}' }] } }; + const tmp = configPath + '.tmp'; + fs.writeFileSync(tmp, yaml.stringify(config), 'utf-8'); + fs.renameSync(tmp, configPath); + console.log('Registered icons at icons.${BRANCH_SLUG}.localdev.boxel.ai -> localhost:${ICONS_PORT}'); + " -pnpm --dir=../boxel-icons serve + wait $ICONS_PID +else + if curl --fail --silent --show-error http://localhost:4206 >/dev/null 2>&1; then + echo "icons server already running on http://localhost:4206, skipping startup" + exit 0 + fi + + pnpm --dir=../boxel-icons serve +fi diff --git a/packages/realm-server/scripts/start-prerender-dev.sh b/packages/realm-server/scripts/start-prerender-dev.sh index a6b1feadc4b..ab92bd12b14 100755 --- a/packages/realm-server/scripts/start-prerender-dev.sh +++ b/packages/realm-server/scripts/start-prerender-dev.sh @@ -1,10 +1,20 @@ #! /bin/sh SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" +# Branch-mode configuration +if [ -n "$BOXEL_BRANCH" ]; then + BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + PRERENDER_PORT=0 + DEFAULT_HOST_URL="http://host.${BRANCH_SLUG}.localdev.boxel.ai" +else + PRERENDER_PORT=4221 + DEFAULT_HOST_URL="http://localhost:4200" +fi + # Environment for development prerender server NODE_ENV=development \ NODE_NO_WARNINGS=1 \ - BOXEL_HOST_URL="${HOST_URL:-http://localhost:4200}" \ + BOXEL_HOST_URL="${HOST_URL:-$DEFAULT_HOST_URL}" \ ts-node \ --transpileOnly prerender/prerender-server \ - --port=4221 + --port="${PRERENDER_PORT}" diff --git a/packages/realm-server/scripts/start-prerender-manager-dev.sh b/packages/realm-server/scripts/start-prerender-manager-dev.sh index 088ad16379a..267db323edd 100755 --- a/packages/realm-server/scripts/start-prerender-manager-dev.sh +++ b/packages/realm-server/scripts/start-prerender-manager-dev.sh @@ -4,10 +4,17 @@ SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" # Start the prerender manager in development # Ports default to 4222 unless PRERENDER_MANAGER_PORT is provided +# Branch-mode configuration +if [ -n "$BOXEL_BRANCH" ]; then + DEFAULT_PRERENDER_MGR_PORT=0 +else + DEFAULT_PRERENDER_MGR_PORT=4222 +fi + NODE_ENV=development \ NODE_NO_WARNINGS=1 \ PRERENDER_MANAGER_VERBOSE_LOGS=false \ ts-node \ --transpileOnly prerender/manager-server \ - --port=${PRERENDER_MANAGER_PORT:-4222} \ + --port=${PRERENDER_MANAGER_PORT:-$DEFAULT_PRERENDER_MGR_PORT} \ --exit-on-signal diff --git a/packages/realm-server/scripts/start-worker-development.sh b/packages/realm-server/scripts/start-worker-development.sh index f9901e84420..a934e8e965a 100755 --- a/packages/realm-server/scripts/start-worker-development.sh +++ b/packages/realm-server/scripts/start-worker-development.sh @@ -4,17 +4,31 @@ SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPTS_DIR/wait-for-prerender.sh" wait_for_postgres -PRERENDER_URL="${PRERENDER_URL:-http://localhost:4222}" + +# Branch-mode configuration +if [ -n "$BOXEL_BRANCH" ]; then + BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + REALM_BASE_URL="http://realm.${BRANCH_SLUG}.localdev.boxel.ai" + WORKER_PORT=0 + PGDATABASE_VAL="boxel_${BRANCH_SLUG}" + PRERENDER_URL="${PRERENDER_URL:-http://prerender-mgr.${BRANCH_SLUG}.localdev.boxel.ai}" +else + REALM_BASE_URL="http://localhost:4201" + WORKER_PORT=4210 + PGDATABASE_VAL="boxel" + PRERENDER_URL="${PRERENDER_URL:-http://localhost:4222}" +fi + wait_for_prerender "$PRERENDER_URL" -DEFAULT_CATALOG_REALM_URL='http://localhost:4201/catalog/' +DEFAULT_CATALOG_REALM_URL="${REALM_BASE_URL}/catalog/" CATALOG_REALM_URL="${RESOLVED_CATALOG_REALM_URL:-$DEFAULT_CATALOG_REALM_URL}" NODE_ENV=development \ NODE_NO_WARNINGS=1 \ NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=4096}" \ PGPORT=5435 \ - PGDATABASE=boxel \ + PGDATABASE="${PGDATABASE_VAL}" \ LOG_LEVELS='*=info' \ REALM_SECRET_SEED="shhh! it's a secret" \ REALM_SERVER_MATRIX_USERNAME=realm_server \ @@ -23,18 +37,18 @@ NODE_ENV=development \ --transpileOnly worker-manager \ --allPriorityCount="${WORKER_ALL_PRIORITY_COUNT:-1}" \ --highPriorityCount="${WORKER_HIGH_PRIORITY_COUNT:-0}" \ - --port=4210 \ + --port="${WORKER_PORT}" \ --matrixURL='http://localhost:8008' \ --prerendererUrl="${PRERENDER_URL}" \ \ --fromUrl='https://cardstack.com/base/' \ - --toUrl='http://localhost:4201/base/' \ + --toUrl="${REALM_BASE_URL}/base/" \ \ - --fromUrl='http://localhost:4201/experiments/' \ - --toUrl='http://localhost:4201/experiments/' \ + --fromUrl="${REALM_BASE_URL}/experiments/" \ + --toUrl="${REALM_BASE_URL}/experiments/" \ \ --fromUrl='@cardstack/catalog/' \ --toUrl="${CATALOG_REALM_URL}" \ \ - --fromUrl='http://localhost:4201/skills/' \ - --toUrl='http://localhost:4201/skills/' + --fromUrl="${REALM_BASE_URL}/skills/" \ + --toUrl="${REALM_BASE_URL}/skills/" diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index adce6ad979b..f634f604c00 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -255,7 +255,9 @@ export class RealmServer { listen(port: number) { let instance = this.app.listen(port); - this.log.info(`Realm server listening on port %s\n`, port); + let actualPort = + (instance.address() as import('net').AddressInfo | null)?.port ?? port; + this.log.info(`Realm server listening on port %s\n`, actualPort); return instance; } diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index a7c55cc5e34..cbc9dfc542f 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -29,6 +29,11 @@ import { createDailyCreditGrantCronJob, parseLowCreditThreshold, } from './lib/daily-credit-grant-config'; +import { + isBranchMode, + registerService, + deregisterBranch, +} from './lib/dev-service-registry'; /* About the Worker Manager * @@ -165,11 +170,20 @@ if (port) { }); webServerInstance = webServer.listen(port); + if (isBranchMode() && webServerInstance) { + registerService(webServerInstance, 'worker'); + } log.info(`worker manager HTTP listening on port ${port}`); } const shutdown = (onShutdown?: () => void) => { log.info(`Shutting down server for worker manager...`); + if (isBranchMode()) { + // deregisterBranch is idempotent; the realm server's shutdown also + // calls it, but calling here ensures cleanup if the worker manager exits + // independently. + deregisterBranch(); + } if (dailyCreditGrantJob) { log.info('Stopping daily-credit-grant cron job...'); diff --git a/scripts/ensure-branch-db.sh b/scripts/ensure-branch-db.sh new file mode 100755 index 00000000000..40180e1dbdc --- /dev/null +++ b/scripts/ensure-branch-db.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Creates a per-branch PostgreSQL database if it doesn't already exist. +# Usage: ensure-branch-db.sh [branch-slug] +# If no branch slug is given, derives it from BOXEL_BRANCH or the current git branch. + +set -e + +if [ -n "$1" ]; then + SLUG="$1" +elif [ -n "$BOXEL_BRANCH" ]; then + SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') +else + SLUG=$(git branch --show-current | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') +fi + +DB_NAME="boxel_${SLUG}" +PGPORT="${PGPORT:-5435}" + +echo "Ensuring database '${DB_NAME}' exists on port ${PGPORT}..." + +# Check if DB exists; create if not +if psql -p "$PGPORT" -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then + echo "Database '${DB_NAME}' already exists." +else + createdb -p "$PGPORT" "$DB_NAME" + echo "Created database '${DB_NAME}'." +fi diff --git a/scripts/start-traefik.sh b/scripts/start-traefik.sh new file mode 100755 index 00000000000..2ad2c994cbb --- /dev/null +++ b/scripts/start-traefik.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Starts the Traefik reverse proxy if not already running. +# Idempotent — safe to call multiple times. + +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +if docker ps --format '{{.Names}}' | grep -q '^boxel-traefik$'; then + echo "Traefik is already running." + exit 0 +fi + +echo "Starting Traefik..." +docker compose -f "$REPO_ROOT/docker-compose.traefik.yml" up -d +echo "Traefik started. Dashboard at http://localhost:8080" diff --git a/traefik/dynamic/.gitkeep b/traefik/dynamic/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/traefik/traefik.yml b/traefik/traefik.yml new file mode 100644 index 00000000000..7c61a9dc383 --- /dev/null +++ b/traefik/traefik.yml @@ -0,0 +1,12 @@ +entryPoints: + web: + address: ":80" + +api: + dashboard: true + insecure: true + +providers: + file: + directory: /etc/traefik/dynamic + watch: true From 48e31b92936d8eaf9253431ac121a885997be3be Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 13:39:36 -0700 Subject: [PATCH 02/21] Change Traefik port --- docker-compose.traefik.yml | 2 +- scripts/start-traefik.sh | 2 +- traefik/traefik.yml | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 280eff3d05d..7ba7218c771 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -4,7 +4,7 @@ services: container_name: boxel-traefik ports: - "80:80" - - "8080:8080" + - "4230:4230" volumes: - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro - ./traefik/dynamic:/etc/traefik/dynamic:ro diff --git a/scripts/start-traefik.sh b/scripts/start-traefik.sh index 2ad2c994cbb..22ea31b4e7d 100755 --- a/scripts/start-traefik.sh +++ b/scripts/start-traefik.sh @@ -13,4 +13,4 @@ fi echo "Starting Traefik..." docker compose -f "$REPO_ROOT/docker-compose.traefik.yml" up -d -echo "Traefik started. Dashboard at http://localhost:8080" +echo "Traefik started. Dashboard at http://localhost:4230" diff --git a/traefik/traefik.yml b/traefik/traefik.yml index 7c61a9dc383..8a44afd6201 100644 --- a/traefik/traefik.yml +++ b/traefik/traefik.yml @@ -1,6 +1,8 @@ entryPoints: web: address: ":80" + traefik: + address: ":4230" api: dashboard: true From 56d3f2c60ac4f6e2dec4a3ddbf8934b616eeefd3 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 13:40:19 -0700 Subject: [PATCH 03/21] Fix startup of host --- packages/host/package.json | 2 +- packages/host/scripts/ember-serve.js | 128 +++++++++++++++++++++++++++ packages/host/scripts/serve-dist.js | 2 +- 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 packages/host/scripts/ember-serve.js diff --git a/packages/host/package.json b/packages/host/package.json index 10e2c9b572c..8193d3fba88 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -22,7 +22,7 @@ "lint:js:fix": "eslint . --report-unused-disable-directives --fix", "lint:glint": "glint", "ensure-boxel-ui": "../boxel-ui/addon/bin/conditional-build.sh", - "start": "pnpm ensure-boxel-ui && NODE_OPTIONS='--max-old-space-size=8192' ember serve", + "start": "pnpm ensure-boxel-ui && node scripts/ember-serve.js", "serve:dist": "node scripts/serve-dist.js", "serve:dist:legacy": "serve --config ../tests/serve.json --single --cors --no-request-logging --no-etag --listen 4200 dist", "start:build": "NODE_OPTIONS='--max-old-space-size=8192' ember build --watch", diff --git a/packages/host/scripts/ember-serve.js b/packages/host/scripts/ember-serve.js new file mode 100644 index 00000000000..6dddb74a9c4 --- /dev/null +++ b/packages/host/scripts/ember-serve.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node + +/** + * Wrapper around `ember serve` that supports dynamic port allocation in branch mode. + * When BOXEL_BRANCH is set, picks a free port, passes --port to ember serve, + * then registers with Traefik so that `host..localdev.boxel.ai` routes here. + * When BOXEL_BRANCH is not set, behaves identically to the old start command. + */ + +const { spawn } = require('child_process'); +const path = require('path'); +const net = require('net'); +const fs = require('fs'); + +const BOXEL_BRANCH = process.env.BOXEL_BRANCH; + +function sanitizeSlug(raw) { + return raw + .toLowerCase() + .replace(/\//g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +function registerWithTraefik(slug, hostname, port) { + const dynamicDir = path.resolve( + __dirname, '..', '..', '..', 'traefik', 'dynamic', + ); + const configPath = path.join(dynamicDir, `${slug}.yml`); + const routerKey = `host-${slug}`; + + let yaml; + try { + const realmServerDir = path.resolve(__dirname, '..', '..', 'realm-server'); + yaml = require(require.resolve('yaml', { paths: [realmServerDir] })); + } catch { + // yaml not resolvable — write minimal YAML by hand + const entry = [ + 'http:', + ' routers:', + ` ${routerKey}:`, + ' rule: "Host(`' + hostname + '`)"', + ` service: ${routerKey}`, + ' entryPoints:', + ' - web', + ' services:', + ` ${routerKey}:`, + ' loadBalancer:', + ' servers:', + ` - url: "http://host.docker.internal:${port}"`, + '', + ].join('\n'); + const tmpPath = configPath + '.tmp'; + fs.writeFileSync(tmpPath, entry, 'utf-8'); + fs.renameSync(tmpPath, configPath); + return; + } + + let config = {}; + try { + config = yaml.parse(fs.readFileSync(configPath, 'utf-8')) || {}; + } catch { + // file may not exist yet + } + if (!config.http) config.http = {}; + if (!config.http.routers) config.http.routers = {}; + if (!config.http.services) config.http.services = {}; + + config.http.routers[routerKey] = { + rule: `Host(\`${hostname}\`)`, + service: routerKey, + entryPoints: ['web'], + }; + config.http.services[routerKey] = { + loadBalancer: { + servers: [{ url: `http://host.docker.internal:${port}` }], + }, + }; + + const tmpPath = configPath + '.tmp'; + fs.writeFileSync(tmpPath, yaml.stringify(config), 'utf-8'); + fs.renameSync(tmpPath, configPath); +} + +function startEmber(port) { + const args = ['serve', '--port', String(port)]; + const child = spawn('ember', args, { + stdio: 'inherit', + cwd: path.join(__dirname, '..'), + shell: true, + env: { + ...process.env, + NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=8192', + }, + }); + child.on('exit', (code) => process.exit(code || 0)); + return child; +} + +if (!BOXEL_BRANCH) { + // Legacy mode: default ember serve on port 4200 + startEmber(4200); +} else { + const slug = sanitizeSlug(BOXEL_BRANCH); + const hostname = `host.${slug}.localdev.boxel.ai`; + + // Find a free port + const srv = net.createServer(); + srv.listen(0, () => { + const port = srv.address().port; + srv.close(() => { + console.log(`[branch-mode] Starting ember serve on dynamic port ${port}`); + console.log(`[branch-mode] Will be accessible at http://${hostname}`); + + startEmber(port); + + try { + registerWithTraefik(slug, hostname, port); + console.log( + `[branch-mode] Registered host at ${hostname} -> localhost:${port}`, + ); + } catch (e) { + console.error('[branch-mode] Failed to register with Traefik:', e.message); + } + }); + }); +} diff --git a/packages/host/scripts/serve-dist.js b/packages/host/scripts/serve-dist.js index 97db946158c..8dd90974d82 100644 --- a/packages/host/scripts/serve-dist.js +++ b/packages/host/scripts/serve-dist.js @@ -71,7 +71,7 @@ if (!BOXEL_BRANCH) { 'http:', ' routers:', ` ${routerKey}:`, - ` rule: "Host(\\`${hostname}\\`)"`, + ' rule: "Host(`' + hostname + '`)"', ` service: ${routerKey}`, ' entryPoints:', ' - web', From 0f437902b9b87dbab9890ac6f58b7d65b57d24b6 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 13:46:31 -0700 Subject: [PATCH 04/21] Change root domain --- packages/host/config/environment.js | 4 ++-- packages/host/scripts/ember-serve.js | 4 ++-- packages/host/scripts/serve-dist.js | 4 ++-- packages/realm-server/lib/dev-service-registry.ts | 2 +- packages/realm-server/main.ts | 2 +- packages/realm-server/scripts/start-all.sh | 4 ++-- packages/realm-server/scripts/start-development.sh | 6 +++--- packages/realm-server/scripts/start-icons.sh | 4 ++-- packages/realm-server/scripts/start-prerender-dev.sh | 2 +- packages/realm-server/scripts/start-worker-development.sh | 4 ++-- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index a77d4e8113e..0b84a5835b2 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -32,11 +32,11 @@ function branchDefaults() { }; } let slug = branchSlug(); - let realmHost = `realm.${slug}.localdev.boxel.ai`; + let realmHost = `realm.${slug}.lvh.me`; return { realmServerURL: `http://${realmHost}/`, realmHost, - iconsURL: `http://icons.${slug}.localdev.boxel.ai`, + iconsURL: `http://icons.${slug}.lvh.me`, baseRealmURL: `http://${realmHost}/base/`, catalogRealmURL: `http://${realmHost}/catalog/`, skillsRealmURL: `http://${realmHost}/skills/`, diff --git a/packages/host/scripts/ember-serve.js b/packages/host/scripts/ember-serve.js index 6dddb74a9c4..c209a865151 100644 --- a/packages/host/scripts/ember-serve.js +++ b/packages/host/scripts/ember-serve.js @@ -3,7 +3,7 @@ /** * Wrapper around `ember serve` that supports dynamic port allocation in branch mode. * When BOXEL_BRANCH is set, picks a free port, passes --port to ember serve, - * then registers with Traefik so that `host..localdev.boxel.ai` routes here. + * then registers with Traefik so that `host..lvh.me` routes here. * When BOXEL_BRANCH is not set, behaves identically to the old start command. */ @@ -103,7 +103,7 @@ if (!BOXEL_BRANCH) { startEmber(4200); } else { const slug = sanitizeSlug(BOXEL_BRANCH); - const hostname = `host.${slug}.localdev.boxel.ai`; + const hostname = `host.${slug}.lvh.me`; // Find a free port const srv = net.createServer(); diff --git a/packages/host/scripts/serve-dist.js b/packages/host/scripts/serve-dist.js index 8dd90974d82..eaffc9cef82 100644 --- a/packages/host/scripts/serve-dist.js +++ b/packages/host/scripts/serve-dist.js @@ -3,7 +3,7 @@ /** * Wrapper around `serve` that supports dynamic port allocation in branch mode. * When BOXEL_BRANCH is set, picks a free port, starts `serve`, then registers - * with Traefik so that `host..localdev.boxel.ai` routes to this instance. + * with Traefik so that `host..lvh.me` routes to this instance. * When BOXEL_BRANCH is not set, behaves identically to the old serve:dist command. */ @@ -115,7 +115,7 @@ if (!BOXEL_BRANCH) { } const slug = sanitizeSlug(BOXEL_BRANCH); - const hostname = `host.${slug}.localdev.boxel.ai`; + const hostname = `host.${slug}.lvh.me`; // Find a free port const srv = net.createServer(); diff --git a/packages/realm-server/lib/dev-service-registry.ts b/packages/realm-server/lib/dev-service-registry.ts index 4630a7c272c..e90e002a1eb 100644 --- a/packages/realm-server/lib/dev-service-registry.ts +++ b/packages/realm-server/lib/dev-service-registry.ts @@ -8,7 +8,7 @@ import yaml from 'yaml'; const log = logger('dev-service-registry'); -const DOMAIN = 'localdev.boxel.ai'; +const DOMAIN = 'lvh.me'; // Resolve traefik/dynamic dir relative to repo root function traefikDynamicDir(): string { diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index a94f049786e..4f9f881bf2f 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -342,7 +342,7 @@ const getIndexHTML = async () => { // PUBLISHED_REALM_BOXEL_SPACE_DOMAIN is used to form urls like "mike.boxel.space/game-mechanics" // PUBLISHED_REALM_BOXEL_SITE_DOMAIN is used to form urls like "mike.boxel.site" let defaultPublishedDomain = isBranchMode() - ? `realm.${getBranchSlug()}.localdev.boxel.ai` + ? `realm.${getBranchSlug()}.lvh.me` : 'localhost:4201'; let domainsForPublishedRealms = { boxelSpace: diff --git a/packages/realm-server/scripts/start-all.sh b/packages/realm-server/scripts/start-all.sh index 07a27d60690..c81a0ee133e 100755 --- a/packages/realm-server/scripts/start-all.sh +++ b/packages/realm-server/scripts/start-all.sh @@ -3,8 +3,8 @@ # Branch-mode: when BOXEL_BRANCH is set, use Traefik hostnames for readiness checks if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') - REALM_HOST="realm.${BRANCH_SLUG}.localdev.boxel.ai" - ICONS_HOST="icons.${BRANCH_SLUG}.localdev.boxel.ai" + REALM_HOST="realm.${BRANCH_SLUG}.lvh.me" + ICONS_HOST="icons.${BRANCH_SLUG}.lvh.me" BASE_REALM="http-get://${REALM_HOST}/base/" CATALOG_REALM="http-get://${REALM_HOST}/catalog/" diff --git a/packages/realm-server/scripts/start-development.sh b/packages/realm-server/scripts/start-development.sh index 6c47d694a54..86f024e6743 100755 --- a/packages/realm-server/scripts/start-development.sh +++ b/packages/realm-server/scripts/start-development.sh @@ -33,7 +33,7 @@ START_SUBMISSION=$(if [ -z "$SKIP_SUBMISSION" ]; then echo "true"; else echo ""; # Traefik routing instead of hardcoded ports. if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') - REALM_BASE_URL="http://realm.${BRANCH_SLUG}.localdev.boxel.ai" + REALM_BASE_URL="http://realm.${BRANCH_SLUG}.lvh.me" REALM_PORT=0 REALMS_ROOT="./realms/${BRANCH_SLUG}" PGDATABASE_VAL="boxel_${BRANCH_SLUG}" @@ -70,8 +70,8 @@ fi # In branch mode, override prerender URL and worker manager arg to use Traefik hostnames if [ -n "$BOXEL_BRANCH" ]; then - PRERENDER_URL="${PRERENDER_URL:-http://prerender.${BRANCH_SLUG}.localdev.boxel.ai}" - WORKER_MANAGER_ARG="--workerManagerUrl=http://worker.${BRANCH_SLUG}.localdev.boxel.ai" + PRERENDER_URL="${PRERENDER_URL:-http://prerender.${BRANCH_SLUG}.lvh.me}" + WORKER_MANAGER_ARG="--workerManagerUrl=http://worker.${BRANCH_SLUG}.lvh.me" else PRERENDER_URL="${PRERENDER_URL:-http://localhost:4221}" WORKER_MANAGER_ARG="$1" diff --git a/packages/realm-server/scripts/start-icons.sh b/packages/realm-server/scripts/start-icons.sh index b38fa195353..76e6dd0b095 100644 --- a/packages/realm-server/scripts/start-icons.sh +++ b/packages/realm-server/scripts/start-icons.sh @@ -22,12 +22,12 @@ if [ -n "$BOXEL_BRANCH" ]; then if (!config.http) config.http = {}; if (!config.http.routers) config.http.routers = {}; if (!config.http.services) config.http.services = {}; - config.http.routers['icons-' + slug] = { rule: 'Host(\`icons.${BRANCH_SLUG}.localdev.boxel.ai\`)', service: 'icons-' + slug, entryPoints: ['web'] }; + config.http.routers['icons-' + slug] = { rule: 'Host(\`icons.${BRANCH_SLUG}.lvh.me\`)', service: 'icons-' + slug, entryPoints: ['web'] }; config.http.services['icons-' + slug] = { loadBalancer: { servers: [{ url: 'http://host.docker.internal:${ICONS_PORT}' }] } }; const tmp = configPath + '.tmp'; fs.writeFileSync(tmp, yaml.stringify(config), 'utf-8'); fs.renameSync(tmp, configPath); - console.log('Registered icons at icons.${BRANCH_SLUG}.localdev.boxel.ai -> localhost:${ICONS_PORT}'); + console.log('Registered icons at icons.${BRANCH_SLUG}.lvh.me -> localhost:${ICONS_PORT}'); " wait $ICONS_PID diff --git a/packages/realm-server/scripts/start-prerender-dev.sh b/packages/realm-server/scripts/start-prerender-dev.sh index ab92bd12b14..07349d79156 100755 --- a/packages/realm-server/scripts/start-prerender-dev.sh +++ b/packages/realm-server/scripts/start-prerender-dev.sh @@ -5,7 +5,7 @@ SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') PRERENDER_PORT=0 - DEFAULT_HOST_URL="http://host.${BRANCH_SLUG}.localdev.boxel.ai" + DEFAULT_HOST_URL="http://host.${BRANCH_SLUG}.lvh.me" else PRERENDER_PORT=4221 DEFAULT_HOST_URL="http://localhost:4200" diff --git a/packages/realm-server/scripts/start-worker-development.sh b/packages/realm-server/scripts/start-worker-development.sh index a934e8e965a..5d7510f8a40 100755 --- a/packages/realm-server/scripts/start-worker-development.sh +++ b/packages/realm-server/scripts/start-worker-development.sh @@ -8,10 +8,10 @@ wait_for_postgres # Branch-mode configuration if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') - REALM_BASE_URL="http://realm.${BRANCH_SLUG}.localdev.boxel.ai" + REALM_BASE_URL="http://realm.${BRANCH_SLUG}.lvh.me" WORKER_PORT=0 PGDATABASE_VAL="boxel_${BRANCH_SLUG}" - PRERENDER_URL="${PRERENDER_URL:-http://prerender-mgr.${BRANCH_SLUG}.localdev.boxel.ai}" + PRERENDER_URL="${PRERENDER_URL:-http://prerender-mgr.${BRANCH_SLUG}.lvh.me}" else REALM_BASE_URL="http://localhost:4201" WORKER_PORT=4210 From 7d4474c7a52def7933765d8ebc92542cada85c6a Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 13:58:26 -0700 Subject: [PATCH 05/21] Add check for Traefik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I had some confusion as Traefik URLs were printing in logs but it wasn’t actually running. --- packages/host/scripts/ember-serve.js | 3 ++ packages/host/scripts/ensure-traefik.js | 29 +++++++++++++++++ packages/host/scripts/serve-dist.js | 3 ++ .../realm-server/scripts/ensure-traefik.sh | 31 +++++++++++++++++++ .../realm-server/scripts/start-development.sh | 3 ++ .../scripts/start-prerender-dev.sh | 3 ++ .../scripts/start-prerender-manager-dev.sh | 3 ++ .../scripts/start-worker-development.sh | 2 ++ 8 files changed, 77 insertions(+) create mode 100644 packages/host/scripts/ensure-traefik.js create mode 100644 packages/realm-server/scripts/ensure-traefik.sh diff --git a/packages/host/scripts/ember-serve.js b/packages/host/scripts/ember-serve.js index c209a865151..fa4560f513e 100644 --- a/packages/host/scripts/ember-serve.js +++ b/packages/host/scripts/ember-serve.js @@ -102,6 +102,9 @@ if (!BOXEL_BRANCH) { // Legacy mode: default ember serve on port 4200 startEmber(4200); } else { + const { ensureTraefik } = require('./ensure-traefik'); + ensureTraefik(); + const slug = sanitizeSlug(BOXEL_BRANCH); const hostname = `host.${slug}.lvh.me`; diff --git a/packages/host/scripts/ensure-traefik.js b/packages/host/scripts/ensure-traefik.js new file mode 100644 index 00000000000..5115786660f --- /dev/null +++ b/packages/host/scripts/ensure-traefik.js @@ -0,0 +1,29 @@ +/** + * Checks that the boxel-traefik Docker container is running. + * Called from branch-mode host scripts before registering with Traefik. + */ + +const { execSync } = require('child_process'); + +function ensureTraefik() { + try { + const output = execSync( + "docker ps --format '{{.Names}}' 2>/dev/null", + { encoding: 'utf-8' }, + ); + if (output.split('\n').some((name) => name.trim() === 'boxel-traefik')) { + return; // already running + } + } catch { + // docker not available or errored + } + + console.error( + '\n[branch-mode] ERROR: Traefik is not running.\n' + + ' Branch mode requires Traefik for hostname-based routing.\n' + + ' Start it with: sh scripts/start-traefik.sh\n', + ); + process.exit(1); +} + +module.exports = { ensureTraefik }; diff --git a/packages/host/scripts/serve-dist.js b/packages/host/scripts/serve-dist.js index eaffc9cef82..8f5ccbbf9c6 100644 --- a/packages/host/scripts/serve-dist.js +++ b/packages/host/scripts/serve-dist.js @@ -36,6 +36,9 @@ if (!BOXEL_BRANCH) { runServe(4200); } else { // Branch mode: dynamic port + Traefik registration + const { ensureTraefik } = require('./ensure-traefik'); + ensureTraefik(); + const net = require('net'); const fs = require('fs'); diff --git a/packages/realm-server/scripts/ensure-traefik.sh b/packages/realm-server/scripts/ensure-traefik.sh new file mode 100644 index 00000000000..7b94fc0cb26 --- /dev/null +++ b/packages/realm-server/scripts/ensure-traefik.sh @@ -0,0 +1,31 @@ +#! /bin/sh + +# Ensures Traefik is running when in branch mode (BOXEL_BRANCH is set). +# Sources like wait-for-pg.sh: `. "$SCRIPTS_DIR/ensure-traefik.sh"` +# Call ensure_traefik after sourcing. + +ensure_traefik() { + if [ -z "$BOXEL_BRANCH" ]; then + return 0 + fi + + if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^boxel-traefik$'; then + return 0 + fi + + echo "Branch mode requires Traefik. Starting it now..." + REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" + if [ ! -f "$REPO_ROOT/docker-compose.traefik.yml" ]; then + echo "ERROR: docker-compose.traefik.yml not found at $REPO_ROOT" + echo "Cannot start Traefik. Please run: sh scripts/start-traefik.sh" + exit 1 + fi + + docker compose -f "$REPO_ROOT/docker-compose.traefik.yml" up -d + if [ $? -ne 0 ]; then + echo "ERROR: Failed to start Traefik." + echo "Is Docker running? Try: sh scripts/start-traefik.sh" + exit 1 + fi + echo "Traefik started. Dashboard at http://localhost:4230" +} diff --git a/packages/realm-server/scripts/start-development.sh b/packages/realm-server/scripts/start-development.sh index 86f024e6743..f9cc1a8f9ce 100755 --- a/packages/realm-server/scripts/start-development.sh +++ b/packages/realm-server/scripts/start-development.sh @@ -1,6 +1,9 @@ #! /bin/sh SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPTS_DIR/wait-for-pg.sh" +. "$SCRIPTS_DIR/ensure-traefik.sh" + +ensure_traefik sh "$SCRIPTS_DIR/start-icons.sh" & ICONS_PID=$! diff --git a/packages/realm-server/scripts/start-prerender-dev.sh b/packages/realm-server/scripts/start-prerender-dev.sh index 07349d79156..786a622393a 100755 --- a/packages/realm-server/scripts/start-prerender-dev.sh +++ b/packages/realm-server/scripts/start-prerender-dev.sh @@ -1,5 +1,8 @@ #! /bin/sh SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPTS_DIR/ensure-traefik.sh" + +ensure_traefik # Branch-mode configuration if [ -n "$BOXEL_BRANCH" ]; then diff --git a/packages/realm-server/scripts/start-prerender-manager-dev.sh b/packages/realm-server/scripts/start-prerender-manager-dev.sh index 267db323edd..97e2c8a2f6d 100755 --- a/packages/realm-server/scripts/start-prerender-manager-dev.sh +++ b/packages/realm-server/scripts/start-prerender-manager-dev.sh @@ -1,5 +1,8 @@ #! /bin/sh SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPTS_DIR/ensure-traefik.sh" + +ensure_traefik # Start the prerender manager in development # Ports default to 4222 unless PRERENDER_MANAGER_PORT is provided diff --git a/packages/realm-server/scripts/start-worker-development.sh b/packages/realm-server/scripts/start-worker-development.sh index 5d7510f8a40..12df2d8f78f 100755 --- a/packages/realm-server/scripts/start-worker-development.sh +++ b/packages/realm-server/scripts/start-worker-development.sh @@ -2,7 +2,9 @@ SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPTS_DIR/wait-for-pg.sh" . "$SCRIPTS_DIR/wait-for-prerender.sh" +. "$SCRIPTS_DIR/ensure-traefik.sh" +ensure_traefik wait_for_postgres # Branch-mode configuration From e139b1717b377d3e3315e062792bf66f9a61ad4a Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 14:16:30 -0700 Subject: [PATCH 06/21] Change to use Traefik config-file-per-service I stopped being able to access the host after the realm server had errors starting up. --- packages/host/scripts/ember-serve.js | 67 +++--------- packages/host/scripts/serve-dist.js | 68 +++--------- .../realm-server/lib/dev-service-registry.ts | 103 +++++++++++------- packages/realm-server/main.ts | 8 +- .../realm-server/prerender/manager-server.ts | 12 +- .../realm-server/prerender/prerender-app.ts | 4 + .../prerender/prerender-server.ts | 15 ++- .../realm-server/prerender/prerenderer.ts | 5 +- packages/realm-server/scripts/start-icons.sh | 28 +++-- packages/realm-server/server.ts | 8 +- packages/realm-server/worker-manager.ts | 19 ++-- 11 files changed, 164 insertions(+), 173 deletions(-) diff --git a/packages/host/scripts/ember-serve.js b/packages/host/scripts/ember-serve.js index fa4560f513e..97b424be1df 100644 --- a/packages/host/scripts/ember-serve.js +++ b/packages/host/scripts/ember-serve.js @@ -27,59 +27,26 @@ function registerWithTraefik(slug, hostname, port) { const dynamicDir = path.resolve( __dirname, '..', '..', '..', 'traefik', 'dynamic', ); - const configPath = path.join(dynamicDir, `${slug}.yml`); + const configPath = path.join(dynamicDir, `${slug}-host.yml`); const routerKey = `host-${slug}`; - let yaml; - try { - const realmServerDir = path.resolve(__dirname, '..', '..', 'realm-server'); - yaml = require(require.resolve('yaml', { paths: [realmServerDir] })); - } catch { - // yaml not resolvable — write minimal YAML by hand - const entry = [ - 'http:', - ' routers:', - ` ${routerKey}:`, - ' rule: "Host(`' + hostname + '`)"', - ` service: ${routerKey}`, - ' entryPoints:', - ' - web', - ' services:', - ` ${routerKey}:`, - ' loadBalancer:', - ' servers:', - ` - url: "http://host.docker.internal:${port}"`, - '', - ].join('\n'); - const tmpPath = configPath + '.tmp'; - fs.writeFileSync(tmpPath, entry, 'utf-8'); - fs.renameSync(tmpPath, configPath); - return; - } - - let config = {}; - try { - config = yaml.parse(fs.readFileSync(configPath, 'utf-8')) || {}; - } catch { - // file may not exist yet - } - if (!config.http) config.http = {}; - if (!config.http.routers) config.http.routers = {}; - if (!config.http.services) config.http.services = {}; - - config.http.routers[routerKey] = { - rule: `Host(\`${hostname}\`)`, - service: routerKey, - entryPoints: ['web'], - }; - config.http.services[routerKey] = { - loadBalancer: { - servers: [{ url: `http://host.docker.internal:${port}` }], - }, - }; - + const entry = [ + 'http:', + ' routers:', + ` ${routerKey}:`, + ' rule: "Host(`' + hostname + '`)"', + ` service: ${routerKey}`, + ' entryPoints:', + ' - web', + ' services:', + ` ${routerKey}:`, + ' loadBalancer:', + ' servers:', + ` - url: "http://host.docker.internal:${port}"`, + '', + ].join('\n'); const tmpPath = configPath + '.tmp'; - fs.writeFileSync(tmpPath, yaml.stringify(config), 'utf-8'); + fs.writeFileSync(tmpPath, entry, 'utf-8'); fs.renameSync(tmpPath, configPath); } diff --git a/packages/host/scripts/serve-dist.js b/packages/host/scripts/serve-dist.js index 8f5ccbbf9c6..f23ade5d2ec 100644 --- a/packages/host/scripts/serve-dist.js +++ b/packages/host/scripts/serve-dist.js @@ -60,60 +60,26 @@ if (!BOXEL_BRANCH) { const dynamicDir = path.resolve( __dirname, '..', '..', '..', 'traefik', 'dynamic', ); - const configPath = path.join(dynamicDir, `${slug}.yml`); + const configPath = path.join(dynamicDir, `${slug}-host.yml`); const routerKey = `host-${slug}`; - let yaml; - try { - // Try to resolve yaml from realm-server's dependencies - const realmServerDir = path.resolve(__dirname, '..', '..', 'realm-server'); - yaml = require(require.resolve('yaml', { paths: [realmServerDir] })); - } catch { - // yaml not resolvable — write minimal YAML by hand - const entry = [ - 'http:', - ' routers:', - ` ${routerKey}:`, - ' rule: "Host(`' + hostname + '`)"', - ` service: ${routerKey}`, - ' entryPoints:', - ' - web', - ' services:', - ` ${routerKey}:`, - ' loadBalancer:', - ' servers:', - ` - url: "http://host.docker.internal:${port}"`, - '', - ].join('\n'); - const tmpPath = configPath + '.tmp'; - fs.writeFileSync(tmpPath, entry, 'utf-8'); - fs.renameSync(tmpPath, configPath); - return; - } - - let config = {}; - try { - config = yaml.parse(fs.readFileSync(configPath, 'utf-8')) || {}; - } catch { - // file may not exist yet - } - if (!config.http) config.http = {}; - if (!config.http.routers) config.http.routers = {}; - if (!config.http.services) config.http.services = {}; - - config.http.routers[routerKey] = { - rule: `Host(\`${hostname}\`)`, - service: routerKey, - entryPoints: ['web'], - }; - config.http.services[routerKey] = { - loadBalancer: { - servers: [{ url: `http://host.docker.internal:${port}` }], - }, - }; - + const entry = [ + 'http:', + ' routers:', + ` ${routerKey}:`, + ' rule: "Host(`' + hostname + '`)"', + ` service: ${routerKey}`, + ' entryPoints:', + ' - web', + ' services:', + ` ${routerKey}:`, + ' loadBalancer:', + ' servers:', + ` - url: "http://host.docker.internal:${port}"`, + '', + ].join('\n'); const tmpPath = configPath + '.tmp'; - fs.writeFileSync(tmpPath, yaml.stringify(config), 'utf-8'); + fs.writeFileSync(tmpPath, entry, 'utf-8'); fs.renameSync(tmpPath, configPath); } diff --git a/packages/realm-server/lib/dev-service-registry.ts b/packages/realm-server/lib/dev-service-registry.ts index e90e002a1eb..b19a1ea3850 100644 --- a/packages/realm-server/lib/dev-service-registry.ts +++ b/packages/realm-server/lib/dev-service-registry.ts @@ -1,5 +1,10 @@ import { execSync } from 'child_process'; -import { writeFileSync, renameSync, unlinkSync, readFileSync } from 'fs'; +import { + writeFileSync, + renameSync, + unlinkSync, + readdirSync, +} from 'fs'; import { join, resolve } from 'path'; import { logger } from '@cardstack/runtime-common'; import type { Server } from 'http'; @@ -56,9 +61,11 @@ export function isBranchMode(): boolean { } /** - * Register a running HTTP server with Traefik by writing/merging a dynamic YAML config. - * The config file is `traefik/dynamic/.yml` and contains routers + services - * for all services in this branch. + * Register a running HTTP server with Traefik by writing a per-service + * dynamic YAML config file: `traefik/dynamic/-.yml`. + * + * Each service gets its own file so services can register/deregister + * independently without interfering with each other. */ export function registerService( server: Server, @@ -78,32 +85,26 @@ export function registerService( `Registering service ${serviceName} (port ${actualPort}) for branch ${slug}`, ); - let configPath = join(traefikDynamicDir(), `${slug}.yml`); - let config = loadExistingConfig(configPath); - + let configPath = join(traefikDynamicDir(), `${slug}-${serviceName}.yml`); let routerKey = `${serviceName}-${slug}`; - let serviceKey = `${serviceName}-${slug}`; let hostname = serviceHostname(serviceName, slug); - if (!config.http) { - config.http = {}; - } - if (!config.http.routers) { - config.http.routers = {}; - } - if (!config.http.services) { - config.http.services = {}; - } - - config.http.routers[routerKey] = { - rule: `Host(\`${hostname}\`)`, - service: serviceKey, - entryPoints: ['web'], - }; - - config.http.services[serviceKey] = { - loadBalancer: { - servers: [{ url: `http://host.docker.internal:${actualPort}` }], + let config: any = { + http: { + routers: { + [routerKey]: { + rule: `Host(\`${hostname}\`)`, + service: routerKey, + entryPoints: ['web'], + }, + }, + services: { + [routerKey]: { + loadBalancer: { + servers: [{ url: `http://host.docker.internal:${actualPort}` }], + }, + }, + }, }, }; @@ -114,27 +115,55 @@ export function registerService( } /** - * Remove the branch's Traefik dynamic config file on shutdown. + * Remove a single service's Traefik config file on shutdown. */ -export function deregisterBranch(branch?: string): void { +export function deregisterService( + serviceName: string, + branch?: string, +): void { let slug = branch ?? getBranchSlug(); - let configPath = join(traefikDynamicDir(), `${slug}.yml`); + let configPath = join(traefikDynamicDir(), `${slug}-${serviceName}.yml`); try { unlinkSync(configPath); - log.info(`Deregistered branch ${slug} from Traefik`); + log.info(`Deregistered ${serviceName} for branch ${slug} from Traefik`); } catch (e: any) { if (e.code !== 'ENOENT') { - log.error(`Failed to deregister branch ${slug}: ${e.message}`); + log.error( + `Failed to deregister ${serviceName} for branch ${slug}: ${e.message}`, + ); } } } -function loadExistingConfig(configPath: string): any { +/** + * Remove all Traefik dynamic config files for a branch on full shutdown. + */ +export function deregisterBranch(branch?: string): void { + let slug = branch ?? getBranchSlug(); + let dir = traefikDynamicDir(); + let prefix = `${slug}-`; try { - let content = readFileSync(configPath, 'utf-8'); - return yaml.parse(content) || {}; - } catch { - return {}; + let files = readdirSync(dir); + let removed = 0; + for (let file of files) { + if (file.startsWith(prefix) && file.endsWith('.yml')) { + try { + unlinkSync(join(dir, file)); + removed++; + } catch (e: any) { + if (e.code !== 'ENOENT') { + log.error(`Failed to remove ${file}: ${e.message}`); + } + } + } + } + if (removed > 0) { + log.info( + `Deregistered branch ${slug} from Traefik (${removed} service(s))`, + ); + } + } catch (e: any) { + log.error(`Failed to deregister branch ${slug}: ${e.message}`); } } diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index 4f9f881bf2f..41923947702 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -377,9 +377,11 @@ const getIndexHTML = async () => { }); let httpServer = server.listen(port); - if (isBranchMode()) { - registerService(httpServer, 'realm'); - } + httpServer.on('listening', () => { + if (isBranchMode()) { + registerService(httpServer, 'realm'); + } + }); process.on('message', (message) => { if (message === 'stop') { console.log(`stopping realm server on port ${port}...`); diff --git a/packages/realm-server/prerender/manager-server.ts b/packages/realm-server/prerender/manager-server.ts index 4f1dd9d366f..987eb7b88cd 100644 --- a/packages/realm-server/prerender/manager-server.ts +++ b/packages/realm-server/prerender/manager-server.ts @@ -38,10 +38,14 @@ let { app } = buildPrerenderManagerApp({ }); let _webServerInstance: Server | undefined; _webServerInstance = createServer(app.callback()).listen(port); -if (isBranchMode() && _webServerInstance) { - registerService(_webServerInstance, 'prerender-mgr'); -} -log.info(`prerender manager HTTP listening on port ${port}`); +_webServerInstance.on('listening', () => { + let actualPort = + (_webServerInstance!.address() as import('net').AddressInfo).port ?? port; + if (isBranchMode()) { + registerService(_webServerInstance!, 'prerender-mgr'); + } + log.info(`prerender manager HTTP listening on port ${actualPort}`); +}); function shutdown(signal: NodeJS.Signals) { if (draining) return; diff --git a/packages/realm-server/prerender/prerender-app.ts b/packages/realm-server/prerender/prerender-app.ts index 818d21eda76..706a62bacf5 100644 --- a/packages/realm-server/prerender/prerender-app.ts +++ b/packages/realm-server/prerender/prerender-app.ts @@ -21,6 +21,7 @@ import { } from '../middleware'; import { Prerenderer } from './index'; import { resolvePrerenderManagerURL } from './config'; +import { isBranchMode, serviceURL } from '../lib/dev-service-registry'; import { PRERENDER_SERVER_DRAINING_STATUS_CODE, PRERENDER_SERVER_STATUS_DRAINING, @@ -657,6 +658,9 @@ export function buildPrerenderApp(options: { } function resolvePrerenderServerURL(port?: number): string { + if (isBranchMode()) { + return serviceURL('prerender'); + } let hostname = process.env.HOSTNAME ?? 'localhost'; let resolvedPort = port ?? defaultPrerenderServerPort; return `http://${hostname}:${resolvedPort}`.replace(/\/$/, ''); diff --git a/packages/realm-server/prerender/prerender-server.ts b/packages/realm-server/prerender/prerender-server.ts index 6482295547c..b1b0932adf3 100644 --- a/packages/realm-server/prerender/prerender-server.ts +++ b/packages/realm-server/prerender/prerender-server.ts @@ -29,10 +29,15 @@ webServerInstance = createPrerenderHttpServer({ silent, port, }).listen(port); -if (isBranchMode() && webServerInstance) { - registerService(webServerInstance, 'prerender'); -} -log.info(`prerender server HTTP listening on port ${port}`); +let actualPort = port; +webServerInstance.on('listening', () => { + actualPort = + (webServerInstance!.address() as import('net').AddressInfo).port ?? port; + if (isBranchMode()) { + registerService(webServerInstance!, 'prerender'); + } + log.info(`prerender server HTTP listening on port ${actualPort}`); +}); function shutdown() { log.info(`Shutting down prerender server...`); @@ -42,7 +47,7 @@ function shutdown() { log.error(`Error while closing prerender server:`, err); process.exit(1); } - log.info(`prerender server HTTP on port ${port} has stopped.`); + log.info(`prerender server HTTP on port ${actualPort} has stopped.`); process.exit(0); }); } diff --git a/packages/realm-server/prerender/prerenderer.ts b/packages/realm-server/prerender/prerenderer.ts index 7bd2bde12df..0662d315456 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -75,7 +75,10 @@ export class Prerenderer { this.#realmIdleEvictMs = this.#resolveRealmIdleEvictMs(); this.#startCleanupLoop(); void this.#pagePool.warmStandbys().catch((e) => { - log.error('Failed to warm standby pages during prerenderer startup:', e); + log.warn( + 'Failed to warm standby pages during prerenderer startup (host app may not be ready yet):', + e, + ); }); } diff --git a/packages/realm-server/scripts/start-icons.sh b/packages/realm-server/scripts/start-icons.sh index 76e6dd0b095..f146e4cdc05 100644 --- a/packages/realm-server/scripts/start-icons.sh +++ b/packages/realm-server/scripts/start-icons.sh @@ -11,21 +11,29 @@ if [ -n "$BOXEL_BRANCH" ]; then # Register icons service with Traefik via a small node script BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') node -e " - const yaml = require('yaml'); const fs = require('fs'); const path = require('path'); const dir = path.resolve(__dirname, '..', '..', 'traefik', 'dynamic'); const slug = '${BRANCH_SLUG}'; - const configPath = path.join(dir, slug + '.yml'); - let config = {}; - try { config = yaml.parse(fs.readFileSync(configPath, 'utf-8')) || {}; } catch {} - if (!config.http) config.http = {}; - if (!config.http.routers) config.http.routers = {}; - if (!config.http.services) config.http.services = {}; - config.http.routers['icons-' + slug] = { rule: 'Host(\`icons.${BRANCH_SLUG}.lvh.me\`)', service: 'icons-' + slug, entryPoints: ['web'] }; - config.http.services['icons-' + slug] = { loadBalancer: { servers: [{ url: 'http://host.docker.internal:${ICONS_PORT}' }] } }; + const routerKey = 'icons-' + slug; + const configPath = path.join(dir, slug + '-icons.yml'); + const entry = [ + 'http:', + ' routers:', + ' ' + routerKey + ':', + ' rule: \"Host(\`icons.${BRANCH_SLUG}.lvh.me\`)\"', + ' service: ' + routerKey, + ' entryPoints:', + ' - web', + ' services:', + ' ' + routerKey + ':', + ' loadBalancer:', + ' servers:', + ' - url: \"http://host.docker.internal:${ICONS_PORT}\"', + '', + ].join('\\n'); const tmp = configPath + '.tmp'; - fs.writeFileSync(tmp, yaml.stringify(config), 'utf-8'); + fs.writeFileSync(tmp, entry, 'utf-8'); fs.renameSync(tmp, configPath); console.log('Registered icons at icons.${BRANCH_SLUG}.lvh.me -> localhost:${ICONS_PORT}'); " diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index f634f604c00..328c0188be7 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -255,9 +255,11 @@ export class RealmServer { listen(port: number) { let instance = this.app.listen(port); - let actualPort = - (instance.address() as import('net').AddressInfo | null)?.port ?? port; - this.log.info(`Realm server listening on port %s\n`, actualPort); + instance.on('listening', () => { + let actualPort = + (instance.address() as import('net').AddressInfo | null)?.port ?? port; + this.log.info(`Realm server listening on port %s\n`, actualPort); + }); return instance; } diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index cbc9dfc542f..7f082b71d7b 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -32,7 +32,7 @@ import { import { isBranchMode, registerService, - deregisterBranch, + deregisterService, } from './lib/dev-service-registry'; /* About the Worker Manager @@ -170,19 +170,20 @@ if (port) { }); webServerInstance = webServer.listen(port); - if (isBranchMode() && webServerInstance) { - registerService(webServerInstance, 'worker'); - } - log.info(`worker manager HTTP listening on port ${port}`); + webServerInstance.on('listening', () => { + let actualPort = + (webServerInstance!.address() as import('net').AddressInfo).port ?? port; + if (isBranchMode()) { + registerService(webServerInstance!, 'worker'); + } + log.info(`worker manager HTTP listening on port ${actualPort}`); + }); } const shutdown = (onShutdown?: () => void) => { log.info(`Shutting down server for worker manager...`); if (isBranchMode()) { - // deregisterBranch is idempotent; the realm server's shutdown also - // calls it, but calling here ensures cleanup if the worker manager exits - // independently. - deregisterBranch(); + deregisterService('worker'); } if (dailyCreditGrantJob) { From 9d7139cdf7077e5cc258648baf50a152fc5bf8e7 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 14:20:10 -0700 Subject: [PATCH 07/21] Fix realm server service name --- packages/host/config/environment.js | 2 +- packages/realm-server/main.ts | 6 +++--- packages/realm-server/scripts/start-all.sh | 2 +- packages/realm-server/scripts/start-development.sh | 2 +- packages/realm-server/scripts/start-worker-development.sh | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index 0b84a5835b2..b567be4b0ba 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -32,7 +32,7 @@ function branchDefaults() { }; } let slug = branchSlug(); - let realmHost = `realm.${slug}.lvh.me`; + let realmHost = `realm-server.${slug}.lvh.me`; return { realmServerURL: `http://${realmHost}/`, realmHost, diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index 41923947702..ccff90d56cc 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -95,7 +95,7 @@ let { matrixURL, realmsRootPath, serverURL = isBranchMode() - ? serviceURL('realm') + ? serviceURL('realm-server') : `http://localhost:${port}`, distURL = isBranchMode() ? serviceURL('host') @@ -342,7 +342,7 @@ const getIndexHTML = async () => { // PUBLISHED_REALM_BOXEL_SPACE_DOMAIN is used to form urls like "mike.boxel.space/game-mechanics" // PUBLISHED_REALM_BOXEL_SITE_DOMAIN is used to form urls like "mike.boxel.site" let defaultPublishedDomain = isBranchMode() - ? `realm.${getBranchSlug()}.lvh.me` + ? `realm-server.${getBranchSlug()}.lvh.me` : 'localhost:4201'; let domainsForPublishedRealms = { boxelSpace: @@ -379,7 +379,7 @@ const getIndexHTML = async () => { let httpServer = server.listen(port); httpServer.on('listening', () => { if (isBranchMode()) { - registerService(httpServer, 'realm'); + registerService(httpServer, 'realm-server'); } }); process.on('message', (message) => { diff --git a/packages/realm-server/scripts/start-all.sh b/packages/realm-server/scripts/start-all.sh index c81a0ee133e..3c89931f6cf 100755 --- a/packages/realm-server/scripts/start-all.sh +++ b/packages/realm-server/scripts/start-all.sh @@ -3,7 +3,7 @@ # Branch-mode: when BOXEL_BRANCH is set, use Traefik hostnames for readiness checks if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') - REALM_HOST="realm.${BRANCH_SLUG}.lvh.me" + REALM_HOST="realm-server.${BRANCH_SLUG}.lvh.me" ICONS_HOST="icons.${BRANCH_SLUG}.lvh.me" BASE_REALM="http-get://${REALM_HOST}/base/" diff --git a/packages/realm-server/scripts/start-development.sh b/packages/realm-server/scripts/start-development.sh index f9cc1a8f9ce..a5cc517cd51 100755 --- a/packages/realm-server/scripts/start-development.sh +++ b/packages/realm-server/scripts/start-development.sh @@ -36,7 +36,7 @@ START_SUBMISSION=$(if [ -z "$SKIP_SUBMISSION" ]; then echo "true"; else echo ""; # Traefik routing instead of hardcoded ports. if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') - REALM_BASE_URL="http://realm.${BRANCH_SLUG}.lvh.me" + REALM_BASE_URL="http://realm-server.${BRANCH_SLUG}.lvh.me" REALM_PORT=0 REALMS_ROOT="./realms/${BRANCH_SLUG}" PGDATABASE_VAL="boxel_${BRANCH_SLUG}" diff --git a/packages/realm-server/scripts/start-worker-development.sh b/packages/realm-server/scripts/start-worker-development.sh index 12df2d8f78f..68c33eb8fdf 100755 --- a/packages/realm-server/scripts/start-worker-development.sh +++ b/packages/realm-server/scripts/start-worker-development.sh @@ -10,7 +10,7 @@ wait_for_postgres # Branch-mode configuration if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') - REALM_BASE_URL="http://realm.${BRANCH_SLUG}.lvh.me" + REALM_BASE_URL="http://realm-server.${BRANCH_SLUG}.lvh.me" WORKER_PORT=0 PGDATABASE_VAL="boxel_${BRANCH_SLUG}" PRERENDER_URL="${PRERENDER_URL:-http://prerender-mgr.${BRANCH_SLUG}.lvh.me}" From 061df15669db50eb14fcb668fb44b82c68f3697d Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 14:36:50 -0700 Subject: [PATCH 08/21] Change to localhost as root domain lvh.me was blocked in Puppeteer! --- packages/host/config/environment.js | 4 ++-- packages/host/scripts/ember-serve.js | 4 ++-- packages/host/scripts/serve-dist.js | 4 ++-- packages/realm-server/lib/dev-service-registry.ts | 2 +- packages/realm-server/main.ts | 2 +- packages/realm-server/prerender/prerenderer.ts | 2 +- packages/realm-server/scripts/start-all.sh | 4 ++-- packages/realm-server/scripts/start-development.sh | 6 +++--- packages/realm-server/scripts/start-icons.sh | 4 ++-- packages/realm-server/scripts/start-prerender-dev.sh | 2 +- packages/realm-server/scripts/start-worker-development.sh | 4 ++-- 11 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/host/config/environment.js b/packages/host/config/environment.js index b567be4b0ba..0998d3cd5e7 100644 --- a/packages/host/config/environment.js +++ b/packages/host/config/environment.js @@ -32,11 +32,11 @@ function branchDefaults() { }; } let slug = branchSlug(); - let realmHost = `realm-server.${slug}.lvh.me`; + let realmHost = `realm-server.${slug}.localhost`; return { realmServerURL: `http://${realmHost}/`, realmHost, - iconsURL: `http://icons.${slug}.lvh.me`, + iconsURL: `http://icons.${slug}.localhost`, baseRealmURL: `http://${realmHost}/base/`, catalogRealmURL: `http://${realmHost}/catalog/`, skillsRealmURL: `http://${realmHost}/skills/`, diff --git a/packages/host/scripts/ember-serve.js b/packages/host/scripts/ember-serve.js index 97b424be1df..0168d7ab7c9 100644 --- a/packages/host/scripts/ember-serve.js +++ b/packages/host/scripts/ember-serve.js @@ -3,7 +3,7 @@ /** * Wrapper around `ember serve` that supports dynamic port allocation in branch mode. * When BOXEL_BRANCH is set, picks a free port, passes --port to ember serve, - * then registers with Traefik so that `host..lvh.me` routes here. + * then registers with Traefik so that `host..localhost` routes here. * When BOXEL_BRANCH is not set, behaves identically to the old start command. */ @@ -73,7 +73,7 @@ if (!BOXEL_BRANCH) { ensureTraefik(); const slug = sanitizeSlug(BOXEL_BRANCH); - const hostname = `host.${slug}.lvh.me`; + const hostname = `host.${slug}.localhost`; // Find a free port const srv = net.createServer(); diff --git a/packages/host/scripts/serve-dist.js b/packages/host/scripts/serve-dist.js index f23ade5d2ec..5fb2dd8c2d4 100644 --- a/packages/host/scripts/serve-dist.js +++ b/packages/host/scripts/serve-dist.js @@ -3,7 +3,7 @@ /** * Wrapper around `serve` that supports dynamic port allocation in branch mode. * When BOXEL_BRANCH is set, picks a free port, starts `serve`, then registers - * with Traefik so that `host..lvh.me` routes to this instance. + * with Traefik so that `host..localhost` routes to this instance. * When BOXEL_BRANCH is not set, behaves identically to the old serve:dist command. */ @@ -84,7 +84,7 @@ if (!BOXEL_BRANCH) { } const slug = sanitizeSlug(BOXEL_BRANCH); - const hostname = `host.${slug}.lvh.me`; + const hostname = `host.${slug}.localhost`; // Find a free port const srv = net.createServer(); diff --git a/packages/realm-server/lib/dev-service-registry.ts b/packages/realm-server/lib/dev-service-registry.ts index b19a1ea3850..d9b2c80b688 100644 --- a/packages/realm-server/lib/dev-service-registry.ts +++ b/packages/realm-server/lib/dev-service-registry.ts @@ -13,7 +13,7 @@ import yaml from 'yaml'; const log = logger('dev-service-registry'); -const DOMAIN = 'lvh.me'; +const DOMAIN = 'localhost'; // Resolve traefik/dynamic dir relative to repo root function traefikDynamicDir(): string { diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index ccff90d56cc..5ac119a1b86 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -342,7 +342,7 @@ const getIndexHTML = async () => { // PUBLISHED_REALM_BOXEL_SPACE_DOMAIN is used to form urls like "mike.boxel.space/game-mechanics" // PUBLISHED_REALM_BOXEL_SITE_DOMAIN is used to form urls like "mike.boxel.site" let defaultPublishedDomain = isBranchMode() - ? `realm-server.${getBranchSlug()}.lvh.me` + ? `realm-server.${getBranchSlug()}.localhost` : 'localhost:4201'; let domainsForPublishedRealms = { boxelSpace: diff --git a/packages/realm-server/prerender/prerenderer.ts b/packages/realm-server/prerender/prerenderer.ts index 0662d315456..392d8c9f257 100644 --- a/packages/realm-server/prerender/prerenderer.ts +++ b/packages/realm-server/prerender/prerenderer.ts @@ -76,7 +76,7 @@ export class Prerenderer { this.#startCleanupLoop(); void this.#pagePool.warmStandbys().catch((e) => { log.warn( - 'Failed to warm standby pages during prerenderer startup (host app may not be ready yet):', + 'Failed to warm standby pages during prerenderer startup:', e, ); }); diff --git a/packages/realm-server/scripts/start-all.sh b/packages/realm-server/scripts/start-all.sh index 3c89931f6cf..96a8a764b52 100755 --- a/packages/realm-server/scripts/start-all.sh +++ b/packages/realm-server/scripts/start-all.sh @@ -3,8 +3,8 @@ # Branch-mode: when BOXEL_BRANCH is set, use Traefik hostnames for readiness checks if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') - REALM_HOST="realm-server.${BRANCH_SLUG}.lvh.me" - ICONS_HOST="icons.${BRANCH_SLUG}.lvh.me" + REALM_HOST="realm-server.${BRANCH_SLUG}.localhost" + ICONS_HOST="icons.${BRANCH_SLUG}.localhost" BASE_REALM="http-get://${REALM_HOST}/base/" CATALOG_REALM="http-get://${REALM_HOST}/catalog/" diff --git a/packages/realm-server/scripts/start-development.sh b/packages/realm-server/scripts/start-development.sh index a5cc517cd51..1180007dab8 100755 --- a/packages/realm-server/scripts/start-development.sh +++ b/packages/realm-server/scripts/start-development.sh @@ -36,7 +36,7 @@ START_SUBMISSION=$(if [ -z "$SKIP_SUBMISSION" ]; then echo "true"; else echo ""; # Traefik routing instead of hardcoded ports. if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') - REALM_BASE_URL="http://realm-server.${BRANCH_SLUG}.lvh.me" + REALM_BASE_URL="http://realm-server.${BRANCH_SLUG}.localhost" REALM_PORT=0 REALMS_ROOT="./realms/${BRANCH_SLUG}" PGDATABASE_VAL="boxel_${BRANCH_SLUG}" @@ -73,8 +73,8 @@ fi # In branch mode, override prerender URL and worker manager arg to use Traefik hostnames if [ -n "$BOXEL_BRANCH" ]; then - PRERENDER_URL="${PRERENDER_URL:-http://prerender.${BRANCH_SLUG}.lvh.me}" - WORKER_MANAGER_ARG="--workerManagerUrl=http://worker.${BRANCH_SLUG}.lvh.me" + PRERENDER_URL="${PRERENDER_URL:-http://prerender.${BRANCH_SLUG}.localhost}" + WORKER_MANAGER_ARG="--workerManagerUrl=http://worker.${BRANCH_SLUG}.localhost" else PRERENDER_URL="${PRERENDER_URL:-http://localhost:4221}" WORKER_MANAGER_ARG="$1" diff --git a/packages/realm-server/scripts/start-icons.sh b/packages/realm-server/scripts/start-icons.sh index f146e4cdc05..7dccd76f3ea 100644 --- a/packages/realm-server/scripts/start-icons.sh +++ b/packages/realm-server/scripts/start-icons.sh @@ -21,7 +21,7 @@ if [ -n "$BOXEL_BRANCH" ]; then 'http:', ' routers:', ' ' + routerKey + ':', - ' rule: \"Host(\`icons.${BRANCH_SLUG}.lvh.me\`)\"', + ' rule: \"Host(\`icons.${BRANCH_SLUG}.localhost\`)\"', ' service: ' + routerKey, ' entryPoints:', ' - web', @@ -35,7 +35,7 @@ if [ -n "$BOXEL_BRANCH" ]; then const tmp = configPath + '.tmp'; fs.writeFileSync(tmp, entry, 'utf-8'); fs.renameSync(tmp, configPath); - console.log('Registered icons at icons.${BRANCH_SLUG}.lvh.me -> localhost:${ICONS_PORT}'); + console.log('Registered icons at icons.${BRANCH_SLUG}.localhost -> localhost:${ICONS_PORT}'); " wait $ICONS_PID diff --git a/packages/realm-server/scripts/start-prerender-dev.sh b/packages/realm-server/scripts/start-prerender-dev.sh index 786a622393a..8a05cc5162c 100755 --- a/packages/realm-server/scripts/start-prerender-dev.sh +++ b/packages/realm-server/scripts/start-prerender-dev.sh @@ -8,7 +8,7 @@ ensure_traefik if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') PRERENDER_PORT=0 - DEFAULT_HOST_URL="http://host.${BRANCH_SLUG}.lvh.me" + DEFAULT_HOST_URL="http://host.${BRANCH_SLUG}.localhost" else PRERENDER_PORT=4221 DEFAULT_HOST_URL="http://localhost:4200" diff --git a/packages/realm-server/scripts/start-worker-development.sh b/packages/realm-server/scripts/start-worker-development.sh index 68c33eb8fdf..a60af23486c 100755 --- a/packages/realm-server/scripts/start-worker-development.sh +++ b/packages/realm-server/scripts/start-worker-development.sh @@ -10,10 +10,10 @@ wait_for_postgres # Branch-mode configuration if [ -n "$BOXEL_BRANCH" ]; then BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') - REALM_BASE_URL="http://realm-server.${BRANCH_SLUG}.lvh.me" + REALM_BASE_URL="http://realm-server.${BRANCH_SLUG}.localhost" WORKER_PORT=0 PGDATABASE_VAL="boxel_${BRANCH_SLUG}" - PRERENDER_URL="${PRERENDER_URL:-http://prerender-mgr.${BRANCH_SLUG}.lvh.me}" + PRERENDER_URL="${PRERENDER_URL:-http://prerender-mgr.${BRANCH_SLUG}.localhost}" else REALM_BASE_URL="http://localhost:4201" WORKER_PORT=4210 From e76b5766cfd8d1f61eb1afa7bf630f09558c1302 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 14:40:27 -0700 Subject: [PATCH 09/21] Add startup check/creation of database --- packages/realm-server/scripts/start-development.sh | 2 ++ scripts/ensure-branch-db.sh | 9 ++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/realm-server/scripts/start-development.sh b/packages/realm-server/scripts/start-development.sh index 1180007dab8..de1aa04707f 100755 --- a/packages/realm-server/scripts/start-development.sh +++ b/packages/realm-server/scripts/start-development.sh @@ -40,6 +40,8 @@ if [ -n "$BOXEL_BRANCH" ]; then REALM_PORT=0 REALMS_ROOT="./realms/${BRANCH_SLUG}" PGDATABASE_VAL="boxel_${BRANCH_SLUG}" + # Ensure per-branch database exists + sh "$SCRIPTS_DIR/../../../scripts/ensure-branch-db.sh" "$BRANCH_SLUG" else REALM_BASE_URL="http://localhost:4201" REALM_PORT=4201 diff --git a/scripts/ensure-branch-db.sh b/scripts/ensure-branch-db.sh index 40180e1dbdc..4e3b9c0c7dd 100755 --- a/scripts/ensure-branch-db.sh +++ b/scripts/ensure-branch-db.sh @@ -14,14 +14,13 @@ else fi DB_NAME="boxel_${SLUG}" -PGPORT="${PGPORT:-5435}" -echo "Ensuring database '${DB_NAME}' exists on port ${PGPORT}..." +echo "Ensuring database '${DB_NAME}' exists..." -# Check if DB exists; create if not -if psql -p "$PGPORT" -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then +# Use docker exec to talk to the boxel-pg container directly +if docker exec boxel-pg psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "$DB_NAME"; then echo "Database '${DB_NAME}' already exists." else - createdb -p "$PGPORT" "$DB_NAME" + docker exec boxel-pg createdb -U postgres "$DB_NAME" echo "Created database '${DB_NAME}'." fi From 5c600f6abd11612c9feea80dd68d36b4e3f139f0 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 15:23:17 -0700 Subject: [PATCH 10/21] Fix port check for worker manager --- packages/realm-server/worker-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index 7f082b71d7b..eafb14c07a0 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -124,7 +124,7 @@ process.on('SIGTERM', () => (isExiting = true)); let webServerInstance: Server | undefined; let autoMigrate = migrateDB || undefined; -if (port) { +if (port != null) { let webServer = new Koa(); let router = new Router(); router.head('/', livenessCheck); From 630440ddbc3a3d748f10df0c664e5721f5b21dd6 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 15:23:43 -0700 Subject: [PATCH 11/21] Change realm server timeout in branch mode --- packages/realm-server/main.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index 5ac119a1b86..ce4b9ffeef0 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -458,7 +458,8 @@ const getIndexHTML = async () => { async function waitForWorkerManager(url: string) { let isReady = false; - let timeout = Date.now() + 30_000; + let timeoutMs = isBranchMode() ? 120_000 : 30_000; + let timeout = Date.now() + timeoutMs; let normalizedUrl = url.replace(/\/$/, '') + '/'; do { try { From 0e3af907ddc96a4ef70cf4b3284b7a93a24087f9 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 15:24:09 -0700 Subject: [PATCH 12/21] Update realm server port logging --- packages/realm-server/main.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index ce4b9ffeef0..7ff096244eb 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -384,7 +384,8 @@ const getIndexHTML = async () => { }); process.on('message', (message) => { if (message === 'stop') { - console.log(`stopping realm server on port ${port}...`); + let stopPort = (httpServer.address() as import('net').AddressInfo | null)?.port ?? port; + console.log(`stopping realm server on port ${stopPort}...`); if (isBranchMode()) { deregisterBranch(); } @@ -392,13 +393,13 @@ const getIndexHTML = async () => { httpServer.close(() => { queue.destroy(); // warning this is async dbAdapter.close(); // warning this is async - console.log(`realm server on port ${port} has stopped`); + console.log(`realm server on port ${stopPort} has stopped`); if (process.send) { process.send('stopped'); } }); } else if (message === 'kill') { - console.log(`Ending server process for ${port}...`); + console.log(`Ending server process...`); process.exit(0); } else if ( typeof message === 'string' && @@ -431,7 +432,8 @@ const getIndexHTML = async () => { await server.start(); - log.info(`Realm server listening on port ${port} is serving realms:`); + let actualPort = (httpServer.address() as import('net').AddressInfo | null)?.port ?? port; + log.info(`Realm server listening on port ${actualPort} is serving realms:`); let additionalMappings = hrefs.slice(paths.length); for (let [index, { url }] of realms.entries()) { log.info(` ${url} => ${hrefs[index][1]}, serving path ${paths[index]}`); From 3c14bebca13ce984fdc6ff7d1c4b19edc5c11ed3 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 15:40:27 -0700 Subject: [PATCH 13/21] Add support for tests in parallel environments --- packages/realm-server/main.ts | 10 ++- packages/realm-server/scripts/start-all.sh | 3 +- .../realm-server/scripts/start-test-realms.sh | 67 ++++++++++++++----- .../realm-server/scripts/start-worker-test.sh | 50 +++++++++++--- packages/realm-server/worker-manager.ts | 10 ++- 5 files changed, 107 insertions(+), 33 deletions(-) diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index 7ff096244eb..1fab5fd99ce 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -94,8 +94,9 @@ let { port, matrixURL, realmsRootPath, + serviceName = 'realm-server', serverURL = isBranchMode() - ? serviceURL('realm-server') + ? serviceURL(serviceName) : `http://localhost:${port}`, distURL = isBranchMode() ? serviceURL('host') @@ -181,6 +182,11 @@ let { description: 'URL of the prerender server to invoke', type: 'string', }, + serviceName: { + description: + 'Traefik service name for registration in branch mode (default: realm-server)', + type: 'string', + }, }) .parseSync(); @@ -379,7 +385,7 @@ const getIndexHTML = async () => { let httpServer = server.listen(port); httpServer.on('listening', () => { if (isBranchMode()) { - registerService(httpServer, 'realm-server'); + registerService(httpServer, serviceName); } }); process.on('message', (message) => { diff --git a/packages/realm-server/scripts/start-all.sh b/packages/realm-server/scripts/start-all.sh index 96a8a764b52..0ddc8794b82 100755 --- a/packages/realm-server/scripts/start-all.sh +++ b/packages/realm-server/scripts/start-all.sh @@ -11,7 +11,8 @@ if [ -n "$BOXEL_BRANCH" ]; then SKILLS_REALM="http-get://${REALM_HOST}/skills/" BOXEL_HOMEPAGE_REALM="http-get://${REALM_HOST}/boxel-homepage/" EXPERIMENTS_REALM="http-get://${REALM_HOST}/experiments/" - NODE_TEST_REALM="http-get://localhost:4202/node-test/" + REALM_TEST_HOST="realm-test.${BRANCH_SLUG}.localhost" + NODE_TEST_REALM="http-get://${REALM_TEST_HOST}/node-test/" ICONS_URL="http://${ICONS_HOST}" else BASE_REALM="http-get://localhost:4201/base/" diff --git a/packages/realm-server/scripts/start-test-realms.sh b/packages/realm-server/scripts/start-test-realms.sh index 70daa8d60f8..d6eaab203a1 100755 --- a/packages/realm-server/scripts/start-test-realms.sh +++ b/packages/realm-server/scripts/start-test-realms.sh @@ -1,18 +1,48 @@ #! /bin/sh SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPTS_DIR/wait-for-pg.sh" +. "$SCRIPTS_DIR/ensure-traefik.sh" -sh "$SCRIPTS_DIR/start-icons.sh" & -ICONS_PID=$! -cleanup_icons_server() { - if [ -n "$ICONS_PID" ]; then - kill "$ICONS_PID" >/dev/null 2>&1 || true - fi -} -trap cleanup_icons_server EXIT INT TERM +# In branch mode, share the dev icons server; otherwise start our own +if [ -z "$BOXEL_BRANCH" ]; then + sh "$SCRIPTS_DIR/start-icons.sh" & + ICONS_PID=$! + cleanup_icons_server() { + if [ -n "$ICONS_PID" ]; then + kill "$ICONS_PID" >/dev/null 2>&1 || true + fi + } + trap cleanup_icons_server EXIT INT TERM +else + ensure_traefik +fi wait_for_postgres -PRERENDER_URL="${PRERENDER_URL:-http://localhost:4221}" + +# Branch-mode configuration +if [ -n "$BOXEL_BRANCH" ]; then + BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + REALM_TEST_URL="http://realm-test.${BRANCH_SLUG}.localhost" + REALM_BASE_URL="http://realm-server.${BRANCH_SLUG}.localhost" + TEST_PORT=0 + PGDATABASE_VAL="boxel_test_${BRANCH_SLUG}" + REALMS_ROOT="./realms/${BRANCH_SLUG}_test" + PRERENDER_URL="${PRERENDER_URL:-http://prerender.${BRANCH_SLUG}.localhost}" + WORKER_MANAGER_ARG="--workerManagerUrl=http://worker-test.${BRANCH_SLUG}.localhost" + SERVICE_NAME_ARG="--serviceName=realm-test" + + # Ensure per-branch test database exists + sh "$SCRIPTS_DIR/../../../scripts/ensure-branch-db.sh" "test_${BRANCH_SLUG}" +else + REALM_TEST_URL="http://localhost:4202" + REALM_BASE_URL="http://localhost:4201" + TEST_PORT=4202 + PGDATABASE_VAL="boxel_test" + REALMS_ROOT="./realms/localhost_4202" + PRERENDER_URL="${PRERENDER_URL:-http://localhost:4221}" + WORKER_MANAGER_ARG="$1" + SERVICE_NAME_ARG="" +fi pnpm --dir=../skills-realm skills:setup @@ -23,7 +53,7 @@ fi NODE_ENV=test \ PGPORT=5435 \ - PGDATABASE=boxel_test \ + PGDATABASE="${PGDATABASE_VAL}" \ NODE_NO_WARNINGS=1 \ REALM_SERVER_SECRET_SEED="mum's the word" \ REALM_SECRET_SEED="shhh! it's a secret" \ @@ -32,22 +62,23 @@ NODE_ENV=test \ REALM_SERVER_MATRIX_USERNAME=realm_server \ ts-node \ --transpileOnly main \ - --port=4202 \ + --port="${TEST_PORT}" \ --matrixURL='http://localhost:8008' \ - --realmsRootPath='./realms/localhost_4202' \ + --realmsRootPath="${REALMS_ROOT}" \ --matrixRegistrationSecretFile='../matrix/registration_secret.txt' \ --migrateDB \ --prerendererUrl="${PRERENDER_URL}" \ - $1 \ + $WORKER_MANAGER_ARG \ + $SERVICE_NAME_ARG \ \ --path='./tests/cards' \ --username='node-test_realm' \ - --fromUrl='http://localhost:4202/node-test/' \ - --toUrl='http://localhost:4202/node-test/' \ + --fromUrl="${REALM_TEST_URL}/node-test/" \ + --toUrl="${REALM_TEST_URL}/node-test/" \ \ --path='../host/tests/cards' \ --username='test_realm' \ - --fromUrl='http://localhost:4202/test/' \ - --toUrl='http://localhost:4202/test/' \ + --fromUrl="${REALM_TEST_URL}/test/" \ + --toUrl="${REALM_TEST_URL}/test/" \ --fromUrl='https://cardstack.com/base/' \ - --toUrl='http://localhost:4201/base/' + --toUrl="${REALM_BASE_URL}/base/" diff --git a/packages/realm-server/scripts/start-worker-test.sh b/packages/realm-server/scripts/start-worker-test.sh index cb8c24deea3..e423acceb3e 100755 --- a/packages/realm-server/scripts/start-worker-test.sh +++ b/packages/realm-server/scripts/start-worker-test.sh @@ -2,14 +2,42 @@ SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPTS_DIR/wait-for-pg.sh" . "$SCRIPTS_DIR/wait-for-prerender.sh" +. "$SCRIPTS_DIR/ensure-traefik.sh" + +if [ -n "$BOXEL_BRANCH" ]; then + ensure_traefik +fi wait_for_postgres -PRERENDER_URL="${PRERENDER_URL:-http://localhost:4222}" + +# Branch-mode configuration +if [ -n "$BOXEL_BRANCH" ]; then + BRANCH_SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + REALM_TEST_URL="http://realm-test.${BRANCH_SLUG}.localhost" + REALM_BASE_URL="http://realm-server.${BRANCH_SLUG}.localhost" + WORKER_PORT=0 + PGDATABASE_VAL="boxel_test_${BRANCH_SLUG}" + PRERENDER_URL="${PRERENDER_URL:-http://prerender-mgr.${BRANCH_SLUG}.localhost}" + SERVICE_NAME_ARG="--serviceName=worker-test" + MIGRATE_ARG="--migrateDB" + + # Ensure per-branch test database exists + sh "$SCRIPTS_DIR/../../../scripts/ensure-branch-db.sh" "test_${BRANCH_SLUG}" +else + REALM_TEST_URL="http://localhost:4202" + REALM_BASE_URL="http://localhost:4201" + WORKER_PORT=4211 + PGDATABASE_VAL="boxel_test" + PRERENDER_URL="${PRERENDER_URL:-http://localhost:4222}" + SERVICE_NAME_ARG="" + MIGRATE_ARG="" +fi + wait_for_prerender "$PRERENDER_URL" NODE_ENV=test \ PGPORT=5435 \ - PGDATABASE=boxel_test \ + PGDATABASE="${PGDATABASE_VAL}" \ NODE_NO_WARNINGS=1 \ NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=4096}" \ REALM_SECRET_SEED="shhh! it's a secret" \ @@ -17,16 +45,18 @@ NODE_ENV=test \ LOW_CREDIT_THRESHOLD=2000 \ ts-node \ --transpileOnly worker-manager \ - --port=4211 \ + --port="${WORKER_PORT}" \ --matrixURL='http://localhost:8008' \ --prerendererUrl="${PRERENDER_URL}" \ + $SERVICE_NAME_ARG \ + $MIGRATE_ARG \ \ - --fromUrl='http://localhost:4202/node-test/' \ - --toUrl='http://localhost:4202/node-test/' \ + --fromUrl="${REALM_TEST_URL}/node-test/" \ + --toUrl="${REALM_TEST_URL}/node-test/" \ \ - --fromUrl='http://localhost:4202/test/' \ - --toUrl='http://localhost:4202/test/' \ + --fromUrl="${REALM_TEST_URL}/test/" \ + --toUrl="${REALM_TEST_URL}/test/" \ --fromUrl='https://cardstack.com/base/' \ - --toUrl='http://localhost:4201/base/' \ - --fromUrl='http://localhost:4201/skills/' \ - --toUrl='http://localhost:4201/skills/' + --toUrl="${REALM_BASE_URL}/base/" \ + --fromUrl="${REALM_BASE_URL}/skills/" \ + --toUrl="${REALM_BASE_URL}/skills/" diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index eafb14c07a0..8573a38e4fc 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -67,6 +67,7 @@ let { toUrl: toUrls, migrateDB, prerendererUrl, + serviceName = 'worker', } = yargs(process.argv.slice(2)) .usage('Start worker manager') .options({ @@ -110,6 +111,11 @@ let { description: 'URL of the prerender server to invoke', type: 'string', }, + serviceName: { + description: + 'Traefik service name for registration in branch mode (default: worker)', + type: 'string', + }, }) .parseSync(); @@ -174,7 +180,7 @@ if (port != null) { let actualPort = (webServerInstance!.address() as import('net').AddressInfo).port ?? port; if (isBranchMode()) { - registerService(webServerInstance!, 'worker'); + registerService(webServerInstance!, serviceName); } log.info(`worker manager HTTP listening on port ${actualPort}`); }); @@ -183,7 +189,7 @@ if (port != null) { const shutdown = (onShutdown?: () => void) => { log.info(`Shutting down server for worker manager...`); if (isBranchMode()) { - deregisterService('worker'); + deregisterService(serviceName); } if (dailyCreditGrantJob) { From 16da61992457b06996e87d2f8f5fc6372fdb093f Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 15:52:24 -0700 Subject: [PATCH 14/21] Add script to stop relevant processes --- scripts/stop-branch.sh | 148 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100755 scripts/stop-branch.sh diff --git a/scripts/stop-branch.sh b/scripts/stop-branch.sh new file mode 100755 index 00000000000..ebc42f26c43 --- /dev/null +++ b/scripts/stop-branch.sh @@ -0,0 +1,148 @@ +#!/bin/sh +# Stop all processes for a given branch and clean up Traefik configs. +# +# Usage: +# ./scripts/stop-branch.sh [branch-name] +# +# If no branch is given, uses $BOXEL_BRANCH or the current git branch. +# Pass --drop-db to also drop the per-branch databases. +# Pass --dry-run to preview what would be killed without taking action. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +TRAEFIK_DIR="$REPO_ROOT/traefik/dynamic" + +DROP_DB=false +DRY_RUN=false +BRANCH="" + +for arg in "$@"; do + case "$arg" in + --drop-db) DROP_DB=true ;; + --dry-run) DRY_RUN=true ;; + -*) echo "Unknown option: $arg" >&2; exit 1 ;; + *) BRANCH="$arg" ;; + esac +done + +if [ -z "$BRANCH" ]; then + BRANCH="${BOXEL_BRANCH:-$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || echo '')}" +fi + +if [ -z "$BRANCH" ]; then + echo "Error: no branch specified and could not detect current branch." >&2 + echo "Usage: $0 [branch-name]" >&2 + exit 1 +fi + +SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + +echo "Stopping all services for branch: $BRANCH (slug: $SLUG)" + +# --- 1. Find and kill processes by branch slug in their arguments --- +# Match any process whose command line contains the branch slug followed by +# .localhost (Traefik hostnames) or as a path segment (realm paths). +# Then walk the process tree to also find child processes (prerender, icons, +# host app, etc.) that don't have the slug in their own arguments. +# Exclude this script itself and grep. +ROOT_PIDS=$(ps ax -o pid,command 2>/dev/null \ + | grep -E "(${SLUG}\.localhost|realms/${SLUG}|boxel_${SLUG}|BOXEL_BRANCH=${BRANCH})" \ + | grep -v "grep" \ + | grep -v "stop-branch" \ + | awk '{print $1}' \ + | sort -u) + +# Collect all descendant PIDs iteratively using pgrep +ALL_PIDS="$ROOT_PIDS" +QUEUE="$ROOT_PIDS" +DEPTH=0 +while [ -n "$QUEUE" ] && [ "$DEPTH" -lt 20 ]; do + DEPTH=$((DEPTH + 1)) + NEXT_QUEUE="" + for pid in $QUEUE; do + CHILDREN=$(pgrep -P "$pid" 2>/dev/null || true) + if [ -n "$CHILDREN" ]; then + ALL_PIDS="$ALL_PIDS +$CHILDREN" + NEXT_QUEUE="$NEXT_QUEUE +$CHILDREN" + fi + done + QUEUE=$(echo "$NEXT_QUEUE" | sed '/^$/d') +done + +# Deduplicate +PIDS=$(echo "$ALL_PIDS" | grep -v '^$' | sort -un | tr '\n' ' ') + +if [ -n "$PIDS" ]; then + COUNT=$(echo "$PIDS" | wc -w | tr -d ' ') + + if [ "$DRY_RUN" = true ]; then + echo "Would kill $COUNT process(es):" + for pid in $PIDS; do + CMD=$(ps -o command= -p "$pid" 2>/dev/null | head -c 120) + echo " $pid: $CMD" + done + else + echo "Found $COUNT process(es) (including children). Sending SIGTERM..." + echo "$PIDS" | xargs kill 2>/dev/null || true + + # Give processes a moment to shut down gracefully + sleep 2 + + # Check for survivors and force kill + SURVIVORS="" + for pid in $PIDS; do + if kill -0 "$pid" 2>/dev/null; then + SURVIVORS="$SURVIVORS $pid" + fi + done + + if [ -n "$SURVIVORS" ]; then + echo "Force killing remaining processes:$SURVIVORS" + echo "$SURVIVORS" | xargs kill -9 2>/dev/null || true + fi + + echo "All processes stopped." + fi +else + echo "No running processes found for branch $SLUG." +fi + +# --- 2. Clean up Traefik dynamic config files --- +if [ -d "$TRAEFIK_DIR" ]; then + REMOVED=0 + for f in "$TRAEFIK_DIR/${SLUG}"-*.yml; do + [ -f "$f" ] || continue + if [ "$DRY_RUN" = true ]; then + echo " Would remove $(basename "$f")" + else + rm -f "$f" + echo " Removed $(basename "$f")" + fi + REMOVED=$((REMOVED + 1)) + done + if [ "$REMOVED" -gt 0 ]; then + [ "$DRY_RUN" = true ] && echo "Would remove $REMOVED Traefik config file(s)." || echo "Removed $REMOVED Traefik config file(s)." + else + echo "No Traefik configs found for branch $SLUG." + fi +fi + +# --- 3. Optionally drop per-branch databases --- +if [ "$DROP_DB" = true ]; then + for DB_NAME in "boxel_${SLUG}" "boxel_test_${SLUG}"; do + if docker exec boxel-pg psql -U postgres -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw "$DB_NAME"; then + if [ "$DRY_RUN" = true ]; then + echo "Would drop database $DB_NAME" + else + echo "Dropping database $DB_NAME..." + docker exec boxel-pg dropdb -U postgres "$DB_NAME" + fi + fi + done +fi + +echo "Done." From 808f9a40e147520a9c42f4f25845f193d4d34b80 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 15:54:04 -0700 Subject: [PATCH 15/21] Add exclusion for Claude --- scripts/stop-branch.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/stop-branch.sh b/scripts/stop-branch.sh index ebc42f26c43..7584f44862f 100755 --- a/scripts/stop-branch.sh +++ b/scripts/stop-branch.sh @@ -51,6 +51,7 @@ ROOT_PIDS=$(ps ax -o pid,command 2>/dev/null \ | grep -E "(${SLUG}\.localhost|realms/${SLUG}|boxel_${SLUG}|BOXEL_BRANCH=${BRANCH})" \ | grep -v "grep" \ | grep -v "stop-branch" \ + | grep -v "shell-snapshots" `# exclude Claude Code shell wrappers` \ | awk '{print $1}' \ | sort -u) From b38ba0845355895478d2bd20be52188d04912c47 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 16:29:37 -0700 Subject: [PATCH 16/21] Add handling for Synapse --- packages/matrix/docker/synapse/index.ts | 82 ++++++++---- packages/matrix/helpers/branch-config.ts | 123 ++++++++++++++++++ packages/matrix/package.json | 7 +- .../matrix/scripts/assert-synapse-running.sh | 18 +++ .../matrix/scripts/register-realm-user.ts | 3 +- .../matrix/scripts/register-realm-users.sh | 17 ++- packages/matrix/scripts/register-test-user.ts | 3 +- packages/matrix/scripts/start-synapse.sh | 13 ++ packages/matrix/scripts/synapse.ts | 12 +- packages/realm-server/scripts/start-all.sh | 6 +- .../realm-server/scripts/start-development.sh | 6 +- .../realm-server/scripts/start-test-realms.sh | 6 +- .../scripts/start-worker-development.sh | 4 +- .../realm-server/scripts/start-worker-test.sh | 4 +- packages/realm-server/synapse.ts | 27 +++- pnpm-lock.yaml | 3 + scripts/stop-branch.sh | 17 ++- 17 files changed, 300 insertions(+), 51 deletions(-) create mode 100644 packages/matrix/helpers/branch-config.ts create mode 100755 packages/matrix/scripts/assert-synapse-running.sh create mode 100755 packages/matrix/scripts/start-synapse.sh diff --git a/packages/matrix/docker/synapse/index.ts b/packages/matrix/docker/synapse/index.ts index fb7a992ae00..eb339838568 100644 --- a/packages/matrix/docker/synapse/index.ts +++ b/packages/matrix/docker/synapse/index.ts @@ -12,6 +12,12 @@ import { } from '../index'; import { APP_BOXEL_REALMS_EVENT_TYPE } from '../../helpers/matrix-constants'; import { appURL } from '../../helpers/isolated-realm-server'; +import { + isBranchMode, + getSynapseContainerName, + getSynapseURL, + registerSynapseWithTraefik, +} from '../../helpers/branch-config'; export const SYNAPSE_IP_ADDRESS = '172.20.0.5'; export const SYNAPSE_PORT = 8008; @@ -111,7 +117,8 @@ export async function synapseStart( ): Promise { if (stopExisting) { // Stop the main server if it's running - let stopPromises = [dockerStop({ containerId: 'boxel-synapse' })]; + let defaultContainerName = getSynapseContainerName(); + let stopPromises = [dockerStop({ containerId: defaultContainerName })]; for (const [id, _synapse] of synapses) { // Stop any other synapses that are running stopPromises.push(synapseStop(id)); @@ -122,28 +129,35 @@ export async function synapseStart( opts?.template ?? 'test', opts?.dataDir, ); - let containerName = opts?.containerName || path.basename(synCfg.configDir); + let containerName = opts?.containerName || (isBranchMode() ? getSynapseContainerName() : path.basename(synCfg.configDir)); console.log( `Starting synapse with config dir ${synCfg.configDir} in container ${containerName}...`, ); await dockerCreateNetwork({ networkName: 'boxel' }); - const synapseId = await dockerRun({ - image: 'matrixdotorg/synapse:v1.126.0', - containerName: 'boxel-synapse', - dockerParams: [ - '--rm', - '-v', - `${synCfg.configDir}:/data`, - '-v', - `${path.join(__dirname, 'templates')}:/custom/templates/`, + + let dockerParams: string[] = [ + '--rm', + '-v', + `${synCfg.configDir}:/data`, + '-v', + `${path.join(__dirname, 'templates')}:/custom/templates/`, + ]; + if (isBranchMode() && !opts?.containerName) { + // Branch mode: dynamic host port, no fixed IP + dockerParams.push('-p', '0:8008/tcp', '--network=boxel'); + } else { + dockerParams.push( `--ip=${synCfg.host}`, - /** - * When using -p flag with --ip, the docker internal port must be used to access from the host - */ '-p', `${synCfg.port}:8008/tcp`, '--network=boxel', - ], + ); + } + + const synapseId = await dockerRun({ + image: 'matrixdotorg/synapse:v1.126.0', + containerName, + dockerParams, applicationParams: ['run'], runAsUser: true, }); @@ -167,6 +181,18 @@ export async function synapseStart( ], }); + // In branch mode, read the dynamic host port and register with Traefik + if (isBranchMode() && !opts?.containerName) { + let { execSync } = await import('child_process'); + let portOutput = execSync(`docker port ${synapseId} 8008/tcp`, { + encoding: 'utf-8', + }).trim(); + let firstLine = portOutput.split('\n')[0]; + let hostPort = parseInt(firstLine.split(':').pop()!, 10); + console.log(`Synapse dynamic host port: ${hostPort}`); + registerSynapseWithTraefik(hostPort); + } + const synapse: SynapseInstance = { synapseId, ...synCfg }; synapses.set(synapseId, synapse); @@ -220,7 +246,7 @@ export async function registerUser( admin = false, displayName?: string, ): Promise { - const url = `http://localhost:${SYNAPSE_PORT}/_synapse/admin/v1/register`; + const url = `${getSynapseURL()}/_synapse/admin/v1/register`; const context = await request.newContext({ baseURL: url }); const { nonce } = await (await context.get(url)).json(); const mac = admin @@ -273,7 +299,7 @@ export async function loginUser( ): Promise { let url = matrixURL ? `${matrixURL}/_matrix/client/r0/login` - : `http://localhost:${SYNAPSE_PORT}/_matrix/client/r0/login`; + : `${getSynapseURL()}/_matrix/client/r0/login`; let response = await ( await fetch(url, { method: 'POST', @@ -298,7 +324,7 @@ export async function updateDisplayName( newDisplayName: string, ): Promise { let response = await fetch( - `http://localhost:${SYNAPSE_PORT}/_matrix/client/v3/profile/${userId}/displayname`, + `${getSynapseURL()}/_matrix/client/v3/profile/${userId}/displayname`, { method: 'PUT', headers: { @@ -323,7 +349,7 @@ export async function createRegistrationToken( usesAllowed = 1000, ) { let res = await fetch( - `http://localhost:${SYNAPSE_PORT}/_synapse/admin/v1/registration_tokens/new`, + `${getSynapseURL()}/_synapse/admin/v1/registration_tokens/new`, { method: 'POST', headers: { @@ -359,7 +385,7 @@ export async function updateUser( ) { let url = matrixURL ? `${matrixURL}/_synapse/admin/v2/users/${userId}` - : `http://localhost:${SYNAPSE_PORT}/_synapse/admin/v2/users/${userId}`; + : `${getSynapseURL()}/_synapse/admin/v2/users/${userId}`; let res = await fetch(url, { method: 'PUT', headers: { @@ -393,7 +419,7 @@ export async function updateAccountData( data: string, ): Promise { let response = await fetch( - `http://localhost:${SYNAPSE_PORT}/_matrix/client/v3/user/${userId}/account_data/${type}`, + `${getSynapseURL()}/_matrix/client/v3/user/${userId}/account_data/${type}`, { method: 'PUT', headers: { @@ -416,7 +442,7 @@ export async function getAccountData( type: string, ): Promise { let response = await fetch( - `http://localhost:${SYNAPSE_PORT}/_matrix/client/v3/user/${userId}/account_data/${type}`, + `${getSynapseURL()}/_matrix/client/v3/user/${userId}/account_data/${type}`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -429,7 +455,7 @@ export async function getAccountData( export async function getJoinedRooms(accessToken: string) { let response = await fetch( - `http://localhost:${SYNAPSE_PORT}/_matrix/client/v3/joined_rooms`, + `${getSynapseURL()}/_matrix/client/v3/joined_rooms`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -446,7 +472,7 @@ export async function getRoomStateEventType( eventType: string, ) { let response = await fetch( - `http://localhost:${SYNAPSE_PORT}/_matrix/client/v3/rooms/${roomId}/state/${eventType}`, + `${getSynapseURL()}/_matrix/client/v3/rooms/${roomId}/state/${eventType}`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -469,7 +495,7 @@ export async function getRoomRetentionPolicy( export async function getRoomMembers(roomId: string, accessToken: string) { let response = await fetch( - `http://localhost:${SYNAPSE_PORT}/_matrix/client/v3/rooms/${roomId}/joined_members`, + `${getSynapseURL()}/_matrix/client/v3/rooms/${roomId}/joined_members`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -481,7 +507,7 @@ export async function getRoomMembers(roomId: string, accessToken: string) { export async function sync(accessToken: string) { let response = await fetch( - `http://localhost:${SYNAPSE_PORT}/_matrix/client/v3/sync`, + `${getSynapseURL()}/_matrix/client/v3/sync`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -507,7 +533,7 @@ export async function getAllRoomEvents( do { let response = await fetch( - `http://localhost:${SYNAPSE_PORT}/_matrix/client/v3/rooms/${roomId}/messages?dir=${ + `${getSynapseURL()}/_matrix/client/v3/rooms/${roomId}/messages?dir=${ opts?.direction ? opts.direction.slice(0, 1) : 'f' }&limit=${opts?.pageSize ?? DEFAULT_PAGE_SIZE}${ from ? '&from=' + from : '' @@ -558,7 +584,7 @@ export async function putEvent( txnId: string, body: any, ) { - let url = `http://localhost:${SYNAPSE_PORT}/_matrix/client/v3/rooms/${roomId}/send/${eventType}/${txnId}`; + let url = `${getSynapseURL()}/_matrix/client/v3/rooms/${roomId}/send/${eventType}/${txnId}`; let res = await await fetch(url, { method: 'PUT', headers: { diff --git a/packages/matrix/helpers/branch-config.ts b/packages/matrix/helpers/branch-config.ts new file mode 100644 index 00000000000..b599ffb122a --- /dev/null +++ b/packages/matrix/helpers/branch-config.ts @@ -0,0 +1,123 @@ +import { execSync } from 'child_process'; +import { + writeFileSync, + renameSync, + unlinkSync, +} from 'fs'; +import { join, resolve } from 'path'; +import yaml from 'yaml'; + +const DOMAIN = 'localhost'; + +function traefikDynamicDir(): string { + return resolve(__dirname, '..', '..', '..', 'traefik', 'dynamic'); +} + +export function isBranchMode(): boolean { + return !!process.env.BOXEL_BRANCH; +} + +export function getBranchSlug(): string { + if (process.env.BOXEL_BRANCH) { + return sanitizeSlug(process.env.BOXEL_BRANCH); + } + try { + let branch = execSync('git branch --show-current', { + encoding: 'utf-8', + }).trim(); + return sanitizeSlug(branch); + } catch { + return 'default'; + } +} + +function sanitizeSlug(raw: string): string { + return raw + .toLowerCase() + .replace(/\//g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); +} + +export function getSynapseContainerName(): string { + if (isBranchMode()) { + return `boxel-synapse-${getBranchSlug()}`; + } + return 'boxel-synapse'; +} + +export function getSynapseURL(): string { + if (!isBranchMode()) { + return 'http://localhost:8008'; + } + let containerName = getSynapseContainerName(); + try { + let output = execSync(`docker port ${containerName} 8008/tcp`, { + encoding: 'utf-8', + }).trim(); + // Output is like "0.0.0.0:55123" or "[::]:55123" — take the first line + let firstLine = output.split('\n')[0]; + let port = firstLine.split(':').pop(); + return `http://localhost:${port}`; + } catch { + // Fallback if container isn't running yet + return 'http://localhost:8008'; + } +} + +export function registerSynapseWithTraefik(hostPort: number): void { + let slug = getBranchSlug(); + let serviceName = 'matrix'; + let configPath = join(traefikDynamicDir(), `${slug}-${serviceName}.yml`); + let routerKey = `${serviceName}-${slug}`; + let hostname = `${serviceName}.${slug}.${DOMAIN}`; + + let config: any = { + http: { + routers: { + [routerKey]: { + rule: `Host(\`${hostname}\`)`, + service: routerKey, + entryPoints: ['web'], + }, + }, + services: { + [routerKey]: { + loadBalancer: { + servers: [{ url: `http://host.docker.internal:${hostPort}` }], + }, + }, + }, + }, + }; + + atomicWrite(configPath, yaml.stringify(config)); + console.log( + `Registered Synapse at ${hostname} -> localhost:${hostPort}`, + ); +} + +export function deregisterSynapseFromTraefik(): void { + if (!isBranchMode()) { + return; + } + let slug = getBranchSlug(); + let configPath = join(traefikDynamicDir(), `${slug}-matrix.yml`); + try { + unlinkSync(configPath); + console.log(`Deregistered Synapse for branch ${slug} from Traefik`); + } catch (e: any) { + if (e.code !== 'ENOENT') { + console.error( + `Failed to deregister Synapse for branch ${slug}: ${e.message}`, + ); + } + } +} + +function atomicWrite(filePath: string, content: string): void { + let tmpPath = `${filePath}.tmp`; + writeFileSync(tmpPath, content, 'utf-8'); + renameSync(tmpPath, filePath); +} diff --git a/packages/matrix/package.json b/packages/matrix/package.json index 9be894852de..720f8b0f0a3 100644 --- a/packages/matrix/package.json +++ b/packages/matrix/package.json @@ -18,12 +18,13 @@ "tmp": "catalog:", "ts-node": "^10.9.1", "typescript": "catalog:", - "uuid": "catalog:" + "uuid": "catalog:", + "yaml": "catalog:" }, "scripts": { - "start:synapse": "mkdir -p ./synapse-data/db && SYNAPSE_DATA_DIR=./synapse-data ts-node --transpileOnly ./scripts/synapse.ts start", + "start:synapse": "./scripts/start-synapse.sh", "stop:synapse": "ts-node --transpileOnly ./scripts/synapse.ts stop", - "assert-synapse-running": "if [ \"`docker ps -f name='boxel-synapse' --format '{{.Names}}'`\" = 'boxel-synapse' ]; then echo 'synapse is already running'; else pnpm run start:synapse; fi", + "assert-synapse-running": "./scripts/assert-synapse-running.sh", "start:smtp": "ts-node --transpileOnly ./scripts/smtp.ts start", "stop:smtp": "ts-node --transpileOnly ./scripts/smtp.ts stop", "assert-smtp-running": "if [ \"`docker ps -f name='boxel-smtp' --format '{{.Names}}'`\" = 'boxel-smtp' ]; then echo 'SMTP is already running'; else pnpm run start:smtp; fi", diff --git a/packages/matrix/scripts/assert-synapse-running.sh b/packages/matrix/scripts/assert-synapse-running.sh new file mode 100755 index 00000000000..bda0af86a5a --- /dev/null +++ b/packages/matrix/scripts/assert-synapse-running.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Check if the correct Synapse container is running (branch-aware). +# If not running, start it. + +if [ -n "$BOXEL_BRANCH" ]; then + SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + CONTAINER_NAME="boxel-synapse-${SLUG}" +else + CONTAINER_NAME="boxel-synapse" +fi + +RUNNING=$(docker ps -f "name=^${CONTAINER_NAME}$" --format '{{.Names}}') + +if [ "$RUNNING" = "$CONTAINER_NAME" ]; then + echo "synapse is already running (${CONTAINER_NAME})" +else + pnpm run start:synapse +fi diff --git a/packages/matrix/scripts/register-realm-user.ts b/packages/matrix/scripts/register-realm-user.ts index 240790cf02c..f9319b6053c 100755 --- a/packages/matrix/scripts/register-realm-user.ts +++ b/packages/matrix/scripts/register-realm-user.ts @@ -1,6 +1,7 @@ import * as childProcess from 'child_process'; import { loginUser } from '../docker/synapse'; +import { getSynapseContainerName } from '../helpers/branch-config'; import { realmPassword } from '../helpers/realm-credentials'; @@ -21,7 +22,7 @@ if (!realmUser) { (async () => { let password = await realmPassword(realmUser, realmSecretSeed); return new Promise((resolve, reject) => { - const command = `docker exec boxel-synapse register_new_matrix_user http://localhost:8008 -c /data/homeserver.yaml -u ${realmUser} -p ${password} --no-admin`; + const command = `docker exec ${getSynapseContainerName()} register_new_matrix_user http://localhost:8008 -c /data/homeserver.yaml -u ${realmUser} -p ${password} --no-admin`; childProcess.exec(command, async (err, stdout) => { if (err) { if (stdout.includes('User ID already taken')) { diff --git a/packages/matrix/scripts/register-realm-users.sh b/packages/matrix/scripts/register-realm-users.sh index 94288108011..95ff3f50f20 100755 --- a/packages/matrix/scripts/register-realm-users.sh +++ b/packages/matrix/scripts/register-realm-users.sh @@ -1,9 +1,24 @@ #! /bin/sh +# Determine the Synapse health check URL (branch-aware) +if [ -n "$BOXEL_BRANCH" ]; then + SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + CONTAINER_NAME="boxel-synapse-${SLUG}" + # Read the dynamic host port from the running container + SYNAPSE_HOST_PORT=$(docker port "$CONTAINER_NAME" 8008/tcp 2>/dev/null | head -1 | awk -F: '{print $NF}') + if [ -z "$SYNAPSE_HOST_PORT" ]; then + echo "Could not determine Synapse host port for container $CONTAINER_NAME" + exit 1 + fi + SYNAPSE_HEALTH_URL="http://localhost:${SYNAPSE_HOST_PORT}" +else + SYNAPSE_HEALTH_URL="http://localhost:8008" +fi + COUNT=0 MAX_ATTEMPTS=24 -until $(curl --output /dev/null --silent --head --fail http://localhost:8008); do +until $(curl --output /dev/null --silent --head --fail "$SYNAPSE_HEALTH_URL"); do printf '.' sleep 5 diff --git a/packages/matrix/scripts/register-test-user.ts b/packages/matrix/scripts/register-test-user.ts index 03a7d224d27..2aa9c757f92 100644 --- a/packages/matrix/scripts/register-test-user.ts +++ b/packages/matrix/scripts/register-test-user.ts @@ -1,6 +1,7 @@ import * as childProcess from 'child_process'; import { loginUser } from '../docker/synapse'; +import { getSynapseContainerName } from '../helpers/branch-config'; export const adminUsername = 'admin'; export const adminPassword = 'password'; @@ -73,7 +74,7 @@ async function ensureUserRecord(matrixUserId: string) { ? username : `@${username}:localhost`; const shouldEnsureUserRecord = isAdmin !== 'TRUE'; - const command = `docker exec boxel-synapse register_new_matrix_user http://localhost:8008 -c /data/homeserver.yaml -u ${username} -p ${password} ${ + const command = `docker exec ${getSynapseContainerName()} register_new_matrix_user http://localhost:8008 -c /data/homeserver.yaml -u ${username} -p ${password} ${ isAdmin === 'TRUE' ? `--admin` : `--no-admin` }`; childProcess.exec(command, async (err, stdout) => { diff --git a/packages/matrix/scripts/start-synapse.sh b/packages/matrix/scripts/start-synapse.sh new file mode 100755 index 00000000000..9d9165ef674 --- /dev/null +++ b/packages/matrix/scripts/start-synapse.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Start Synapse with per-branch data directory when BOXEL_BRANCH is set. + +if [ -n "$BOXEL_BRANCH" ]; then + SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + SYNAPSE_DATA_DIR="./synapse-data-${SLUG}" +else + SYNAPSE_DATA_DIR="./synapse-data" +fi + +mkdir -p "${SYNAPSE_DATA_DIR}/db" +export SYNAPSE_DATA_DIR +exec ts-node --transpileOnly ./scripts/synapse.ts start diff --git a/packages/matrix/scripts/synapse.ts b/packages/matrix/scripts/synapse.ts index ea3f1399c31..ed6c0229860 100644 --- a/packages/matrix/scripts/synapse.ts +++ b/packages/matrix/scripts/synapse.ts @@ -1,22 +1,28 @@ import { synapseStart } from '../docker/synapse'; import { dockerStop } from '../docker'; import { resolve } from 'path'; +import { + getSynapseContainerName, + deregisterSynapseFromTraefik, +} from '../helpers/branch-config'; const [command] = process.argv.slice(2); let dataDir = process.env.SYNAPSE_DATA_DIR ? resolve(process.env.SYNAPSE_DATA_DIR) : undefined; +let containerName = getSynapseContainerName(); (async () => { if (command === 'start') { await synapseStart({ template: 'dev', dataDir, - containerName: 'boxel-synapse', + containerName, suppressRegistrationSecretFile: true, }); } else if (command === 'stop') { - await dockerStop({ containerId: 'boxel-synapse' }); - console.log(`stopped container 'boxel-synapse'`); + deregisterSynapseFromTraefik(); + await dockerStop({ containerId: containerName }); + console.log(`stopped container '${containerName}'`); } else { console.error( `Unknown command "${command}", available commands are "start" and "stop"`, diff --git a/packages/realm-server/scripts/start-all.sh b/packages/realm-server/scripts/start-all.sh index 0ddc8794b82..6615fa6103d 100755 --- a/packages/realm-server/scripts/start-all.sh +++ b/packages/realm-server/scripts/start-all.sh @@ -33,7 +33,11 @@ BOXEL_HOMEPAGE_REALM_READY="$BOXEL_HOMEPAGE_REALM$READY_PATH" EXPERIMENTS_REALM_READY="$EXPERIMENTS_REALM$READY_PATH" NODE_TEST_REALM_READY="$NODE_TEST_REALM$READY_PATH" -SYNAPSE_URL="http://localhost:8008" +if [ -n "$BOXEL_BRANCH" ]; then + SYNAPSE_URL="http://matrix.${BRANCH_SLUG}.localhost" +else + SYNAPSE_URL="http://localhost:8008" +fi SMTP_4_DEV_URL="http://localhost:5001" WAIT_ON_TIMEOUT=2000000 NODE_NO_WARNINGS=1 start-server-and-test \ diff --git a/packages/realm-server/scripts/start-development.sh b/packages/realm-server/scripts/start-development.sh index de1aa04707f..d093dc3b148 100755 --- a/packages/realm-server/scripts/start-development.sh +++ b/packages/realm-server/scripts/start-development.sh @@ -40,6 +40,7 @@ if [ -n "$BOXEL_BRANCH" ]; then REALM_PORT=0 REALMS_ROOT="./realms/${BRANCH_SLUG}" PGDATABASE_VAL="boxel_${BRANCH_SLUG}" + MATRIX_URL_VAL="http://matrix.${BRANCH_SLUG}.localhost" # Ensure per-branch database exists sh "$SCRIPTS_DIR/../../../scripts/ensure-branch-db.sh" "$BRANCH_SLUG" else @@ -47,6 +48,7 @@ else REALM_PORT=4201 REALMS_ROOT="./realms/localhost_4201" PGDATABASE_VAL="boxel" + MATRIX_URL_VAL="http://localhost:8008" fi DEFAULT_CATALOG_REALM_URL="${REALM_BASE_URL}/catalog/" @@ -91,13 +93,13 @@ LOW_CREDIT_THRESHOLD="${LOW_CREDIT_THRESHOLD:-2000}" \ REALM_SERVER_SECRET_SEED="mum's the word" \ REALM_SECRET_SEED="shhh! it's a secret" \ GRAFANA_SECRET="shhh! it's a secret" \ - MATRIX_URL=http://localhost:8008 \ + MATRIX_URL="${MATRIX_URL_VAL}" \ REALM_SERVER_MATRIX_USERNAME=realm_server \ ENABLE_FILE_WATCHER=true \ ts-node \ --transpileOnly main \ --port="${REALM_PORT}" \ - --matrixURL='http://localhost:8008' \ + --matrixURL="${MATRIX_URL_VAL}" \ --realmsRootPath="${REALMS_ROOT}" \ --prerendererUrl="${PRERENDER_URL}" \ --migrateDB \ diff --git a/packages/realm-server/scripts/start-test-realms.sh b/packages/realm-server/scripts/start-test-realms.sh index d6eaab203a1..d24c0b75c63 100755 --- a/packages/realm-server/scripts/start-test-realms.sh +++ b/packages/realm-server/scripts/start-test-realms.sh @@ -30,6 +30,7 @@ if [ -n "$BOXEL_BRANCH" ]; then PRERENDER_URL="${PRERENDER_URL:-http://prerender.${BRANCH_SLUG}.localhost}" WORKER_MANAGER_ARG="--workerManagerUrl=http://worker-test.${BRANCH_SLUG}.localhost" SERVICE_NAME_ARG="--serviceName=realm-test" + MATRIX_URL_VAL="http://matrix.${BRANCH_SLUG}.localhost" # Ensure per-branch test database exists sh "$SCRIPTS_DIR/../../../scripts/ensure-branch-db.sh" "test_${BRANCH_SLUG}" @@ -42,6 +43,7 @@ else PRERENDER_URL="${PRERENDER_URL:-http://localhost:4221}" WORKER_MANAGER_ARG="$1" SERVICE_NAME_ARG="" + MATRIX_URL_VAL="http://localhost:8008" fi pnpm --dir=../skills-realm skills:setup @@ -58,12 +60,12 @@ NODE_ENV=test \ REALM_SERVER_SECRET_SEED="mum's the word" \ REALM_SECRET_SEED="shhh! it's a secret" \ GRAFANA_SECRET="shhh! it's a secret" \ - MATRIX_URL=http://localhost:8008 \ + MATRIX_URL="${MATRIX_URL_VAL}" \ REALM_SERVER_MATRIX_USERNAME=realm_server \ ts-node \ --transpileOnly main \ --port="${TEST_PORT}" \ - --matrixURL='http://localhost:8008' \ + --matrixURL="${MATRIX_URL_VAL}" \ --realmsRootPath="${REALMS_ROOT}" \ --matrixRegistrationSecretFile='../matrix/registration_secret.txt' \ --migrateDB \ diff --git a/packages/realm-server/scripts/start-worker-development.sh b/packages/realm-server/scripts/start-worker-development.sh index a60af23486c..53f94f255ed 100755 --- a/packages/realm-server/scripts/start-worker-development.sh +++ b/packages/realm-server/scripts/start-worker-development.sh @@ -14,11 +14,13 @@ if [ -n "$BOXEL_BRANCH" ]; then WORKER_PORT=0 PGDATABASE_VAL="boxel_${BRANCH_SLUG}" PRERENDER_URL="${PRERENDER_URL:-http://prerender-mgr.${BRANCH_SLUG}.localhost}" + MATRIX_URL_VAL="http://matrix.${BRANCH_SLUG}.localhost" else REALM_BASE_URL="http://localhost:4201" WORKER_PORT=4210 PGDATABASE_VAL="boxel" PRERENDER_URL="${PRERENDER_URL:-http://localhost:4222}" + MATRIX_URL_VAL="http://localhost:8008" fi wait_for_prerender "$PRERENDER_URL" @@ -40,7 +42,7 @@ NODE_ENV=development \ --allPriorityCount="${WORKER_ALL_PRIORITY_COUNT:-1}" \ --highPriorityCount="${WORKER_HIGH_PRIORITY_COUNT:-0}" \ --port="${WORKER_PORT}" \ - --matrixURL='http://localhost:8008' \ + --matrixURL="${MATRIX_URL_VAL}" \ --prerendererUrl="${PRERENDER_URL}" \ \ --fromUrl='https://cardstack.com/base/' \ diff --git a/packages/realm-server/scripts/start-worker-test.sh b/packages/realm-server/scripts/start-worker-test.sh index e423acceb3e..8df3d50017d 100755 --- a/packages/realm-server/scripts/start-worker-test.sh +++ b/packages/realm-server/scripts/start-worker-test.sh @@ -20,6 +20,7 @@ if [ -n "$BOXEL_BRANCH" ]; then PRERENDER_URL="${PRERENDER_URL:-http://prerender-mgr.${BRANCH_SLUG}.localhost}" SERVICE_NAME_ARG="--serviceName=worker-test" MIGRATE_ARG="--migrateDB" + MATRIX_URL_VAL="http://matrix.${BRANCH_SLUG}.localhost" # Ensure per-branch test database exists sh "$SCRIPTS_DIR/../../../scripts/ensure-branch-db.sh" "test_${BRANCH_SLUG}" @@ -31,6 +32,7 @@ else PRERENDER_URL="${PRERENDER_URL:-http://localhost:4222}" SERVICE_NAME_ARG="" MIGRATE_ARG="" + MATRIX_URL_VAL="http://localhost:8008" fi wait_for_prerender "$PRERENDER_URL" @@ -46,7 +48,7 @@ NODE_ENV=test \ ts-node \ --transpileOnly worker-manager \ --port="${WORKER_PORT}" \ - --matrixURL='http://localhost:8008' \ + --matrixURL="${MATRIX_URL_VAL}" \ --prerendererUrl="${PRERENDER_URL}" \ $SERVICE_NAME_ARG \ $MIGRATE_ARG \ diff --git a/packages/realm-server/synapse.ts b/packages/realm-server/synapse.ts index be98d1aaf63..98fc0309775 100644 --- a/packages/realm-server/synapse.ts +++ b/packages/realm-server/synapse.ts @@ -5,13 +5,30 @@ import { createHmac } from 'crypto'; import yaml from 'yaml'; import { existsSync } from 'fs'; -const homeserverFile = resolve( - join(__dirname, '..', 'matrix', 'synapse-data', 'homeserver.yaml'), -); +function homeserverFile(): string { + if (process.env.BOXEL_BRANCH) { + let slug = process.env.BOXEL_BRANCH + .toLowerCase() + .replace(/\//g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + let branchFile = resolve( + join(__dirname, '..', 'matrix', `synapse-data-${slug}`, 'homeserver.yaml'), + ); + if (existsSync(branchFile)) { + return branchFile; + } + } + return resolve( + join(__dirname, '..', 'matrix', 'synapse-data', 'homeserver.yaml'), + ); +} export function getLocalConfig() { - if (existsSync(homeserverFile)) { - let homeserverYml = readFileSync(homeserverFile, 'utf8'); + let file = homeserverFile(); + if (existsSync(file)) { + let homeserverYml = readFileSync(file, 'utf8'); return yaml.parse(homeserverYml) as Record; } return undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bbb8cb603a..caafa3a787e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2655,6 +2655,9 @@ importers: uuid: specifier: 'catalog:' version: 9.0.1 + yaml: + specifier: 'catalog:' + version: 2.8.2 packages/postgres: dependencies: diff --git a/scripts/stop-branch.sh b/scripts/stop-branch.sh index 7584f44862f..03c89986967 100755 --- a/scripts/stop-branch.sh +++ b/scripts/stop-branch.sh @@ -112,7 +112,20 @@ else echo "No running processes found for branch $SLUG." fi -# --- 2. Clean up Traefik dynamic config files --- +# --- 2. Stop per-branch Synapse container --- +SYNAPSE_CONTAINER="boxel-synapse-${SLUG}" +if docker ps -a --format '{{.Names}}' | grep -qx "$SYNAPSE_CONTAINER"; then + if [ "$DRY_RUN" = true ]; then + echo "Would stop Synapse container: $SYNAPSE_CONTAINER" + else + echo "Stopping Synapse container: $SYNAPSE_CONTAINER" + docker stop "$SYNAPSE_CONTAINER" 2>/dev/null || true + fi +else + echo "No Synapse container found for branch $SLUG." +fi + +# --- 3. Clean up Traefik dynamic config files --- if [ -d "$TRAEFIK_DIR" ]; then REMOVED=0 for f in "$TRAEFIK_DIR/${SLUG}"-*.yml; do @@ -132,7 +145,7 @@ if [ -d "$TRAEFIK_DIR" ]; then fi fi -# --- 3. Optionally drop per-branch databases --- +# --- 4. Optionally drop per-branch databases --- if [ "$DROP_DB" = true ]; then for DB_NAME in "boxel_${SLUG}" "boxel_test_${SLUG}"; do if docker exec boxel-pg psql -U postgres -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw "$DB_NAME"; then From bcf9c61662e97a0c8a3537e72b6eb4eb6f0cdf43 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 16:39:37 -0700 Subject: [PATCH 17/21] Fix Synapse script --- packages/matrix/docker/synapse/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/matrix/docker/synapse/index.ts b/packages/matrix/docker/synapse/index.ts index eb339838568..98d25372b62 100644 --- a/packages/matrix/docker/synapse/index.ts +++ b/packages/matrix/docker/synapse/index.ts @@ -142,7 +142,7 @@ export async function synapseStart( '-v', `${path.join(__dirname, 'templates')}:/custom/templates/`, ]; - if (isBranchMode() && !opts?.containerName) { + if (isBranchMode()) { // Branch mode: dynamic host port, no fixed IP dockerParams.push('-p', '0:8008/tcp', '--network=boxel'); } else { @@ -182,7 +182,7 @@ export async function synapseStart( }); // In branch mode, read the dynamic host port and register with Traefik - if (isBranchMode() && !opts?.containerName) { + if (isBranchMode()) { let { execSync } = await import('child_process'); let portOutput = execSync(`docker port ${synapseId} 8008/tcp`, { encoding: 'utf-8', From 7697b41202a9bb9894c1398a2c9b4a3ac2473c31 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 16:40:56 -0700 Subject: [PATCH 18/21] Add Synapse branches to .gitignore --- packages/matrix/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/matrix/.gitignore b/packages/matrix/.gitignore index c70fe39c712..fcc6309b35e 100644 --- a/packages/matrix/.gitignore +++ b/packages/matrix/.gitignore @@ -3,4 +3,5 @@ node_modules/ /playwright-report/ /playwright /synapse-data +/synapse-data-* /registration_secret.txt From 6dfe2b42aeac8a33fff359c4f92f0df8bb837e37 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 16:49:52 -0700 Subject: [PATCH 19/21] Add Matrix URL override on branch mode --- packages/host/scripts/ember-serve.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/host/scripts/ember-serve.js b/packages/host/scripts/ember-serve.js index 0168d7ab7c9..eac7bdffdc1 100644 --- a/packages/host/scripts/ember-serve.js +++ b/packages/host/scripts/ember-serve.js @@ -75,6 +75,11 @@ if (!BOXEL_BRANCH) { const slug = sanitizeSlug(BOXEL_BRANCH); const hostname = `host.${slug}.localhost`; + // Point the client at the per-branch Synapse via Traefik + if (!process.env.MATRIX_URL) { + process.env.MATRIX_URL = `http://matrix.${slug}.localhost`; + } + // Find a free port const srv = net.createServer(); srv.listen(0, () => { From 41e17c20ab21703448d7be05b3894450d318324d Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 19:26:22 -0700 Subject: [PATCH 20/21] Add user creation before Matrix startup --- packages/realm-server/package.json | 2 +- packages/realm-server/scripts/start-matrix.sh | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100755 packages/realm-server/scripts/start-matrix.sh diff --git a/packages/realm-server/package.json b/packages/realm-server/package.json index aeca97f9085..9f4d37a13c9 100644 --- a/packages/realm-server/package.json +++ b/packages/realm-server/package.json @@ -88,7 +88,7 @@ "scripts": { "test": "./scripts/remove-test-dbs.sh; LOG_LEVELS=\"*=error,prerenderer-chrome=silent,pg-adapter=warn,realm:requests=warn${LOG_LEVELS:+,}${LOG_LEVELS}\" NODE_NO_WARNINGS=1 PGPORT=5435 STRIPE_WEBHOOK_SECRET=stripe-webhook-secret STRIPE_API_KEY=stripe-api-key qunit --require ts-node/register/transpile-only tests/index.ts", "test-module": "./scripts/remove-test-dbs.sh; LOG_LEVELS=\"*=error,prerenderer-chrome=silent,pg-adapter=warn,realm:requests=warn${LOG_LEVELS:+,}${LOG_LEVELS}\" NODE_NO_WARNINGS=1 PGPORT=5435 STRIPE_WEBHOOK_SECRET=stripe-webhook-secret STRIPE_API_KEY=stripe-api-key qunit --require ts-node/register/transpile-only --module ${TEST_MODULE} tests/index.ts", - "start:matrix": "cd ../matrix && pnpm assert-synapse-running", + "start:matrix": "./scripts/start-matrix.sh", "start:smtp": "cd ../matrix && pnpm assert-smtp-running", "start:icons": "sh ./scripts/start-icons.sh", "start:pg": "./scripts/start-pg.sh", diff --git a/packages/realm-server/scripts/start-matrix.sh b/packages/realm-server/scripts/start-matrix.sh new file mode 100755 index 00000000000..d3054099013 --- /dev/null +++ b/packages/realm-server/scripts/start-matrix.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Ensure Synapse is running. In branch mode, also auto-register users +# since each branch gets a fresh Synapse data dir. + +cd ../matrix + +pnpm assert-synapse-running + +# In branch mode, register users automatically — each branch starts +# with an empty Synapse, so users must be created on every fresh start. +if [ -n "$BOXEL_BRANCH" ]; then + SLUG=$(echo "$BOXEL_BRANCH" | tr '[:upper:]' '[:lower:]' | sed 's|/|-|g; s|[^a-z0-9-]||g; s|-\+|-|g; s|^-\|-$||g') + export PGDATABASE="${PGDATABASE:-boxel_${SLUG}}" + export PGPORT="${PGPORT:-5435}" + pnpm register-all +fi From dcc047e61166a7369d10f00780407da7697fcfa6 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 27 Feb 2026 19:44:35 -0700 Subject: [PATCH 21/21] Add branch mode realm URL permissions fix --- packages/postgres/pg-adapter.ts | 48 +++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/postgres/pg-adapter.ts b/packages/postgres/pg-adapter.ts index c6ca800e1e7..b0daeadc659 100644 --- a/packages/postgres/pg-adapter.ts +++ b/packages/postgres/pg-adapter.ts @@ -208,6 +208,7 @@ export class PgAdapter implements DBAdapter { ignorePattern: '.*\\.eslintrc\\.js', log: enableLogging ? (...args) => log.info(...args) : () => undefined, }); + await this.fixupBranchModePermissions(config); return; } catch (err: any) { if (!err.message?.includes('Another migration is already running')) { @@ -219,6 +220,53 @@ export class PgAdapter implements DBAdapter { } } + // In branch mode, migrations seed realm_user_permissions with hardcoded + // localhost:4201/4202 URLs. Rewrite them to the Traefik hostnames so realm + // ownership lookups work. + private async fixupBranchModePermissions(config: Config) { + let branch = process.env.BOXEL_BRANCH; + if (!branch) { + return; + } + let slug = branch + .toLowerCase() + .replace(/\//g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + let client = new Client(config); + try { + await client.connect(); + let realmServerUrl = `http://realm-server.${slug}.localhost`; + let realmTestUrl = `http://realm-test.${slug}.localhost`; + let result = await client.query( + `UPDATE realm_user_permissions + SET realm_url = regexp_replace(realm_url, '^http://localhost:4201/', $1) + WHERE realm_url LIKE 'http://localhost:4201/%'`, + [`${realmServerUrl}/`], + ); + if (result.rowCount && result.rowCount > 0) { + log.info( + `Branch mode: rewrote ${result.rowCount} permission URL(s) from localhost:4201 to ${realmServerUrl}`, + ); + } + let result2 = await client.query( + `UPDATE realm_user_permissions + SET realm_url = regexp_replace(realm_url, '^http://localhost:4202/', $1) + WHERE realm_url LIKE 'http://localhost:4202/%'`, + [`${realmTestUrl}/`], + ); + if (result2.rowCount && result2.rowCount > 0) { + log.info( + `Branch mode: rewrote ${result2.rowCount} permission URL(s) from localhost:4202 to ${realmTestUrl}`, + ); + } + } finally { + await client.end(); + } + } + private async fixMigrationNames(config: Config) { if (!migrationRenames.length) { return;