mfeflow 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +8 -3
  2. package/assets/features/deploy-cloudflare/feature.json +85 -0
  3. package/assets/features/deploy-cloudflare/files/cloudflare.json.eta +13 -0
  4. package/assets/features/deploy-cloudflare/files/cloudflare.md.eta +104 -0
  5. package/assets/features/deploy-cloudflare/files/deploy-cloudflare.ts +318 -0
  6. package/assets/features/deploy-cloudflare/files/deploy-cloudflare.yml +111 -0
  7. package/assets/features/deploy-cloudflare/files/headers.remote +14 -0
  8. package/assets/features/deploy-cloudflare/files/headers.shell.eta +18 -0
  9. package/assets/features/deploy-cloudflare/files/r2-cors.json +10 -0
  10. package/assets/features/deploy-cloudflare/files/registry-proxy.ts +80 -0
  11. package/assets/features/deploy-cloudflare/files/wrangler.remote.jsonc.eta +21 -0
  12. package/assets/features/deploy-cloudflare/files/wrangler.shell.jsonc.eta +40 -0
  13. package/assets/features/deploy-docker/feature.json +22 -0
  14. package/assets/features/deploy-docker/files/40-mfe-config.sh +76 -0
  15. package/assets/features/deploy-docker/files/Dockerfile +48 -0
  16. package/assets/features/deploy-docker/files/compose-registry.json.eta +19 -0
  17. package/assets/features/deploy-docker/files/compose.yaml.eta +50 -0
  18. package/assets/features/deploy-docker/files/docker.md +52 -0
  19. package/assets/features/deploy-docker/files/dockerignore +17 -0
  20. package/assets/features/deploy-docker/files/k8s.yaml +114 -0
  21. package/assets/features/deploy-docker/files/nginx.remote.conf +73 -0
  22. package/assets/features/deploy-docker/files/nginx.shell.conf +84 -0
  23. package/assets/features/registry-server/feature.json +128 -0
  24. package/assets/features/registry-server/files/deploy-server.yml +69 -0
  25. package/assets/features/registry-server/files/registry-server.md.eta +52 -0
  26. package/assets/features/registry-server/files/registry-sources.ts +12 -0
  27. package/assets/features/registry-static/feature.json +61 -0
  28. package/assets/features/registry-static/files/deploy.yml +61 -0
  29. package/assets/features/registry-static/files/registry.md.eta +49 -0
  30. package/assets/features/release/feature.json +43 -0
  31. package/assets/features/release/files/changeset-readme.md +7 -0
  32. package/assets/features/release/files/config.json +10 -0
  33. package/assets/features/release/files/releasing.md +21 -0
  34. package/assets/templates/monorepo/feature.json +7 -6
  35. package/assets/templates/monorepo/files/remote/{mfe.json.eta → mfeflow.app.json.eta} +1 -0
  36. package/assets/templates/monorepo/files/root/README.md.eta +1 -1
  37. package/assets/templates/monorepo/files/root/pnpm-workspace.yaml.eta +3 -2
  38. package/assets/templates/monorepo/files/shell/{mfe.json.eta → mfeflow.app.json.eta} +1 -0
  39. package/assets/templates/monorepo/files/shell/rsbuild.config.ts.eta +42 -6
  40. package/assets/templates/monorepo/migrations/0.4.0-app-file.mjs +58 -0
  41. package/assets/templates/polyrepo/feature.json +305 -0
  42. package/assets/templates/polyrepo/files/AGENTS.md.eta +15 -0
  43. package/assets/templates/polyrepo/files/changeset/README.md +5 -0
  44. package/assets/templates/polyrepo/files/changeset/config.json +9 -0
  45. package/assets/templates/polyrepo/files/packages/contracts/package.json.eta +23 -0
  46. package/assets/templates/polyrepo/files/packages/contracts/src/index.ts +11 -0
  47. package/assets/templates/polyrepo/files/packages/contracts/tsconfig.json.eta +5 -0
  48. package/assets/templates/polyrepo/files/packages/tokens/package.json.eta +23 -0
  49. package/assets/templates/polyrepo/files/packages/tokens/src/index.ts +13 -0
  50. package/assets/templates/polyrepo/files/packages/tokens/tsconfig.json.eta +5 -0
  51. package/assets/templates/polyrepo/files/platform/README.md.eta +43 -0
  52. package/assets/templates/polyrepo/files/platform/default.json.eta +27 -0
  53. package/assets/templates/polyrepo/files/remote/README.md.eta +23 -0
  54. package/assets/templates/polyrepo/files/renovate.json.eta +4 -0
  55. package/assets/templates/polyrepo/files/shell/README.md.eta +32 -0
  56. package/assets/templates/polyrepo/files/tsconfig.base.json.eta +13 -0
  57. package/assets/templates/polyrepo/files/workflows/platform-ci.yml +29 -0
  58. package/assets/templates/polyrepo/files/workflows/release.yml +40 -0
  59. package/assets/templates/polyrepo/files/workflows/reusable-ci.yml.eta +50 -0
  60. package/assets/templates/polyrepo/files/workflows/team-ci.yml.eta +12 -0
  61. package/dist/against.d.ts +16 -0
  62. package/dist/catalog.d.ts +4 -0
  63. package/dist/checks/compat.d.ts +12 -0
  64. package/dist/checks/index.d.ts +0 -1
  65. package/dist/commands/add.d.ts +0 -2
  66. package/dist/commands/adopt.d.ts +54 -0
  67. package/dist/commands/deploy.d.ts +35 -0
  68. package/dist/commands/doctor.d.ts +10 -0
  69. package/dist/commands/init.d.ts +9 -1
  70. package/dist/commands/server-mode.d.ts +210 -0
  71. package/dist/commands/status.d.ts +5 -0
  72. package/dist/commands/team-repo.d.ts +9 -0
  73. package/dist/commands/upgrade.d.ts +66 -0
  74. package/dist/devremotes.d.ts +15 -0
  75. package/dist/exit.d.ts +1 -0
  76. package/dist/glue.d.ts +6 -0
  77. package/dist/index.d.ts +1 -1
  78. package/dist/index.js +4600 -1875
  79. package/dist/inspect.d.ts +14 -2
  80. package/dist/output.d.ts +138 -3
  81. package/dist/pipeline.d.ts +5 -2
  82. package/dist/polyrepo.d.ts +69 -0
  83. package/dist/publish.d.ts +10 -1
  84. package/dist/runtime.d.ts +2 -0
  85. package/dist/server-client.d.ts +69 -0
  86. package/dist/system.d.ts +2 -0
  87. package/dist/testing.d.ts +17 -1
  88. package/dist/upgrade.d.ts +25 -0
  89. package/dist/write.d.ts +8 -0
  90. package/package.json +5 -5
package/README.md CHANGED
@@ -26,7 +26,7 @@ pnpm dev # shell on :4300, remotes on :4311,
26
26
  | `mfeflow registry build <dir>` | Check a folder of features and write `index.json` and the files to host |
27
27
  | `mfeflow dev` | Run the shell and every remote, one prefixed log each; follows `add remote` |
28
28
  | `mfeflow doctor` | Singletons, compatibility, config health, drift; `--fix` repairs the safe ones |
29
- | `mfeflow validate` | `mfeflow.json`, every `mfe.json` and every registry entry against their schemas |
29
+ | `mfeflow validate` | `mfeflow.json`, every `mfeflow.app.json` and every registry entry against schemas |
30
30
  | `mfeflow check-compat` | Each remote's shared ranges against the shells it runs in |
31
31
  | `mfeflow budget` | Built first-load JS of the shell and each remote against the cost-model budget |
32
32
  | `mfeflow deploy <remote> --env <env>` | Upload a remote's build to a static registry host and point the env's index at it |
@@ -48,13 +48,17 @@ runs part of the project; `--remote name=url` uses a server you run yourself (th
48
48
  localhost unless `--allow-origin <origin>` names it); Ctrl+C stops every app, process trees included, on Windows too.
49
49
  It exits 130 after Ctrl+C.
50
50
 
51
+ Each app keeps its owner, routes and budgets in `mfeflow.app.json`. An app that still has the old `mfe.json` works:
52
+ the CLI reads it and prints one notice per command. An app with both files is an error (E122).
53
+
51
54
  `doctor` runs these checks, each with a code and a docs page (`https://mfeflow.github.io/mfeflow/errors/<code>`). It exits with 0
52
55
  when there are no errors, else with the lowest exit code among the errors' ranges (project 3 before build 5).
53
56
 
54
57
  | Check | Code | Fixed by `--fix` |
55
58
  | ------------------------------------- | -------------- | ----------------------------- |
56
59
  | every app in mfeflow.json is on disk | E112 | |
57
- | valid `mfe.json`, name matches | E113, E116 | |
60
+ | valid `mfeflow.app.json`, name match | E113, E116 | |
61
+ | no app has both app file names | E122 | |
58
62
  | no shared port | E115 | |
59
63
  | no route claimed twice | E116 | |
60
64
  | registry entry per remote and env | E114 | writes the missing ones |
@@ -70,7 +74,7 @@ when there are no errors, else with the lowest exit code among the errors' range
70
74
  | server keys listed in `turbo.json` | E121 (warning) | |
71
75
 
72
76
  `validate` runs the schema and registry checks, `check-compat` the compatibility check, `budget` reads the build output
73
- (`dist/`): each remote's entry against `budget.remoteKb` (10), a route against `mfe.json` `budgets.initialJsKb`, and the
77
+ (`dist/`): each remote's entry against `budget.remoteKb` (10), a route against `mfeflow.app.json` `budgets.initialJsKb`, and the
74
78
  shell against `budget.baselineKb + budget.shellKb` (32) when a baseline is set. All four take `--json` and exit non-zero on
75
79
  errors. LCP is measured by the perf job, not by `budget`.
76
80
 
@@ -111,6 +115,7 @@ MFEFLOW_E108 The target folder is not empty: /work/app already has notes.txt
111
115
  | 5 | build or runtime problem: `MFEFLOW_E3xx` |
112
116
  | 6 | feature registry problem: `MFEFLOW_E4xx` |
113
117
  | 7 | deploy problem: `MFEFLOW_E5xx` |
118
+ | 8 | upgrade problem, or files that need a decision: `MFEFLOW_E6xx` |
114
119
  | 130 | cancelled at a prompt (nothing was written), or Ctrl+C in `mfeflow dev` |
115
120
 
116
121
  ## Try it from a checkout
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "deploy-cloudflare",
3
+ "version": "0.1.0",
4
+ "description": "One way to host the shell and remotes on Cloudflare Workers static assets: a wrangler.jsonc per app with staging and prod, _headers with the cache and CORS rules, a script that picks each environment's runtime config and registry index without rebuilding, an optional R2 proxy for /registry/*, and, when ci-github is present, a workflow. Opt-in; every file is yours to edit or delete.",
5
+ "keywords": ["cloudflare", "workers", "wrangler", "r2", "deploy"],
6
+ "requires": ["core"],
7
+ "prompts": [
8
+ {
9
+ "name": "r2Proxy",
10
+ "type": "boolean",
11
+ "message": "Serve /registry/* from an R2 bucket through a small Worker (the remotes are then published with mfeflow deploy)",
12
+ "default": false
13
+ }
14
+ ],
15
+ "files": [
16
+ {
17
+ "to": "wrangler.jsonc",
18
+ "target": "shell",
19
+ "from": "files/wrangler.shell.jsonc.eta",
20
+ "template": true
21
+ },
22
+ {
23
+ "to": "wrangler.jsonc",
24
+ "target": "remotes",
25
+ "from": "files/wrangler.remote.jsonc.eta",
26
+ "template": true,
27
+ "when": { "r2Proxy": false }
28
+ },
29
+ {
30
+ "to": "public/_headers",
31
+ "target": "shell",
32
+ "from": "files/headers.shell.eta",
33
+ "template": true
34
+ },
35
+ {
36
+ "to": "public/_headers",
37
+ "target": "remotes",
38
+ "from": "files/headers.remote",
39
+ "when": { "r2Proxy": false }
40
+ },
41
+ {
42
+ "to": "worker/registry-proxy.ts",
43
+ "target": "shell",
44
+ "from": "files/registry-proxy.ts",
45
+ "when": { "r2Proxy": true }
46
+ },
47
+ {
48
+ "to": "deploy/r2-cors.json",
49
+ "target": "root",
50
+ "from": "files/r2-cors.json",
51
+ "when": { "r2Proxy": true }
52
+ },
53
+ {
54
+ "to": "deploy/cloudflare.json",
55
+ "target": "root",
56
+ "from": "files/cloudflare.json.eta",
57
+ "template": true
58
+ },
59
+ {
60
+ "to": "scripts/deploy-cloudflare.ts",
61
+ "target": "root",
62
+ "from": "files/deploy-cloudflare.ts"
63
+ },
64
+ {
65
+ "to": "docs/cloudflare.md",
66
+ "target": "root",
67
+ "from": "files/cloudflare.md.eta",
68
+ "template": true
69
+ },
70
+ {
71
+ "to": ".github/workflows/deploy-cloudflare.yml",
72
+ "target": "root",
73
+ "from": "files/deploy-cloudflare.yml",
74
+ "when": { "feature:ci-github": true }
75
+ }
76
+ ],
77
+ "dependencies": [{ "name": "wrangler", "range": "4.156.0", "target": "root", "dev": true }],
78
+ "scripts": [
79
+ {
80
+ "name": "deploy:cloudflare",
81
+ "command": "node --experimental-strip-types scripts/deploy-cloudflare.ts",
82
+ "target": "root"
83
+ }
84
+ ]
85
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "registry": "<%= it.options.r2Proxy ? 'r2' : 'assets' %>",
3
+ "envs": {
4
+ "staging": {
5
+ "shell": "",
6
+ "remotes": {}
7
+ },
8
+ "prod": {
9
+ "shell": "",
10
+ "remotes": {}
11
+ }
12
+ }
13
+ }
@@ -0,0 +1,104 @@
1
+ # Cloudflare
2
+
3
+ This is one way to host the project on Cloudflare Workers. The files were added by `deploy-cloudflare`. They are
4
+ yours: edit them, replace them, or delete them. Nothing else in the project depends on them.
5
+
6
+ ## What was added
7
+
8
+ <% if (it.options.r2Proxy) { %>- `apps/shell/wrangler.jsonc`: the shell as a Worker with static assets, with a `staging` and a `prod` environment.
9
+ - `apps/shell/worker/registry-proxy.ts`: a small Worker that serves `/registry/*` from an R2 bucket.
10
+ - `deploy/r2-cors.json`: the CORS rules for the bucket, for the case where you serve it from its own domain.
11
+ <% } else { %>- `apps/<app>/wrangler.jsonc` for the shell and for each remote: one Worker per app, each with a `staging` and a `prod` environment. A remote added later gets its own file.
12
+ <% } %>- `apps/<app>/public/_headers`: the cache rules<% if (!it.options.r2Proxy) { %>, and on remotes the CORS header<% } %>.
13
+ - `deploy/cloudflare.json`: the address each environment is served from.
14
+ - `scripts/deploy-cloudflare.ts` and the script `pnpm deploy:cloudflare`.
15
+ - `.github/workflows/deploy-cloudflare.yml`, only when `ci-github` is in the project.
16
+ - `wrangler` as a dev dependency.
17
+
18
+ ## How it works
19
+
20
+ <% if (it.options.r2Proxy) { %>The shell is one Worker. Remotes are published to an R2 bucket with `mfeflow deploy`, and the shell Worker serves the bucket's index and files under `/registry`. <% } else { %>The shell and each remote are separate Workers on their own addresses, so the shell loads remotes from another origin. <% } %>The build is made once. `pnpm deploy:cloudflare prod` then copies each `dist` into `apps/<app>/.mfeflow/cloudflare/prod`, and changes only that copy:
21
+
22
+ - The shell gets `config/runtime-config.json` from `runtime-config.prod.json`. The other environments' files are left out.
23
+ <% if (!it.options.r2Proxy) { %>- The shell gets `registry/index.json`, written from the remotes' versions and the addresses in `deploy/cloudflare.json`, and their origins are added to `registryOrigins` so the runtime accepts them.
24
+ - Each remote's `_headers` gets the shell's address as its `Access-Control-Allow-Origin`.
25
+ <% } %>
26
+ Then it runs `wrangler deploy --env prod` for each app. Cloudflare makes one Worker per environment, named `<%= it.scope.slice(1) %>-<app>-prod`. Bindings and `vars` are not inherited by an environment, so they are repeated in each block.
27
+
28
+ ## Set it up
29
+
30
+ 1. Put the real addresses in `deploy/cloudflare.json`. The script stops on an empty or `example.com` address.
31
+ 2. Choose how each Worker is reached. Without a `routes` entry, Cloudflare uses a `workers.dev` address. For your own domain, add `"routes": [{ "pattern": "shop.example.com", "custom_domain": true }]` to the environment in `wrangler.jsonc`.
32
+ 3. Build and look at what would be deployed:
33
+
34
+ ```sh
35
+ pnpm build
36
+ pnpm deploy:cloudflare staging --dry-run
37
+ ```
38
+
39
+ 4. Deploy. Wrangler reads `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` from the environment:
40
+
41
+ ```sh
42
+ pnpm deploy:cloudflare staging
43
+ ```
44
+
45
+ Pass app names to deploy only those: `pnpm deploy:cloudflare prod shell`.
46
+
47
+ <% if (it.options.r2Proxy) { %>## The R2 bucket
48
+
49
+ Create one bucket for each environment, named in `wrangler.jsonc` (`<%= it.scope.slice(1) %>-registry-staging` and `<%= it.scope.slice(1) %>-registry-prod`):
50
+
51
+ ```sh
52
+ pnpm wrangler r2 bucket create <%= it.scope.slice(1) %>-registry-prod
53
+ ```
54
+
55
+ Publish remotes with `mfeflow deploy`, using the bucket's S3 endpoint and the shell's address plus `/registry` as the base URL, so that the entries in the index point through the Worker:
56
+
57
+ ```sh
58
+ export MFEFLOW_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
59
+ export MFEFLOW_S3_REGION=auto
60
+ mfeflow deploy catalog --env prod --host s3://<%= it.scope.slice(1) %>-registry-prod --base-url https://shop.acme.dev/registry
61
+ ```
62
+
63
+ The access key and secret come from an R2 API token, in `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`.
64
+
65
+ The Worker answers `/registry/index.json` from `envs/<env>/index.json`, and `/registry/<name>/<version>/<file>` from the same key in the bucket. The index is checked on every load, and versioned files are cached for a year.
66
+
67
+ Set `registryUrls` in the shell's runtime config only if you use another address. The default is `/registry/index.json`.
68
+
69
+ If you would rather serve the bucket from its own domain, connect the domain in the dashboard and allow the shell's origin. Edit `deploy/r2-cors.json`, then:
70
+
71
+ ```sh
72
+ pnpm wrangler r2 bucket cors set <%= it.scope.slice(1) %>-registry-prod --file deploy/r2-cors.json
73
+ ```
74
+
75
+ <% } %>## In CI
76
+
77
+ With `ci-github` in the project, `.github/workflows/deploy-cloudflare.yml` builds once and deploys each app with `cloudflare/wrangler-action`. A push to `main` deploys staging. Run it by hand to choose `prod`. Add these as secrets on the GitHub environments `cloudflare-staging` and `cloudflare-prod`:
78
+
79
+ - `CLOUDFLARE_API_TOKEN`: an API token that may edit Workers
80
+ - `CLOUDFLARE_ACCOUNT_ID`
81
+
82
+ Add a required reviewer to `cloudflare-prod` to hold production until someone approves. The workflow starts with no permissions, gives each job `contents: read`, and pins every action to a commit SHA. The workflow is only written when `ci-github` is already in the project, so add `ci-github` first.
83
+
84
+ ## Cache rules
85
+
86
+ `_headers` is read by Cloudflare for static assets. It is not applied to responses your own Worker code makes<% if (it.options.r2Proxy) { %>, which is why `registry-proxy.ts` sets its own `Cache-Control`<% } %>. Each path matches one rule, because a header set by two rules is joined with a comma. The file holds up to 100 rules.
87
+
88
+ - `no-cache`: `/`, `/index.html`, `/mf-manifest.json`, `config/runtime-config*`<% if (!it.options.r2Proxy) { %>, `registry/index.json`<% } %>
89
+ - `public, max-age=31536000, immutable`: `/static/*`, whose names carry a hash
90
+
91
+ ## Change or remove it
92
+
93
+ - Rename a Worker or add a route: edit `wrangler.jsonc`.
94
+ - Add a third environment: add a block to each `wrangler.jsonc`, to `deploy/cloudflare.json`, and the name to `ENVS` in the script.
95
+ - Stop using it: delete the files above and `pnpm remove wrangler`. Run `pnpm mfeflow validate` afterwards.
96
+ - Roll back: `pnpm wrangler rollback --env prod --config apps/<app>/wrangler.jsonc` makes the previous version of a Worker live again.
97
+
98
+ ## Gotchas
99
+
100
+ - With the shell's single-page fallback, a mistyped path returns `index.html` with status 200. Remotes have no fallback, so a missing file is a 404.
101
+ - pnpm 11 stops an install when a package has an install script nobody has approved. `workerd`, which wrangler pulls in, is set to `false` under `allowBuilds` in `pnpm-workspace.yaml` of new projects, and wrangler deploys without running that script, and `wrangler dev` starts and serves the build with it (tried with wrangler 4.156.0). If your workspace file lacks the entry, add `workerd: false` there, or the install fails with `ERR_PNPM_IGNORED_BUILDS`.
102
+ - `compatibility_date` in `wrangler.jsonc` is `2026-10-28`. `wrangler dev` refuses a date newer than its bundled runtime knows, so raise the date together with wrangler.
103
+ - Each environment has one shell address, because a remote sends a single `Access-Control-Allow-Origin`. Two shell addresses for the same remote need two environments, or a rule of your own in the remote's `_headers`.
104
+ - The shell's `_headers` also sends `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options: SAMEORIGIN`. Remove the last one if another site must embed your shell in a frame.
@@ -0,0 +1,318 @@
1
+ // Stages each app's build for one environment and deploys it to Cloudflare Workers. It is your code:
2
+ // change it freely. The build is made once; only the staged copy differs per environment.
3
+ //
4
+ // node scripts/deploy-cloudflare.ts <staging|prod> [app...] stage, then wrangler deploy
5
+ // node scripts/deploy-cloudflare.ts <env> [app...] --prepare-only stage only (CI runs wrangler itself)
6
+ // node scripts/deploy-cloudflare.ts <env> [app...] --dry-run stage and print the wrangler commands
7
+ // node scripts/deploy-cloudflare.ts plan <env>[,env] print the CI matrix as JSON
8
+ import { spawnSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ const ENVS = ['staging', 'prod'];
13
+ const STAGE = '.mfeflow/cloudflare';
14
+ const SHELL_ORIGIN_TOKEN = '__SHELL_ORIGIN__';
15
+
16
+ interface Project {
17
+ shell: string;
18
+ remotes: string[];
19
+ packageDirs: Record<string, string>;
20
+ }
21
+
22
+ interface EnvConfig {
23
+ shell?: string;
24
+ remotes?: Record<string, string>;
25
+ }
26
+
27
+ interface Config {
28
+ registry: string;
29
+ envs: Record<string, EnvConfig>;
30
+ }
31
+
32
+ function readJson(file: string): unknown {
33
+ let text: string;
34
+ try {
35
+ text = fs.readFileSync(file, 'utf8');
36
+ } catch {
37
+ throw new Error(`${file} is missing`);
38
+ }
39
+ try {
40
+ return JSON.parse(text);
41
+ } catch {
42
+ throw new Error(`${file} is not valid JSON`);
43
+ }
44
+ }
45
+
46
+ function isRecord(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ export function readProject(root: string): Project {
51
+ const file = path.join(root, 'mfeflow.json');
52
+ const json = readJson(file);
53
+ const apps = isRecord(json) && isRecord(json.apps) ? json.apps : {};
54
+ let shell = '';
55
+ const remotes: string[] = [];
56
+ const packageDirs: Record<string, string> = {};
57
+ for (const [name, app] of Object.entries(apps)) {
58
+ if (!isRecord(app) || typeof app.path !== 'string') continue;
59
+ // These end up in file paths and in the CI matrix, so keep to what mfeflow itself allows.
60
+ if (
61
+ !/^[a-z][a-z0-9_]*$/.test(name) ||
62
+ !/^[\w.-]+(\/[\w.-]+)*$/.test(app.path) ||
63
+ app.path.split('/').includes('..')
64
+ ) {
65
+ throw new Error(`${file}: app "${name}" has a name or path this script will not use`);
66
+ }
67
+ packageDirs[name] = app.path;
68
+ if (app.kind === 'shell') shell = name;
69
+ if (app.kind === 'remote') remotes.push(name);
70
+ }
71
+ if (shell === '') throw new Error(`${file} has no shell app`);
72
+ return { shell, remotes, packageDirs };
73
+ }
74
+
75
+ export function readConfig(root: string): Config {
76
+ const json = readJson(path.join(root, 'deploy/cloudflare.json'));
77
+ if (!isRecord(json) || !isRecord(json.envs))
78
+ throw new Error('deploy/cloudflare.json has no "envs"');
79
+ const envs: Record<string, EnvConfig> = {};
80
+ for (const [name, value] of Object.entries(json.envs)) {
81
+ if (!isRecord(value)) continue;
82
+ const remotes: Record<string, string> = {};
83
+ if (isRecord(value.remotes)) {
84
+ for (const [remote, origin] of Object.entries(value.remotes)) {
85
+ if (typeof origin === 'string') remotes[remote] = origin;
86
+ }
87
+ }
88
+ envs[name] = { remotes, ...(typeof value.shell === 'string' ? { shell: value.shell } : {}) };
89
+ }
90
+ return { registry: json.registry === 'r2' ? 'r2' : 'assets', envs };
91
+ }
92
+
93
+ /**
94
+ * The origin (scheme and host) of an address, or an error saying which setting is wrong. https only;
95
+ * http is accepted for localhost outside prod, for trying a deploy on your own machine.
96
+ */
97
+ export function checkOrigin(value: string | undefined, label: string, env = 'staging'): string {
98
+ if (value === undefined || value === '') {
99
+ throw new Error(
100
+ `${label} is empty. Set it in deploy/cloudflare.json to the address people will use.`,
101
+ );
102
+ }
103
+ // The URL parser drops tabs and newlines, so a value with one would pass as something else.
104
+ if (
105
+ /\s/.test(value) ||
106
+ Array.from(value, (c) => c.charCodeAt(0)).some((n) => n < 32 || n === 127)
107
+ ) {
108
+ throw new Error(`${label} must not contain spaces or control characters`);
109
+ }
110
+ const url = URL.canParse(value) ? new URL(value) : undefined;
111
+ const local = url?.protocol === 'http:' && url.hostname === 'localhost' && env !== 'prod';
112
+ if (url === undefined || (url.protocol !== 'https:' && !local)) {
113
+ throw new Error(`${label} must be an https address, got "${value}"`);
114
+ }
115
+ if (url.username !== '' || url.password !== '') {
116
+ throw new Error(`${label} must not contain a user name or password`);
117
+ }
118
+ if (/(^|\.)example\.(com|org|net)$/.test(url.hostname)) {
119
+ throw new Error(
120
+ `${label} still holds the placeholder "${value}". Put your real address in deploy/cloudflare.json.`,
121
+ );
122
+ }
123
+ if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
124
+ throw new Error(`${label} must be an origin without a path, got "${value}"`);
125
+ }
126
+ return url.origin;
127
+ }
128
+
129
+ export interface IndexEntry {
130
+ name: string;
131
+ version: string;
132
+ entry: string;
133
+ routes: string[];
134
+ }
135
+
136
+ export function buildIndex(env: string, remotes: IndexEntry[]): string {
137
+ return `${JSON.stringify({ env, remotes }, null, 2)}\n`;
138
+ }
139
+
140
+ function selectApps(project: Project, config: Config, requested: string[]): string[] {
141
+ const deployable =
142
+ config.registry === 'r2' ? [project.shell] : [project.shell, ...project.remotes];
143
+ if (requested.length === 0) return deployable;
144
+ for (const app of requested) {
145
+ if (!deployable.includes(app))
146
+ throw new Error(`"${app}" is not an app this project deploys (${deployable.join(', ')})`);
147
+ }
148
+ return requested;
149
+ }
150
+
151
+ function packageVersion(root: string, project: Project, app: string): string {
152
+ const dir = project.packageDirs[app] ?? `apps/${app}`;
153
+ const json = readJson(path.join(root, dir, 'package.json'));
154
+ return isRecord(json) && typeof json.version === 'string' ? json.version : '0.0.0';
155
+ }
156
+
157
+ function stageDir(root: string, project: Project, app: string, env: string): string {
158
+ return path.join(root, project.packageDirs[app] ?? `apps/${app}`, STAGE, env);
159
+ }
160
+
161
+ /** Copies the app's dist to its staging folder and applies this environment's settings. Returns the folder. */
162
+ export function prepareApp(root: string, app: string, env: string): string {
163
+ const project = readProject(root);
164
+ const config = readConfig(root);
165
+ const envConfig = config.envs[env];
166
+ if (envConfig === undefined)
167
+ throw new Error(`deploy/cloudflare.json has no "${env}" environment`);
168
+ const dist = path.join(root, project.packageDirs[app] ?? `apps/${app}`, 'dist');
169
+ if (!fs.existsSync(dist)) throw new Error(`${dist} does not exist. Run the build first.`);
170
+
171
+ const out = stageDir(root, project, app, env);
172
+ fs.rmSync(out, { recursive: true, force: true });
173
+ fs.cpSync(dist, out, { recursive: true });
174
+
175
+ if (app === project.shell) prepareShell(root, project, config, envConfig, env, out);
176
+ else prepareRemote(app, envConfig, env, out);
177
+ return out;
178
+ }
179
+
180
+ function prepareShell(
181
+ root: string,
182
+ project: Project,
183
+ config: Config,
184
+ envConfig: EnvConfig,
185
+ env: string,
186
+ out: string,
187
+ ): void {
188
+ checkOrigin(envConfig.shell, `envs.${env}.shell`, env);
189
+ const configDir = path.join(out, 'config');
190
+ const chosen = path.join(configDir, `runtime-config.${env}.json`);
191
+ if (!fs.existsSync(chosen)) throw new Error(`The build has no config/runtime-config.${env}.json`);
192
+ const runtime = readJson(chosen);
193
+ const values: Record<string, unknown> = isRecord(runtime) ? runtime : {};
194
+
195
+ const origins = new Map<string, string>();
196
+ if (config.registry === 'assets') {
197
+ for (const remote of project.remotes) {
198
+ origins.set(
199
+ remote,
200
+ checkOrigin(envConfig.remotes?.[remote], `envs.${env}.remotes.${remote}`, env),
201
+ );
202
+ }
203
+ const known = Array.isArray(values.registryOrigins)
204
+ ? values.registryOrigins.filter((o) => typeof o === 'string')
205
+ : [];
206
+ values.registryOrigins = [...new Set([...known, ...origins.values()])];
207
+ const entries = project.remotes.map((remote) => ({
208
+ name: remote,
209
+ version: packageVersion(root, project, remote),
210
+ entry: `${origins.get(remote) ?? ''}/mf-manifest.json`,
211
+ routes: [`/${remote}`],
212
+ }));
213
+ fs.mkdirSync(path.join(out, 'registry'), { recursive: true });
214
+ fs.writeFileSync(path.join(out, 'registry/index.json'), buildIndex(env, entries));
215
+ }
216
+
217
+ for (const file of fs.readdirSync(configDir)) {
218
+ if (/^runtime-config\..+\.json$/.test(file)) fs.rmSync(path.join(configDir, file));
219
+ }
220
+ fs.writeFileSync(
221
+ path.join(configDir, 'runtime-config.json'),
222
+ `${JSON.stringify(values, null, 2)}\n`,
223
+ );
224
+ }
225
+
226
+ function prepareRemote(app: string, envConfig: EnvConfig, env: string, out: string): void {
227
+ const shellOrigin = checkOrigin(envConfig.shell, `envs.${env}.shell`, env);
228
+ if (!fs.existsSync(path.join(out, 'mf-manifest.json')))
229
+ throw new Error(`${out} has no mf-manifest.json. Is this a remote build?`);
230
+ const headers = path.join(out, '_headers');
231
+ if (!fs.existsSync(headers)) {
232
+ throw new Error(
233
+ `${out} has no _headers, so the shell could not load ${app} across origins. Keep apps/${app}/public/_headers and rebuild.`,
234
+ );
235
+ }
236
+ fs.writeFileSync(
237
+ headers,
238
+ fs.readFileSync(headers, 'utf8').replaceAll(SHELL_ORIGIN_TOKEN, () => shellOrigin),
239
+ );
240
+ }
241
+
242
+ export interface Target {
243
+ app: string;
244
+ env: string;
245
+ /** The app's wrangler config, relative to the project root. */
246
+ config: string;
247
+ }
248
+
249
+ /** The CI matrix: one entry per app and environment. */
250
+ export function plan(root: string, envs: string[]): Target[] {
251
+ for (const env of envs) {
252
+ if (!ENVS.includes(env)) throw new Error(`unknown environment "${env}" (${ENVS.join(', ')})`);
253
+ }
254
+ const project = readProject(root);
255
+ const apps = selectApps(project, readConfig(root), []);
256
+ return envs.flatMap((env) =>
257
+ apps.map((app) => ({
258
+ app,
259
+ env,
260
+ config: `${project.packageDirs[app] ?? `apps/${app}`}/wrangler.jsonc`,
261
+ })),
262
+ );
263
+ }
264
+
265
+ function wranglerArgs(app: string, env: string, project: Project): string[] {
266
+ const dir = project.packageDirs[app] ?? `apps/${app}`;
267
+ return ['deploy', '--config', `${dir}/wrangler.jsonc`, '--env', env];
268
+ }
269
+
270
+ /** Wrangler's own script, so it can run under node without a shell, which works on every OS. */
271
+ function wranglerScript(root: string): string {
272
+ const manifest = path.join(root, 'node_modules/wrangler/package.json');
273
+ const json = readJson(manifest);
274
+ const bin = isRecord(json) && isRecord(json.bin) ? json.bin.wrangler : undefined;
275
+ if (typeof bin !== 'string')
276
+ throw new Error(`${manifest} names no wrangler script. Run pnpm install.`);
277
+ return path.join(path.dirname(manifest), bin);
278
+ }
279
+
280
+ export function main(argv: string[], root = process.cwd()): number {
281
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
282
+ const [first, ...rest] = argv.filter((a) => !a.startsWith('--'));
283
+ try {
284
+ if (first === 'plan') {
285
+ console.log(JSON.stringify(plan(root, (rest[0] ?? '').split(',').filter(Boolean))));
286
+ return 0;
287
+ }
288
+ if (first === undefined || !ENVS.includes(first)) {
289
+ throw new Error(
290
+ `usage: deploy-cloudflare <${ENVS.join('|')}> [app...] [--prepare-only] [--dry-run]`,
291
+ );
292
+ }
293
+ const project = readProject(root);
294
+ for (const app of selectApps(project, readConfig(root), rest)) {
295
+ const out = prepareApp(root, app, first);
296
+ console.log(`staged ${app} for ${first}: ${path.relative(root, out)}`);
297
+ if (flags.has('--prepare-only')) continue;
298
+ const args = wranglerArgs(app, first, project);
299
+ if (flags.has('--dry-run')) {
300
+ console.log(`wrangler ${args.join(' ')}`);
301
+ continue;
302
+ }
303
+ const run = spawnSync(process.execPath, [wranglerScript(root), ...args], {
304
+ cwd: root,
305
+ stdio: 'inherit',
306
+ });
307
+ if (run.status !== 0) throw new Error(`wrangler deploy failed for ${app}`);
308
+ }
309
+ return 0;
310
+ } catch (error) {
311
+ console.error(error instanceof Error ? error.message : error);
312
+ return 1;
313
+ }
314
+ }
315
+
316
+ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === import.meta.filename) {
317
+ process.exitCode = main(process.argv.slice(2));
318
+ }
@@ -0,0 +1,111 @@
1
+ name: deploy-cloudflare
2
+
3
+ # Builds once, then deploys every app to the Cloudflare Workers named in deploy/cloudflare.json. A push to main
4
+ # deploys staging. Run it by hand to choose staging or prod. Needs the secrets CLOUDFLARE_API_TOKEN and
5
+ # CLOUDFLARE_ACCOUNT_ID in the GitHub environments cloudflare-staging and cloudflare-prod (see docs/cloudflare.md).
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ workflow_dispatch:
10
+ inputs:
11
+ env:
12
+ description: Environment to deploy to
13
+ type: choice
14
+ options: [staging, prod]
15
+ default: staging
16
+
17
+ permissions: {}
18
+
19
+ # Two deploys at once would race on the same Workers; let the first finish.
20
+ concurrency:
21
+ group: deploy-cloudflare
22
+ cancel-in-progress: false
23
+
24
+ jobs:
25
+ plan:
26
+ runs-on: ubuntu-latest
27
+ timeout-minutes: 5
28
+ # A manual run from another branch would otherwise deploy that branch.
29
+ if: github.ref == 'refs/heads/main'
30
+ permissions:
31
+ contents: read
32
+ outputs:
33
+ targets: ${{ steps.plan.outputs.targets }}
34
+ steps:
35
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
36
+ with:
37
+ persist-credentials: false
38
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
39
+ with:
40
+ node-version: lts/*
41
+ - name: List the apps and environments
42
+ id: plan
43
+ env:
44
+ DEPLOY_ENV: ${{ inputs.env || 'staging' }}
45
+ run: |
46
+ set -euo pipefail
47
+ targets=$(node --experimental-strip-types scripts/deploy-cloudflare.ts plan "$DEPLOY_ENV")
48
+ echo "targets=$targets" >> "$GITHUB_OUTPUT"
49
+
50
+ build:
51
+ runs-on: ubuntu-latest
52
+ timeout-minutes: 20
53
+ if: github.ref == 'refs/heads/main'
54
+ permissions:
55
+ contents: read
56
+ steps:
57
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
58
+ with:
59
+ persist-credentials: false
60
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
61
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
62
+ with:
63
+ node-version: lts/*
64
+ cache: pnpm
65
+ - run: pnpm install --frozen-lockfile
66
+ - run: pnpm build
67
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
68
+ with:
69
+ name: dist
70
+ path: apps/*/dist
71
+ if-no-files-found: error
72
+
73
+ deploy:
74
+ needs: [plan, build]
75
+ runs-on: ubuntu-latest
76
+ timeout-minutes: 15
77
+ environment: cloudflare-${{ matrix.target.env }}
78
+ permissions:
79
+ contents: read
80
+ strategy:
81
+ fail-fast: false
82
+ matrix:
83
+ target: ${{ fromJSON(needs.plan.outputs.targets) }}
84
+ steps:
85
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
86
+ with:
87
+ persist-credentials: false
88
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
89
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
90
+ with:
91
+ node-version: lts/*
92
+ cache: pnpm
93
+ - run: pnpm install --frozen-lockfile
94
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
95
+ with:
96
+ name: dist
97
+ path: apps
98
+ - name: Stage the build for this environment
99
+ env:
100
+ DEPLOY_ENV: ${{ matrix.target.env }}
101
+ DEPLOY_APP: ${{ matrix.target.app }}
102
+ run: |
103
+ set -euo pipefail
104
+ node --experimental-strip-types scripts/deploy-cloudflare.ts "$DEPLOY_ENV" "$DEPLOY_APP" --prepare-only
105
+ - name: Deploy
106
+ uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3
107
+ with:
108
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
109
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
110
+ packageManager: pnpm
111
+ command: deploy --config ${{ matrix.target.config }} --env ${{ matrix.target.env }}