bank20baht-cli 0.6.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 (79) hide show
  1. package/README.md +59 -0
  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/remote-plan.js +156 -0
  9. package/package.json +4 -3
  10. package/templates/angular/README.md.hbs +46 -0
  11. package/templates/angular/_dependency-cruiser.mjs +51 -0
  12. package/templates/angular/apps/server/package.json +30 -0
  13. package/templates/angular/apps/server/src/app.ts +14 -0
  14. package/templates/angular/apps/server/src/index.ts +8 -0
  15. package/templates/angular/apps/server/test/app.test.ts +12 -0
  16. package/templates/angular/apps/server/tsconfig.json +7 -0
  17. package/templates/angular/apps/server/tsconfig.types.json +12 -0
  18. package/templates/angular/apps/shell/_editorconfig +17 -0
  19. package/templates/angular/apps/shell/angular.json +115 -0
  20. package/templates/angular/apps/shell/federation.config.mjs +44 -0
  21. package/templates/angular/apps/shell/package.json +42 -0
  22. package/templates/angular/apps/shell/public/federation.manifest.json +1 -0
  23. package/templates/angular/apps/shell/src/app/app.config.ts +20 -0
  24. package/templates/angular/apps/shell/src/app/app.css +4 -0
  25. package/templates/angular/apps/shell/src/app/app.html.hbs +16 -0
  26. package/templates/angular/apps/shell/src/app/app.routes.ts.hbs +39 -0
  27. package/templates/angular/apps/shell/src/app/app.ts +57 -0
  28. package/templates/angular/apps/shell/src/app/core/federation/load-remote.ts +31 -0
  29. package/templates/angular/apps/shell/src/app/core/federation/manifest.spec.ts +46 -0
  30. package/templates/angular/apps/shell/src/app/core/federation/manifest.ts +37 -0
  31. package/templates/angular/apps/shell/src/app/core/federation/remote-loader.ts +20 -0
  32. package/templates/angular/apps/shell/src/app/core/federation/timeout.ts +17 -0
  33. package/templates/angular/apps/shell/src/app/modules/home/home.component.ts.hbs +46 -0
  34. package/templates/angular/apps/shell/src/app/modules/unavailable/unavailable.component.ts +39 -0
  35. package/templates/angular/apps/shell/src/bootstrap.ts +25 -0
  36. package/templates/angular/apps/shell/src/environments/environment.development.ts +10 -0
  37. package/templates/angular/apps/shell/src/environments/environment.ts +10 -0
  38. package/templates/angular/apps/shell/src/index.html.hbs +12 -0
  39. package/templates/angular/apps/shell/src/main.ts +43 -0
  40. package/templates/angular/apps/shell/src/styles.css +19 -0
  41. package/templates/angular/apps/shell/tsconfig.app.json +10 -0
  42. package/templates/angular/apps/shell/tsconfig.federation.json +4 -0
  43. package/templates/angular/apps/shell/tsconfig.json.hbs +39 -0
  44. package/templates/angular/apps/shell/tsconfig.spec.json +9 -0
  45. package/templates/angular/bahtui.json +6 -0
  46. package/templates/angular/eslint.config.ts.hbs +28 -0
  47. package/templates/angular/knip.ts +27 -0
  48. package/templates/angular/package.json.hbs +52 -0
  49. package/templates/angular/packages/kit/package.json.hbs +27 -0
  50. package/templates/angular/packages/kit/src/api.ts +21 -0
  51. package/templates/angular/packages/kit/src/errors.ts +28 -0
  52. package/templates/angular/packages/kit/src/index.ts +2 -0
  53. package/templates/angular/packages/kit/test/errors.test.ts +28 -0
  54. package/templates/angular/packages/kit/tsconfig.json +9 -0
  55. package/templates/create/_gitignore +1 -0
  56. package/templates/create/apps/web/package.json.hbs +1 -1
  57. package/templates/create/package.json.hbs +1 -1
  58. package/templates/deploy/deploy/__project__.service.hbs +23 -0
  59. package/templates/deploy/deploy/env.example.hbs +4 -0
  60. package/templates/deploy/deploy/install.sh.hbs +58 -0
  61. package/templates/deploy/deploy/nginx.conf.hbs +67 -0
  62. package/templates/deploy/scripts/deploy.ts.hbs +33 -0
  63. package/templates/deploy/scripts/preview.ts.hbs +57 -0
  64. package/templates/deploy/scripts/release.ts.hbs +48 -0
  65. package/templates/new/_gitignore +3 -0
  66. package/templates/new/_prettierignore +1 -0
  67. package/templates/new/package.json.hbs +2 -2
  68. package/templates/remote/_editorconfig +17 -0
  69. package/templates/remote/angular.json.hbs +95 -0
  70. package/templates/remote/federation.config.mjs.hbs +48 -0
  71. package/templates/remote/package.json.hbs +37 -0
  72. package/templates/remote/src/app/__name__.routes.ts.hbs +12 -0
  73. package/templates/remote/src/app/modules/__name__/__name__.component.ts.hbs +37 -0
  74. package/templates/remote/src/app/services/__name__.service.ts.hbs +14 -0
  75. package/templates/remote/src/index.html.hbs +11 -0
  76. package/templates/remote/src/main.ts.hbs +3 -0
  77. package/templates/remote/tsconfig.app.json +10 -0
  78. package/templates/remote/tsconfig.federation.json +4 -0
  79. package/templates/remote/tsconfig.json.hbs +36 -0
@@ -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
+ })
@@ -0,0 +1,37 @@
1
+ /** One page a remote adds to the navigator. */
2
+ type RemoteNav = { label: string; path: string }
3
+
4
+ /** A microfrontend: where its code is, the URL prefix it owns and what it puts in the navigator. */
5
+ type RemoteEntry = {
6
+ /** URL of its `remoteEntry.json`. */
7
+ entry: string
8
+ /** Group heading in the navigator. */
9
+ title: string
10
+ /** First URL segment; everything under it is routed by the remote's `./Routes`. */
11
+ base: string
12
+ nav: RemoteNav[]
13
+ }
14
+
15
+ export type Manifest = Record<string, RemoteEntry>
16
+
17
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
18
+ typeof value === 'object' && value !== null && !Array.isArray(value)
19
+
20
+ const isNav = (value: unknown): value is RemoteNav =>
21
+ isRecord(value) && typeof value['label'] === 'string' && typeof value['path'] === 'string'
22
+
23
+ const isEntry = (value: unknown): value is RemoteEntry =>
24
+ isRecord(value) &&
25
+ typeof value['entry'] === 'string' &&
26
+ typeof value['title'] === 'string' &&
27
+ typeof value['base'] === 'string' &&
28
+ /^[a-z0-9-]+$/.test(value['base']) &&
29
+ Array.isArray(value['nav']) &&
30
+ value['nav'].every(isNav)
31
+
32
+ /** The manifest when every entry has the right shape, else null: a broken file must not half-load. */
33
+ export const parseManifest = (json: unknown): Manifest | null => {
34
+ if (!isRecord(json)) return null
35
+ const entries = Object.entries(json)
36
+ return entries.every(([, value]) => isEntry(value)) ? (json as Manifest) : null
37
+ }
@@ -0,0 +1,20 @@
1
+ import { InjectionToken } from '@angular/core'
2
+ import type { NativeFederationResult } from '@angular-architects/native-federation'
3
+ import type { Manifest } from './manifest'
4
+
5
+ export type RemoteLoader = NativeFederationResult['loadRemoteModule']
6
+
7
+ /**
8
+ * The `loadRemoteModule` that `initFederation` resolved with in `main.ts`. Null when federation
9
+ * failed to start (or in unit tests); every remote route then shows the fallback page.
10
+ */
11
+ export const REMOTE_LOADER = new InjectionToken<RemoteLoader | null>('REMOTE_LOADER', {
12
+ providedIn: 'root',
13
+ factory: () => null,
14
+ })
15
+
16
+ /** The remotes this host knows, as read at boot. */
17
+ export const MANIFEST = new InjectionToken<Manifest>('MANIFEST', {
18
+ providedIn: 'root',
19
+ factory: () => ({}),
20
+ })
@@ -0,0 +1,17 @@
1
+ /** `promise`, or a rejection once `ms` pass without it settling. */
2
+ export const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> =>
3
+ new Promise<T>((resolve, reject) => {
4
+ const timer = setTimeout(() => {
5
+ reject(new Error(`timed out after ${String(ms)} ms`))
6
+ }, ms)
7
+ promise.then(
8
+ (value) => {
9
+ clearTimeout(timer)
10
+ resolve(value)
11
+ },
12
+ (error: unknown) => {
13
+ clearTimeout(timer)
14
+ reject(error instanceof Error ? error : new Error(String(error)))
15
+ },
16
+ )
17
+ })