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.
- package/README.md +59 -0
- package/dist/application/create.js +13 -2
- package/dist/application/generate.js +95 -0
- package/dist/args.js +1 -0
- package/dist/cli.js +41 -4
- package/dist/domain/create-plan.js +21 -3
- package/dist/domain/deploy-plan.js +82 -0
- package/dist/domain/remote-plan.js +156 -0
- package/package.json +4 -3
- package/templates/angular/README.md.hbs +46 -0
- package/templates/angular/_dependency-cruiser.mjs +51 -0
- package/templates/angular/apps/server/package.json +30 -0
- package/templates/angular/apps/server/src/app.ts +14 -0
- package/templates/angular/apps/server/src/index.ts +8 -0
- package/templates/angular/apps/server/test/app.test.ts +12 -0
- package/templates/angular/apps/server/tsconfig.json +7 -0
- package/templates/angular/apps/server/tsconfig.types.json +12 -0
- package/templates/angular/apps/shell/_editorconfig +17 -0
- package/templates/angular/apps/shell/angular.json +115 -0
- package/templates/angular/apps/shell/federation.config.mjs +44 -0
- package/templates/angular/apps/shell/package.json +42 -0
- package/templates/angular/apps/shell/public/federation.manifest.json +1 -0
- package/templates/angular/apps/shell/src/app/app.config.ts +20 -0
- package/templates/angular/apps/shell/src/app/app.css +4 -0
- package/templates/angular/apps/shell/src/app/app.html.hbs +16 -0
- package/templates/angular/apps/shell/src/app/app.routes.ts.hbs +39 -0
- package/templates/angular/apps/shell/src/app/app.ts +57 -0
- package/templates/angular/apps/shell/src/app/core/federation/load-remote.ts +31 -0
- package/templates/angular/apps/shell/src/app/core/federation/manifest.spec.ts +46 -0
- package/templates/angular/apps/shell/src/app/core/federation/manifest.ts +37 -0
- package/templates/angular/apps/shell/src/app/core/federation/remote-loader.ts +20 -0
- package/templates/angular/apps/shell/src/app/core/federation/timeout.ts +17 -0
- package/templates/angular/apps/shell/src/app/modules/home/home.component.ts.hbs +46 -0
- package/templates/angular/apps/shell/src/app/modules/unavailable/unavailable.component.ts +39 -0
- package/templates/angular/apps/shell/src/bootstrap.ts +25 -0
- package/templates/angular/apps/shell/src/environments/environment.development.ts +10 -0
- package/templates/angular/apps/shell/src/environments/environment.ts +10 -0
- package/templates/angular/apps/shell/src/index.html.hbs +12 -0
- package/templates/angular/apps/shell/src/main.ts +43 -0
- package/templates/angular/apps/shell/src/styles.css +19 -0
- package/templates/angular/apps/shell/tsconfig.app.json +10 -0
- package/templates/angular/apps/shell/tsconfig.federation.json +4 -0
- package/templates/angular/apps/shell/tsconfig.json.hbs +39 -0
- package/templates/angular/apps/shell/tsconfig.spec.json +9 -0
- package/templates/angular/bahtui.json +6 -0
- package/templates/angular/eslint.config.ts.hbs +28 -0
- package/templates/angular/knip.ts +27 -0
- package/templates/angular/package.json.hbs +52 -0
- package/templates/angular/packages/kit/package.json.hbs +27 -0
- package/templates/angular/packages/kit/src/api.ts +21 -0
- package/templates/angular/packages/kit/src/errors.ts +28 -0
- package/templates/angular/packages/kit/src/index.ts +2 -0
- package/templates/angular/packages/kit/test/errors.test.ts +28 -0
- package/templates/angular/packages/kit/tsconfig.json +9 -0
- package/templates/create/_gitignore +1 -0
- package/templates/create/apps/web/package.json.hbs +1 -1
- package/templates/create/package.json.hbs +1 -1
- package/templates/deploy/deploy/__project__.service.hbs +23 -0
- package/templates/deploy/deploy/env.example.hbs +4 -0
- package/templates/deploy/deploy/install.sh.hbs +58 -0
- package/templates/deploy/deploy/nginx.conf.hbs +67 -0
- package/templates/deploy/scripts/deploy.ts.hbs +33 -0
- package/templates/deploy/scripts/preview.ts.hbs +57 -0
- package/templates/deploy/scripts/release.ts.hbs +48 -0
- package/templates/new/_gitignore +3 -0
- package/templates/new/_prettierignore +1 -0
- package/templates/new/package.json.hbs +2 -2
- package/templates/remote/_editorconfig +17 -0
- package/templates/remote/angular.json.hbs +95 -0
- package/templates/remote/federation.config.mjs.hbs +48 -0
- package/templates/remote/package.json.hbs +37 -0
- package/templates/remote/src/app/__name__.routes.ts.hbs +12 -0
- package/templates/remote/src/app/modules/__name__/__name__.component.ts.hbs +37 -0
- package/templates/remote/src/app/services/__name__.service.ts.hbs +14 -0
- package/templates/remote/src/index.html.hbs +11 -0
- package/templates/remote/src/main.ts.hbs +3 -0
- package/templates/remote/tsconfig.app.json +10 -0
- package/templates/remote/tsconfig.federation.json +4 -0
- 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,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 @@
|
|
|
1
|
+
{}
|
|
@@ -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,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
|
+
})
|