diff --git a/.changeset/staging-host-secrets.md b/.changeset/staging-host-secrets.md new file mode 100644 index 00000000..57ae3e11 --- /dev/null +++ b/.changeset/staging-host-secrets.md @@ -0,0 +1,17 @@ +--- +"everything-dev": minor +--- + +Add `bos deploy` command, host secrets, and staging environment support + +- **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. + +- **New `ci` config section**: `bos.config.json` now accepts `ci.railway.service` for Railway integration. Child projects inherit this via extends. + +- **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"`. + +- **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`. + +- **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/.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/.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/workflows/publish.yml b/.github/templates/workflows/deploy.yml similarity index 59% rename from .github/workflows/publish.yml rename to .github/templates/workflows/deploy.yml index 22eebf6b..aff1bc72 100644 --- a/.github/workflows/publish.yml +++ b/.github/templates/workflows/deploy.yml @@ -1,31 +1,34 @@ -name: Publish Config +name: Deploy on: - push: - branches: - - main - paths: - - "bos.config.json" + workflow_run: + workflows: [CI] + types: [completed] workflow_dispatch: - inputs: - deploy: - description: "Build/deploy all workspaces before publish" - required: false - default: false - type: boolean + +concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: contents: write jobs: - publish: - if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]') + 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 @@ -44,16 +47,14 @@ jobs: - 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: 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" + file_pattern: "**/bos.config.json" \ No newline at end of file diff --git a/.github/templates/workflows/staging.yml b/.github/templates/workflows/staging.yml new file mode 100644 index 00000000..2b9542b6 --- /dev/null +++ b/.github/templates/workflows/staging.yml @@ -0,0 +1,54 @@ +name: Staging + +on: + push: + branches: + - staging + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + deploy-staging: + name: Deploy Staging + 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_STAGING_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 --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" \ No newline at end of file diff --git a/.github/templates/workflows/publish.yml b/.github/workflows/deploy.yml similarity index 54% rename from .github/templates/workflows/publish.yml rename to .github/workflows/deploy.yml index 3a5eef02..71ea0346 100644 --- a/.github/templates/workflows/publish.yml +++ b/.github/workflows/deploy.yml @@ -1,31 +1,34 @@ -name: Publish Config +name: Deploy on: - push: - branches: - - main - paths: - - "bos.config.json" + workflow_run: + workflows: [CI] + types: [completed] workflow_dispatch: - inputs: - deploy: - description: "Build/deploy all workspaces before publish" - required: false - default: false - type: boolean + +concurrency: ${{ github.workflow }}-${{ github.ref }} permissions: contents: write jobs: - publish: - if: github.event_name == 'workflow_dispatch' || !contains(github.event.head_commit.message, '[skip ci]') + 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 @@ -38,19 +41,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 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: 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" + file_pattern: "**/bos.config.json" \ 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/release.yml b/.github/workflows/release.yml index bfef56b2..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,18 +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" diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml new file mode 100644 index 00000000..5aab61b4 --- /dev/null +++ b/.github/workflows/staging.yml @@ -0,0 +1,55 @@ +name: Staging + +on: + push: + branches: + - staging + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + packages: write + +jobs: + deploy-staging: + name: Deploy Staging + 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_STAGING_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 --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" \ 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..3f19b2e6 100644 --- a/bos.config.json +++ b/bos.config.json @@ -1,28 +1,40 @@ { "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": { + "railway": { + "service": "app" + } + }, "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-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", - "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 +47,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 +66,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 +83,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/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('