mfeflow 0.1.0 → 0.2.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/assets/features/ci-github/files/ci.md +1 -1
- package/assets/features/ci-github/files/ci.yml +4 -4
- package/assets/features/env-t3/feature.json +21 -0
- package/assets/features/env-t3/files/env.arktype.ts +1 -1
- package/assets/features/env-t3/files/env.valibot.ts +1 -1
- package/assets/features/env-t3/files/env.zod.ts +1 -1
- package/assets/features/env-t3/files/schema.arktype.ts +1 -1
- package/assets/features/env-t3/files/schema.valibot.ts +1 -1
- package/assets/features/env-t3/files/schema.zod.ts +1 -1
- package/assets/features/event-bus/feature.json +100 -0
- package/assets/features/event-bus/files/announce.tsx.eta +10 -0
- package/assets/features/event-bus/files/contracts-events.ts +11 -0
- package/assets/features/event-bus/files/contracts-package.json.eta +10 -0
- package/assets/features/event-bus/files/contracts-readme.md +10 -0
- package/assets/features/event-bus/files/events-test.ts.eta +18 -0
- package/assets/features/event-bus/files/events.ts.eta +20 -0
- package/assets/features/event-bus/files/shell-events-test.ts.eta +22 -0
- package/assets/features/event-bus/files/shell-events.ts.eta +19 -0
- package/assets/features/http/feature.json +81 -0
- package/assets/features/http/files/client-test.ts +19 -0
- package/assets/features/http/files/client.ts +20 -0
- package/assets/features/route-factory/feature.json +60 -0
- package/assets/features/route-factory/files/contract.ts.eta +7 -0
- package/assets/features/route-factory/files/routes-contract.ts.eta +11 -0
- package/assets/features/route-factory/files/routes-test.ts.eta +9 -0
- package/assets/features/route-factory/files/routes.ts.eta +11 -0
- package/assets/features/url-factory/files/endpoints.ts +13 -2
- package/assets/templates/monorepo/feature.json +26 -0
- package/assets/templates/monorepo/files/root/AGENTS.md.eta +3 -3
- package/assets/templates/monorepo/files/root/README.md.eta +11 -11
- package/assets/templates/monorepo/files/shell/src/pages/Home.tsx.eta +3 -1
- package/dist/index.js +17 -0
- package/package.json +4 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# CI
|
|
2
2
|
|
|
3
3
|
`.github/workflows/ci.yml` runs on every pull request: lint, formatting, typecheck, tests, build, then
|
|
4
|
-
`mfeflow doctor`, `validate`, `check-compat` and `budget`, the runtime-config check and the Playwright smoke test.
|
|
4
|
+
`pnpm mfeflow doctor`, `validate`, `check-compat` and `budget`, the runtime-config check and the Playwright smoke test.
|
|
5
5
|
A step for a feature the project does not have is skipped, so adding `testing` or `env-t3` later needs no edit to the
|
|
6
6
|
workflow. Every action is pinned to a commit SHA, the workflow starts from `permissions: {}`, and the job gets
|
|
7
7
|
`contents: read` only. Dependabot or Renovate can keep the pins current.
|
|
@@ -39,13 +39,13 @@ jobs:
|
|
|
39
39
|
run: pnpm run --if-present test
|
|
40
40
|
- run: pnpm build
|
|
41
41
|
- name: mfeflow doctor
|
|
42
|
-
run: pnpm
|
|
42
|
+
run: pnpm mfeflow doctor
|
|
43
43
|
- name: mfeflow validate
|
|
44
|
-
run: pnpm
|
|
44
|
+
run: pnpm mfeflow validate
|
|
45
45
|
- name: mfeflow check-compat
|
|
46
|
-
run: pnpm
|
|
46
|
+
run: pnpm mfeflow check-compat
|
|
47
47
|
- name: mfeflow budget
|
|
48
|
-
run: pnpm
|
|
48
|
+
run: pnpm mfeflow budget
|
|
49
49
|
- name: Check runtime config
|
|
50
50
|
run: pnpm run --if-present check:runtime-config
|
|
51
51
|
- name: Install Playwright browser
|
|
@@ -373,6 +373,27 @@
|
|
|
373
373
|
"when": {
|
|
374
374
|
"feature:telemetry-otel": true
|
|
375
375
|
}
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
"op": "add-env-key",
|
|
379
|
+
"file": "src/config/schema.ts",
|
|
380
|
+
"target": "shell",
|
|
381
|
+
"key": "apiUrl",
|
|
382
|
+
"scope": "runtime",
|
|
383
|
+
"schema": {
|
|
384
|
+
"zod": "z.string().min(1)",
|
|
385
|
+
"valibot": "v.pipe(v.string(), v.minLength(1))",
|
|
386
|
+
"arktype": "'string > 0'"
|
|
387
|
+
},
|
|
388
|
+
"value": "/api",
|
|
389
|
+
"configFiles": [
|
|
390
|
+
"public/config/runtime-config.dev.json",
|
|
391
|
+
"public/config/runtime-config.staging.json",
|
|
392
|
+
"public/config/runtime-config.prod.json"
|
|
393
|
+
],
|
|
394
|
+
"when": {
|
|
395
|
+
"feature:http": true
|
|
396
|
+
}
|
|
376
397
|
}
|
|
377
398
|
]
|
|
378
399
|
}
|
|
@@ -2,7 +2,7 @@ import { createEnv } from '@t3-oss/env-core';
|
|
|
2
2
|
import { type } from 'arktype';
|
|
3
3
|
|
|
4
4
|
// Server and build-time variables, checked when a process that imports this file starts.
|
|
5
|
-
// Add one: mfeflow add env-key API_ORIGIN --scope server --schema "type('string.url')"
|
|
5
|
+
// Add one: pnpm mfeflow add env-key API_ORIGIN --scope server --schema "type('string.url')"
|
|
6
6
|
export const env = createEnv({
|
|
7
7
|
server: {
|
|
8
8
|
NODE_ENV: type("'development' | 'production' | 'test' | undefined"),
|
|
@@ -2,7 +2,7 @@ import { createEnv } from '@t3-oss/env-core';
|
|
|
2
2
|
import * as v from 'valibot';
|
|
3
3
|
|
|
4
4
|
// Server and build-time variables, checked when a process that imports this file starts.
|
|
5
|
-
// Add one: mfeflow add env-key API_ORIGIN --scope server --schema "v.pipe(v.string(), v.url())"
|
|
5
|
+
// Add one: pnpm mfeflow add env-key API_ORIGIN --scope server --schema "v.pipe(v.string(), v.url())"
|
|
6
6
|
export const env = createEnv({
|
|
7
7
|
server: {
|
|
8
8
|
NODE_ENV: v.optional(v.picklist(['development', 'production', 'test'])),
|
|
@@ -2,7 +2,7 @@ import { createEnv } from '@t3-oss/env-core';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
|
|
4
4
|
// Server and build-time variables, checked when a process that imports this file starts.
|
|
5
|
-
// Add one: mfeflow add env-key API_ORIGIN --scope server --schema "z.url()"
|
|
5
|
+
// Add one: pnpm mfeflow add env-key API_ORIGIN --scope server --schema "z.url()"
|
|
6
6
|
export const env = createEnv({
|
|
7
7
|
server: {
|
|
8
8
|
NODE_ENV: z.enum(['development', 'production', 'test']).optional(),
|
|
@@ -2,7 +2,7 @@ import { type } from 'arktype';
|
|
|
2
2
|
|
|
3
3
|
// The shape of public/config/runtime-config.<env>.json. The browser only imports this file's type
|
|
4
4
|
// (see ./runtime), so the validator stays out of the bundle; `pnpm check:runtime-config` validates the files.
|
|
5
|
-
// Add a key: mfeflow add env-key GTM_ID --scope runtime --schema "type('string > 0')"
|
|
5
|
+
// Add a key: pnpm mfeflow add env-key GTM_ID --scope runtime --schema "type('string > 0')"
|
|
6
6
|
export const runtimeConfigSchema = type({
|
|
7
7
|
environment: "'dev' | 'staging' | 'prod'",
|
|
8
8
|
});
|
|
@@ -2,7 +2,7 @@ import * as v from 'valibot';
|
|
|
2
2
|
|
|
3
3
|
// The shape of public/config/runtime-config.<env>.json. The browser only imports this file's type
|
|
4
4
|
// (see ./runtime), so the validator stays out of the bundle; `pnpm check:runtime-config` validates the files.
|
|
5
|
-
// Add a key: mfeflow add env-key GTM_ID --scope runtime --schema "v.pipe(v.string(), v.minLength(1))"
|
|
5
|
+
// Add a key: pnpm mfeflow add env-key GTM_ID --scope runtime --schema "v.pipe(v.string(), v.minLength(1))"
|
|
6
6
|
export const runtimeConfigSchema = v.object({
|
|
7
7
|
environment: v.picklist(['dev', 'staging', 'prod']),
|
|
8
8
|
});
|
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
|
|
3
3
|
// The shape of public/config/runtime-config.<env>.json. The browser only imports this file's type
|
|
4
4
|
// (see ./runtime), so the validator stays out of the bundle; `pnpm check:runtime-config` validates the files.
|
|
5
|
-
// Add a key: mfeflow add env-key GTM_ID --scope runtime --schema "z.string().min(1)"
|
|
5
|
+
// Add a key: pnpm mfeflow add env-key GTM_ID --scope runtime --schema "z.string().min(1)"
|
|
6
6
|
export const runtimeConfigSchema = z.object({
|
|
7
7
|
environment: z.enum(['dev', 'staging', 'prod']),
|
|
8
8
|
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "event-bus",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed events between remotes (@mfeflow/event-bus) and the contracts package that holds their types: packages/contracts/src/events, merged into the bus in every app, with a sample event.",
|
|
5
|
+
"requires": ["core", "tsconfig"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "packages/contracts/package.json",
|
|
9
|
+
"target": "root",
|
|
10
|
+
"from": "files/contracts-package.json.eta",
|
|
11
|
+
"template": true
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"to": "packages/contracts/src/events/index.ts",
|
|
15
|
+
"target": "root",
|
|
16
|
+
"from": "files/contracts-events.ts"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"to": "packages/contracts/README.md",
|
|
20
|
+
"target": "root",
|
|
21
|
+
"from": "files/contracts-readme.md"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"to": "src/events.ts",
|
|
25
|
+
"target": "shell",
|
|
26
|
+
"from": "files/shell-events.ts.eta",
|
|
27
|
+
"template": true
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"to": "src/events.test.ts",
|
|
31
|
+
"target": "shell",
|
|
32
|
+
"from": "files/shell-events-test.ts.eta",
|
|
33
|
+
"template": true,
|
|
34
|
+
"when": {
|
|
35
|
+
"feature:testing": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"to": "src/events.ts",
|
|
40
|
+
"target": "remotes",
|
|
41
|
+
"from": "files/events.ts.eta",
|
|
42
|
+
"template": true
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"to": "src/events.test.ts",
|
|
46
|
+
"target": "remotes",
|
|
47
|
+
"from": "files/events-test.ts.eta",
|
|
48
|
+
"template": true,
|
|
49
|
+
"when": {
|
|
50
|
+
"feature:testing": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"to": "src/Announce.tsx",
|
|
55
|
+
"target": "remotes",
|
|
56
|
+
"from": "files/announce.tsx.eta",
|
|
57
|
+
"template": true
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"dependencies": [
|
|
61
|
+
{
|
|
62
|
+
"name": "@mfeflow/event-bus",
|
|
63
|
+
"range": "latest",
|
|
64
|
+
"target": "shell"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"name": "@mfeflow/event-bus",
|
|
68
|
+
"range": "latest",
|
|
69
|
+
"target": "remotes"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "{{scope}}/contracts",
|
|
73
|
+
"range": "workspace:*",
|
|
74
|
+
"target": "shell"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "{{scope}}/contracts",
|
|
78
|
+
"range": "workspace:*",
|
|
79
|
+
"target": "remotes"
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
"transforms": [
|
|
83
|
+
{
|
|
84
|
+
"op": "add-boot-step",
|
|
85
|
+
"file": "src/bootstrap.tsx",
|
|
86
|
+
"target": "shell",
|
|
87
|
+
"call": "configureShellEvents",
|
|
88
|
+
"from": "./events",
|
|
89
|
+
"await": false
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"op": "wrap-provider",
|
|
93
|
+
"file": "src/exposed/App.tsx",
|
|
94
|
+
"target": "remotes",
|
|
95
|
+
"component": "Announce",
|
|
96
|
+
"from": "../Announce",
|
|
97
|
+
"function": "App"
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from 'react';
|
|
2
|
+
import { announceReady } from './events';
|
|
3
|
+
|
|
4
|
+
/** Tells the other remotes that <%= it.app.name %> is on the page, each time it mounts. */
|
|
5
|
+
export function Announce({ children }: { children: ReactNode }) {
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
announceReady();
|
|
8
|
+
}, []);
|
|
9
|
+
return <>{children}</>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every event the platform can carry, by versioned name: <domain>.<event>.v<major>, like
|
|
3
|
+
* cart.item-added.v1. Types only: nothing here runs, so any remote can read it.
|
|
4
|
+
*
|
|
5
|
+
* Add an event by adding a line. Change a payload in a way old listeners cannot read by adding
|
|
6
|
+
* a new version (cart.item-added.v2) and publishing both until every listener has moved.
|
|
7
|
+
*/
|
|
8
|
+
export interface Events {
|
|
9
|
+
/** A remote is on the page. The sample event: every remote announces itself, every other one can listen. */
|
|
10
|
+
'mfe.ready.v1': { mfe: string };
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# contracts
|
|
2
|
+
|
|
3
|
+
What the micro-frontends agree on, and nothing else. A remote never imports another remote; it
|
|
4
|
+
imports this.
|
|
5
|
+
|
|
6
|
+
- `src/events`: the events remotes publish and listen to, as types.
|
|
7
|
+
- `src/routes`: each remote's public paths, as plain data (added by `route-factory`).
|
|
8
|
+
|
|
9
|
+
Keep it to types and plain data with no imports, so reading it costs a remote nothing at run time
|
|
10
|
+
and it never pulls in another remote's code.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { publish, resetEventBusForTests } from '@mfeflow/event-bus';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { announceReady, onOtherRemoteReady } from './events';
|
|
4
|
+
|
|
5
|
+
afterEach(resetEventBusForTests);
|
|
6
|
+
|
|
7
|
+
describe('the <%= it.app.name %> events', () => {
|
|
8
|
+
it('hears another remote and not itself', () => {
|
|
9
|
+
const heard = vi.fn();
|
|
10
|
+
const stop = onOtherRemoteReady(heard);
|
|
11
|
+
announceReady();
|
|
12
|
+
publish('mfe.ready.v1', { mfe: 'somewhere-else' });
|
|
13
|
+
expect(heard).toHaveBeenCalledExactlyOnceWith('somewhere-else');
|
|
14
|
+
stop();
|
|
15
|
+
publish('mfe.ready.v1', { mfe: 'later' });
|
|
16
|
+
expect(heard).toHaveBeenCalledOnce();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { publish, subscribe } from '@mfeflow/event-bus';
|
|
2
|
+
import type { Events } from '<%= it.scope %>/contracts/events';
|
|
3
|
+
|
|
4
|
+
// The platform's event map is the contracts package's. This one declaration merges it in.
|
|
5
|
+
declare module '@mfeflow/event-bus' {
|
|
6
|
+
// oxlint-disable-next-line typescript/no-empty-interface -- merges the contracts into the platform map
|
|
7
|
+
interface EventMap extends Events {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Tells the other remotes that <%= it.app.name %> is on the page. */
|
|
11
|
+
export function announceReady(): void {
|
|
12
|
+
publish('mfe.ready.v1', { mfe: '<%= it.app.name %>' });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Calls `handler` with the name of each other remote that announces itself. Returns the function that stops it. */
|
|
16
|
+
export function onOtherRemoteReady(handler: (mfe: string) => void): () => void {
|
|
17
|
+
return subscribe('mfe.ready.v1', ({ mfe }) => {
|
|
18
|
+
if (mfe !== '<%= it.app.name %>') handler(mfe);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { publish, resetEventBusForTests, subscribe } from '@mfeflow/event-bus';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { configureShellEvents } from './events';
|
|
4
|
+
|
|
5
|
+
afterEach(resetEventBusForTests);
|
|
6
|
+
|
|
7
|
+
describe('the shell event bus', () => {
|
|
8
|
+
it('carries the contracts events between remotes', () => {
|
|
9
|
+
const heard = vi.fn();
|
|
10
|
+
subscribe('mfe.ready.v1', heard);
|
|
11
|
+
publish('mfe.ready.v1', { mfe: 'cart' });
|
|
12
|
+
expect(heard).toHaveBeenCalledExactlyOnceWith({ mfe: 'cart' });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('replays the latest announcement to a remote that mounts late', () => {
|
|
16
|
+
configureShellEvents();
|
|
17
|
+
publish('mfe.ready.v1', { mfe: 'cart' });
|
|
18
|
+
const late = vi.fn();
|
|
19
|
+
subscribe('mfe.ready.v1', late);
|
|
20
|
+
expect(late).toHaveBeenCalledExactlyOnceWith({ mfe: 'cart' });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { configureEvent } from '@mfeflow/event-bus';
|
|
2
|
+
import type { Events } from '<%= it.scope %>/contracts/events';
|
|
3
|
+
|
|
4
|
+
// The platform's event map is the contracts package's. This one declaration merges it in.
|
|
5
|
+
declare module '@mfeflow/event-bus' {
|
|
6
|
+
// oxlint-disable-next-line typescript/no-empty-interface -- merges the contracts into the platform map
|
|
7
|
+
interface EventMap extends Events {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How the platform's events behave, set once at boot. The shell imports the bus here, which makes
|
|
12
|
+
* it the one that provides the copy every remote shares.
|
|
13
|
+
*/
|
|
14
|
+
export function configureShellEvents(): void {
|
|
15
|
+
// "This remote is up" is a fact about the page, not a moment: a remote that mounts later still
|
|
16
|
+
// hears the latest announcement. Only the last one is kept. Leave replay off for events that
|
|
17
|
+
// are things that happened, like cart.item-added.v1.
|
|
18
|
+
configureEvent('mfe.ready.v1', { replay: true });
|
|
19
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "http",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A fetch client over your url-factory endpoints (@mfeflow/http): src/api/client.ts in every app, with retries for idempotent requests, timeouts and typed errors. The API base URL is the runtime config's apiUrl.",
|
|
5
|
+
"requires": ["core", "tsconfig", "url-factory"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "src/api/client.ts",
|
|
9
|
+
"target": "shell",
|
|
10
|
+
"from": "files/client.ts"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"to": "src/api/client.ts",
|
|
14
|
+
"target": "remotes",
|
|
15
|
+
"from": "files/client.ts"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"to": "src/api/client.test.ts",
|
|
19
|
+
"target": "shell",
|
|
20
|
+
"from": "files/client-test.ts",
|
|
21
|
+
"when": {
|
|
22
|
+
"feature:testing": true
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"to": "src/api/client.test.ts",
|
|
27
|
+
"target": "remotes",
|
|
28
|
+
"from": "files/client-test.ts",
|
|
29
|
+
"when": {
|
|
30
|
+
"feature:testing": true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"dependencies": [
|
|
35
|
+
{
|
|
36
|
+
"name": "@mfeflow/http",
|
|
37
|
+
"range": "latest",
|
|
38
|
+
"target": "shell"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "@mfeflow/http",
|
|
42
|
+
"range": "latest",
|
|
43
|
+
"target": "remotes"
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"transforms": [
|
|
47
|
+
{
|
|
48
|
+
"op": "add-env-key",
|
|
49
|
+
"file": "src/config/schema.ts",
|
|
50
|
+
"target": "shell",
|
|
51
|
+
"key": "apiUrl",
|
|
52
|
+
"scope": "runtime",
|
|
53
|
+
"schema": {
|
|
54
|
+
"zod": "z.string().min(1)",
|
|
55
|
+
"valibot": "v.pipe(v.string(), v.minLength(1))",
|
|
56
|
+
"arktype": "'string > 0'"
|
|
57
|
+
},
|
|
58
|
+
"value": "/api",
|
|
59
|
+
"configFiles": [
|
|
60
|
+
"public/config/runtime-config.dev.json",
|
|
61
|
+
"public/config/runtime-config.staging.json",
|
|
62
|
+
"public/config/runtime-config.prod.json"
|
|
63
|
+
],
|
|
64
|
+
"when": {
|
|
65
|
+
"feature:env-t3": true
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"op": "add-env-key",
|
|
70
|
+
"file": "public/config/runtime-config.json",
|
|
71
|
+
"target": "shell",
|
|
72
|
+
"key": "apiUrl",
|
|
73
|
+
"scope": "runtime",
|
|
74
|
+
"schema": "z.string().min(1)",
|
|
75
|
+
"value": "/api",
|
|
76
|
+
"when": {
|
|
77
|
+
"feature:env-t3": false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isHttpError } from '@mfeflow/http';
|
|
2
|
+
import { http, HttpResponse } from 'msw';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { server } from '../mocks/server';
|
|
5
|
+
import { api } from './client';
|
|
6
|
+
|
|
7
|
+
describe('the api client', () => {
|
|
8
|
+
it('reads an endpoint through the mock server', async () => {
|
|
9
|
+
expect(await api.get('health')).toEqual({ ok: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('turns a failure into an HttpError with the status', async () => {
|
|
13
|
+
server.use(
|
|
14
|
+
http.get('*/api/health', () => HttpResponse.json({ error: 'down' }, { status: 404 })),
|
|
15
|
+
);
|
|
16
|
+
const error = await api.get('health').catch((e: unknown) => e);
|
|
17
|
+
expect(isHttpError(error) && error.status).toBe(404);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHttpClient } from '@mfeflow/http';
|
|
2
|
+
import { apiUrl, type endpoints } from './endpoints';
|
|
3
|
+
|
|
4
|
+
// What each endpoint answers with, by the name in ./endpoints. Add a line per endpoint you call.
|
|
5
|
+
interface Responses {
|
|
6
|
+
health: { ok: boolean };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One client per app over the endpoints in ./endpoints. The base URL is the runtime config's
|
|
11
|
+
* `apiUrl`. Idempotent requests retry on a network error, a timeout and a 502, 503 or 504; a
|
|
12
|
+
* failure is an HttpError with the status and body, and no URL in its message.
|
|
13
|
+
*
|
|
14
|
+
* const { ok } = await api.get('health');
|
|
15
|
+
*/
|
|
16
|
+
export const api = createHttpClient<typeof endpoints, Responses>({
|
|
17
|
+
url: apiUrl,
|
|
18
|
+
// Return the signed-in user's token; no header is sent while this returns undefined.
|
|
19
|
+
// getToken: () => session.accessToken(),
|
|
20
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "route-factory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed links between remotes (@mfeflow/route-factory): src/routes.ts in every remote. With event-bus the paths live in the contracts package, so remotes link to each other without importing each other.",
|
|
5
|
+
"requires": ["core", "tsconfig"],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"to": "src/routes.ts",
|
|
9
|
+
"target": "remotes",
|
|
10
|
+
"from": "files/routes.ts.eta",
|
|
11
|
+
"template": true,
|
|
12
|
+
"when": {
|
|
13
|
+
"feature:event-bus": false
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"to": "src/routes.ts",
|
|
18
|
+
"target": "remotes",
|
|
19
|
+
"from": "files/routes-contract.ts.eta",
|
|
20
|
+
"template": true,
|
|
21
|
+
"when": {
|
|
22
|
+
"feature:event-bus": true
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"to": "packages/contracts/src/routes/{{app.name}}.ts",
|
|
27
|
+
"target": "remotes",
|
|
28
|
+
"atRoot": true,
|
|
29
|
+
"from": "files/contract.ts.eta",
|
|
30
|
+
"template": true,
|
|
31
|
+
"when": {
|
|
32
|
+
"feature:event-bus": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"to": "src/routes.test.ts",
|
|
37
|
+
"target": "remotes",
|
|
38
|
+
"from": "files/routes-test.ts.eta",
|
|
39
|
+
"template": true,
|
|
40
|
+
"when": {
|
|
41
|
+
"feature:testing": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"dependencies": [
|
|
46
|
+
{
|
|
47
|
+
"name": "@mfeflow/route-factory",
|
|
48
|
+
"range": "latest",
|
|
49
|
+
"target": "remotes"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "{{scope}}/contracts",
|
|
53
|
+
"range": "workspace:*",
|
|
54
|
+
"target": "remotes",
|
|
55
|
+
"when": {
|
|
56
|
+
"feature:event-bus": true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createLinks, defineRoutes } from '@mfeflow/route-factory';
|
|
2
|
+
import { routes as contract } from '<%= it.scope %>/contracts/routes/<%= it.app.name %>';
|
|
3
|
+
|
|
4
|
+
// The paths are in the contracts package, so another remote can link here without importing this
|
|
5
|
+
// one. `/<%= it.app.name %>` is where the shell serves this remote.
|
|
6
|
+
export const routes = defineRoutes('/<%= it.app.name %>', contract);
|
|
7
|
+
|
|
8
|
+
// Links to this remote's routes. Another remote links here with
|
|
9
|
+
// import { routes as <%= it.app.name %> } from '<%= it.scope %>/contracts/routes/<%= it.app.name %>'
|
|
10
|
+
// and createLinks({ <%= it.app.name %>: defineRoutes('/<%= it.app.name %>', <%= it.app.name %>) }).
|
|
11
|
+
export const links = createLinks({ <%= it.app.name %>: routes });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { links, routes } from './routes';
|
|
3
|
+
|
|
4
|
+
describe('the <%= it.app.name %> routes', () => {
|
|
5
|
+
it('are served under /<%= it.app.name %>', () => {
|
|
6
|
+
expect(routes.mount).toBe('/<%= it.app.name %>');
|
|
7
|
+
expect(links.href('<%= it.app.name %>.home')).toBe('/<%= it.app.name %>');
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createLinks, defineRoutes } from '@mfeflow/route-factory';
|
|
2
|
+
|
|
3
|
+
// <%= it.app.name %>'s public paths. `/<%= it.app.name %>` is where the shell serves this remote.
|
|
4
|
+
// A route is a path template: `:id` is a path param, and `?q&page?` after the path names search
|
|
5
|
+
// params (q is required, page is optional). Links to a route are checked against this table.
|
|
6
|
+
export const routes = defineRoutes('/<%= it.app.name %>', {
|
|
7
|
+
home: '/',
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Links to this remote's routes. Another remote links here by adding `{ <%= it.app.name %>: routes }` to its own createLinks.
|
|
11
|
+
export const links = createLinks({ <%= it.app.name %>: routes });
|
|
@@ -4,9 +4,20 @@ export const endpoints = defineEndpoints({
|
|
|
4
4
|
health: { service: 'api', path: '/health' },
|
|
5
5
|
});
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const defaults: Record<string, string> = { api: '/api' };
|
|
8
|
+
|
|
9
|
+
// A service's base is `<service>Url` in the runtime config (apiUrl for api) when the shell loaded
|
|
10
|
+
// one, so a build runs in every environment; the default above is for tests and standalone runs.
|
|
11
|
+
function configured(service: string): string | undefined {
|
|
12
|
+
const config: unknown = Reflect.get(globalThis, '__MFE_RUNTIME_CONFIG__');
|
|
13
|
+
const value: unknown =
|
|
14
|
+
typeof config === 'object' && config !== null
|
|
15
|
+
? Reflect.get(config, `${service}Url`)
|
|
16
|
+
: undefined;
|
|
17
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
18
|
+
}
|
|
8
19
|
|
|
9
20
|
export const apiUrl = createUrlFactory(endpoints, (service) => {
|
|
10
|
-
const base =
|
|
21
|
+
const base = configured(service) ?? defaults[service];
|
|
11
22
|
return base === undefined ? undefined : new URL(base, location.origin).toString();
|
|
12
23
|
});
|
|
@@ -164,6 +164,12 @@
|
|
|
164
164
|
"target": "root",
|
|
165
165
|
"dev": true
|
|
166
166
|
},
|
|
167
|
+
{
|
|
168
|
+
"name": "mfeflow",
|
|
169
|
+
"range": "latest",
|
|
170
|
+
"target": "root",
|
|
171
|
+
"dev": true
|
|
172
|
+
},
|
|
167
173
|
{
|
|
168
174
|
"name": "@mfeflow/runtime",
|
|
169
175
|
"range": "latest",
|
|
@@ -271,6 +277,26 @@
|
|
|
271
277
|
"command": "turbo run build",
|
|
272
278
|
"target": "root"
|
|
273
279
|
},
|
|
280
|
+
{
|
|
281
|
+
"name": "doctor",
|
|
282
|
+
"command": "mfeflow doctor",
|
|
283
|
+
"target": "root"
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
"name": "validate",
|
|
287
|
+
"command": "mfeflow validate",
|
|
288
|
+
"target": "root"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"name": "check-compat",
|
|
292
|
+
"command": "mfeflow check-compat",
|
|
293
|
+
"target": "root"
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"name": "budget",
|
|
297
|
+
"command": "mfeflow budget",
|
|
298
|
+
"target": "root"
|
|
299
|
+
},
|
|
274
300
|
{
|
|
275
301
|
"name": "dev",
|
|
276
302
|
"command": "rsbuild dev",
|
|
@@ -4,14 +4,14 @@ Guidance for coding agents working in this repo.
|
|
|
4
4
|
|
|
5
5
|
- Package manager: pnpm (via corepack). Never npm or yarn. Versions live in the `catalog` of
|
|
6
6
|
`pnpm-workspace.yaml`; add dependencies there and reference them as `catalog:`.
|
|
7
|
-
- `pnpm dev` runs the shell and every remote. `pnpm build` builds all of them. `mfeflow dev` does the same with
|
|
7
|
+
- `pnpm dev` runs the shell and every remote. `pnpm build` builds all of them. `pnpm mfeflow dev` does the same with
|
|
8
8
|
per-app logs and picks up new remotes without a restart.
|
|
9
|
-
- Before finishing: `
|
|
9
|
+
- Before finishing: `pnpm doctor` (and `pnpm validate`, `pnpm check-compat`, `pnpm budget` after a build) must exit 0.
|
|
10
10
|
- TypeScript 7, strict. Relative imports have no file extension (`./plan`, not `./plan.js`).
|
|
11
11
|
- Each remote is deployed on its own. A remote must not import from another remote or from
|
|
12
12
|
the shell; talk through the registry, events or URLs.
|
|
13
13
|
- A remote's public surface is `src/exposed/`. Everything else is private to it.
|
|
14
|
-
- Add apps and features with the `mfeflow` CLI (`mfeflow add remote <name>`, `mfeflow features`).
|
|
14
|
+
- Add apps and features with the `mfeflow` CLI, installed as a root dev dependency (`pnpm mfeflow add remote <name>`, `pnpm mfeflow features`).
|
|
15
15
|
`mfeflow.json` is the CLI's record of the project; do not edit the file hashes by hand.
|
|
16
16
|
- Shared singletons (react, react-dom, `@mfeflow/runtime`) are provided by the shell. Do not
|
|
17
17
|
bundle your own copy.
|
|
@@ -7,7 +7,7 @@ one shell and independently deployable remotes, loaded at runtime through Module
|
|
|
7
7
|
apps/shell the host: routes, layout, registry loading
|
|
8
8
|
apps/<remote> one micro-frontend each, exposing ./App
|
|
9
9
|
registry/envs which build of each remote every environment serves
|
|
10
|
-
mfeflow.json what the CLI generated; `mfeflow add` reads and updates it
|
|
10
|
+
mfeflow.json what the CLI generated; `pnpm mfeflow add` reads and updates it
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Run it
|
|
@@ -20,28 +20,28 @@ pnpm dev
|
|
|
20
20
|
`pnpm dev` starts the shell on http://localhost:4300 and every remote on its own port
|
|
21
21
|
(see `registry/envs/dev`). The shell reads that dev registry and renders the remotes.
|
|
22
22
|
|
|
23
|
-
`mfeflow dev` does the same with a prefixed, coloured log per app, and it follows the project:
|
|
24
|
-
add a remote with `mfeflow add remote checkout` and it starts, and the shell lists it, without a restart.
|
|
25
|
-
Use a remote you already run elsewhere with `mfeflow dev --remote checkout=http://localhost:3001`,
|
|
23
|
+
`pnpm mfeflow dev` does the same with a prefixed, coloured log per app, and it follows the project:
|
|
24
|
+
add a remote with `pnpm mfeflow add remote checkout` and it starts, and the shell lists it, without a restart.
|
|
25
|
+
Use a remote you already run elsewhere with `pnpm mfeflow dev --remote checkout=http://localhost:3001`,
|
|
26
26
|
or run a few apps with `--only shell,catalog`. Ctrl+C stops everything.
|
|
27
27
|
|
|
28
28
|
## Change it
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
mfeflow add remote checkout # a new remote, its registry files and its shell route
|
|
32
|
-
mfeflow add <feature> # see `mfeflow features`
|
|
31
|
+
pnpm mfeflow add remote checkout # a new remote, its registry files and its shell route
|
|
32
|
+
pnpm mfeflow add <feature> # see `pnpm mfeflow features`
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
## Check it
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
pnpm doctor # singletons, compatibility, config, drift; `pnpm doctor --fix` repairs the safe ones
|
|
39
|
+
pnpm validate # mfeflow.json, every mfe.json and every registry entry
|
|
40
|
+
pnpm check-compat # each remote's shared ranges against its shell
|
|
41
|
+
pnpm budget # after `pnpm build`: first-load JS per shell and remote
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
All of them take `--json` and exit non-zero on errors, so they can run in CI. `
|
|
44
|
+
Each is a root script for the `mfeflow` CLI in your devDependencies (`pnpm mfeflow <command>` runs it directly). All of them take `--json` and exit non-zero on errors, so they can run in CI. `pnpm budget` checks each remote's entry
|
|
45
45
|
against the 10 KB a remote may add; set `budget.baselineKb` in `mfeflow.json` (your pages built as one app) to check
|
|
46
46
|
the shell against baseline + 32 KB too.
|
|
47
47
|
|
|
@@ -3,7 +3,9 @@ import { RemoteBoundary, registeredRemotes } from '@mfeflow/runtime';
|
|
|
3
3
|
// Shows every remote the registry listed, so a new remote appears here without a code change.
|
|
4
4
|
export function Home() {
|
|
5
5
|
const remotes = registeredRemotes();
|
|
6
|
-
if (remotes.length === 0)
|
|
6
|
+
if (remotes.length === 0) {
|
|
7
|
+
return <p>No remotes yet. Run `pnpm mfeflow add remote <name>`.</p>;
|
|
8
|
+
}
|
|
7
9
|
return remotes.map((remote) => (
|
|
8
10
|
<section key={remote.name}>
|
|
9
11
|
<RemoteBoundary id={`${remote.name}/App`} loading={<p>Loading {remote.name}…</p>} />
|
package/dist/index.js
CHANGED
|
@@ -156,6 +156,21 @@ const FIRST_PARTY = [
|
|
|
156
156
|
always: false,
|
|
157
157
|
default: false
|
|
158
158
|
},
|
|
159
|
+
{
|
|
160
|
+
name: 'event-bus',
|
|
161
|
+
always: false,
|
|
162
|
+
default: false
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: 'route-factory',
|
|
166
|
+
always: false,
|
|
167
|
+
default: false
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'http',
|
|
171
|
+
always: false,
|
|
172
|
+
default: false
|
|
173
|
+
},
|
|
159
174
|
{
|
|
160
175
|
name: 'ci-github',
|
|
161
176
|
always: false,
|
|
@@ -1583,6 +1598,8 @@ const SINGLETON_SPECIFIERS = [
|
|
|
1583
1598
|
'@mfeflow/analytics',
|
|
1584
1599
|
'@mfeflow/analytics/react',
|
|
1585
1600
|
'@mfeflow/telemetry',
|
|
1601
|
+
'@mfeflow/event-bus',
|
|
1602
|
+
'@mfeflow/event-bus/react',
|
|
1586
1603
|
'@mfeflow/storage',
|
|
1587
1604
|
'@mfeflow/storage/react',
|
|
1588
1605
|
'@mfeflow/theme',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mfeflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Create and grow a React micro-frontend platform: mfeflow init, add remote, add <feature>.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"micro-frontend",
|
|
@@ -49,14 +49,14 @@
|
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@clack/prompts": "1.8.1",
|
|
52
|
-
"@mfeflow/contracts": "0.
|
|
53
|
-
"@mfeflow/core": "0.
|
|
52
|
+
"@mfeflow/contracts": "0.2.0",
|
|
53
|
+
"@mfeflow/core": "0.2.0",
|
|
54
54
|
"citty": "0.2.2",
|
|
55
55
|
"semver": "7.8.5",
|
|
56
56
|
"yaml": "2.9.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@mfeflow/rsbuild": "0.
|
|
59
|
+
"@mfeflow/rsbuild": "0.2.0",
|
|
60
60
|
"@rslib/core": "1.0.5",
|
|
61
61
|
"@types/node": "26.6.6",
|
|
62
62
|
"@types/semver": "7.8.0",
|