From ecaeb5a52c2862841a17acba6d4e674bf9183e08 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Wed, 20 May 2026 16:59:25 -0500 Subject: [PATCH 1/3] feat/staging --- .changeset/staging-host-secrets.md | 15 ++++ .env.example | 4 ++ .github/templates/workflows/publish.yml | 9 +++ .github/templates/workflows/staging.yml | 69 ++++++++++++++++++ .github/workflows/docker.yml | 3 +- .github/workflows/publish.yml | 9 +++ .github/workflows/release.yml | 9 +++ .github/workflows/staging.yml | 73 ++++++++++++++++++++ Dockerfile | 2 + bos.config.json | 27 +++++--- host/.env.example | 11 ++- packages/everything-dev/src/cli/sync.ts | 1 + packages/everything-dev/src/cli/upgrade.ts | 1 - packages/everything-dev/src/contract.meta.ts | 6 ++ packages/everything-dev/src/contract.ts | 1 + packages/everything-dev/src/plugin.ts | 68 +++++++++++++----- 16 files changed, 275 insertions(+), 33 deletions(-) create mode 100644 .changeset/staging-host-secrets.md create mode 100644 .github/templates/workflows/staging.yml create mode 100644 .github/workflows/staging.yml diff --git a/.changeset/staging-host-secrets.md b/.changeset/staging-host-secrets.md new file mode 100644 index 00000000..a8b701c9 --- /dev/null +++ b/.changeset/staging-host-secrets.md @@ -0,0 +1,15 @@ +--- +"everything-dev": minor +--- + +Add host secrets to config and staging environment support + +- Added `secrets` array to `app.host` in `bos.config.json` for tenant-related environment variables (`TENANT_WHITELIST`, `ALLOW_OVERRIDE`, `ALLOW_UNTRUSTED_SSR`, `CSP_STRICT`). These are now surfaced in `bos infra` generated `.env.example` and validated during `bos start`. + +- Added `BOS_ENV` environment variable support: setting `BOS_ENV=staging` in a Docker/Railway deployment enables staging mode without needing the `--env` CLI flag. This follows the same pattern as `BOS_ACCOUNT` and `BOS_GATEWAY`. + +- Staging mode now overrides `runtimeConfig.domain` with `staging.domain` and sets `runtimeConfig.env = "staging"`, so the host correctly uses the staging gateway for tenant subdomain resolution and CORS origins. + +- Fixed secret validation in `bos start` to include `host.secrets` (previously only validated `auth`, `api`, and `plugin` secrets). + +- Updated `.env.example` and `host/.env.example` with the new host secrets. \ No newline at end of file diff --git a/.env.example b/.env.example index 5396bdfe..98afd68b 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ # app.host CORS_ORIGIN=http://localhost:3000 +TENANT_WHITELIST= +ALLOW_OVERRIDE= +ALLOW_UNTRUSTED_SSR= +CSP_STRICT= # app.api API_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5432/api_db diff --git a/.github/templates/workflows/publish.yml b/.github/templates/workflows/publish.yml index 3a5eef02..79f66d03 100644 --- a/.github/templates/workflows/publish.yml +++ b/.github/templates/workflows/publish.yml @@ -54,3 +54,12 @@ jobs: with: commit_message: "chore: update deployment URLs [skip ci]" file_pattern: "**/bos.config.json" + + - name: Trigger Railway production redeploy + if: vars.RAILWAY_PRODUCTION_SERVICE_ID + run: | + curl -s -X POST \ + "https://backboard.railway.com/graphql/v2" \ + -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_PRODUCTION_SERVICE_ID }}\") { id } }"}' diff --git a/.github/templates/workflows/staging.yml b/.github/templates/workflows/staging.yml new file mode 100644 index 00000000..75855294 --- /dev/null +++ b/.github/templates/workflows/staging.yml @@ -0,0 +1,69 @@ +name: Staging + +on: + push: + branches: + - staging + workflow_dispatch: + inputs: + deploy: + description: "Build/deploy all workspaces before publish" + required: false + default: true + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + id-token: write + +jobs: + deploy-staging: + name: Deploy Staging + runs-on: ubuntu-latest + env: + BOS_INSTALL_NEAR_CLI: "true" + BOS_ENV: staging + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} + ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.2.20" + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Run postinstall + run: bun run postinstall + + - name: Publish staging config + run: | + if [ "${{ inputs.deploy }}" = "false" ]; then + bun packages/everything-dev/src/cli.ts publish --env staging + else + bun packages/everything-dev/src/cli.ts publish --env staging --deploy + fi + + - name: Commit bos.config.json updates + uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 + with: + commit_message: "chore: update staging deployment URLs [skip ci]" + file_pattern: "**/bos.config.json" + + - name: Trigger Railway staging redeploy + if: vars.RAILWAY_STAGING_SERVICE_ID + run: | + curl -s -X POST \ + "https://backboard.railway.com/graphql/v2" \ + -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_STAGING_SERVICE_ID }}\") { id } }"}' \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 50965846..f7b945d3 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -24,7 +24,7 @@ jobs: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' + (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'staging') ) steps: - name: Checkout code @@ -65,6 +65,7 @@ jobs: images: ghcr.io/${{ env.REPO }} tags: | type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=staging,enable=${{ github.ref == 'refs/heads/staging' }} type=ref,event=branch type=sha diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 22eebf6b..c07fd0d6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -57,3 +57,12 @@ jobs: with: commit_message: "chore: update deployment URLs [skip ci]" file_pattern: "**/bos.config.json" + + - name: Trigger Railway production redeploy + if: vars.RAILWAY_PRODUCTION_SERVICE_ID + run: | + curl -s -X POST \ + "https://backboard.railway.com/graphql/v2" \ + -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_PRODUCTION_SERVICE_ID }}\") { id } }"}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfef56b2..ae3423c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -186,3 +186,12 @@ jobs: with: commit_message: "chore: update deployment URLs [skip ci]" file_pattern: "**/bos.config.json" + + - name: Trigger Railway production redeploy + if: steps.release_mode.outputs.should_publish == 'true' && vars.RAILWAY_PRODUCTION_SERVICE_ID + run: | + curl -s -X POST \ + "https://backboard.railway.com/graphql/v2" \ + -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_PRODUCTION_SERVICE_ID }}\") { id } }"}' diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml new file mode 100644 index 00000000..3ef263d2 --- /dev/null +++ b/.github/workflows/staging.yml @@ -0,0 +1,73 @@ +name: Staging + +on: + push: + branches: + - staging + workflow_dispatch: + inputs: + deploy: + description: "Build/deploy all workspaces before publish" + required: false + default: true + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + id-token: write + packages: write + +jobs: + deploy-staging: + name: Deploy Staging + runs-on: ubuntu-latest + env: + BOS_INSTALL_NEAR_CLI: "true" + BOS_ENV: staging + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} + ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.2.20" + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Build every-plugin + run: bun run --cwd packages/every-plugin build + + - name: Run postinstall + run: bun run postinstall + + - name: Publish staging config + run: | + if [ "${{ inputs.deploy }}" = "false" ]; then + bun packages/everything-dev/src/cli.ts publish --env staging + else + bun packages/everything-dev/src/cli.ts publish --env staging --deploy + fi + + - name: Commit bos.config.json updates + uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 + with: + commit_message: "chore: update staging deployment URLs [skip ci]" + file_pattern: "**/bos.config.json" + + - name: Trigger Railway staging redeploy + if: vars.RAILWAY_STAGING_SERVICE_ID + run: | + curl -s -X POST \ + "https://backboard.railway.com/graphql/v2" \ + -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_STAGING_SERVICE_ID }}\") { id } }"}' \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index fe61153d..021c4263 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,8 @@ RUN mkdir -p .bos/generated .bos/logs && \ ENV NODE_ENV=production ENV PORT=3000 ENV HOST=0.0.0.0 +# BOS_ENV: set to "staging" to enable staging mode (uses staging domain for BOS_GATEWAY) +# Defaults to "production" if unset. EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ diff --git a/bos.config.json b/bos.config.json index a5229464..641a39f2 100644 --- a/bos.config.json +++ b/bos.config.json @@ -11,18 +11,25 @@ "app": { "host": { "development": "local:host", - "production": "https://elliot-braem-3974-host-everything-dev-nearbuilder-8513ab298-ze.zephyrcloud.app", - "integrity": "sha384-cJSFftKsfmRX3ezuW4BP/VFK+VSzhTSXqu0eLDgHKUmOohuDPkWt7RMvDd2egijq" + "production": "https://elliot-braem-3982-host-everything-dev-nearbuilder-ce31237a5-ze.zephyrcloud.app", + "integrity": "sha384-xpMal1DC+V2vazkrYCJspPT6XldFXbd0bcd1Qq9lc6mVcaLLy9PWQLpDbuxxoVSs", + "secrets": [ + "CORS_ORIGIN", + "TENANT_WHITELIST", + "ALLOW_OVERRIDE", + "ALLOW_UNTRUSTED_SSR", + "CSP_STRICT" + ] }, "ui": { "development": "local:ui", - "production": "https://elliot-braem-3968-ui-everything-dev-nearbuilders-6cd4c405b-ze.zephyrcloud.app", - "ssr": "https://elliot-braem-3969-ui-everything-dev-nearbuilders-e369c17ce-ze.zephyrcloud.app", - "integrity": "sha384-ni2kNqkd7aFlf9eSxChty8Tds1g5Wz31FAkW1DiMDLcSkbQJ9peFXH9mwSp92qYD" + "production": "https://elliot-braem-3976-ui-everything-dev-nearbuilders-dd42b658b-ze.zephyrcloud.app", + "ssr": "https://elliot-braem-3977-ui-everything-dev-nearbuilders-a43563c52-ze.zephyrcloud.app", + "integrity": "sha384-CPc7Ruz83GD5ZSz//iDGelzs66TWP3nRroIa6vNejj3AyP9D7rbSYCY0B9QmKkOO" }, "api": { "development": "local:api", - "production": "https://elliot-braem-3970-api-everything-dev-nearbuilders-7d41ad764-ze.zephyrcloud.app", + "production": "https://elliot-braem-3978-api-everything-dev-nearbuilders-6f0545872-ze.zephyrcloud.app", "integrity": "sha384-f/HuutBh3+h3xiBvJvJu4HDAp2jhSLDR0OLjSjhLjg595qGOQLBu2oYO/u+vjdzq", "secrets": [ "API_DATABASE_URL" @@ -35,8 +42,8 @@ "plugins": { "apps": { "development": "local:plugins/apps", - "production": "https://elliot-braem-3971-everything-dev-apps-plugin-ever-91dca9a92-ze.zephyrcloud.app", - "integrity": "sha384-zfHOSR0mbcu4l8PkZqli4BlDlnfhw5HhgtP2jGiys90/qKKAh74/l7LYI+7j6r9L", + "production": "https://elliot-braem-3979-everything-dev-apps-plugin-ever-c59643b12-ze.zephyrcloud.app", + "integrity": "sha384-fGQEg7G+Ze38bNMMrhVboO/vbz9WHISeG0nhHuSQki4Xu5ZpKwu1EwXFFRAc9QUy", "variables": { "registryNamespace": "dev.everything.near" }, @@ -54,7 +61,7 @@ }, "projects": { "development": "local:plugins/projects", - "production": "https://elliot-braem-3972-everything-dev-projects-plugin--bcdb70e9a-ze.zephyrcloud.app", + "production": "https://elliot-braem-3980-everything-dev-projects-plugin--330a6cdf9-ze.zephyrcloud.app", "integrity": "sha384-oSAXUrJT2x5NfuEcbPS9GvmzC8E0INdCarPSQxFJ4SwxKfyCQEoWGkFTl7Ja86z/", "secrets": [ "PROJECTS_DATABASE_URL" @@ -71,7 +78,7 @@ }, "settings": { "development": "local:plugins/settings", - "production": "https://elliot-braem-3973-every-plugin-settings-everythin-6b4774e41-ze.zephyrcloud.app", + "production": "https://elliot-braem-3981-every-plugin-settings-everythin-e8acf157d-ze.zephyrcloud.app", "integrity": "sha384-f0n7itNtGKV74WX+KSu7anEpB0MEQcFxhZw2rJbHEWIWPzvLnNf/tEGX/7Hp2haY", "routes": [ "ui/src/routes/_layout/_authenticated/settings.tsx" diff --git a/host/.env.example b/host/.env.example index 79a57c7c..1283ab78 100644 --- a/host/.env.example +++ b/host/.env.example @@ -1,2 +1,9 @@ -API_DATABASE_URL= -API_DATABASE_AUTH_TOKEN= +# app.host +CORS_ORIGIN=http://localhost:3000 +TENANT_WHITELIST= +ALLOW_OVERRIDE= +ALLOW_UNTRUSTED_SSR= +CSP_STRICT= + +# app.api +API_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5432/api_db \ No newline at end of file diff --git a/packages/everything-dev/src/cli/sync.ts b/packages/everything-dev/src/cli/sync.ts index d97c5386..ccec9aa8 100644 --- a/packages/everything-dev/src/cli/sync.ts +++ b/packages/everything-dev/src/cli/sync.ts @@ -26,6 +26,7 @@ const FRAMEWORK_OWNED_SYNC_FILES = new Set([ ".changeset/README.md", ".github/workflows/ci.yml", ".github/workflows/publish.yml", + ".github/workflows/staging.yml", "ui/package.json", "ui/postcss.config.mjs", "ui/rsbuild.config.ts", diff --git a/packages/everything-dev/src/cli/upgrade.ts b/packages/everything-dev/src/cli/upgrade.ts index 966d523b..b3ba63c9 100644 --- a/packages/everything-dev/src/cli/upgrade.ts +++ b/packages/everything-dev/src/cli/upgrade.ts @@ -51,7 +51,6 @@ const OBSOLETE_FILES = [ ".github/workflows/packages-release.yml", ".github/workflows/release.yml", ".github/workflows/release-sync.yml", - ".github/workflows/staging.yml", "packages/everything-dev/cli.js", ".templatekeep", ".templatesync-exclude", diff --git a/packages/everything-dev/src/contract.meta.ts b/packages/everything-dev/src/contract.meta.ts index 826b4a84..c92bcabe 100644 --- a/packages/everything-dev/src/contract.meta.ts +++ b/packages/everything-dev/src/contract.meta.ts @@ -74,6 +74,12 @@ export const cliCommandMeta = { commandPath: ["publish"], summary: "Publish the current workspace configuration", interactive: false, + fields: { + deploy: { description: "Build and deploy all workspaces before publish" }, + dryRun: { description: "Preview what would be published without writing" }, + env: { description: "Environment: production or staging" }, + network: { description: "NEAR network: mainnet or testnet" }, + }, }, keyPublish: { commandPath: ["key", "publish"], diff --git a/packages/everything-dev/src/contract.ts b/packages/everything-dev/src/contract.ts index b72f04ef..7febc9a4 100644 --- a/packages/everything-dev/src/contract.ts +++ b/packages/everything-dev/src/contract.ts @@ -121,6 +121,7 @@ export const PublishOptionsSchema = z.object({ packages: z.string().default("all"), network: z.enum(["mainnet", "testnet"]).optional(), privateKey: z.string().optional(), + env: z.enum(["production", "staging"]).default("production"), }); export const PublishResultSchema = z.object({ diff --git a/packages/everything-dev/src/plugin.ts b/packages/everything-dev/src/plugin.ts index de109b47..f7a53741 100644 --- a/packages/everything-dev/src/plugin.ts +++ b/packages/everything-dev/src/plugin.ts @@ -435,8 +435,11 @@ async function fetchPublishedConfig( ): Promise { try { return await fetchBosConfigFromFastKv(`bos://${accountId}/${gatewayId}`); - } catch { - return null; + } catch (error) { + if (error instanceof Error && error.message.startsWith("No config found")) { + return null; + } + throw error; } } @@ -929,6 +932,7 @@ export default createPlugin({ pluginEvents.emit("progress", { phase: "config", status: "running" } satisfies ProgressEvent); + const bosEnv = input.env ?? (process.env.BOS_ENV === "staging" ? "staging" : "production"); const account = input.account ?? process.env.BOS_ACCOUNT; const domain = input.domain ?? process.env.BOS_GATEWAY; @@ -936,17 +940,25 @@ export default createPlugin({ let remoteConfig: BosConfig | null = null; if (account && domain) { - remoteConfig = await fetchPublishedConfig(account, domain); - if (remoteConfig) { - config = remoteConfig; - } else { - console.warn( - `[Start] Failed to fetch remote config for ${account}/${domain}, falling back to local bos.config.json`, - ); + try { + remoteConfig = await fetchPublishedConfig(account, domain); + if (remoteConfig) { + config = remoteConfig; + } else { + return { + status: "error" as const, + url: "", + error: `No config found at bos://${account}/${domain}. Verify the account and gateway are correct and the config has been published.\nExpected URL: ${buildRegistryConfigUrl(account, domain)}`, + }; + } + } catch (error) { + return { + status: "error" as const, + url: "", + error: `Failed to fetch config for bos://${account}/${domain}: ${error instanceof Error ? error.message : "Unknown error"}\nExpected URL: ${buildRegistryConfigUrl(account, domain)}`, + }; } - } - - if (!config) { + } else { config = deps.bosConfig; } @@ -954,8 +966,7 @@ export default createPlugin({ return { status: "error" as const, url: "", - error: - "No configuration found. Set BOS_ACCOUNT and BOS_GATEWAY environment variables, or provide a local bos.config.json.", + error: "No configuration found. Provide --account and --gateway flags, or create a local bos.config.json.", }; } @@ -968,7 +979,7 @@ export default createPlugin({ } const port = input.port ?? getHostDevelopmentPort(config.app.host.development); - const isStaging = input.env === "staging"; + const isStaging = bosEnv === "staging"; const runtimePlugins = await buildRuntimePluginsForConfig( config, deps.configDir, @@ -986,6 +997,14 @@ export default createPlugin({ drainConfigWarnings(); resumeWarnings(); + if (isStaging && config.staging?.domain) { + runtimeConfig.domain = config.staging.domain; + } + + if (isStaging) { + runtimeConfig.env = "staging"; + } + syncGeneratedInfra(deps.configDir, runtimeConfig); if (!existsSync(join(deps.configDir, ".env"))) { ensureEnvFile(deps.configDir); @@ -1011,7 +1030,8 @@ export default createPlugin({ // Default CORS_ORIGIN to the configured domain if not set if (!process.env.CORS_ORIGIN && config.domain) { - const defaultOrigin = `https://${config.domain}`; + const effectiveDomain = isStaging ? (config.staging?.domain ?? config.domain) : config.domain; + const defaultOrigin = `https://${effectiveDomain}`; productionEnv.CORS_ORIGIN = defaultOrigin; warnings.push(`CORS_ORIGIN defaulting to ${defaultOrigin}`); } @@ -1020,6 +1040,9 @@ export default createPlugin({ const requiredSecrets = new Set(); const missingSecrets: string[] = []; + if (runtimeConfig.host.secrets) { + for (const s of runtimeConfig.host.secrets) requiredSecrets.add(s); + } if (runtimeConfig.auth?.secrets) { for (const s of runtimeConfig.auth.secrets) requiredSecrets.add(s); } @@ -1164,8 +1187,11 @@ export default createPlugin({ }; } + const isStagingPublish = input.env === "staging"; const account = deps.bosConfig.account; - const gateway = deps.bosConfig.domain; + const gateway = isStagingPublish + ? (deps.bosConfig.staging?.domain ?? deps.bosConfig.domain) + : deps.bosConfig.domain; if (!gateway) { return { status: "error" as const, @@ -1179,7 +1205,9 @@ export default createPlugin({ const registryUrl = buildRegistryConfigUrlForNetwork(network, account, gateway); const targets = selectWorkspaceTargets(input.packages, deps.bosConfig); - let publishConfig = deps.bosConfig; + let publishConfig: BosConfig = isStagingPublish + ? { ...deps.bosConfig, domain: gateway } + : deps.bosConfig; let built: string[] | undefined; let skipped: string[] | undefined; @@ -1212,7 +1240,9 @@ export default createPlugin({ if (refreshed?.config) { deps.bosConfig = refreshed.config; deps.runtimeConfig = refreshed.runtime; - publishConfig = refreshed.config; + publishConfig = isStagingPublish + ? { ...refreshed.config, domain: gateway } + : refreshed.config; } } From cbcbf9f09ae68c841798198585921bab3ac5aae1 Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Thu, 21 May 2026 08:49:29 -0500 Subject: [PATCH 2/3] staging --- .changeset/staging-host-secrets.md | 14 +- .github/templates/workflows/deploy.yml | 60 ++++ .github/templates/workflows/publish.yml | 65 ---- .github/templates/workflows/staging.yml | 35 +- .github/workflows/deploy.yml | 60 ++++ .github/workflows/publish.yml | 68 ---- .github/workflows/release.yml | 25 -- .github/workflows/staging.yml | 32 +- bos.config.json | 5 + packages/everything-dev/src/cli.ts | 47 +++ packages/everything-dev/src/cli/init.ts | 1 + packages/everything-dev/src/cli/sync.ts | 3 +- packages/everything-dev/src/cli/upgrade.ts | 1 + packages/everything-dev/src/contract.meta.ts | 11 + packages/everything-dev/src/contract.ts | 27 ++ packages/everything-dev/src/merge.ts | 1 + packages/everything-dev/src/plugin.ts | 345 ++++++++++++++----- packages/everything-dev/src/types.ts | 13 + 18 files changed, 503 insertions(+), 310 deletions(-) create mode 100644 .github/templates/workflows/deploy.yml delete mode 100644 .github/templates/workflows/publish.yml create mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/publish.yml diff --git a/.changeset/staging-host-secrets.md b/.changeset/staging-host-secrets.md index a8b701c9..57ae3e11 100644 --- a/.changeset/staging-host-secrets.md +++ b/.changeset/staging-host-secrets.md @@ -2,14 +2,16 @@ "everything-dev": minor --- -Add host secrets to config and staging environment support +Add `bos deploy` command, host secrets, and staging environment support -- Added `secrets` array to `app.host` in `bos.config.json` for tenant-related environment variables (`TENANT_WHITELIST`, `ALLOW_OVERRIDE`, `ALLOW_UNTRUSTED_SSR`, `CSP_STRICT`). These are now surfaced in `bos infra` generated `.env.example` and validated during `bos start`. +- **New `bos deploy` command**: Publishes config to FastKV and triggers Railway redeploy in one step. Reads service name from `ci.railway.service` in `bos.config.json`. Uses `RAILWAY_TOKEN` (environment-scoped) instead of deployment IDs. -- Added `BOS_ENV` environment variable support: setting `BOS_ENV=staging` in a Docker/Railway deployment enables staging mode without needing the `--env` CLI flag. This follows the same pattern as `BOS_ACCOUNT` and `BOS_GATEWAY`. +- **New `ci` config section**: `bos.config.json` now accepts `ci.railway.service` for Railway integration. Child projects inherit this via extends. -- Staging mode now overrides `runtimeConfig.domain` with `staging.domain` and sets `runtimeConfig.env = "staging"`, so the host correctly uses the staging gateway for tenant subdomain resolution and CORS origins. +- **Staging environment support**: `BOS_ENV=staging` or `--env staging` enables staging mode. `staging.domain` overrides `domain`, FastKV publishes under the staging gateway key, and runtime sets `env = "staging"`. -- Fixed secret validation in `bos start` to include `host.secrets` (previously only validated `auth`, `api`, and `plugin` secrets). +- **Host secrets**: Added `secrets` array to `app.host` for tenant-related environment variables (`TENANT_WHITELIST`, `ALLOW_OVERRIDE`, `ALLOW_UNTRUSTED_SSR`, `CSP_STRICT`). Validated during `bos start` and surfaced in `bos infra`. -- Updated `.env.example` and `host/.env.example` with the new host secrets. \ No newline at end of file +- **Workflow simplification**: Replaced `publish.yml` with `deploy.yml`. `release.yml` now only handles npm package releases. `staging.yml` uses `bos deploy --env staging`. All workflows use `railway redeploy` via CLI instead of raw GraphQL API calls. + +- **Removed**: `RAILWAY_PRODUCTION_SERVICE_ID` and `RAILWAY_STAGING_SERVICE_ID` variables — replaced by environment-scoped `RAILWAY_TOKEN` secrets. \ No newline at end of file diff --git a/.github/templates/workflows/deploy.yml b/.github/templates/workflows/deploy.yml new file mode 100644 index 00000000..aff1bc72 --- /dev/null +++ b/.github/templates/workflows/deploy.yml @@ -0,0 +1,60 @@ +name: Deploy + +on: + workflow_run: + workflows: [CI] + types: [completed] + workflow_dispatch: + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write + +jobs: + deploy: + name: Deploy Production + if: > + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + ) + runs-on: ubuntu-latest + env: + BOS_INSTALL_NEAR_CLI: "true" + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} + ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.2.20" + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Build every-plugin + run: bun run --cwd packages/every-plugin build + + - name: Run postinstall + run: bun run postinstall + + - name: Install Railway CLI + run: npm i -g @railway/cli + + - name: Deploy + run: bos deploy + + - name: Commit bos.config.json updates + uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 + with: + commit_message: "chore: update deployment URLs [skip ci]" + file_pattern: "**/bos.config.json" \ No newline at end of file diff --git a/.github/templates/workflows/publish.yml b/.github/templates/workflows/publish.yml deleted file mode 100644 index 79f66d03..00000000 --- a/.github/templates/workflows/publish.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Publish Config - -on: - push: - branches: - - main - paths: - - "bos.config.json" - workflow_dispatch: - inputs: - deploy: - description: "Build/deploy all workspaces before publish" - required: false - default: false - type: boolean - -permissions: - contents: write - -jobs: - publish: - if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]') - runs-on: ubuntu-latest - env: - BOS_INSTALL_NEAR_CLI: "true" - NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} - ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} - ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: "1.2.20" - - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts - - - name: Run postinstall - run: bun run postinstall - - - name: Publish app - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.deploy }}" = "true" ]; then - bun packages/everything-dev/src/cli.ts publish --deploy - else - bun packages/everything-dev/src/cli.ts publish - fi - - - name: Commit bos.config.json updates - uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 - with: - commit_message: "chore: update deployment URLs [skip ci]" - file_pattern: "**/bos.config.json" - - - name: Trigger Railway production redeploy - if: vars.RAILWAY_PRODUCTION_SERVICE_ID - run: | - curl -s -X POST \ - "https://backboard.railway.com/graphql/v2" \ - -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_PRODUCTION_SERVICE_ID }}\") { id } }"}' diff --git a/.github/templates/workflows/staging.yml b/.github/templates/workflows/staging.yml index 75855294..2b9542b6 100644 --- a/.github/templates/workflows/staging.yml +++ b/.github/templates/workflows/staging.yml @@ -5,12 +5,6 @@ on: branches: - staging workflow_dispatch: - inputs: - deploy: - description: "Build/deploy all workspaces before publish" - required: false - default: true - type: boolean concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -18,7 +12,6 @@ concurrency: permissions: contents: write - id-token: write jobs: deploy-staging: @@ -26,10 +19,10 @@ jobs: runs-on: ubuntu-latest env: BOS_INSTALL_NEAR_CLI: "true" - BOS_ENV: staging NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_STAGING_TOKEN }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -42,28 +35,20 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile --ignore-scripts + - name: Build every-plugin + run: bun run --cwd packages/every-plugin build + - name: Run postinstall run: bun run postinstall - - name: Publish staging config - run: | - if [ "${{ inputs.deploy }}" = "false" ]; then - bun packages/everything-dev/src/cli.ts publish --env staging - else - bun packages/everything-dev/src/cli.ts publish --env staging --deploy - fi + - name: Install Railway CLI + run: npm i -g @railway/cli + + - name: Deploy + run: bos deploy --env staging - name: Commit bos.config.json updates uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 with: commit_message: "chore: update staging deployment URLs [skip ci]" - file_pattern: "**/bos.config.json" - - - name: Trigger Railway staging redeploy - if: vars.RAILWAY_STAGING_SERVICE_ID - run: | - curl -s -X POST \ - "https://backboard.railway.com/graphql/v2" \ - -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_STAGING_SERVICE_ID }}\") { id } }"}' \ No newline at end of file + file_pattern: "**/bos.config.json" \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..71ea0346 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,60 @@ +name: Deploy + +on: + workflow_run: + workflows: [CI] + types: [completed] + workflow_dispatch: + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: write + +jobs: + deploy: + name: Deploy Production + if: > + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + ) + runs-on: ubuntu-latest + env: + BOS_INSTALL_NEAR_CLI: "true" + NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} + ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} + ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.2.20" + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Build every-plugin + run: bun run --cwd packages/every-plugin build + + - name: Run postinstall + run: bun run postinstall + + - name: Install Railway CLI + run: npm i -g @railway/cli + + - name: Deploy + run: bun packages/everything-dev/src/cli.ts deploy + + - name: Commit bos.config.json updates + uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 + with: + commit_message: "chore: update deployment URLs [skip ci]" + file_pattern: "**/bos.config.json" \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index c07fd0d6..00000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Publish Config - -on: - push: - branches: - - main - paths: - - "bos.config.json" - workflow_dispatch: - inputs: - deploy: - description: "Build/deploy all workspaces before publish" - required: false - default: false - type: boolean - -permissions: - contents: write - -jobs: - publish: - if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]') - runs-on: ubuntu-latest - env: - BOS_INSTALL_NEAR_CLI: "true" - NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} - ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} - ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Setup Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: "1.2.20" - - - name: Install dependencies - run: bun install --frozen-lockfile --ignore-scripts - - - name: Build every-plugin - run: bun run --cwd packages/every-plugin build - - - name: Run postinstall - run: bun run postinstall - - - name: Publish app - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.deploy }}" = "true" ]; then - bun packages/everything-dev/src/cli.ts publish --deploy - else - bun packages/everything-dev/src/cli.ts publish - fi - - - name: Commit bos.config.json updates - uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 - with: - commit_message: "chore: update deployment URLs [skip ci]" - file_pattern: "**/bos.config.json" - - - name: Trigger Railway production redeploy - if: vars.RAILWAY_PRODUCTION_SERVICE_ID - run: | - curl -s -X POST \ - "https://backboard.railway.com/graphql/v2" \ - -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_PRODUCTION_SERVICE_ID }}\") { id } }"}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae3423c8..e63172be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,6 @@ jobs: github.event.workflow_run.head_branch == 'main' ) env: - BOS_INSTALL_NEAR_CLI: "true" TARGET_REF: ${{ inputs.ref != '' && inputs.ref || (github.event_name == 'workflow_run' && github.event.workflow_run.head_sha) || github.sha }} outputs: should_publish: ${{ steps.release_mode.outputs.should_publish }} @@ -171,27 +170,3 @@ jobs: --notes "$NOTES" \ --target main done - - - name: Publish runtime config - if: steps.release_mode.outputs.should_publish == 'true' - env: - NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} - ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} - ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} - run: bun packages/everything-dev/src/cli.ts publish --deploy - - - name: Commit bos.config.json updates - if: steps.release_mode.outputs.should_publish == 'true' - uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 - with: - commit_message: "chore: update deployment URLs [skip ci]" - file_pattern: "**/bos.config.json" - - - name: Trigger Railway production redeploy - if: steps.release_mode.outputs.should_publish == 'true' && vars.RAILWAY_PRODUCTION_SERVICE_ID - run: | - curl -s -X POST \ - "https://backboard.railway.com/graphql/v2" \ - -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_PRODUCTION_SERVICE_ID }}\") { id } }"}' diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 3ef263d2..5aab61b4 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -5,12 +5,6 @@ on: branches: - staging workflow_dispatch: - inputs: - deploy: - description: "Build/deploy all workspaces before publish" - required: false - default: true - type: boolean concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -18,7 +12,6 @@ concurrency: permissions: contents: write - id-token: write packages: write jobs: @@ -27,10 +20,10 @@ jobs: runs-on: ubuntu-latest env: BOS_INSTALL_NEAR_CLI: "true" - BOS_ENV: staging NEAR_PRIVATE_KEY: ${{ secrets.NEAR_PRIVATE_KEY }} ZE_SECRET_TOKEN: ${{ secrets.ZEPHYR_AUTH_TOKEN }} ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }} + RAILWAY_TOKEN: ${{ secrets.RAILWAY_STAGING_TOKEN }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -49,25 +42,14 @@ jobs: - name: Run postinstall run: bun run postinstall - - name: Publish staging config - run: | - if [ "${{ inputs.deploy }}" = "false" ]; then - bun packages/everything-dev/src/cli.ts publish --env staging - else - bun packages/everything-dev/src/cli.ts publish --env staging --deploy - fi + - name: Install Railway CLI + run: npm i -g @railway/cli + + - name: Deploy + run: bun packages/everything-dev/src/cli.ts deploy --env staging - name: Commit bos.config.json updates uses: stefanzweifel/git-auto-commit-action@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 with: commit_message: "chore: update staging deployment URLs [skip ci]" - file_pattern: "**/bos.config.json" - - - name: Trigger Railway staging redeploy - if: vars.RAILWAY_STAGING_SERVICE_ID - run: | - curl -s -X POST \ - "https://backboard.railway.com/graphql/v2" \ - -H "Authorization: Bearer ${{ secrets.RAILWAY_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d '{"query":"mutation { deploymentRedeploy(deploymentId: \"${{ vars.RAILWAY_STAGING_SERVICE_ID }}\") { id } }"}' \ No newline at end of file + file_pattern: "**/bos.config.json" \ No newline at end of file diff --git a/bos.config.json b/bos.config.json index 641a39f2..49193913 100644 --- a/bos.config.json +++ b/bos.config.json @@ -8,6 +8,11 @@ "domain": "staging.dev.everything.dev" }, "repository": "https://github.com/nearbuilders/everything-dev", + "ci": { + "railway": { + "service": "app" + } + }, "app": { "host": { "development": "local:host", diff --git a/packages/everything-dev/src/cli.ts b/packages/everything-dev/src/cli.ts index cda98574..d6eefd0e 100644 --- a/packages/everything-dev/src/cli.ts +++ b/packages/everything-dev/src/cli.ts @@ -761,6 +761,53 @@ async function main() { return; } } + + if (descriptor.key === "deploy") { + const deployResult = result as any; + if (deployResult.status === "dry-run") { + console.log(); + console.log(colors.cyan(`${icons.ok} Dry run complete`)); + console.log(` ${colors.dim("Registry URL:")} ${deployResult.registryUrl}`); + console.log(); + return; + } + + if (deployResult.status === "deployed") { + console.log(); + console.log(colors.green(`${icons.ok} Deployed successfully`)); + console.log(` ${colors.dim("Registry URL:")} ${deployResult.registryUrl}`); + if (deployResult.txHash) { + console.log(` ${colors.dim("Transaction:")} ${deployResult.txHash}`); + } + if (deployResult.built && deployResult.built.length > 0) { + console.log(` ${colors.dim("Built:")} ${deployResult.built.join(", ")}`); + } + if (deployResult.skipped && deployResult.skipped.length > 0) { + console.log(` ${colors.dim("Skipped:")} ${deployResult.skipped.join(", ")}`); + } + if (deployResult.redeployed) { + console.log(` ${colors.dim("Railway:")} redeployed ${deployResult.service ?? "service"}`); + } else if (!process.env.RAILWAY_TOKEN) { + console.log(` ${colors.yellow("Railway:")} skipped (RAILWAY_TOKEN not set)`); + } + console.log(); + return; + } + + if (deployResult.status === "published") { + console.log(); + console.log(colors.yellow(`${icons.err} Config published, but Railway redeploy failed`)); + console.log(` ${colors.dim("Registry URL:")} ${deployResult.registryUrl}`); + if (deployResult.txHash) { + console.log(` ${colors.dim("Transaction:")} ${deployResult.txHash}`); + } + if (deployResult.error) { + console.log(` ${colors.dim("Railway:")} ${deployResult.error}`); + } + console.log(); + process.exit(1); + } + } } catch (error) { console.error(`[CLI] ${error instanceof Error ? error.message : String(error)}`); process.exit(1); diff --git a/packages/everything-dev/src/cli/init.ts b/packages/everything-dev/src/cli/init.ts index c368f50e..1e174271 100644 --- a/packages/everything-dev/src/cli/init.ts +++ b/packages/everything-dev/src/cli/init.ts @@ -37,6 +37,7 @@ export const INIT_ROOT_PATTERNS = [ "bunfig.toml", "Dockerfile", "railway.json", + "railway.toml", "AGENTS.md", ".changeset/config.json", ".changeset/README.md", diff --git a/packages/everything-dev/src/cli/sync.ts b/packages/everything-dev/src/cli/sync.ts index ccec9aa8..d12c5fa9 100644 --- a/packages/everything-dev/src/cli/sync.ts +++ b/packages/everything-dev/src/cli/sync.ts @@ -25,8 +25,9 @@ const FRAMEWORK_OWNED_SYNC_FILES = new Set([ ".changeset/config.json", ".changeset/README.md", ".github/workflows/ci.yml", - ".github/workflows/publish.yml", + ".github/workflows/deploy.yml", ".github/workflows/staging.yml", + "railway.toml", "ui/package.json", "ui/postcss.config.mjs", "ui/rsbuild.config.ts", diff --git a/packages/everything-dev/src/cli/upgrade.ts b/packages/everything-dev/src/cli/upgrade.ts index b3ba63c9..a80c0c5c 100644 --- a/packages/everything-dev/src/cli/upgrade.ts +++ b/packages/everything-dev/src/cli/upgrade.ts @@ -49,6 +49,7 @@ const OBSOLETE_FILES = [ ".github/templates/dependabot.yml", ".github/renovate.json", ".github/workflows/packages-release.yml", + ".github/workflows/publish.yml", ".github/workflows/release.yml", ".github/workflows/release-sync.yml", "packages/everything-dev/cli.js", diff --git a/packages/everything-dev/src/contract.meta.ts b/packages/everything-dev/src/contract.meta.ts index c92bcabe..104d4742 100644 --- a/packages/everything-dev/src/contract.meta.ts +++ b/packages/everything-dev/src/contract.meta.ts @@ -81,6 +81,17 @@ export const cliCommandMeta = { network: { description: "NEAR network: mainnet or testnet" }, }, }, + deploy: { + commandPath: ["deploy"], + summary: "Publish config and trigger Railway redeploy", + interactive: false, + fields: { + env: { description: "Environment: production or staging" }, + build: { description: "Build and deploy workspaces before publish (default: true)" }, + dryRun: { description: "Preview what would be deployed without writing" }, + service: { description: "Override Railway service name from config" }, + }, + }, keyPublish: { commandPath: ["key", "publish"], summary: "Generate a publish access key", diff --git a/packages/everything-dev/src/contract.ts b/packages/everything-dev/src/contract.ts index 7febc9a4..f548b4f6 100644 --- a/packages/everything-dev/src/contract.ts +++ b/packages/everything-dev/src/contract.ts @@ -133,6 +133,27 @@ export const PublishResultSchema = z.object({ skipped: z.array(z.string()).optional(), }); +export const DeployOptionsSchema = z.object({ + env: z.enum(["production", "staging"]).default("production"), + build: z.boolean().default(true), + dryRun: z.boolean().default(false), + packages: z.string().default("all"), + network: z.enum(["mainnet", "testnet"]).optional(), + privateKey: z.string().optional(), + service: z.string().optional(), +}); + +export const DeployResultSchema = z.object({ + status: z.enum(["deployed", "published", "error", "dry-run"]), + registryUrl: z.string(), + txHash: z.string().optional(), + built: z.array(z.string()).optional(), + skipped: z.array(z.string()).optional(), + redeployed: z.boolean(), + service: z.string().optional(), + error: z.string().optional(), +}); + export const KeyPublishOptionsSchema = z.object({ allowance: z.string().default("0.25NEAR"), }); @@ -291,6 +312,10 @@ export const bosContract = oc.router({ .route({ method: "POST", path: "/publish" }) .input(PublishOptionsSchema) .output(PublishResultSchema), + deploy: oc + .route({ method: "POST", path: "/deploy" }) + .input(DeployOptionsSchema) + .output(DeployResultSchema), keyPublish: oc .route({ method: "POST", path: "/key/publish" }) .input(KeyPublishOptionsSchema) @@ -328,6 +353,8 @@ export type PluginListResult = z.infer; export type PluginPublishOptions = z.infer; export type PluginPublishResult = z.infer; export type PublishOptions = z.infer; +export type DeployOptions = z.infer; +export type DeployResult = z.infer; export type KeyPublishOptions = z.infer; export type KeyPublishResult = z.infer; export type InitOptions = z.infer; diff --git a/packages/everything-dev/src/merge.ts b/packages/everything-dev/src/merge.ts index 5131f763..b8cb671d 100644 --- a/packages/everything-dev/src/merge.ts +++ b/packages/everything-dev/src/merge.ts @@ -10,6 +10,7 @@ export const BOS_CONFIG_ORDER = [ "testnet", "staging", "repository", + "ci", "app", "plugins", "shared", diff --git a/packages/everything-dev/src/plugin.ts b/packages/everything-dev/src/plugin.ts index f7a53741..5e06b1b8 100644 --- a/packages/everything-dev/src/plugin.ts +++ b/packages/everything-dev/src/plugin.ts @@ -1178,7 +1178,7 @@ export default createPlugin({ }; }), - publish: builder.publish.handler(async ({ input }) => { +publish: builder.publish.handler(async ({ input }) => { if (!deps.bosConfig) { return { status: "error" as const, @@ -1187,126 +1187,137 @@ export default createPlugin({ }; } - const isStagingPublish = input.env === "staging"; - const account = deps.bosConfig.account; - const gateway = isStagingPublish - ? (deps.bosConfig.staging?.domain ?? deps.bosConfig.domain) - : deps.bosConfig.domain; - if (!gateway) { + const result = await publishToFastKv({ + bosConfig: deps.bosConfig, + runtimeConfig: deps.runtimeConfig, + configDir: deps.configDir, + env: input.env, + build: input.deploy, + dryRun: input.dryRun, + packages: input.packages, + network: input.network, + privateKey: input.privateKey, + }); + + if (result.publishConfig) { + const refreshed = await loadResolvedConfig({ cwd: deps.configDir }); + if (refreshed?.config) { + deps.bosConfig = refreshed.config; + deps.runtimeConfig = refreshed.runtime; + } + } + + return { + status: result.status, + registryUrl: result.registryUrl, + txHash: result.txHash, + error: result.error, + built: result.built, + skipped: result.skipped, + }; + }), + + deploy: builder.deploy.handler(async ({ input }) => { + if (!deps.bosConfig) { return { status: "error" as const, registryUrl: "", - error: "bos.config.json must define domain to publish", + redeployed: false, + error: "No bos.config.json found", }; } - const network = input.network ?? getNetworkIdForAccount(account); - const bosUrl = `bos://${account}/${gateway}`; - const registryUrl = buildRegistryConfigUrlForNetwork(network, account, gateway); - const targets = selectWorkspaceTargets(input.packages, deps.bosConfig); + const result = await publishToFastKv({ + bosConfig: deps.bosConfig, + runtimeConfig: deps.runtimeConfig, + configDir: deps.configDir, + env: input.env, + build: input.build, + dryRun: input.dryRun, + packages: input.packages, + network: input.network, + privateKey: input.privateKey, + }); - let publishConfig: BosConfig = isStagingPublish - ? { ...deps.bosConfig, domain: gateway } - : deps.bosConfig; - let built: string[] | undefined; - let skipped: string[] | undefined; + if (result.status === "error") { + return { + status: "error" as const, + registryUrl: result.registryUrl, + txHash: result.txHash, + built: result.built, + skipped: result.skipped, + redeployed: false, + error: result.error, + }; + } - if (input.dryRun) { + if (result.status === "dry-run") { return { status: "dry-run" as const, - registryUrl, - built, - skipped, + registryUrl: result.registryUrl, + built: result.built, + skipped: result.skipped, + redeployed: false, }; } - if (input.deploy) { - await generateCodeArtifacts(deps.configDir, deps.bosConfig, { - env: "production", - runtimeConfig: deps.runtimeConfig ?? undefined, - }); - - const result = await buildWorkspaceTargets({ - configDir: deps.configDir, - bosConfig: deps.bosConfig, - runtimeConfig: deps.runtimeConfig, - targets, - deploy: true, - }); - built = result.built; - skipped = result.skipped; - + if (result.publishConfig) { const refreshed = await loadResolvedConfig({ cwd: deps.configDir }); if (refreshed?.config) { deps.bosConfig = refreshed.config; deps.runtimeConfig = refreshed.runtime; - publishConfig = isStagingPublish - ? { ...refreshed.config, domain: gateway } - : refreshed.config; } } - const registryEntries: Record = { - [`apps/${account}/${gateway}/bos.config.json`]: JSON.stringify(publishConfig), - }; + let redeployed = false; + let service: string | undefined; - const payload = JSON.stringify(registryEntries); - const argsBase64 = Buffer.from(payload).toString("base64"); - const privateKey = - input.privateKey || process.env.NEAR_PRIVATE_KEY || process.env.BOS_NEAR_PRIVATE_KEY; - - try { - await Effect.runPromise(ensureNearCli); - let txHash: string | undefined; + if (process.env.RAILWAY_TOKEN) { + const railwayService = input.service ?? deps.bosConfig.ci?.railway?.service; + if (!railwayService) { + return { + status: "published" as const, + registryUrl: result.registryUrl, + txHash: result.txHash, + built: result.built, + skipped: result.skipped, + redeployed: false, + error: "Config published but Railway redeploy failed: ci.railway.service is not configured in bos.config.json", + }; + } + service = railwayService; try { - const tx = await Effect.runPromise( - executeTransaction({ - account, - contract: getRegistryNamespaceForNetwork(network), - method: "__fastdata_kv", - argsBase64, - network, - privateKey, - gas: "300Tgas", - deposit: "0NEAR", - }), - ); - txHash = tx.txHash; + await run("railway", ["redeploy", "--service", railwayService, "--yes"]); + redeployed = true; } catch (error) { - txHash = extractTransactionHash(error); - - if (!txHash) { - throw error; - } - - try { - const verifiedConfig = await fetchBosConfigFromFastKv(bosUrl); - if (JSON.stringify(verifiedConfig) !== JSON.stringify(publishConfig)) { - throw error; - } - } catch { - // Config may not exist yet on first publish or propagation delay; - // a valid txHash is sufficient proof the transaction was submitted. - } + const message = error instanceof Error ? error.message : String(error); + const railError = + message.includes("not found") || message.includes("ENOENT") + ? "Railway CLI not found. Install it: npm i -g @railway/cli" + : `Railway redeploy failed: ${message}`; + return { + status: "published" as const, + registryUrl: result.registryUrl, + txHash: result.txHash, + built: result.built, + skipped: result.skipped, + redeployed: false, + service, + error: `Config published but ${railError}`, + }; } - - return { - status: "published" as const, - registryUrl, - txHash, - built, - skipped, - }; - } catch (error) { - return { - status: "error" as const, - registryUrl, - error: error instanceof Error ? error.message : "Unknown error", - built, - skipped, - }; } + + return { + status: "deployed" as const, + registryUrl: result.registryUrl, + txHash: result.txHash, + built: result.built, + skipped: result.skipped, + redeployed, + service, + }; }), keyPublish: builder.keyPublish.handler(async ({ input }) => { @@ -1801,6 +1812,150 @@ export default createPlugin({ }), }); +interface PublishToFastKvInput { + bosConfig: BosConfig; + runtimeConfig: RuntimeConfig | null; + configDir: string; + env: "production" | "staging"; + build: boolean; + dryRun: boolean; + packages: string; + network?: "mainnet" | "testnet"; + privateKey?: string; +} + +interface PublishToFastKvResult { + status: "published" | "error" | "dry-run"; + registryUrl: string; + txHash?: string; + built?: string[]; + skipped?: string[]; + error?: string; + publishConfig?: BosConfig; +} + +async function publishToFastKv(input: PublishToFastKvInput): Promise { + const { env, dryRun, configDir } = input; + let bosConfig = input.bosConfig; + const runtimeConfig = input.runtimeConfig; + + const isStaging = env === "staging"; + const account = bosConfig.account; + const gateway = isStaging + ? (bosConfig.staging?.domain ?? bosConfig.domain) + : bosConfig.domain; + if (!gateway) { + return { + status: "error", + registryUrl: "", + error: "bos.config.json must define domain to publish", + }; + } + + const network = input.network ?? getNetworkIdForAccount(account); + const registryUrl = buildRegistryConfigUrlForNetwork(network, account, gateway); + const targets = selectWorkspaceTargets(input.packages, bosConfig); + + let publishConfig: BosConfig = isStaging + ? { ...bosConfig, domain: gateway } + : bosConfig; + let built: string[] | undefined; + let skipped: string[] | undefined; + + if (dryRun) { + return { status: "dry-run", registryUrl, built, skipped }; + } + + if (input.build) { + await generateCodeArtifacts(configDir, bosConfig, { + env: "production", + runtimeConfig: runtimeConfig ?? undefined, + }); + + const result = await buildWorkspaceTargets({ + configDir, + bosConfig, + runtimeConfig, + targets, + deploy: true, + }); + built = result.built; + skipped = result.skipped; + + const refreshed = await loadResolvedConfig({ cwd: configDir }); + if (refreshed?.config) { + bosConfig = refreshed.config; + publishConfig = isStaging + ? { ...refreshed.config, domain: gateway } + : refreshed.config; + } + } + + const registryEntries: Record = { + [`apps/${account}/${gateway}/bos.config.json`]: JSON.stringify(publishConfig), + }; + + const payload = JSON.stringify(registryEntries); + const argsBase64 = Buffer.from(payload).toString("base64"); + const privateKey = + input.privateKey || process.env.NEAR_PRIVATE_KEY || process.env.BOS_NEAR_PRIVATE_KEY; + + try { + await Effect.runPromise(ensureNearCli); + let txHash: string | undefined; + + try { + const tx = await Effect.runPromise( + executeTransaction({ + account, + contract: getRegistryNamespaceForNetwork(network), + method: "__fastdata_kv", + argsBase64, + network, + privateKey, + gas: "300Tgas", + deposit: "0NEAR", + }), + ); + txHash = tx.txHash; + } catch (error) { + txHash = extractTransactionHash(error); + + if (!txHash) { + throw error; + } + + try { + const bosUrl = `bos://${account}/${gateway}`; + const verifiedConfig = await fetchBosConfigFromFastKv(bosUrl); + if (JSON.stringify(verifiedConfig) !== JSON.stringify(publishConfig)) { + throw error; + } + } catch { + // Config may not exist yet on first publish or propagation delay; + // a valid txHash is sufficient proof the transaction was submitted. + } + } + + return { + status: "published", + registryUrl, + txHash, + built, + skipped, + publishConfig, + }; + } catch (error) { + return { + status: "error", + registryUrl, + error: error instanceof Error ? error.message : "Unknown error", + built, + skipped, + }; + } +} + function extractTransactionHash(error: unknown) { const message = error instanceof Error ? error.message : String(error); const match = message.match(/Transaction ID:\s*([A-Za-z0-9]+)/i); diff --git a/packages/everything-dev/src/types.ts b/packages/everything-dev/src/types.ts index 6647ff6d..e0fe022d 100644 --- a/packages/everything-dev/src/types.ts +++ b/packages/everything-dev/src/types.ts @@ -180,6 +180,7 @@ export const BosConfigInputSchema: z.ZodType = z.lazy(() => app: z.record(z.string(), BosConfigInputAppEntrySchema).optional(), shared: z.record(z.string(), z.record(z.string(), SharedConfigSchema)).optional(), plugins: z.record(z.string(), z.union([z.string(), BosConfigInputSchema])).optional(), + ci: CiConfigSchema.optional(), }), ); @@ -209,8 +210,19 @@ export interface BosConfigInput { app?: Record; shared?: Record>; plugins?: Record; + ci?: CiConfig; } +export const RailwayCiSchema = z.object({ + service: z.string(), +}); +export type RailwayCi = z.infer; + +export const CiConfigSchema = z.object({ + railway: RailwayCiSchema.optional(), +}); +export type CiConfig = z.infer; + export const BosConfigSchema = z.object({ account: z.string(), extends: ExtendsSchema.optional(), @@ -220,6 +232,7 @@ export const BosConfigSchema = z.object({ testnet: z.string().optional(), staging: BosStagingSchema.optional(), repository: z.string().optional(), + ci: CiConfigSchema.optional(), shared: z.record(z.string(), z.record(z.string(), SharedConfigSchema)).optional(), plugins: z.record(z.string(), z.union([z.string(), BosPluginRefSchema])).optional(), app: z.object({ From 971065ff28d70b3910a9e0cd40f7bad9fe38789f Mon Sep 17 00:00:00 2001 From: Elliot Braem Date: Fri, 22 May 2026 14:24:36 -0500 Subject: [PATCH 3/3] ssr fix --- .changeset/tenant-ssr-integrity-gate.md | 10 + bos.config.json | 12 +- host/src/services/errors.ts | 16 +- host/src/services/tenant-runtime.ts | 5 +- host/tests/helpers/bundled-host.ts | 133 +++++++ host/tests/helpers/bundled-ssr-module.ts | 96 +++++ host/tests/helpers/runtime-config.ts | 8 + host/tests/helpers/static-dist-server.ts | 68 ++++ host/tests/integration/runtime-remote.test.ts | 3 + host/tests/integration/seo.test.ts | 14 +- .../integration/ssr-bundled-runtime.test.ts | 54 +++ host/tests/integration/ssr-fallback.test.ts | 333 ++++++++++++++++++ .../integration/ssr-federation-error.test.ts | 240 +++++++++++++ host/tests/integration/ssr.test.ts | 20 +- host/tests/integration/tenant-runtime.test.ts | 126 +++++++ .../integration/ui-public-assets.test.ts | 184 ++++++++++ packages/everything-dev/src/mf.ts | 3 + plugins/apps/types/contract.d.ts | 17 + ui/rsbuild.config.ts | 21 +- 19 files changed, 1337 insertions(+), 26 deletions(-) create mode 100644 .changeset/tenant-ssr-integrity-gate.md create mode 100644 host/tests/helpers/bundled-host.ts create mode 100644 host/tests/helpers/bundled-ssr-module.ts create mode 100644 host/tests/helpers/static-dist-server.ts create mode 100644 host/tests/integration/ssr-bundled-runtime.test.ts create mode 100644 host/tests/integration/ssr-fallback.test.ts create mode 100644 host/tests/integration/ssr-federation-error.test.ts create mode 100644 host/tests/integration/ui-public-assets.test.ts diff --git a/.changeset/tenant-ssr-integrity-gate.md b/.changeset/tenant-ssr-integrity-gate.md new file mode 100644 index 00000000..fea5de7c --- /dev/null +++ b/.changeset/tenant-ssr-integrity-gate.md @@ -0,0 +1,10 @@ +--- +"everything-dev": patch +"host": patch +--- + +Require ssrIntegrity for tenant SSR — prevent no-cache-per-request MF instance creation + +Tenant SSR now requires both `ssrUrl` and `ssrIntegrity` to be present. Previously, a whitelisted tenant with `ssrUrl` but no `ssrIntegrity` would bypass the router module cache (`shouldCacheRouterModule` returns false without `ssrIntegrity`), causing a new Module Federation instance to be created on every SSR request — the same pattern that caused the production SSR failure. + +Also fixes pre-existing typecheck errors in host test files (Effect Either narrowing, FederationError type annotation). diff --git a/bos.config.json b/bos.config.json index 49193913..3f19b2e6 100644 --- a/bos.config.json +++ b/bos.config.json @@ -1,11 +1,10 @@ { "account": "dev.everything.near", - "domain": "dev.everything.dev", + "domain": "everything.dev", "title": "everything.dev", "description": "Open runtime for apps on NEAR, composed from published config and loaded through a shared host, UI, and API runtime.", - "testnet": "dev.allthethings.testnet", "staging": { - "domain": "staging.dev.everything.dev" + "domain": "dev.everything.dev" }, "repository": "https://github.com/nearbuilders/everything-dev", "ci": { @@ -28,9 +27,10 @@ }, "ui": { "development": "local:ui", - "production": "https://elliot-braem-3976-ui-everything-dev-nearbuilders-dd42b658b-ze.zephyrcloud.app", - "ssr": "https://elliot-braem-3977-ui-everything-dev-nearbuilders-a43563c52-ze.zephyrcloud.app", - "integrity": "sha384-CPc7Ruz83GD5ZSz//iDGelzs66TWP3nRroIa6vNejj3AyP9D7rbSYCY0B9QmKkOO" + "production": "https://elliot-braem-4009-ui-everything-dev-nearbuilders-08b0deb6b-ze.zephyrcloud.app", + "ssr": "https://elliot-braem-4010-ui-everything-dev-nearbuilders-41017c5ae-ze.zephyrcloud.app", + "integrity": "sha384-CPc7Ruz83GD5ZSz//iDGelzs66TWP3nRroIa6vNejj3AyP9D7rbSYCY0B9QmKkOO", + "ssrIntegrity": "sha384-Xl88t66al3NxqvJKtHAD+9V0X1Zf984ZqInTb+mnvwZK3HmUvJmlzNJmyu4l9g1v" }, "api": { "development": "local:api", diff --git a/host/src/services/errors.ts b/host/src/services/errors.ts index 70161513..22e8dc7e 100644 --- a/host/src/services/errors.ts +++ b/host/src/services/errors.ts @@ -4,10 +4,22 @@ export class FederationError extends Data.TaggedError("FederationError")<{ readonly remoteName: string; readonly remoteUrl?: string; readonly cause?: unknown; -}> {} +}> { + get message() { + const raw = this.cause instanceof FederationError ? this.cause.cause : this.cause; + const detail = raw instanceof Error ? raw.message : String(raw ?? ""); + return `Failed to load ${this.remoteName}${this.remoteUrl ? ` from ${this.remoteUrl}` : ""}: ${detail}`; + } +} export class PluginError extends Data.TaggedError("PluginError")<{ readonly pluginName?: string; readonly pluginUrl?: string; readonly cause?: unknown; -}> {} +}> { + get message() { + const raw = this.cause instanceof PluginError ? this.cause.cause : this.cause; + const detail = raw instanceof Error ? raw.message : String(raw ?? ""); + return `Plugin ${this.pluginName ?? "unknown"}${this.pluginUrl ? ` at ${this.pluginUrl}` : ""} failed: ${detail}`; + } +} diff --git a/host/src/services/tenant-runtime.ts b/host/src/services/tenant-runtime.ts index 4fe63253..983bff8e 100644 --- a/host/src/services/tenant-runtime.ts +++ b/host/src/services/tenant-runtime.ts @@ -506,7 +506,10 @@ export async function resolveRequestRuntime( } } - const ssrAllowed = Boolean(effectiveConfig.ui.ssrUrl) && isSsrAllowed(tenantAccountId); + const ssrAllowed = + Boolean(effectiveConfig.ui.ssrUrl) && + Boolean(effectiveConfig.ui.ssrIntegrity) && + isSsrAllowed(tenantAccountId); return { config: ssrAllowed diff --git a/host/tests/helpers/bundled-host.ts b/host/tests/helpers/bundled-host.ts new file mode 100644 index 00000000..62cded36 --- /dev/null +++ b/host/tests/helpers/bundled-host.ts @@ -0,0 +1,133 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { createInstance } from "@module-federation/enhanced/runtime"; +import { setGlobalFederationInstance } from "@module-federation/runtime-core"; +import { patchManifestFetchForSsrPublicPath } from "everything-dev/mf"; +import type { RuntimeConfig } from "../../src/services/config"; +import { getAvailablePort } from "./ports"; +import { startStaticDistServer } from "./static-dist-server"; +import { loadHostTestEnv } from "./test-env"; + +interface ServerHandle { + ready: Promise; + shutdown: () => Promise; +} + +interface HostRemoteModule { + runServer: (input: { config: RuntimeConfig }) => ServerHandle; +} + +export interface BundledHostRuntime { + baseUrl: string; + hostAssetsUrl: string; + uiAssetsUrl: string; + stop: () => Promise; +} + +export interface BundledHostUrls { + baseUrl: string; + hostAssetsUrl: string; + uiAssetsUrl: string; +} + +const workspaceRoot = path.resolve(import.meta.dirname, "../../.."); +const hostDir = path.join(workspaceRoot, "host"); +const uiDir = path.join(workspaceRoot, "ui"); +let buildReady = false; + +loadHostTestEnv(workspaceRoot); + +function ensureBuild(cwd: string) { + const result = spawnSync("bun", ["run", "build"], { + cwd, + stdio: "inherit", + env: { ...process.env }, + }); + + if (result.status !== 0) { + throw new Error(`Build failed for ${cwd} (exit ${result.status ?? "unknown"})`); + } +} + +function ensureBuilds() { + if (buildReady) return; + + if (!existsSync(path.join(hostDir, "dist", "remoteEntry.js"))) { + ensureBuild(hostDir); + } + if (!existsSync(path.join(uiDir, "dist", "remoteEntry.server.js"))) { + ensureBuild(uiDir); + } + buildReady = true; +} + +let instanceCounter = 0; + +async function loadBundledHostModule(hostAssetsUrl: string): Promise { + instanceCounter++; + const remoteName = `host_${instanceCounter}`; + const mf = createInstance({ name: `bundled-host-${instanceCounter}`, remotes: [] }); + setGlobalFederationInstance(mf as any); + patchManifestFetchForSsrPublicPath(mf as any); + + const entryUrl = `${hostAssetsUrl}/mf-manifest.json`; + (mf as any).registerRemotes([{ name: remoteName, entry: entryUrl, alias: "host" }]); + + const hostModule = (await (mf as any).loadRemote(`${remoteName}/Server`)) as HostRemoteModule | null; + if (!hostModule?.runServer) { + throw new Error("Bundled host module did not export runServer"); + } + + return hostModule; +} + +export async function startBundledHost( + buildConfig: (urls: BundledHostUrls) => RuntimeConfig, +): Promise { + ensureBuilds(); + + const hostAssetsServer = await startStaticDistServer(path.join(hostDir, "dist")); + const uiAssetsServer = await startStaticDistServer(path.join(uiDir, "dist")); + const port = await getAvailablePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const runtimeConfig = buildConfig({ + baseUrl, + hostAssetsUrl: hostAssetsServer.baseUrl, + uiAssetsUrl: uiAssetsServer.baseUrl, + }); + + const previousNodeEnv = process.env.NODE_ENV; + const previousHost = process.env.HOST; + const previousPort = process.env.PORT; + process.env.NODE_ENV = "production"; + process.env.HOST = "127.0.0.1"; + process.env.PORT = String(port); + + let serverHandle: ServerHandle | null = null; + + try { + const hostModule = await loadBundledHostModule(hostAssetsServer.baseUrl); + serverHandle = hostModule.runServer({ config: runtimeConfig }); + await serverHandle.ready; + } catch (error) { + await Promise.allSettled([hostAssetsServer.stop(), uiAssetsServer.stop()]); + process.env.NODE_ENV = previousNodeEnv; + process.env.HOST = previousHost; + process.env.PORT = previousPort; + throw error; + } + + return { + baseUrl, + hostAssetsUrl: hostAssetsServer.baseUrl, + uiAssetsUrl: uiAssetsServer.baseUrl, + stop: async () => { + await serverHandle?.shutdown(); + await Promise.allSettled([hostAssetsServer.stop(), uiAssetsServer.stop()]); + process.env.NODE_ENV = previousNodeEnv; + process.env.HOST = previousHost; + process.env.PORT = previousPort; + }, + }; +} diff --git a/host/tests/helpers/bundled-ssr-module.ts b/host/tests/helpers/bundled-ssr-module.ts new file mode 100644 index 00000000..d372890d --- /dev/null +++ b/host/tests/helpers/bundled-ssr-module.ts @@ -0,0 +1,96 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { createInstance } from "@module-federation/enhanced/runtime"; +import { setGlobalFederationInstance } from "@module-federation/runtime-core"; +import { patchManifestFetchForSsrPublicPath } from "everything-dev/mf"; +import type { RouterModule } from "../../src/types"; +import { startStaticDistServer, type StaticDistServer } from "./static-dist-server"; +import { loadHostTestEnv } from "./test-env"; + +const workspaceRoot = path.resolve(import.meta.dirname, "../../.."); +const uiDir = path.join(workspaceRoot, "ui"); +let buildReady = false; + +loadHostTestEnv(workspaceRoot); + +function ensureUiServerBuild() { + if (buildReady) return; + + const serverEntry = path.join(uiDir, "dist", "remoteEntry.server.js"); + if (existsSync(serverEntry)) { + buildReady = true; + return; + } + + const result = spawnSync("bun", ["run", "build"], { + cwd: uiDir, + stdio: "inherit", + env: { ...process.env, BUILD_TARGET: "server" }, + }); + + if (result.status !== 0) { + throw new Error(`UI server build failed (exit ${result.status ?? "unknown"})`); + } + + buildReady = true; +} + +let activeSsrLoader: { + uiServer: StaticDistServer; + mf: ReturnType; +} | null = null; + +export async function loadBundledRouterModule(): Promise<{ + routerModule: RouterModule; + assetsUrl: string; + cleanup: () => Promise; +}> { + ensureUiServerBuild(); + + if (activeSsrLoader) { + const mod = await (activeSsrLoader.mf as any).loadRemote("ui/Router", { from: "build" }); + return { + routerModule: mod.default as RouterModule, + assetsUrl: activeSsrLoader.uiServer.baseUrl, + cleanup: async () => {}, + }; + } + + const uiServer = await startStaticDistServer(path.join(uiDir, "dist")); + + const mf = createInstance({ + name: "ssr-test-host", + remotes: [ + { + name: "ui", + entry: `${uiServer.baseUrl}/mf-manifest.json`, + alias: "ui", + }, + ], + }); + setGlobalFederationInstance(mf as any); + patchManifestFetchForSsrPublicPath(mf as any); + + const mod = await (mf as any).loadRemote("ui/Router", { from: "build" }); + if (!mod?.default) { + await uiServer.stop(); + throw new Error("Bundled UI remote did not export Router module"); + } + + activeSsrLoader = { uiServer, mf }; + + return { + routerModule: mod.default as RouterModule, + assetsUrl: uiServer.baseUrl, + cleanup: async () => { + if (activeSsrLoader) { + await activeSsrLoader.uiServer.stop(); + if ((mf as any).getInstance) { + setGlobalFederationInstance(undefined as any); + } + activeSsrLoader = null; + } + }, + }; +} diff --git a/host/tests/helpers/runtime-config.ts b/host/tests/helpers/runtime-config.ts index 8fa122a9..a1c0cd4e 100644 --- a/host/tests/helpers/runtime-config.ts +++ b/host/tests/helpers/runtime-config.ts @@ -61,6 +61,14 @@ export function buildTestClientRuntimeConfig(config: RuntimeConfig): Partial Promise; +} + +const MIME_TYPES: Record = { + ".css": "text/css", + ".html": "text/html", + ".ico": "image/x-icon", + ".js": "application/javascript", + ".json": "application/json", + ".mjs": "application/javascript", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain", + ".webmanifest": "application/manifest+json", +}; + +function getContentType(filePath: string) { + return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + +export async function startStaticDistServer(rootDir: string): Promise { + const port = await getAvailablePort(); + const normalizedRoot = path.resolve(rootDir); + + const server = createServer((req, res) => { + const rawPath = (req.url ?? "/").split("?")[0] ?? "/"; + const relativePath = rawPath === "/" ? "/index.html" : rawPath; + const filePath = path.resolve(normalizedRoot, `.${relativePath}`); + + if (!filePath.startsWith(normalizedRoot)) { + res.statusCode = 403; + res.end("forbidden"); + return; + } + + if (!existsSync(filePath) || statSync(filePath).isDirectory()) { + res.statusCode = 404; + res.end("not found"); + return; + } + + res.statusCode = 200; + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("content-type", getContentType(filePath)); + res.end(readFileSync(filePath)); + }); + + await new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(port, "127.0.0.1", () => resolve()); + }); + + return { + baseUrl: `http://127.0.0.1:${port}`, + stop: async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} diff --git a/host/tests/integration/runtime-remote.test.ts b/host/tests/integration/runtime-remote.test.ts index afa7347c..c5b885bc 100644 --- a/host/tests/integration/runtime-remote.test.ts +++ b/host/tests/integration/runtime-remote.test.ts @@ -73,6 +73,9 @@ for (const scenario of scenarios) { if (scenario.ssr) { expect(html).toContain('
'); expect(html).toContain("manifest.json"); + expect(html).toContain('