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.
- package/README.md +61 -2
- 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/form-dsl.js +2 -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/README.md.hbs +1 -1
- package/templates/create/_gitignore +1 -0
- package/templates/create/apps/web/index.html.hbs +8 -0
- package/templates/create/apps/web/package.json.hbs +1 -0
- package/templates/create/apps/web/src/App.tsx.hbs +18 -7
- package/templates/create/apps/web/src/app.css +27 -0
- package/templates/create/apps/web/src/health.test.ts.hbs +18 -4
- package/templates/create/apps/web/src/health.ts.hbs +8 -0
- package/templates/create/apps/web/src/main.tsx.hbs +12 -1
- package/templates/create/package.json.hbs +15 -5
- 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,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,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
|
+
})
|