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
@@ -0,0 +1,14 @@
1
+ # The shell's origin is filled in from deploy/cloudflare.json when the build is staged, so the shell can load
2
+ # this remote with crossorigin. Each path matches one Cache-Control rule, because a header set by two rules is
3
+ # joined with a comma. Hashed files under /static never change.
4
+ /*
5
+ Access-Control-Allow-Origin: __SHELL_ORIGIN__
6
+ X-Content-Type-Options: nosniff
7
+ /
8
+ Cache-Control: no-cache
9
+ /index.html
10
+ Cache-Control: no-cache
11
+ /mf-manifest.json
12
+ Cache-Control: no-cache
13
+ /static/*
14
+ Cache-Control: public, max-age=31536000, immutable
@@ -0,0 +1,18 @@
1
+ # Files that decide what is live are checked on every load. Hashed files never change, so they are cached
2
+ # for a year. Each path matches one rule, because a header set by two rules is joined with a comma.
3
+ /*
4
+ X-Content-Type-Options: nosniff
5
+ Referrer-Policy: strict-origin-when-cross-origin
6
+ X-Frame-Options: SAMEORIGIN
7
+ /
8
+ Cache-Control: no-cache
9
+ /index.html
10
+ Cache-Control: no-cache
11
+ /mf-manifest.json
12
+ Cache-Control: no-cache
13
+ /config/runtime-config*
14
+ Cache-Control: no-cache
15
+ <% if (!it.options.r2Proxy) { %>/registry/*
16
+ Cache-Control: no-cache
17
+ <% } %>/static/*
18
+ Cache-Control: public, max-age=31536000, immutable
@@ -0,0 +1,10 @@
1
+ {
2
+ "rules": [
3
+ {
4
+ "allowed": {
5
+ "origins": ["https://shop.example.com"],
6
+ "methods": ["GET"]
7
+ }
8
+ }
9
+ ]
10
+ }
@@ -0,0 +1,80 @@
1
+ // Serves /registry/* from the REGISTRY R2 binding. `assets.run_worker_first` sends only those paths here.
2
+ // _headers does not apply to responses a Worker makes, so the cache rules are set below.
3
+ // /registry/index.json -> envs/<MFE_ENV>/index.json checked on every load
4
+ // /registry/<name>/<version>/<file> -> <name>/<version>/<file> never changes
5
+ // The types below cover only what this file uses of the Cloudflare runtime.
6
+ interface R2Object {
7
+ httpEtag: string;
8
+ body?: ReadableStream;
9
+ writeHttpMetadata(headers: Headers): void;
10
+ }
11
+
12
+ interface Env {
13
+ MFE_ENV: string;
14
+ REGISTRY: {
15
+ get(key: string, options?: { onlyIf?: Headers }): Promise<R2Object | null>;
16
+ head(key: string): Promise<R2Object | null>;
17
+ };
18
+ }
19
+
20
+ const INDEX_CACHE = 'no-cache';
21
+ const VERSIONED_CACHE = 'public, max-age=31536000, immutable';
22
+
23
+ const PREFIX = '/registry/';
24
+
25
+ /** A plain response that also tells browsers not to guess the type. */
26
+ function reply(
27
+ body: ReadableStream | string | null,
28
+ init: ResponseInit = {},
29
+ extra?: Headers,
30
+ ): Response {
31
+ const headers = new Headers(extra);
32
+ for (const [name, value] of new Headers(init.headers)) headers.set(name, value);
33
+ headers.set('X-Content-Type-Options', 'nosniff');
34
+ return new Response(body, { ...init, headers });
35
+ }
36
+
37
+ // The key is the decoded path. Anything that could climb out of /registry or into envs/ is not a key.
38
+ function keyFor(pathname: string, env: Env): { key: string; cache: string } | undefined {
39
+ if (!pathname.startsWith(PREFIX)) return undefined;
40
+ const rest = decodeURIComponent(pathname.slice(PREFIX.length));
41
+ if (
42
+ rest.includes('\\') ||
43
+ rest.split('/').some((part) => part === '' || part === '.' || part === '..')
44
+ ) {
45
+ return undefined;
46
+ }
47
+ if (rest === 'index.json') return { key: `envs/${env.MFE_ENV}/index.json`, cache: INDEX_CACHE };
48
+ if (/^(?!envs\/)[^/]+\/[^/]+\/.+/.test(rest)) return { key: rest, cache: VERSIONED_CACHE };
49
+ return undefined;
50
+ }
51
+
52
+ export default {
53
+ async fetch(request: Request, env: Env): Promise<Response> {
54
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
55
+ return reply('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } });
56
+ }
57
+ let target: ReturnType<typeof keyFor>;
58
+ try {
59
+ target = keyFor(new URL(request.url).pathname, env);
60
+ } catch {
61
+ return reply('Bad request', { status: 400 });
62
+ }
63
+ if (target === undefined) return reply('Not found', { status: 404 });
64
+
65
+ const object =
66
+ request.method === 'HEAD'
67
+ ? await env.REGISTRY.head(target.key)
68
+ : await env.REGISTRY.get(target.key, { onlyIf: request.headers });
69
+ if (object === null) return reply('Not found', { status: 404 });
70
+
71
+ const headers = new Headers();
72
+ object.writeHttpMetadata(headers);
73
+ headers.set('etag', object.httpEtag);
74
+ headers.set('Cache-Control', target.cache);
75
+ if (request.method === 'HEAD') return reply(null, {}, headers);
76
+ // An object with no body means the If-None-Match or If-Modified-Since check matched.
77
+ if (object.body === undefined) return reply(null, { status: 304 }, headers);
78
+ return reply(object.body, {}, headers);
79
+ },
80
+ };
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "../../node_modules/wrangler/config-schema.json",
3
+ // A remote as its own Worker with static assets. This file is yours: change names, add routes, drop an environment.
4
+ // Deploy with `pnpm deploy:cloudflare <staging|prod>`. Remotes have no fallback page, so a mistyped path is a
5
+ // 404 and not an HTML page where JavaScript was expected.
6
+ "name": "<%= it.scope.slice(1) %>-<%= it.app.name %>",
7
+ "compatibility_date": "2026-10-28",
8
+ "assets": { "directory": "./dist", "not_found_handling": "none" },
9
+ // The shell loads this remote from its own address, so give each environment a route or use its workers.dev name:
10
+ // "routes": [{ "pattern": "<%= it.app.name %>.shop.example.com", "custom_domain": true }]
11
+ "env": {
12
+ "staging": {
13
+ "name": "<%= it.scope.slice(1) %>-<%= it.app.name %>-staging",
14
+ "assets": { "directory": "./.mfeflow/cloudflare/staging", "not_found_handling": "none" },
15
+ },
16
+ "prod": {
17
+ "name": "<%= it.scope.slice(1) %>-<%= it.app.name %>-prod",
18
+ "assets": { "directory": "./.mfeflow/cloudflare/prod", "not_found_handling": "none" },
19
+ },
20
+ },
21
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "../../node_modules/wrangler/config-schema.json",
3
+ // The shell as a Worker with static assets. This file is yours: change names, add routes, drop an environment.
4
+ // Deploy with `pnpm deploy:cloudflare <staging|prod>`. It stages the build with that environment's runtime
5
+ // config, then runs `wrangler deploy --env <name>`. A bare `wrangler deploy` would publish the top level.
6
+ "name": "<%= it.scope.slice(1) %>-<%= it.app.name %>",
7
+ <% if (it.options.r2Proxy) { %> "main": "worker/registry-proxy.ts",
8
+ <% } %> "compatibility_date": "2026-10-28",
9
+ // `wrangler dev` and the top level serve the build as is. The environments serve the staged copies.
10
+ "assets": {
11
+ "directory": "./dist",
12
+ "not_found_handling": "single-page-application",
13
+ <% if (it.options.r2Proxy) { %> "run_worker_first": ["/registry/*"],
14
+ <% } %> },
15
+ <% if (it.options.r2Proxy) { %> "vars": { "MFE_ENV": "dev" },
16
+ "r2_buckets": [{ "binding": "REGISTRY", "bucket_name": "<%= it.scope.slice(1) %>-registry-dev" }],
17
+ <% } %> // To serve a domain, add to each environment: "routes": [{ "pattern": "shop.example.com", "custom_domain": true }]
18
+ "env": {
19
+ "staging": {
20
+ "name": "<%= it.scope.slice(1) %>-<%= it.app.name %>-staging",
21
+ "assets": {
22
+ "directory": "./.mfeflow/cloudflare/staging",
23
+ "not_found_handling": "single-page-application",
24
+ <% if (it.options.r2Proxy) { %> "run_worker_first": ["/registry/*"],
25
+ <% } %> },
26
+ <% if (it.options.r2Proxy) { %> "vars": { "MFE_ENV": "staging" },
27
+ "r2_buckets": [{ "binding": "REGISTRY", "bucket_name": "<%= it.scope.slice(1) %>-registry-staging" }],
28
+ <% } %> },
29
+ "prod": {
30
+ "name": "<%= it.scope.slice(1) %>-<%= it.app.name %>-prod",
31
+ "assets": {
32
+ "directory": "./.mfeflow/cloudflare/prod",
33
+ "not_found_handling": "single-page-application",
34
+ <% if (it.options.r2Proxy) { %> "run_worker_first": ["/registry/*"],
35
+ <% } %> },
36
+ <% if (it.options.r2Proxy) { %> "vars": { "MFE_ENV": "prod" },
37
+ "r2_buckets": [{ "binding": "REGISTRY", "bucket_name": "<%= it.scope.slice(1) %>-registry-prod" }],
38
+ <% } %> },
39
+ },
40
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "deploy-docker",
3
+ "version": "0.1.0",
4
+ "description": "Package the shell and each remote as a non-root nginx image: a Dockerfile with an APP build argument, nginx cache and CORS rules, a start script that picks the runtime config from MFE_ENV, a Kubernetes example, a compose file and a .dockerignore. Opt-in code you own; nothing in mfeflow calls it.",
5
+ "requires": ["core"],
6
+ "files": [
7
+ { "to": "docker/Dockerfile", "target": "root", "from": "files/Dockerfile" },
8
+ { "to": "docker/nginx.shell.conf", "target": "root", "from": "files/nginx.shell.conf" },
9
+ { "to": "docker/nginx.remote.conf", "target": "root", "from": "files/nginx.remote.conf" },
10
+ { "to": "docker/40-mfe-config.sh", "target": "root", "from": "files/40-mfe-config.sh" },
11
+ {
12
+ "to": "docker/compose-registry.json",
13
+ "target": "root",
14
+ "from": "files/compose-registry.json.eta",
15
+ "template": true
16
+ },
17
+ { "to": "compose.yaml", "target": "root", "from": "files/compose.yaml.eta", "template": true },
18
+ { "to": "k8s/mfe.yaml", "target": "root", "from": "files/k8s.yaml" },
19
+ { "to": ".dockerignore", "target": "root", "from": "files/dockerignore" },
20
+ { "to": "docs/docker.md", "target": "root", "from": "files/docker.md" }
21
+ ]
22
+ }
@@ -0,0 +1,76 @@
1
+ #!/bin/sh
2
+ # Runs before nginx starts (the base image runs everything in /docker-entrypoint.d, then execs
3
+ # nginx). A non-zero exit stops the container, so a bad setting never serves the wrong config.
4
+ #
5
+ # Shell image: MFE_ENV picks /opt/mfe/configs/runtime-config.$MFE_ENV.json and it is copied to
6
+ # $MFE_RUNTIME_DIR/runtime-config.json. A runtime-config.json already in that folder (a mounted
7
+ # ConfigMap) wins and MFE_ENV is not needed.
8
+ # Remote image: SHELL_ORIGINS (space or comma separated origins) become the CORS allow-list.
9
+ set -eu
10
+
11
+ runtime="${MFE_RUNTIME_DIR:-/tmp/mfe-runtime}"
12
+ configs="${MFE_CONFIGS_DIR:-/opt/mfe/configs}"
13
+
14
+ fail() {
15
+ echo "mfe: $*" >&2
16
+ exit 1
17
+ }
18
+
19
+ write_cors() {
20
+ case "$1" in
21
+ *'
22
+ '*) fail "SHELL_ORIGINS must not contain a newline" ;;
23
+ esac
24
+ lines=""
25
+ for origin in $(echo "$1" | tr ',' ' '); do
26
+ case "$origin" in
27
+ http:// | https://) fail "SHELL_ORIGINS entry '$origin' has no host" ;;
28
+ http://* | https://*) ;;
29
+ *) fail "SHELL_ORIGINS entry '$origin' is not an origin like https://shop.example.com" ;;
30
+ esac
31
+ case "$origin" in
32
+ *[!A-Za-z0-9.:/_-]* | */*/*/*) fail "SHELL_ORIGINS entry '$origin' is not a plain origin (no path, no spaces)" ;;
33
+ esac
34
+ lines="$lines\"$origin\" \"$origin\";
35
+ "
36
+ done
37
+ mkdir -p "$runtime"
38
+ out="$runtime/cors-origins.map"
39
+ printf '%s' "$lines" > "$out" || fail "cannot write $out; make /tmp writable"
40
+ echo "mfe: CORS allowed for $(echo "$1" | tr ',' ' ')"
41
+ }
42
+
43
+ pick_runtime_config() {
44
+ if [ -f "$runtime/runtime-config.json" ]; then
45
+ echo "mfe: using the runtime-config.json already in $runtime"
46
+ return
47
+ fi
48
+ if [ -n "${MFE_ENV+set}" ]; then
49
+ case "$MFE_ENV" in
50
+ '' | *[!A-Za-z0-9._-]* | *..*) fail "MFE_ENV='$MFE_ENV' is not a plain environment name (letters, digits, . _ -)" ;;
51
+ esac
52
+ chosen="$configs/runtime-config.$MFE_ENV.json"
53
+ if [ ! -f "$chosen" ]; then
54
+ echo "mfe: no runtime config for MFE_ENV=$MFE_ENV. Available:" >&2
55
+ ls "$configs" >&2
56
+ exit 1
57
+ fi
58
+ elif [ -f "$configs/runtime-config.json" ]; then
59
+ chosen="$configs/runtime-config.json"
60
+ else
61
+ echo "mfe: no runtime config. Set MFE_ENV, or mount runtime-config.json in $runtime. Available in $configs:" >&2
62
+ ls "$configs" >&2 || true
63
+ exit 1
64
+ fi
65
+ mkdir -p "$runtime"
66
+ cp "$chosen" "$runtime/runtime-config.json" || fail "cannot write $runtime; make /tmp writable"
67
+ echo "mfe: runtime config from $chosen"
68
+ }
69
+
70
+ kind_file="${MFE_KIND_FILE:-/opt/mfe/kind}"
71
+ if [ -f "$kind_file" ] && [ "$(cat "$kind_file")" = shell ] ||
72
+ { [ -d "$configs" ] && [ -n "$(ls "$configs" 2>/dev/null)" ]; }; then
73
+ pick_runtime_config
74
+ elif [ -n "${SHELL_ORIGINS:-}" ]; then
75
+ write_cors "$SHELL_ORIGINS"
76
+ fi
@@ -0,0 +1,48 @@
1
+ # One Dockerfile for every app. Pick the app with a build argument:
2
+ #
3
+ # docker build -f docker/Dockerfile --build-arg APP=shell -t shop-shell:1.0.0 .
4
+ # docker build -f docker/Dockerfile --build-arg APP=catalog -t shop-catalog:1.0.0 .
5
+ #
6
+ # Build once, run anywhere: nothing about an environment is baked in. The shell reads its runtime
7
+ # config at start (MFE_ENV picks runtime-config.<env>.json); a remote reads the shell origins it
8
+ # allows (SHELL_ORIGINS). Both base images are pinned by digest; Dependabot or a manual bump moves
9
+ # them. Run from the repository root so pnpm-lock.yaml is in the build context.
10
+
11
+ ARG NODE_IMAGE=node:24.22.2-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c
12
+ ARG NGINX_IMAGE=nginx/nginx-unprivileged:1.30.6-alpine3.24@sha256:2daef144b574804198caccc4041819fa1801ecd5df2252a39646bfa2b1ff3bea
13
+
14
+ # Node 24 still ships corepack; Node 25 and later do not.
15
+ FROM ${NODE_IMAGE} AS build
16
+ ARG APP=shell
17
+ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
18
+ RUN corepack enable
19
+ WORKDIR /app
20
+ COPY . .
21
+ RUN pnpm install --frozen-lockfile
22
+ RUN case "${APP}" in ''|*[!a-z0-9-]*) echo "APP must be an app name from mfeflow.json" >&2; exit 1;; esac
23
+ RUN pnpm --filter "./apps/${APP}" build
24
+ RUN set -eu; \
25
+ kind="$(node -p "require('./mfeflow.json').apps['${APP}'].kind")"; \
26
+ version="$(node -p "require('./apps/${APP}/package.json').version")"; \
27
+ case "${version}" in ''|*[!0-9A-Za-z.+-]*) echo "unusable version: ${version}" >&2; exit 1;; esac; \
28
+ mkdir -p /out/html /out/configs; \
29
+ echo "${kind}" > /out/kind; \
30
+ cp -R "apps/${APP}/dist/." /out/html/; \
31
+ if [ "${kind}" = shell ]; then \
32
+ if [ -d /out/html/config ]; then mv /out/html/config/runtime-config*.json /out/configs/; rm -r /out/html/config; fi; \
33
+ cp docker/nginx.shell.conf /out/default.conf; \
34
+ else \
35
+ sed -e "s/__NAME__/${APP}/g" -e "s/__VERSION__/${version}/g" docker/nginx.remote.conf > /out/default.conf; \
36
+ fi
37
+
38
+ FROM ${NGINX_IMAGE}
39
+ COPY --from=build /out/html /usr/share/nginx/html
40
+ COPY --from=build /out/configs /opt/mfe/configs
41
+ COPY --from=build /out/kind /opt/mfe/kind
42
+ COPY --from=build /out/default.conf /etc/nginx/conf.d/default.conf
43
+ COPY --chmod=755 docker/40-mfe-config.sh /docker-entrypoint.d/40-mfe-config.sh
44
+ # The base image already runs as user 101 (nginx). The start script writes to /tmp/mfe-runtime.
45
+ EXPOSE 8080
46
+ # busybox wget ships with the alpine image; there is no curl.
47
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
48
+ CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
@@ -0,0 +1,19 @@
1
+ <% const used = new Set(it.apps.flatMap((a) => (a.port === undefined ? [] : [a.port])));
2
+ let spare = 8080;
3
+ const ports = Object.fromEntries(it.apps.map((a) => {
4
+ if (a.port !== undefined) return [a.name, a.port];
5
+ while (used.has(spare)) spare++;
6
+ used.add(spare);
7
+ return [a.name, spare];
8
+ }));
9
+ -%>
10
+ {
11
+ "env": "local",
12
+ "remotes": [
13
+ <% const remotes = it.apps.filter((a) => a.kind === 'remote'); remotes.forEach((a, i) => { %> {
14
+ "name": "<%= a.name %>",
15
+ "entry": "http://localhost:<%= ports[a.name] %>/mf-manifest.json",
16
+ "routes": ["/<%= a.name %>"]
17
+ }<%= i < remotes.length - 1 ? ',' : '' %>
18
+ <% }) %> ]
19
+ }
@@ -0,0 +1,50 @@
1
+ <% const used = new Set(it.apps.flatMap((a) => (a.port === undefined ? [] : [a.port])));
2
+ let spare = 8080;
3
+ const ports = Object.fromEntries(it.apps.map((a) => {
4
+ if (a.port !== undefined) return [a.name, a.port];
5
+ while (used.has(spare)) spare++;
6
+ used.add(spare);
7
+ return [a.name, spare];
8
+ }));
9
+ const shell = it.apps.find((a) => a.kind === 'shell');
10
+ const shellPort = shell === undefined ? 4300 : ports[shell.name]; -%>
11
+ # Runs the shell and every remote locally from one image build each, on the ports mfeflow.json gives
12
+ # them. The image is the same one you would deploy: only the environment variables differ.
13
+ #
14
+ # docker compose up --build
15
+ # MFE_ENV=staging docker compose up
16
+ #
17
+ # Ports bind to 127.0.0.1 only. An app with no port in mfeflow.json gets a spare one starting at
18
+ # 8080; set a port for it in mfeflow.json if you want a fixed one.
19
+ #
20
+ # The shell may only load remotes from origins its runtime config allows. Add them to
21
+ # apps/shell/public/config/runtime-config.<env>.json (and rebuild):
22
+ # "registryOrigins": [<%= it.apps.filter((a) => a.kind === 'remote').map((a) => '"http://localhost:' + ports[a.name] + '"').join(', ') %>]
23
+ x-app: &app
24
+ read_only: true
25
+ tmpfs:
26
+ - /tmp
27
+ security_opt:
28
+ - no-new-privileges:true
29
+ cap_drop:
30
+ - ALL
31
+ restart: unless-stopped
32
+
33
+ services:
34
+ <% for (const a of it.apps) { %> <%= a.name %>:
35
+ <<: *app
36
+ build:
37
+ context: .
38
+ dockerfile: docker/Dockerfile
39
+ args:
40
+ APP: <%= a.name %>
41
+ <% if (a.kind === 'shell') { %> environment:
42
+ MFE_ENV: ${MFE_ENV:-dev}
43
+ volumes:
44
+ # The shell reads its registry index from its own origin. This one lists the remotes below.
45
+ - ./docker/compose-registry.json:/tmp/mfe-registry/index.json:ro
46
+ <% } else { %> environment:
47
+ SHELL_ORIGINS: http://localhost:<%= shellPort %>
48
+ <% } %> ports:
49
+ - '127.0.0.1:<%= ports[a.name] %>:8080'
50
+ <% } %>
@@ -0,0 +1,52 @@
1
+ # Docker
2
+
3
+ This project can package each app as a small nginx image. Everything here is plain code in your repository: change it, or delete it. mfeflow does not call it.
4
+
5
+ ## Files
6
+
7
+ - `docker/Dockerfile`: one file for every app. `--build-arg APP=<name>` picks the app from `mfeflow.json`.
8
+ - `docker/nginx.shell.conf` and `docker/nginx.remote.conf`: cache rules, the SPA fallback (shell only), CORS (remotes only).
9
+ - `docker/40-mfe-config.sh`: runs before nginx starts and picks the runtime config.
10
+ - `compose.yaml`: the shell and the remotes locally, on the ports from `mfeflow.json`.
11
+ - `k8s/mfe.yaml`: the shell on Kubernetes, with the runtime config in a ConfigMap.
12
+ - `.dockerignore`.
13
+
14
+ ## Build once, run in every environment
15
+
16
+ ```sh
17
+ docker build -f docker/Dockerfile --build-arg APP=shell -t shop-shell:1.0.0 .
18
+ docker run --read-only --tmpfs /tmp \
19
+ -e MFE_ENV=staging -p 8080:8080 shop-shell:1.0.0
20
+ ```
21
+
22
+ `MFE_ENV` picks `runtime-config.<env>.json` from the build. The same image runs as `staging` or `prod`. If the file is missing the container stops and lists what it has.
23
+
24
+ A remote image needs the origins of the shells that may load it:
25
+
26
+ ```sh
27
+ docker build -f docker/Dockerfile --build-arg APP=catalog -t shop-catalog:1.0.0 .
28
+ docker run --read-only --tmpfs /tmp \
29
+ -e SHELL_ORIGINS=https://staging.shop.example.com -p 8081:8080 shop-catalog:1.0.0
30
+ ```
31
+
32
+ ## What each path is cached for
33
+
34
+ | Path | Cache-Control |
35
+ | ------------------------------------------- | ------------------------------------- |
36
+ | `index.html`, `/config/runtime-config.json` | `no-cache` |
37
+ | `/registry/index.json`, `mf-manifest.json` | `no-cache` |
38
+ | `/static/*` (hashed files) | `public, max-age=31536000, immutable` |
39
+ | `/mfe/<name>/<version>/*` on a remote | `public, max-age=31536000, immutable` |
40
+
41
+ Bump the version in a remote's `package.json` for every release. The versioned path is cached for a year, so the same version must always mean the same files.
42
+
43
+ ## Registry index
44
+
45
+ The shell reads `/registry/index.json` from its own origin. In Kubernetes put it in its own ConfigMap, mounted as a directory at `/tmp/mfe-registry`; with Docker mount a file at `/tmp/mfe-registry/index.json`, or set `registryUrls` in the runtime config to a registry you host elsewhere.
46
+
47
+ ## Notes
48
+
49
+ - Compression is gzip. The nginx image has no brotli module.
50
+ - The image runs as user 101 on port 8080 and works with a read-only root filesystem. nginx and the start script both write only under `/tmp`.
51
+ - Build on Node 24. Node 25 and later no longer ship corepack.
52
+ - The base images are pinned by digest. Update the tag and the digest together.
@@ -0,0 +1,17 @@
1
+ # Keep the build context small and keep local state out of the image.
2
+ node_modules
3
+ **/node_modules
4
+ **/dist
5
+ **/.turbo
6
+ **/coverage
7
+ .git
8
+ .github
9
+ .env
10
+ .env.*
11
+ !.env.example
12
+ *.log
13
+ .DS_Store
14
+ playwright-report
15
+ test-results
16
+ k8s
17
+ compose.yaml
@@ -0,0 +1,114 @@
1
+ # The shell on Kubernetes. Parsed as YAML in mfeflow's tests, not applied to a cluster. Change the
2
+ # image, names and config to yours. A remote has the same shape: copy the Deployment and Service,
3
+ # use its image, drop the ConfigMap and set SHELL_ORIGINS.
4
+ apiVersion: v1
5
+ kind: ConfigMap
6
+ metadata:
7
+ name: mfe-shell-runtime
8
+ data:
9
+ runtime-config.json: |
10
+ {
11
+ "environment": "prod"
12
+ }
13
+ ---
14
+ # The registry index. Leave this out (and its mount below) if runtime-config.json points
15
+ # registryUrls at a registry you host elsewhere.
16
+ apiVersion: v1
17
+ kind: ConfigMap
18
+ metadata:
19
+ name: mfe-shell-registry
20
+ data:
21
+ index.json: |
22
+ {
23
+ "env": "prod",
24
+ "remotes": []
25
+ }
26
+ ---
27
+ apiVersion: apps/v1
28
+ kind: Deployment
29
+ metadata:
30
+ name: mfe-shell
31
+ spec:
32
+ replicas: 2
33
+ selector:
34
+ matchLabels:
35
+ app: mfe-shell
36
+ template:
37
+ metadata:
38
+ labels:
39
+ app: mfe-shell
40
+ spec:
41
+ automountServiceAccountToken: false
42
+ securityContext:
43
+ runAsNonRoot: true
44
+ runAsUser: 101
45
+ runAsGroup: 101
46
+ seccompProfile:
47
+ type: RuntimeDefault
48
+ containers:
49
+ - name: shell
50
+ image: registry.example.com/mfe-shell:1.0.0
51
+ ports:
52
+ - name: http
53
+ containerPort: 8080
54
+ securityContext:
55
+ runAsNonRoot: true
56
+ runAsUser: 101
57
+ runAsGroup: 101
58
+ allowPrivilegeEscalation: false
59
+ readOnlyRootFilesystem: true
60
+ capabilities:
61
+ drop: ['ALL']
62
+ readinessProbe:
63
+ httpGet:
64
+ path: /healthz
65
+ port: http
66
+ periodSeconds: 5
67
+ livenessProbe:
68
+ httpGet:
69
+ path: /healthz
70
+ port: http
71
+ initialDelaySeconds: 10
72
+ periodSeconds: 20
73
+ resources:
74
+ requests:
75
+ cpu: 25m
76
+ memory: 32Mi
77
+ limits:
78
+ memory: 128Mi
79
+ volumeMounts:
80
+ # nginx writes its pid file and temp files here, and the start script its config.
81
+ - name: tmp
82
+ mountPath: /tmp
83
+ # The ConfigMap is mounted as a whole directory on purpose. A file mounted with
84
+ # subPath never receives later changes to the ConfigMap; a directory mount does.
85
+ # The start script finds runtime-config.json here and leaves it alone, so MFE_ENV is
86
+ # not needed. To use the files baked into the image instead, set MFE_ENV and drop
87
+ # this mount.
88
+ - name: runtime
89
+ mountPath: /tmp/mfe-runtime
90
+ readOnly: true
91
+ - name: registry
92
+ mountPath: /tmp/mfe-registry
93
+ readOnly: true
94
+ volumes:
95
+ - name: runtime
96
+ configMap:
97
+ name: mfe-shell-runtime
98
+ - name: registry
99
+ configMap:
100
+ name: mfe-shell-registry
101
+ - name: tmp
102
+ emptyDir: {}
103
+ ---
104
+ apiVersion: v1
105
+ kind: Service
106
+ metadata:
107
+ name: mfe-shell
108
+ spec:
109
+ selector:
110
+ app: mfe-shell
111
+ ports:
112
+ - name: http
113
+ port: 80
114
+ targetPort: http
@@ -0,0 +1,73 @@
1
+ # A remote. __NAME__ and __VERSION__ are filled in when the image is built, from the app name and
2
+ # its package.json version. The files are stored once and answer on two paths:
3
+ # /mfe/__NAME__/__VERSION__/... the registry layout, cached for a year (the version is the name)
4
+ # /... the same files, revalidated every time
5
+ # Any other /mfe/ path is a 404 here, so an old URL never gets a year of someone else's files.
6
+
7
+ # The stock mime.types has no .mjs. This block sits at http level, where it adds to the stock
8
+ # list; inside a server block a types block would replace the whole list.
9
+ types {
10
+ text/javascript mjs;
11
+ }
12
+
13
+ # add_header is not inherited by a location that sets its own, so every location repeats the
14
+ # security headers beside Cache-Control.
15
+ gzip on;
16
+ gzip_vary on;
17
+ gzip_min_length 256;
18
+ gzip_types text/css text/javascript application/javascript application/json image/svg+xml;
19
+ # This image has no brotli module. Let a CDN or ingress do brotli if you want it.
20
+
21
+ # The shell origins the start script wrote from SHELL_ORIGINS. With none set the file does not
22
+ # exist (the * makes nginx accept that), the variable is empty and no CORS header is sent.
23
+ map $http_origin $mfe_cors_origin {
24
+ default "";
25
+ include /tmp/mfe-runtime/cors-origins*.map;
26
+ }
27
+
28
+ server {
29
+ listen 8080;
30
+ server_name _;
31
+ server_tokens off;
32
+ root /usr/share/nginx/html;
33
+
34
+ location = /healthz {
35
+ access_log off;
36
+ default_type text/plain;
37
+ return 200 "ok\n";
38
+ }
39
+
40
+ # Dotfiles (.env, .git) are never served. .well-known stays reachable.
41
+ location ~ /\.(?!well-known) { return 404; }
42
+
43
+ location ^~ /mfe/__NAME__/__VERSION__/ {
44
+ alias /usr/share/nginx/html/;
45
+ add_header Cache-Control "public, max-age=31536000, immutable";
46
+ add_header X-Content-Type-Options "nosniff" always;
47
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
48
+ add_header Access-Control-Allow-Origin $mfe_cors_origin always;
49
+ add_header Vary Origin always;
50
+ }
51
+
52
+ location ^~ /mfe/ { return 404; }
53
+
54
+ # Hashed build output.
55
+ location ^~ /static/ {
56
+ try_files $uri =404;
57
+ add_header Cache-Control "public, max-age=31536000, immutable";
58
+ add_header X-Content-Type-Options "nosniff" always;
59
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
60
+ add_header Access-Control-Allow-Origin $mfe_cors_origin always;
61
+ add_header Vary Origin always;
62
+ }
63
+
64
+ # mf-manifest.json, remoteEntry.js and everything else without a hash in its name.
65
+ location / {
66
+ try_files $uri =404;
67
+ add_header Cache-Control "no-cache" always;
68
+ add_header X-Content-Type-Options "nosniff" always;
69
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
70
+ add_header Access-Control-Allow-Origin $mfe_cors_origin always;
71
+ add_header Vary Origin always;
72
+ }
73
+ }