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.
- package/README.md +8 -3
- package/assets/features/deploy-cloudflare/feature.json +85 -0
- package/assets/features/deploy-cloudflare/files/cloudflare.json.eta +13 -0
- package/assets/features/deploy-cloudflare/files/cloudflare.md.eta +104 -0
- package/assets/features/deploy-cloudflare/files/deploy-cloudflare.ts +318 -0
- package/assets/features/deploy-cloudflare/files/deploy-cloudflare.yml +111 -0
- package/assets/features/deploy-cloudflare/files/headers.remote +14 -0
- package/assets/features/deploy-cloudflare/files/headers.shell.eta +18 -0
- package/assets/features/deploy-cloudflare/files/r2-cors.json +10 -0
- package/assets/features/deploy-cloudflare/files/registry-proxy.ts +80 -0
- package/assets/features/deploy-cloudflare/files/wrangler.remote.jsonc.eta +21 -0
- package/assets/features/deploy-cloudflare/files/wrangler.shell.jsonc.eta +40 -0
- package/assets/features/deploy-docker/feature.json +22 -0
- package/assets/features/deploy-docker/files/40-mfe-config.sh +76 -0
- package/assets/features/deploy-docker/files/Dockerfile +48 -0
- package/assets/features/deploy-docker/files/compose-registry.json.eta +19 -0
- package/assets/features/deploy-docker/files/compose.yaml.eta +50 -0
- package/assets/features/deploy-docker/files/docker.md +52 -0
- package/assets/features/deploy-docker/files/dockerignore +17 -0
- package/assets/features/deploy-docker/files/k8s.yaml +114 -0
- package/assets/features/deploy-docker/files/nginx.remote.conf +73 -0
- package/assets/features/deploy-docker/files/nginx.shell.conf +84 -0
- package/assets/features/registry-server/feature.json +128 -0
- package/assets/features/registry-server/files/deploy-server.yml +69 -0
- package/assets/features/registry-server/files/registry-server.md.eta +52 -0
- package/assets/features/registry-server/files/registry-sources.ts +12 -0
- package/assets/features/registry-static/feature.json +61 -0
- package/assets/features/registry-static/files/deploy.yml +61 -0
- package/assets/features/registry-static/files/registry.md.eta +49 -0
- package/assets/features/release/feature.json +43 -0
- package/assets/features/release/files/changeset-readme.md +7 -0
- package/assets/features/release/files/config.json +10 -0
- package/assets/features/release/files/releasing.md +21 -0
- package/assets/templates/monorepo/feature.json +7 -6
- package/assets/templates/monorepo/files/remote/{mfe.json.eta → mfeflow.app.json.eta} +1 -0
- package/assets/templates/monorepo/files/root/README.md.eta +1 -1
- package/assets/templates/monorepo/files/root/pnpm-workspace.yaml.eta +3 -2
- package/assets/templates/monorepo/files/shell/{mfe.json.eta → mfeflow.app.json.eta} +1 -0
- package/assets/templates/monorepo/files/shell/rsbuild.config.ts.eta +42 -6
- package/assets/templates/monorepo/migrations/0.4.0-app-file.mjs +58 -0
- package/assets/templates/polyrepo/feature.json +305 -0
- package/assets/templates/polyrepo/files/AGENTS.md.eta +15 -0
- package/assets/templates/polyrepo/files/changeset/README.md +5 -0
- package/assets/templates/polyrepo/files/changeset/config.json +9 -0
- package/assets/templates/polyrepo/files/packages/contracts/package.json.eta +23 -0
- package/assets/templates/polyrepo/files/packages/contracts/src/index.ts +11 -0
- package/assets/templates/polyrepo/files/packages/contracts/tsconfig.json.eta +5 -0
- package/assets/templates/polyrepo/files/packages/tokens/package.json.eta +23 -0
- package/assets/templates/polyrepo/files/packages/tokens/src/index.ts +13 -0
- package/assets/templates/polyrepo/files/packages/tokens/tsconfig.json.eta +5 -0
- package/assets/templates/polyrepo/files/platform/README.md.eta +43 -0
- package/assets/templates/polyrepo/files/platform/default.json.eta +27 -0
- package/assets/templates/polyrepo/files/remote/README.md.eta +23 -0
- package/assets/templates/polyrepo/files/renovate.json.eta +4 -0
- package/assets/templates/polyrepo/files/shell/README.md.eta +32 -0
- package/assets/templates/polyrepo/files/tsconfig.base.json.eta +13 -0
- package/assets/templates/polyrepo/files/workflows/platform-ci.yml +29 -0
- package/assets/templates/polyrepo/files/workflows/release.yml +40 -0
- package/assets/templates/polyrepo/files/workflows/reusable-ci.yml.eta +50 -0
- package/assets/templates/polyrepo/files/workflows/team-ci.yml.eta +12 -0
- package/dist/against.d.ts +16 -0
- package/dist/catalog.d.ts +4 -0
- package/dist/checks/compat.d.ts +12 -0
- package/dist/checks/index.d.ts +0 -1
- package/dist/commands/add.d.ts +0 -2
- package/dist/commands/adopt.d.ts +54 -0
- package/dist/commands/deploy.d.ts +35 -0
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/init.d.ts +9 -1
- package/dist/commands/server-mode.d.ts +210 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/team-repo.d.ts +9 -0
- package/dist/commands/upgrade.d.ts +66 -0
- package/dist/devremotes.d.ts +15 -0
- package/dist/exit.d.ts +1 -0
- package/dist/glue.d.ts +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4600 -1875
- package/dist/inspect.d.ts +14 -2
- package/dist/output.d.ts +138 -3
- package/dist/pipeline.d.ts +5 -2
- package/dist/polyrepo.d.ts +69 -0
- package/dist/publish.d.ts +10 -1
- package/dist/runtime.d.ts +2 -0
- package/dist/server-client.d.ts +69 -0
- package/dist/system.d.ts +2 -0
- package/dist/testing.d.ts +17 -1
- package/dist/upgrade.d.ts +25 -0
- package/dist/write.d.ts +8 -0
- 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,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
|
+
}
|