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.
Files changed (33) hide show
  1. package/assets/features/ci-github/files/ci.md +1 -1
  2. package/assets/features/ci-github/files/ci.yml +4 -4
  3. package/assets/features/env-t3/feature.json +21 -0
  4. package/assets/features/env-t3/files/env.arktype.ts +1 -1
  5. package/assets/features/env-t3/files/env.valibot.ts +1 -1
  6. package/assets/features/env-t3/files/env.zod.ts +1 -1
  7. package/assets/features/env-t3/files/schema.arktype.ts +1 -1
  8. package/assets/features/env-t3/files/schema.valibot.ts +1 -1
  9. package/assets/features/env-t3/files/schema.zod.ts +1 -1
  10. package/assets/features/event-bus/feature.json +100 -0
  11. package/assets/features/event-bus/files/announce.tsx.eta +10 -0
  12. package/assets/features/event-bus/files/contracts-events.ts +11 -0
  13. package/assets/features/event-bus/files/contracts-package.json.eta +10 -0
  14. package/assets/features/event-bus/files/contracts-readme.md +10 -0
  15. package/assets/features/event-bus/files/events-test.ts.eta +18 -0
  16. package/assets/features/event-bus/files/events.ts.eta +20 -0
  17. package/assets/features/event-bus/files/shell-events-test.ts.eta +22 -0
  18. package/assets/features/event-bus/files/shell-events.ts.eta +19 -0
  19. package/assets/features/http/feature.json +81 -0
  20. package/assets/features/http/files/client-test.ts +19 -0
  21. package/assets/features/http/files/client.ts +20 -0
  22. package/assets/features/route-factory/feature.json +60 -0
  23. package/assets/features/route-factory/files/contract.ts.eta +7 -0
  24. package/assets/features/route-factory/files/routes-contract.ts.eta +11 -0
  25. package/assets/features/route-factory/files/routes-test.ts.eta +9 -0
  26. package/assets/features/route-factory/files/routes.ts.eta +11 -0
  27. package/assets/features/url-factory/files/endpoints.ts +13 -2
  28. package/assets/templates/monorepo/feature.json +26 -0
  29. package/assets/templates/monorepo/files/root/AGENTS.md.eta +3 -3
  30. package/assets/templates/monorepo/files/root/README.md.eta +11 -11
  31. package/assets/templates/monorepo/files/shell/src/pages/Home.tsx.eta +3 -1
  32. package/dist/index.js +17 -0
  33. 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 exec mfeflow doctor
42
+ run: pnpm mfeflow doctor
43
43
  - name: mfeflow validate
44
- run: pnpm exec mfeflow validate
44
+ run: pnpm mfeflow validate
45
45
  - name: mfeflow check-compat
46
- run: pnpm exec mfeflow check-compat
46
+ run: pnpm mfeflow check-compat
47
47
  - name: mfeflow budget
48
- run: pnpm exec mfeflow budget
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
+ {
2
+ "name": "<%= it.scope %>/contracts",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ "./events": "./src/events/index.ts",
8
+ "./routes/*": "./src/routes/*.ts"
9
+ }
10
+ }
@@ -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,7 @@
1
+ /**
2
+ * <%= it.app.name %>'s public paths, as plain data: no imports, so any remote can read them.
3
+ * `:id` is a path param; `?q&page?` after the path names search params (q is required).
4
+ */
5
+ export const routes = {
6
+ home: '/',
7
+ } as const;
@@ -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 services: Record<string, string> = { api: '/api' };
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 = services[service];
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: `mfeflow doctor` (and `mfeflow validate`, `mfeflow check-compat`, `mfeflow budget` after a build) must exit 0.
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
- mfeflow doctor # singletons, compatibility, config, drift; --fix repairs the safe ones
39
- mfeflow validate # mfeflow.json, every mfe.json and every registry entry
40
- mfeflow check-compat # each remote's shared ranges against its shell
41
- mfeflow budget # after `pnpm build`: first-load JS per shell and remote
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. `mfeflow budget` checks each remote's entry
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) return <p>No remotes yet. Run `mfeflow add remote &lt;name&gt;`.</p>;
6
+ if (remotes.length === 0) {
7
+ return <p>No remotes yet. Run `pnpm mfeflow add remote &lt;name&gt;`.</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.1.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.1.0",
53
- "@mfeflow/core": "0.1.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.1.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",