bank20baht-cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +61 -2
  2. package/dist/application/create.js +13 -2
  3. package/dist/application/generate.js +95 -0
  4. package/dist/args.js +1 -0
  5. package/dist/cli.js +41 -4
  6. package/dist/domain/create-plan.js +21 -3
  7. package/dist/domain/deploy-plan.js +82 -0
  8. package/dist/domain/form-dsl.js +2 -0
  9. package/dist/domain/remote-plan.js +156 -0
  10. package/package.json +4 -3
  11. package/templates/angular/README.md.hbs +46 -0
  12. package/templates/angular/_dependency-cruiser.mjs +51 -0
  13. package/templates/angular/apps/server/package.json +30 -0
  14. package/templates/angular/apps/server/src/app.ts +14 -0
  15. package/templates/angular/apps/server/src/index.ts +8 -0
  16. package/templates/angular/apps/server/test/app.test.ts +12 -0
  17. package/templates/angular/apps/server/tsconfig.json +7 -0
  18. package/templates/angular/apps/server/tsconfig.types.json +12 -0
  19. package/templates/angular/apps/shell/_editorconfig +17 -0
  20. package/templates/angular/apps/shell/angular.json +115 -0
  21. package/templates/angular/apps/shell/federation.config.mjs +44 -0
  22. package/templates/angular/apps/shell/package.json +42 -0
  23. package/templates/angular/apps/shell/public/federation.manifest.json +1 -0
  24. package/templates/angular/apps/shell/src/app/app.config.ts +20 -0
  25. package/templates/angular/apps/shell/src/app/app.css +4 -0
  26. package/templates/angular/apps/shell/src/app/app.html.hbs +16 -0
  27. package/templates/angular/apps/shell/src/app/app.routes.ts.hbs +39 -0
  28. package/templates/angular/apps/shell/src/app/app.ts +57 -0
  29. package/templates/angular/apps/shell/src/app/core/federation/load-remote.ts +31 -0
  30. package/templates/angular/apps/shell/src/app/core/federation/manifest.spec.ts +46 -0
  31. package/templates/angular/apps/shell/src/app/core/federation/manifest.ts +37 -0
  32. package/templates/angular/apps/shell/src/app/core/federation/remote-loader.ts +20 -0
  33. package/templates/angular/apps/shell/src/app/core/federation/timeout.ts +17 -0
  34. package/templates/angular/apps/shell/src/app/modules/home/home.component.ts.hbs +46 -0
  35. package/templates/angular/apps/shell/src/app/modules/unavailable/unavailable.component.ts +39 -0
  36. package/templates/angular/apps/shell/src/bootstrap.ts +25 -0
  37. package/templates/angular/apps/shell/src/environments/environment.development.ts +10 -0
  38. package/templates/angular/apps/shell/src/environments/environment.ts +10 -0
  39. package/templates/angular/apps/shell/src/index.html.hbs +12 -0
  40. package/templates/angular/apps/shell/src/main.ts +43 -0
  41. package/templates/angular/apps/shell/src/styles.css +19 -0
  42. package/templates/angular/apps/shell/tsconfig.app.json +10 -0
  43. package/templates/angular/apps/shell/tsconfig.federation.json +4 -0
  44. package/templates/angular/apps/shell/tsconfig.json.hbs +39 -0
  45. package/templates/angular/apps/shell/tsconfig.spec.json +9 -0
  46. package/templates/angular/bahtui.json +6 -0
  47. package/templates/angular/eslint.config.ts.hbs +28 -0
  48. package/templates/angular/knip.ts +27 -0
  49. package/templates/angular/package.json.hbs +52 -0
  50. package/templates/angular/packages/kit/package.json.hbs +27 -0
  51. package/templates/angular/packages/kit/src/api.ts +21 -0
  52. package/templates/angular/packages/kit/src/errors.ts +28 -0
  53. package/templates/angular/packages/kit/src/index.ts +2 -0
  54. package/templates/angular/packages/kit/test/errors.test.ts +28 -0
  55. package/templates/angular/packages/kit/tsconfig.json +9 -0
  56. package/templates/create/README.md.hbs +1 -1
  57. package/templates/create/_gitignore +1 -0
  58. package/templates/create/apps/web/index.html.hbs +8 -0
  59. package/templates/create/apps/web/package.json.hbs +1 -0
  60. package/templates/create/apps/web/src/App.tsx.hbs +18 -7
  61. package/templates/create/apps/web/src/app.css +27 -0
  62. package/templates/create/apps/web/src/health.test.ts.hbs +18 -4
  63. package/templates/create/apps/web/src/health.ts.hbs +8 -0
  64. package/templates/create/apps/web/src/main.tsx.hbs +12 -1
  65. package/templates/create/package.json.hbs +15 -5
  66. package/templates/deploy/deploy/__project__.service.hbs +23 -0
  67. package/templates/deploy/deploy/env.example.hbs +4 -0
  68. package/templates/deploy/deploy/install.sh.hbs +58 -0
  69. package/templates/deploy/deploy/nginx.conf.hbs +67 -0
  70. package/templates/deploy/scripts/deploy.ts.hbs +33 -0
  71. package/templates/deploy/scripts/preview.ts.hbs +57 -0
  72. package/templates/deploy/scripts/release.ts.hbs +48 -0
  73. package/templates/new/_gitignore +3 -0
  74. package/templates/new/_prettierignore +1 -0
  75. package/templates/new/package.json.hbs +2 -2
  76. package/templates/remote/_editorconfig +17 -0
  77. package/templates/remote/angular.json.hbs +95 -0
  78. package/templates/remote/federation.config.mjs.hbs +48 -0
  79. package/templates/remote/package.json.hbs +37 -0
  80. package/templates/remote/src/app/__name__.routes.ts.hbs +12 -0
  81. package/templates/remote/src/app/modules/__name__/__name__.component.ts.hbs +37 -0
  82. package/templates/remote/src/app/services/__name__.service.ts.hbs +14 -0
  83. package/templates/remote/src/index.html.hbs +11 -0
  84. package/templates/remote/src/main.ts.hbs +3 -0
  85. package/templates/remote/tsconfig.app.json +10 -0
  86. package/templates/remote/tsconfig.federation.json +4 -0
  87. package/templates/remote/tsconfig.json.hbs +36 -0
@@ -0,0 +1,46 @@
1
+ # {{name}}
2
+
3
+ Angular microfrontends on Native Federation, scaffolded by
4
+ [`bank20baht-cli`](https://github.com/bank20baht/bahtui) (`bahtui create --angular`).
5
+
6
+ ```
7
+ apps/shell the host (:4200): navigator from the federation manifest, a fallback page per remote
8
+ apps/hello-web a remote (:4201): one page calling one API route, exposed as ./Routes
9
+ apps/server Elysia API (:3000) under /api; exports `type App` as built declarations
10
+ packages/kit what the web apps share (typed ngx-eden client, error text), as @{{name}}/kit
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ ```sh
16
+ bun install
17
+ bun run dev # every apps/* dev script in parallel
18
+ bun run check # lint, format, typecheck, madge, boundaries, knip, dup, build, test
19
+ ```
20
+
21
+ ## Add a remote
22
+
23
+ ```sh
24
+ bahtui g remote orders # apps/orders-web on the next port, mounted at /orders
25
+ bun install
26
+ ```
27
+
28
+ It also adds the remote to `eslint.config.ts`, `knip.ts`, `.dependency-cruiser.mjs`, the dev
29
+ manifest (`apps/shell/src/environments/environment.development.ts`) and the deployed one
30
+ (`apps/shell/public/federation.manifest.json`). Each of those holds a `// bahtui:remotes` line (the
31
+ JSON manifest is parsed instead); keep it, the generator inserts above it.
32
+
33
+ ## Things that are this way on purpose
34
+
35
+ - `features.ignoreUnusedDeps` is off in every `federation.config.mjs`: its scan refuses sources
36
+ outside the app folder, and the kit lives in `packages/kit`. So every `package.json` dependency is
37
+ shared, and each app lists bank20baht-ui's optional peers (`marked`, `dompurify`, `flatpickr`);
38
+ `/^marked\/bin/` stays in `skip`.
39
+ - `fileReplacements` sit on `esbuild:development` in `angular.json`, not on `build`.
40
+ - The web apps see the server only as types, from `apps/server/dist/types` (`build:types` runs
41
+ before every web build and typecheck). Keep the server's imports extensionless: Angular's
42
+ compiler rejects `./x.ts` specifiers in sources it follows.
43
+ - A remote that is down when the host boots stays unavailable until a reload, so every remote's
44
+ URL prefix falls back to a page that says so, and the rest keeps working.
45
+ - In production the host reads `/federation.manifest.json` at runtime: serve each remote's build
46
+ under `/remotes/<app>/` and `/api` from the server on the same origin.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Architecture boundaries, checked with `bun run deps`. `generic` gives no-circular and
3
+ * production-not-tests. The rules below keep the microfrontends apart: the host and the remotes
4
+ * meet only at runtime through the federation manifest, and share code only through packages/kit.
5
+ * Every rule is `error`: a violation is a decision, not a warning.
6
+ */
7
+ import { makeConfig } from 'bank20baht-strict/depcruise'
8
+
9
+ const HOST = 'shell'
10
+
11
+ /** `bahtui g remote` adds a line. */
12
+ const REMOTES = [
13
+ // bahtui:remotes
14
+ ]
15
+
16
+ const WEB_APPS = [HOST, ...REMOTES]
17
+
18
+ const base = makeConfig({ generic: true })
19
+
20
+ const apart = WEB_APPS.map((app) => ({
21
+ name: `${app}-imports-no-other-app`,
22
+ severity: 'error',
23
+ comment:
24
+ 'Web apps never import each other: a static import would bundle one app into another build.',
25
+ from: { path: `^apps/${app}/src/` },
26
+ to: { path: `^apps/(?!${app}/)(${WEB_APPS.join('|')})/` },
27
+ }))
28
+
29
+ const layers = WEB_APPS.map((app) => ({
30
+ name: `${app}-lower-layers-know-no-page`,
31
+ severity: 'error',
32
+ comment: 'core/, services/ and shared/ know no page; pages are reached through the router.',
33
+ from: { path: `^apps/${app}/src/app/(core|services|shared)/` },
34
+ to: { path: `^apps/${app}/src/app/modules/` },
35
+ }))
36
+
37
+ export default {
38
+ ...base,
39
+ forbidden: [
40
+ ...base.forbidden,
41
+ ...apart,
42
+ ...layers,
43
+ {
44
+ name: 'packages-know-no-app',
45
+ severity: 'error',
46
+ comment: 'packages/ are below the apps; the server reaches them only as built types.',
47
+ from: { path: '^packages/' },
48
+ to: { path: '^apps/', pathNot: '^apps/server/dist/types/' },
49
+ },
50
+ ],
51
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "server",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/types/index.d.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "types": "dist/types/index.d.ts",
13
+ "scripts": {
14
+ "dev": "bun run --watch src/index.ts",
15
+ "start": "bun run src/index.ts",
16
+ "build": "bun build src/index.ts --target bun --outdir dist",
17
+ "build:types": "tsc -p tsconfig.types.json",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "bun test",
20
+ "test:coverage": "bun test --coverage"
21
+ },
22
+ "dependencies": {
23
+ "@elysiajs/cors": "^1.4.3",
24
+ "elysia": "^1.4.31"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "^1.3.0",
28
+ "typescript": "^5.9.3"
29
+ }
30
+ }
@@ -0,0 +1,14 @@
1
+ import { cors } from '@elysiajs/cors'
2
+ import { Elysia } from 'elysia'
3
+
4
+ /** In dev the host and the remotes run on their own localhost ports; deployed, all share one origin. */
5
+ const DEV_ORIGIN = /^http:\/\/localhost:\d+$/
6
+
7
+ /**
8
+ * The API under `/api`. Keep imports extensionless (`./x`, never `./x.ts`): the web apps compile
9
+ * this file's declarations, and Angular's compiler rejects `.ts` specifiers.
10
+ */
11
+ export const buildApp = () =>
12
+ new Elysia({ prefix: '/api' })
13
+ .use(cors({ origin: DEV_ORIGIN, credentials: true }))
14
+ .get('/hello', () => ({ message: 'Hello from the server' }))
@@ -0,0 +1,8 @@
1
+ import { buildApp } from './app'
2
+
3
+ const app = buildApp().listen(Number(process.env['PORT'] ?? 3000))
4
+
5
+ console.log(`server on http://localhost:${String(app.server?.port)}`)
6
+
7
+ /** What the web apps type their client with (through `dist/types`, `bun run build:types`). */
8
+ export type App = typeof app
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { buildApp } from '../src/app'
4
+
5
+ describe('api', () => {
6
+ test('says hello', async () => {
7
+ const response = await buildApp().handle(new Request('http://localhost/api/hello'))
8
+
9
+ expect(response.status).toBe(200)
10
+ expect(await response.json()).toEqual({ message: 'Hello from the server' })
11
+ })
12
+ })
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["bun"]
5
+ },
6
+ "include": ["src", "test"]
7
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "declaration": true,
6
+ "emitDeclarationOnly": true,
7
+ "declarationMap": false,
8
+ "outDir": "dist/types",
9
+ "rootDir": "src"
10
+ },
11
+ "include": ["src"]
12
+ }
@@ -0,0 +1,17 @@
1
+ # Editor configuration, see https://editorconfig.org
2
+ root = true
3
+
4
+ [*]
5
+ charset = utf-8
6
+ indent_style = space
7
+ indent_size = 2
8
+ insert_final_newline = true
9
+ trim_trailing_whitespace = true
10
+
11
+ [*.ts]
12
+ quote_type = single
13
+ ij_typescript_use_double_quotes = false
14
+
15
+ [*.md]
16
+ max_line_length = off
17
+ trim_trailing_whitespace = false
@@ -0,0 +1,115 @@
1
+ {
2
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3
+ "version": 1,
4
+ "cli": {
5
+ "packageManager": "bun"
6
+ },
7
+ "newProjectRoot": "projects",
8
+ "projects": {
9
+ "shell": {
10
+ "projectType": "application",
11
+ "schematics": {},
12
+ "root": "",
13
+ "sourceRoot": "src",
14
+ "prefix": "app",
15
+ "architect": {
16
+ "build": {
17
+ "builder": "@angular-architects/native-federation:build",
18
+ "options": {
19
+ "cacheExternalArtifacts": true,
20
+ "tsConfig": "tsconfig.federation.json"
21
+ },
22
+ "configurations": {
23
+ "production": {
24
+ "target": "shell:esbuild:production"
25
+ },
26
+ "development": {
27
+ "target": "shell:esbuild:development",
28
+ "dev": true
29
+ }
30
+ },
31
+ "defaultConfiguration": "production"
32
+ },
33
+ "serve": {
34
+ "builder": "@angular-architects/native-federation:build",
35
+ "options": {
36
+ "target": "shell:serve-original:development",
37
+ "tsConfig": "tsconfig.federation.json",
38
+ "rebuildDelay": 500,
39
+ "cacheExternalArtifacts": true,
40
+ "dev": true,
41
+ "devServer": true,
42
+ "port": 0
43
+ }
44
+ },
45
+ "test": {
46
+ "builder": "@angular/build:unit-test",
47
+ "options": {
48
+ "buildTarget": "shell:esbuild:development",
49
+ "coverageReporters": ["text", "lcov"],
50
+ "coverageExclude": ["src/main.ts", "src/bootstrap.ts", "src/environments/**"]
51
+ }
52
+ },
53
+ "esbuild": {
54
+ "builder": "@angular/build:application",
55
+ "options": {
56
+ "browser": "src/main.ts",
57
+ "tsConfig": "tsconfig.app.json",
58
+ "assets": [
59
+ {
60
+ "glob": "**/*",
61
+ "input": "public"
62
+ }
63
+ ],
64
+ "styles": ["node_modules/bank20baht-ui/dist/tokens.css", "src/styles.css"],
65
+ "polyfills": ["es-module-shims"]
66
+ },
67
+ "configurations": {
68
+ "production": {
69
+ "budgets": [
70
+ {
71
+ "type": "initial",
72
+ "maximumWarning": "500kB",
73
+ "maximumError": "1MB"
74
+ },
75
+ {
76
+ "type": "anyComponentStyle",
77
+ "maximumWarning": "4kB",
78
+ "maximumError": "8kB"
79
+ }
80
+ ],
81
+ "outputHashing": "all"
82
+ },
83
+ "development": {
84
+ "optimization": false,
85
+ "extractLicenses": false,
86
+ "sourceMap": true,
87
+ "fileReplacements": [
88
+ {
89
+ "replace": "src/environments/environment.ts",
90
+ "with": "src/environments/environment.development.ts"
91
+ }
92
+ ]
93
+ }
94
+ },
95
+ "defaultConfiguration": "production"
96
+ },
97
+ "serve-original": {
98
+ "builder": "@angular/build:dev-server",
99
+ "configurations": {
100
+ "production": {
101
+ "buildTarget": "shell:esbuild:production"
102
+ },
103
+ "development": {
104
+ "buildTarget": "shell:esbuild:development"
105
+ }
106
+ },
107
+ "defaultConfiguration": "development",
108
+ "options": {
109
+ "port": 4210
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,44 @@
1
+ import { withNativeFederation, fromPackageJson } from '@angular-architects/native-federation/config'
2
+
3
+ export default withNativeFederation({
4
+ name: 'shell',
5
+
6
+ shared: fromPackageJson({
7
+ singleton: true,
8
+ strictVersion: true,
9
+ requiredVersion: 'auto',
10
+ build: 'package',
11
+ })
12
+ // includeSecondaries is an opt-out of ignoreUnusedDeps, so all of
13
+ // @angular/core is shared to prevent mismatches.
14
+ .patch(['@angular/core'], { includeSecondaries: { keepAll: true } })
15
+ // Same for bank20baht-ui: the remotes import bank20baht-ui/overlay, the
16
+ // host only bank20baht-ui. Unshared, each side bundles its own copy of the
17
+ // elements and the second customElements.define('baht-button') throws.
18
+ .patch(['bank20baht-ui'], { includeSecondaries: { keepAll: true } }),
19
+
20
+ skip: [
21
+ 'rxjs/ajax',
22
+ 'rxjs/fetch',
23
+ 'rxjs/testing',
24
+ 'rxjs/webSocket',
25
+ // Server / React entry points of bank20baht-ui (pulled in by keepAll above).
26
+ 'bank20baht-ui/ssr',
27
+ 'bank20baht-ui/hydrate',
28
+ 'bank20baht-ui/react',
29
+ // marked's command-line entry imports Node built-ins.
30
+ /^marked\/bin/,
31
+ ],
32
+
33
+ // The kit (a tsconfig path into packages/kit) is compiled into each app, not shared.
34
+ sharedMappings: [],
35
+
36
+ features: {
37
+ // Off: the unused-dependency scan (sheriff) refuses sources outside the app folder, and
38
+ // the kit lives in packages/kit. Every package.json dependency is shared instead, so each
39
+ // app lists bank20baht-ui's optional peers (marked, dompurify, flatpickr) itself.
40
+ ignoreUnusedDeps: false,
41
+ // Groups chunks in remoteEntry.json for a smaller metadata file.
42
+ denseChunking: true,
43
+ },
44
+ })
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "shell",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "ng": "ng",
7
+ "dev": "ng serve",
8
+ "build": "ng build",
9
+ "test": "ng test",
10
+ "test:coverage": "ng test --watch=false --coverage",
11
+ "typecheck": "tsc -b --noEmit",
12
+ "pretypecheck": "bun run --filter server build:types",
13
+ "prebuild": "bun run --filter server build:types"
14
+ },
15
+ "dependencies": {
16
+ "@angular-architects/native-federation": "^22.2.1",
17
+ "@angular/common": "^22.2.0",
18
+ "@angular/compiler": "^22.2.0",
19
+ "@angular/core": "^22.2.0",
20
+ "@angular/platform-browser": "^22.2.0",
21
+ "@angular/router": "^22.2.0",
22
+ "bank20baht-ui": "0.19.0",
23
+ "bank20baht-ui-angular": "0.2.0",
24
+ "dompurify": "3.4.17",
25
+ "es-module-shims": "^2.8.0",
26
+ "flatpickr": "4.6.13",
27
+ "marked": "18.1.3",
28
+ "ngx-eden": "^0.2.0",
29
+ "rxjs": "~7.8.0",
30
+ "tslib": "^2.3.0"
31
+ },
32
+ "devDependencies": {
33
+ "@angular/build": "^22.2.0",
34
+ "@angular/cli": "^22.2.0",
35
+ "@angular/compiler-cli": "^22.2.0",
36
+ "@softarc/native-federation-orchestrator": "^4.6.0",
37
+ "@vitest/coverage-v8": "^4.0.8",
38
+ "jsdom": "^30.0.0",
39
+ "typescript": "~6.0.2",
40
+ "vitest": "^4.0.8"
41
+ }
42
+ }
@@ -0,0 +1,20 @@
1
+ import type { ApplicationConfig } from '@angular/core'
2
+ import { provideBrowserGlobalErrorListeners } from '@angular/core'
3
+ import { provideHttpClient } from '@angular/common/http'
4
+ import { provideRouter, withComponentInputBinding } from '@angular/router'
5
+ import { routesFor } from './app.routes'
6
+ import type { Manifest } from './core/federation/manifest'
7
+
8
+ /**
9
+ * The host owns the root: router and `HttpClient`. The remotes' services are
10
+ * `providedIn: 'root'`, so they use this `HttpClient` too.
11
+ */
12
+ export function appConfig(manifest: Manifest): ApplicationConfig {
13
+ return {
14
+ providers: [
15
+ provideBrowserGlobalErrorListeners(),
16
+ provideRouter(routesFor(manifest), withComponentInputBinding()),
17
+ provideHttpClient(),
18
+ ],
19
+ }
20
+ }
@@ -0,0 +1,4 @@
1
+ .shell-main {
2
+ display: block;
3
+ min-width: 0;
4
+ }
@@ -0,0 +1,16 @@
1
+ <baht-app-shell
2
+ brand="{{name}}"
3
+ brand-href="/"
4
+ label="{{name}}"
5
+ [items]="items"
6
+ [value]="current()"
7
+ (baht-nav)="navigate($event)"
8
+ >
9
+ <div data-slot="actions">
10
+ <baht-theme-toggle default="system"></baht-theme-toggle>
11
+ </div>
12
+ <main class="shell-main">
13
+ <router-outlet />
14
+ </main>
15
+ </baht-app-shell>
16
+ <baht-toast></baht-toast>
@@ -0,0 +1,39 @@
1
+ import type { Routes } from '@angular/router'
2
+ import { loadRemoteRoutes } from './core/federation/load-remote'
3
+ import type { Manifest } from './core/federation/manifest'
4
+
5
+ const unavailable = () =>
6
+ import('./modules/unavailable/unavailable.component').then((m) => m.UnavailableComponent)
7
+
8
+ /**
9
+ * The home page, one URL prefix per remote in the manifest, and a not-found page. A remote that
10
+ * cannot be loaded shows a fallback page under its prefix; the rest of the app keeps working.
11
+ */
12
+ export const routesFor = (manifest: Manifest): Routes => [
13
+ {
14
+ path: '',
15
+ pathMatch: 'full',
16
+ title: '{{name}}',
17
+ loadComponent: () => import('./modules/home/home.component').then((m) => m.HomeComponent),
18
+ },
19
+ ...Object.entries(manifest).map(([name, remote]) => ({
20
+ path: remote.base,
21
+ title: `${remote.title} · {{name}}`,
22
+ loadChildren: loadRemoteRoutes(name, () => [
23
+ {
24
+ path: '**',
25
+ data: {
26
+ heading: `${remote.title} is unavailable`,
27
+ message: 'This part of the app could not be loaded. The rest still works.',
28
+ },
29
+ loadComponent: unavailable,
30
+ },
31
+ ]),
32
+ })),
33
+ {
34
+ path: '**',
35
+ title: 'Not found · {{name}}',
36
+ data: { heading: 'Page not found', message: 'Nothing lives at this address.' },
37
+ loadComponent: unavailable,
38
+ },
39
+ ]
@@ -0,0 +1,57 @@
1
+ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'
2
+ import { toSignal } from '@angular/core/rxjs-interop'
3
+ import { NavigationEnd, Router, RouterOutlet } from '@angular/router'
4
+ import type { ShellNavItem } from 'bank20baht-ui'
5
+ import { BAHT_UI } from 'bank20baht-ui-angular'
6
+ import { filter, map } from 'rxjs'
7
+ import { MANIFEST } from './core/federation/remote-loader'
8
+
9
+ /** The frame every page sits in: brand, a navigator built from the federation manifest, the page. */
10
+ @Component({
11
+ changeDetection: ChangeDetectionStrategy.OnPush,
12
+ imports: [RouterOutlet, BAHT_UI],
13
+ selector: 'app-root',
14
+ templateUrl: './app.html',
15
+ styleUrl: './app.css',
16
+ })
17
+ export class App {
18
+ private readonly router = inject(Router)
19
+ private readonly manifest = inject(MANIFEST)
20
+
21
+ private readonly url = toSignal(
22
+ this.router.events.pipe(
23
+ filter((event) => event instanceof NavigationEnd),
24
+ map((event) => event.urlAfterRedirects),
25
+ ),
26
+ { initialValue: this.router.url },
27
+ )
28
+
29
+ protected readonly items: ShellNavItem[] = [
30
+ { key: '', label: 'Home', href: '/' },
31
+ ...Object.values(this.manifest).flatMap((remote) =>
32
+ remote.nav.map((nav) => ({
33
+ key: nav.path,
34
+ label: nav.label,
35
+ href: `/${nav.path}`,
36
+ group: remote.title,
37
+ })),
38
+ ),
39
+ ]
40
+
41
+ /** The navigator entry whose path the URL sits under; the longest one wins. */
42
+ protected readonly current = computed(() => {
43
+ const path = (this.url().split(/[?#]/)[0] ?? '').replace(/^\//, '')
44
+ const keys = this.items
45
+ .map((item) => item.key)
46
+ .filter((key) => key !== '' && (path === key || path.startsWith(`${key}/`)))
47
+ return keys.sort((a, b) => b.length - a.length)[0] ?? ''
48
+ })
49
+
50
+ /** Route in place instead of letting the link reload the page. */
51
+ protected navigate(event: Event): void {
52
+ const { href } = (event as CustomEvent<{ href: string | undefined }>).detail
53
+ if (href === undefined) return
54
+ event.preventDefault()
55
+ void this.router.navigateByUrl(href)
56
+ }
57
+ }
@@ -0,0 +1,31 @@
1
+ import { inject } from '@angular/core'
2
+ import type { Routes } from '@angular/router'
3
+ import { REMOTE_LOADER } from './remote-loader'
4
+ import { withTimeout } from './timeout'
5
+
6
+ /** What every remote exposes as `./Routes`. */
7
+ type RoutesModule = { routes: Routes }
8
+
9
+ /** How long a first visit waits for a remote before showing the fallback page. */
10
+ const LOAD_TIMEOUT_MS = 10_000
11
+
12
+ /**
13
+ * `loadChildren` for a remote's URL prefix: its `./Routes`, or `fallback()` when federation is
14
+ * off or the remote cannot be loaded. One remote being down never takes the others with it.
15
+ */
16
+ export const loadRemoteRoutes =
17
+ (name: string, fallback: () => Routes): (() => Promise<Routes>) =>
18
+ async () => {
19
+ const loadRemoteModule = inject(REMOTE_LOADER)
20
+ if (loadRemoteModule === null) return fallback()
21
+ try {
22
+ const module = await withTimeout(
23
+ loadRemoteModule<RoutesModule>(name, './Routes'),
24
+ LOAD_TIMEOUT_MS,
25
+ )
26
+ return module.routes
27
+ } catch (err) {
28
+ console.error(`${name} ./Routes failed to load`, err)
29
+ return fallback()
30
+ }
31
+ }
@@ -0,0 +1,46 @@
1
+ import { parseManifest } from './manifest'
2
+ import { withTimeout } from './timeout'
3
+
4
+ const entry = {
5
+ entry: 'http://localhost:4201/remoteEntry.json',
6
+ title: 'Hello',
7
+ base: 'hello',
8
+ nav: [{ label: 'Hello', path: 'hello' }],
9
+ }
10
+
11
+ describe('parseManifest', () => {
12
+ it('accepts well-formed entries', () => {
13
+ expect(parseManifest({ 'hello-web': entry })).toEqual({ 'hello-web': entry })
14
+ expect(parseManifest({})).toEqual({})
15
+ })
16
+
17
+ it('rejects the whole file when one entry is wrong', () => {
18
+ expect(parseManifest(null)).toBeNull()
19
+ expect(parseManifest([entry])).toBeNull()
20
+ expect(parseManifest({ ok: entry, bad: { ...entry, entry: 1 } })).toBeNull()
21
+ expect(parseManifest({ bad: { ...entry, base: '../etc' } })).toBeNull()
22
+ expect(parseManifest({ bad: { ...entry, nav: [{ label: 'x' }] } })).toBeNull()
23
+ })
24
+ })
25
+
26
+ describe('withTimeout', () => {
27
+ it('passes a settled promise through', async () => {
28
+ await expect(withTimeout(Promise.resolve('ok'), 50)).resolves.toBe('ok')
29
+ await expect(withTimeout(Promise.reject(new Error('no')), 50)).rejects.toThrow('no')
30
+ await expect(
31
+ withTimeout(
32
+ new Promise((_resolve, reject) => {
33
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- the case under test: a non-Error rejection is wrapped
34
+ reject('text')
35
+ }),
36
+ 50,
37
+ ),
38
+ ).rejects.toThrow('text')
39
+ })
40
+
41
+ it('rejects when the promise is too slow', async () => {
42
+ await expect(withTimeout(new Promise(() => undefined), 5)).rejects.toThrow(
43
+ 'timed out after 5 ms',
44
+ )
45
+ })
46
+ })