Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ node_modules
schema_tmp.sql
test-results/.last-run.json
.claude/settings.local.json
traefik/dynamic/*.yml
13 changes: 13 additions & 0 deletions docker-compose.traefik.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
services:
traefik:
image: traefik:v3.2
container_name: boxel-traefik
ports:
- "80:80"
- "4230:4230"
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
56 changes: 47 additions & 9 deletions packages/host/config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-server.${slug}.localhost`;
return {
realmServerURL: `http://${realmHost}/`,
realmHost,
iconsURL: `http://icons.${slug}.localhost`,
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,
Expand Down Expand Up @@ -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,
},
Expand All @@ -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';
}
}

Expand Down
5 changes: 3 additions & 2 deletions packages/host/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
"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",
"serve:dist": "serve --config ../tests/serve.json --single --cors --no-request-logging --no-etag --listen 4200 dist",
"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",
"test": "concurrently \"pnpm:lint\" \"pnpm:test:*\" --names \"lint,test:\"",
"test-with-percy": "percy exec --parallel -- pnpm test:wait-for-servers",
Expand Down
103 changes: 103 additions & 0 deletions packages/host/scripts/ember-serve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/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.<branch>.localhost` 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}-host.yml`);
const routerKey = `host-${slug}`;

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);
}

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 { ensureTraefik } = require('./ensure-traefik');
ensureTraefik();

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, () => {
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);
}
});
});
}
29 changes: 29 additions & 0 deletions packages/host/scripts/ensure-traefik.js
Original file line number Diff line number Diff line change
@@ -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 };
109 changes: 109 additions & 0 deletions packages/host/scripts/serve-dist.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/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.<branch>.localhost` 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 { ensureTraefik } = require('./ensure-traefik');
ensureTraefik();

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}-host.yml`);
const routerKey = `host-${slug}`;

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);
}

const slug = sanitizeSlug(BOXEL_BRANCH);
const hostname = `host.${slug}.localhost`;

// 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);
}
});
});
}
1 change: 1 addition & 0 deletions packages/matrix/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ node_modules/
/playwright-report/
/playwright
/synapse-data
/synapse-data-*
/registration_secret.txt
Loading
Loading