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,46 @@
1
+ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
2
+ import { Router } from '@angular/router'
3
+ import { BAHT_UI } from 'bank20baht-ui-angular'
4
+ import { MANIFEST } from '../../core/federation/remote-loader'
5
+
6
+ /** The landing page: one card per remote in the federation manifest. */
7
+ @Component({
8
+ changeDetection: ChangeDetectionStrategy.OnPush,
9
+ imports: [BAHT_UI],
10
+ selector: 'shell-home',
11
+ styles: `
12
+ .remotes {
13
+ display: grid;
14
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
15
+ gap: var(--baht-space-4);
16
+ }
17
+ `,
18
+ template: `
19
+ <div class="app-page">
20
+ <baht-page-header
21
+ heading="{{name}}"
22
+ description="A Native Federation host. Each card is a remote, loaded at runtime."
23
+ ></baht-page-header>
24
+ <div class="remotes">
25
+ @for (remote of remotes; track remote.base) {
26
+ <baht-card [heading]="remote.title">
27
+ <baht-button variant="secondary" (click)="go(remote.base)">Open</baht-button>
28
+ </baht-card>
29
+ } @empty {
30
+ <baht-empty-state
31
+ message="No remotes yet"
32
+ hint="Add one with bahtui g remote, then restart bun run dev."
33
+ ></baht-empty-state>
34
+ }
35
+ </div>
36
+ </div>
37
+ `,
38
+ })
39
+ export class HomeComponent {
40
+ private readonly router = inject(Router)
41
+ protected readonly remotes = Object.values(inject(MANIFEST))
42
+
43
+ protected go(base: string): void {
44
+ void this.router.navigateByUrl(`/${base}`)
45
+ }
46
+ }
@@ -0,0 +1,39 @@
1
+ import { ChangeDetectionStrategy, Component, input } from '@angular/core'
2
+ import { BAHT_UI } from 'bank20baht-ui-angular'
3
+
4
+ /**
5
+ * Shown when a remote's routes cannot be loaded over Native Federation, and for unknown URLs.
6
+ * The route's `data` sets the copy; all this page can offer is another try.
7
+ */
8
+ @Component({
9
+ changeDetection: ChangeDetectionStrategy.OnPush,
10
+ imports: [BAHT_UI],
11
+ selector: 'shell-unavailable',
12
+ styles: `
13
+ :host {
14
+ display: grid;
15
+ place-content: center;
16
+ min-height: 60dvh;
17
+ padding: var(--baht-space-4);
18
+ }
19
+ baht-error-state {
20
+ max-width: 440px;
21
+ }
22
+ `,
23
+ template: `
24
+ <baht-error-state
25
+ [attr.heading]="heading()"
26
+ [attr.message]="message()"
27
+ retry-label="Try again"
28
+ (bahtRetry)="retry()"
29
+ ></baht-error-state>
30
+ `,
31
+ })
32
+ export class UnavailableComponent {
33
+ readonly heading = input('Something is unavailable')
34
+ readonly message = input('This page could not be loaded. Try again in a moment.')
35
+
36
+ protected retry(): void {
37
+ window.location.reload()
38
+ }
39
+ }
@@ -0,0 +1,25 @@
1
+ import type { ApplicationRef } from '@angular/core'
2
+ import { bootstrapApplication } from '@angular/platform-browser'
3
+ import { appConfig } from './app/app.config'
4
+ import { App } from './app/app'
5
+ import type { Manifest } from './app/core/federation/manifest'
6
+ import { MANIFEST, REMOTE_LOADER, type RemoteLoader } from './app/core/federation/remote-loader'
7
+
8
+ export async function bootstrap(
9
+ loadRemoteModule: RemoteLoader | null,
10
+ manifest: Manifest,
11
+ ): Promise<ApplicationRef> {
12
+ // Registers the <baht-*> custom elements in its own chunk; they upgrade in place once it
13
+ // lands. Shared with the remotes, so each tag is defined once.
14
+ void import('bank20baht-ui')
15
+
16
+ const config = appConfig(manifest)
17
+ return bootstrapApplication(App, {
18
+ ...config,
19
+ providers: [
20
+ ...config.providers,
21
+ { provide: REMOTE_LOADER, useValue: loadRemoteModule },
22
+ { provide: MANIFEST, useValue: manifest },
23
+ ],
24
+ })
25
+ }
@@ -0,0 +1,10 @@
1
+ import type { Manifest } from '../app/core/federation/manifest'
2
+
3
+ /** The remotes on their dev servers (`bun run dev`). `bahtui g remote` adds an entry. */
4
+ const remotes: Manifest = {
5
+ // bahtui:remotes
6
+ }
7
+
8
+ export const federation: string | Manifest = remotes
9
+
10
+ export const federationFallback: Manifest = remotes
@@ -0,0 +1,10 @@
1
+ import type { Manifest } from '../app/core/federation/manifest'
2
+
3
+ /**
4
+ * Where the remotes load from: `public/federation.manifest.json`, read at runtime, so a remote can
5
+ * be added or repointed on the web server with no host build.
6
+ */
7
+ export const federation: string | Manifest = '/federation.manifest.json'
8
+
9
+ /** What the host boots with when that file cannot be fetched: no remotes, the home page only. */
10
+ export const federationFallback: Manifest = {}
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>{{name}}</title>
6
+ <base href="/" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
8
+ </head>
9
+ <body>
10
+ <app-root></app-root>
11
+ </body>
12
+ </html>
@@ -0,0 +1,43 @@
1
+ import { initFederation, type NativeFederationResult } from '@angular-architects/native-federation'
2
+ import { parseManifest, type Manifest } from './app/core/federation/manifest'
3
+ import { withTimeout } from './app/core/federation/timeout'
4
+ import { federation, federationFallback } from './environments/environment'
5
+
6
+ /** How long the shell waits for the manifest, then for the remotes, before booting without them. */
7
+ const BOOT_TIMEOUT_MS = 8_000
8
+
9
+ // Only federation starts here: shared packages (@angular/*, bank20baht-ui) resolve through the
10
+ // import map once initFederation is done, so everything else loads from ./bootstrap. A failed or
11
+ // slow start still boots the shell; the remotes' pages then show the fallback.
12
+ const manifest = async (): Promise<Manifest> => {
13
+ if (typeof federation !== 'string') return federation
14
+ try {
15
+ const res = await fetch(federation, { signal: AbortSignal.timeout(BOOT_TIMEOUT_MS) })
16
+ if (!res.ok) throw new Error(`federation manifest: HTTP ${String(res.status)}`)
17
+ const parsed = parseManifest(await res.json())
18
+ if (parsed === null) throw new Error('federation manifest: an entry has the wrong shape')
19
+ return parsed
20
+ } catch (err) {
21
+ console.error('federation manifest failed to load, using the built-in one', err)
22
+ return federationFallback
23
+ }
24
+ }
25
+
26
+ const start = async () => {
27
+ const remotes = await manifest()
28
+ const entries = Object.fromEntries(
29
+ Object.entries(remotes).map(([name, remote]) => [name, remote.entry]),
30
+ )
31
+ let nf: NativeFederationResult | null = null
32
+ try {
33
+ nf = await withTimeout(initFederation(entries), BOOT_TIMEOUT_MS)
34
+ } catch (err) {
35
+ console.error(err)
36
+ }
37
+ const { bootstrap } = await import('./bootstrap')
38
+ await bootstrap(nf?.loadRemoteModule ?? null, remotes)
39
+ }
40
+
41
+ start().catch((err: unknown) => {
42
+ console.error(err)
43
+ })
@@ -0,0 +1,19 @@
1
+ /* bank20baht-ui's tokens.css comes first (angular.json); this file only sets the page up. */
2
+ html,
3
+ body {
4
+ margin: 0;
5
+ min-height: 100dvh;
6
+ background: var(--baht-color-bg);
7
+ color: var(--baht-color-text);
8
+ font-family: var(--baht-font-family);
9
+ }
10
+
11
+ /* The width every routed page keeps to inside the shell. */
12
+ .app-page {
13
+ display: flex;
14
+ flex-direction: column;
15
+ gap: var(--baht-space-5);
16
+ box-sizing: border-box;
17
+ max-width: 1200px;
18
+ padding: var(--baht-space-5);
19
+ }
@@ -0,0 +1,10 @@
1
+ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
2
+ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
3
+ {
4
+ "extends": "./tsconfig.json",
5
+ "compilerOptions": {
6
+ "types": []
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": ["src/**/*.spec.ts"]
10
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.app.json",
3
+ "include": ["src/**/*.d.ts"]
4
+ }
@@ -0,0 +1,39 @@
1
+ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
2
+ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
3
+ {
4
+ "compileOnSave": false,
5
+ "compilerOptions": {
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "exactOptionalPropertyTypes": true,
9
+ "verbatimModuleSyntax": true,
10
+ "noImplicitOverride": true,
11
+ "noPropertyAccessFromIndexSignature": true,
12
+ "noImplicitReturns": true,
13
+ "noFallthroughCasesInSwitch": true,
14
+ "skipLibCheck": true,
15
+ "isolatedModules": true,
16
+ "experimentalDecorators": true,
17
+ "importHelpers": true,
18
+ "target": "ES2022",
19
+ "module": "preserve",
20
+ "paths": {
21
+ "@{{name}}/kit": ["../../packages/kit/src/index.ts"]
22
+ }
23
+ },
24
+ "angularCompilerOptions": {
25
+ "enableI18nLegacyMessageIdFormat": false,
26
+ "strictInjectionParameters": true,
27
+ "strictTemplates": true,
28
+ "strictInputAccessModifiers": true
29
+ },
30
+ "files": [],
31
+ "references": [
32
+ {
33
+ "path": "./tsconfig.app.json"
34
+ },
35
+ {
36
+ "path": "./tsconfig.spec.json"
37
+ }
38
+ ]
39
+ }
@@ -0,0 +1,9 @@
1
+ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
2
+ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
3
+ {
4
+ "extends": "./tsconfig.json",
5
+ "compilerOptions": {
6
+ "types": ["vitest/globals"]
7
+ },
8
+ "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
9
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "apps": { "web": "apps/shell", "api": "apps/server" },
3
+ "libsDir": "packages",
4
+ "frontend": "angular",
5
+ "backend": "elysia"
6
+ }
@@ -0,0 +1,28 @@
1
+ import { defineConfig } from 'eslint/config'
2
+
3
+ import { angular, bahtui, base } from 'bank20baht-strict/eslint'
4
+
5
+ /** The host, its remotes, and the kit they all compile in. `bahtui g remote` adds a line. */
6
+ const WEB = [
7
+ 'apps/shell/src/**',
8
+ 'packages/kit/src/**',
9
+ // bahtui:remotes
10
+ ]
11
+
12
+ // Every rule is an error. Add app overrides below, each with a reason:
13
+ // // eslint-disable-next-line <rule> -- <why>
14
+ export default defineConfig([
15
+ ...base,
16
+ ...angular({
17
+ files: WEB,
18
+ restrictedImports: [
19
+ {
20
+ name: 'server',
21
+ allowTypeImports: true,
22
+ message:
23
+ 'The web apps share the server only as types (the eden App type); no server runtime code in a bundle.',
24
+ },
25
+ ],
26
+ }),
27
+ ...bahtui({ files: WEB, tokenFiles: ['apps/shell/src/styles.css'] }),
28
+ ])
@@ -0,0 +1,27 @@
1
+ import { knip } from 'bank20baht-strict/knip'
2
+
3
+ const SRC = ['src/**/*.ts']
4
+
5
+ /** A Native Federation remote: its entry is the routes module federation.config.mjs exposes. */
6
+ const remote = (name: string) => ({ entry: [`src/app/${name}.routes.ts`], project: SRC })
7
+
8
+ /** A workspace tested with `bun test`: the tests are its entries. */
9
+ const tested = { entry: ['test/**/*.test.ts'], project: [...SRC, 'test/**/*.ts'] }
10
+
11
+ export default knip({
12
+ ignoreDependencies: [
13
+ // The runtime loader behind @angular-architects/native-federation; nothing imports it directly.
14
+ '@softarc/native-federation-orchestrator',
15
+ // Reached through the kit; listed in each app so federation shares it as one singleton.
16
+ 'ngx-eden',
17
+ // Angular's compiler emits tslib helpers (importHelpers).
18
+ 'tslib',
19
+ ],
20
+ workspaces: {
21
+ '.': { entry: ['scripts/*.ts'], project: ['scripts/**/*.ts'] },
22
+ 'apps/server': tested,
23
+ 'apps/shell': { project: SRC },
24
+ 'packages/kit': tested,
25
+ // bahtui:remotes
26
+ },
27
+ })
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "private": true,
4
+ "type": "module",
5
+ "workspaces": [
6
+ "apps/*",
7
+ "packages/*"
8
+ ],
9
+ "scripts": {
10
+ "dev": "bun run scripts/dev.ts",
11
+ "build": "bun run scripts/each.ts build",
12
+ "typecheck": "tsc -p tsconfig.json && bun run scripts/each.ts typecheck",
13
+ "lint": "eslint .",
14
+ "lint:fix": "eslint . --fix",
15
+ "format": "prettier --write .",
16
+ "format:check": "prettier --check .",
17
+ "knip": "knip",
18
+ "madge": "madge --circular --extensions ts,tsx --exclude '(^|/)(dist|node_modules|\\.angular)/' apps packages scripts",
19
+ "deps": "depcruise apps packages scripts --config .dependency-cruiser.mjs",
20
+ "dup": "jscpd",
21
+ "audit": "bun audit --audit-level=high",
22
+ "secrets": "gitleaks git --redact --no-banner",
23
+ "test": "bun run scripts/each.ts test:coverage",
24
+ "check:fast": "bun run lint && bun run format:check && bun run typecheck && bun run madge && bun run deps",
25
+ "check": "bun run check:fast && bun run knip && bun run dup && bun run build && bun run test",
26
+ "prepare": "husky"
27
+ },
28
+ "prettier": "bank20baht-strict/prettier",
29
+ "packageManager": "bun@1.3.14",
30
+ "devDependencies": {
31
+ "@commitlint/cli": "^21.2.2",
32
+ "@commitlint/config-conventional": "^21.2.2",
33
+ "@eslint/css": "^2.0.0",
34
+ "@eslint/js": "^10.0.1",
35
+ "@types/bun": "^1.3.0",
36
+ "angular-eslint": "22.5.0",
37
+ "bank20baht-strict": "0.0.6",
38
+ "dependency-cruiser": "^18.3.1",
39
+ "eslint": "^10.10.0",
40
+ "eslint-config-prettier": "^10.1.8",
41
+ "eslint-plugin-sonarjs": "^4.2.1",
42
+ "husky": "^9.1.7",
43
+ "jiti": "^2.6.1",
44
+ "jscpd": "^5.3.0",
45
+ "knip": "^6.35.1",
46
+ "lint-staged": "^17.5.1",
47
+ "madge": "^8.0.0",
48
+ "prettier": "^3.9.6",
49
+ "typescript": "^5.9.3",
50
+ "typescript-eslint": "^8.70.0"
51
+ }
52
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "{{name}}-kit",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "What the host and its remotes share: the typed API client and error text. Imported as @{{name}}/kit through a tsconfig path, so each app compiles its own copy.",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "tsc --noEmit",
12
+ "pretypecheck": "bun run --filter server build:types",
13
+ "test": "bun test",
14
+ "test:coverage": "bun test --coverage"
15
+ },
16
+ "dependencies": {
17
+ "@angular/core": "^22.2.0",
18
+ "ngx-eden": "^0.2.0",
19
+ "rxjs": "~7.8.0",
20
+ "tslib": "^2.3.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/bun": "^1.3.0",
24
+ "server": "workspace:*",
25
+ "typescript": "~6.0.2"
26
+ }
27
+ }
@@ -0,0 +1,21 @@
1
+ import { Injectable, InjectionToken, inject, isDevMode } from '@angular/core'
2
+ import { createEden } from 'ngx-eden'
3
+ import type { App } from 'server'
4
+
5
+ /**
6
+ * Where the API lives. In dev the Angular servers run on :4200 and up and Elysia on :3000;
7
+ * deployed, one web server serves the apps and proxies `/api` on the same origin.
8
+ */
9
+ export const API_BASE = new InjectionToken<string>('API_BASE', {
10
+ providedIn: 'root',
11
+ factory: () => (isDevMode() ? 'http://localhost:3000' : globalThis.location.origin),
12
+ })
13
+
14
+ /**
15
+ * The typed client of the Elysia server: `inject(Api).client.api.hello.get()`.
16
+ * Types come from `apps/server/dist/types` (`bun run --filter server build:types`).
17
+ */
18
+ @Injectable({ providedIn: 'root' })
19
+ export class Api {
20
+ readonly client = createEden<App>({ baseUrl: inject(API_BASE) })
21
+ }
@@ -0,0 +1,28 @@
1
+ const FALLBACK = 'Something went wrong. Try again.'
2
+
3
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
4
+ typeof value === 'object' && value !== null
5
+
6
+ /** Where the response body sits: `value` from `unwrap()`, `error` on an `HttpErrorResponse`. */
7
+ const bodyOf = (error: Record<string, unknown>): Record<string, unknown> => {
8
+ for (const key of ['value', 'error']) {
9
+ const body = error[key]
10
+ if (isRecord(body)) return body
11
+ }
12
+ return error
13
+ }
14
+
15
+ /**
16
+ * The sentence to show for a failed API call: the server's `{ message }` or Elysia's validator
17
+ * `{ summary }`, as ngx-eden's `unwrap()` or a resource's `error()` carries it.
18
+ */
19
+ export const describeError = (error: unknown): string => {
20
+ if (!isRecord(error)) return FALLBACK
21
+ if (error['status'] === 0) return 'The server cannot be reached.'
22
+ const body = bodyOf(error)
23
+ for (const key of ['message', 'summary']) {
24
+ const text = body[key]
25
+ if (typeof text === 'string' && text !== '') return text
26
+ }
27
+ return FALLBACK
28
+ }
@@ -0,0 +1,2 @@
1
+ export { API_BASE, Api } from './api'
2
+ export { describeError } from './errors'
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { describeError } from '../src/errors'
4
+
5
+ describe('describeError', () => {
6
+ test('reads the body ngx-eden wraps', () => {
7
+ expect(describeError({ status: 404, value: { message: 'Not found' } })).toBe('Not found')
8
+ expect(describeError({ status: 422, value: { summary: 'Expected string' } })).toBe(
9
+ 'Expected string',
10
+ )
11
+ })
12
+
13
+ test('reads the body of an HttpErrorResponse', () => {
14
+ expect(
15
+ describeError({ status: 404, error: { message: 'Gone' }, message: 'Http failure' }),
16
+ ).toBe('Gone')
17
+ expect(describeError({ status: 0, message: 'Http failure response: 0 Unknown Error' })).toBe(
18
+ 'The server cannot be reached.',
19
+ )
20
+ })
21
+
22
+ test('reads a plain Error and falls back', () => {
23
+ expect(describeError(new Error('boom'))).toBe('boom')
24
+ expect(describeError({ status: 0 })).toBe('The server cannot be reached.')
25
+ expect(describeError({ status: 500, value: 'oops' })).toBe('Something went wrong. Try again.')
26
+ expect(describeError(null)).toBe('Something went wrong. Try again.')
27
+ })
28
+ })
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "lib": ["ES2022", "DOM"],
5
+ "types": ["bun"],
6
+ "experimentalDecorators": true
7
+ },
8
+ "include": ["src", "test"]
9
+ }
@@ -1,5 +1,6 @@
1
1
  node_modules/
2
2
  dist/
3
+ coverage/
3
4
  *.local
4
5
  *.tsbuildinfo
5
6
  .DS_Store
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@{{name}}/eden": "workspace:*",
12
- "bank20baht-ui": "^0.18.8",
12
+ "bank20baht-ui": "^0.19.0",
13
13
  "react": "^19.1.0",
14
14
  "react-dom": "^19.1.0"
15
15
  },
@@ -15,7 +15,7 @@
15
15
  "format": "prettier --write .",
16
16
  "format:check": "prettier --check .",
17
17
  "knip": "knip",
18
- "deps:circular": "madge --circular --extensions ts,tsx apps libs",
18
+ "deps:circular": "madge --circular --extensions ts,tsx --exclude '(^|/)(dist|node_modules)/' apps libs",
19
19
  "prepare": "husky"
20
20
  },
21
21
  "lint-staged": {
@@ -0,0 +1,23 @@
1
+ [Unit]
2
+ Description={{project}} API server
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ User={{project}}
8
+ Group={{project}}
9
+ WorkingDirectory=/var/lib/{{project}}
10
+ EnvironmentFile=/etc/{{project}}/env
11
+ ExecStart=/usr/local/bin/bun /opt/{{project}}/server/index.js
12
+ Restart=on-failure
13
+ RestartSec=3
14
+
15
+ # It only needs its own folder and the network.
16
+ NoNewPrivileges=true
17
+ ProtectSystem=strict
18
+ ProtectHome=true
19
+ PrivateTmp=true
20
+ ReadWritePaths=/var/lib/{{project}}
21
+
22
+ [Install]
23
+ WantedBy=multi-user.target
@@ -0,0 +1,4 @@
1
+ # /etc/{{project}}/env, read by {{project}}.service. Created once by install.sh; edit it there.
2
+
3
+ # nginx is in front on the same machine and proxies /api/ here.
4
+ PORT={{apiPort}}
@@ -0,0 +1,58 @@
1
+ #!/bin/sh
2
+ # Runs on the target, as root, from the unpacked release (scripts/deploy.ts does that).
3
+ # Safe to run again: the first run sets the machine up, every run installs the release.
4
+ set -eu
5
+
6
+ RELEASE=$(cd "$(dirname "$0")" && pwd)
7
+ APP={{project}}
8
+
9
+ if ! command -v nginx >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
10
+ export DEBIAN_FRONTEND=noninteractive
11
+ apt-get update -qq
12
+ apt-get install -y -qq nginx curl unzip ca-certificates
13
+ fi
14
+
15
+ if [ ! -x /usr/local/bin/bun ]; then
16
+ # bun's installer is a bash script (dash, Debian's sh, refuses its `set -o pipefail`).
17
+ curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash >/dev/null
18
+ fi
19
+
20
+ id "$APP" >/dev/null 2>&1 || useradd --system --home "/var/lib/$APP" --shell /usr/sbin/nologin "$APP"
21
+ install -d -o "$APP" -g "$APP" "/var/lib/$APP"
22
+ install -d "/etc/$APP" "/opt/$APP" "/var/www/$APP"
23
+
24
+ # Settings are written once and never overwritten: they hold what was set on this machine.
25
+ if [ ! -f "/etc/$APP/env" ]; then
26
+ install -m 640 -o root -g "$APP" "$RELEASE/env.example" "/etc/$APP/env"
27
+ echo "created /etc/$APP/env"
28
+ fi
29
+ if [ ! -f "/etc/$APP/federation.manifest.json" ]; then
30
+ install -m 644 "$RELEASE/federation.manifest.json" "/etc/$APP/federation.manifest.json"
31
+ fi
32
+
33
+ # The release itself: swap whole folders so a half-copied one is never served.
34
+ for part in server www; do
35
+ rm -rf "/opt/$APP/$part.new"
36
+ cp -R "$RELEASE/$part" "/opt/$APP/$part.new"
37
+ done
38
+ rm -rf "/opt/$APP/server.old" && { [ ! -d "/opt/$APP/server" ] || mv "/opt/$APP/server" "/opt/$APP/server.old"; }
39
+ mv "/opt/$APP/server.new" "/opt/$APP/server"
40
+ rm -rf "/var/www/$APP.old" && mv "/var/www/$APP" "/var/www/$APP.old"
41
+ mv "/opt/$APP/www.new" "/var/www/$APP"
42
+ rm -rf "/var/www/$APP.old" "/opt/$APP/server.old"
43
+
44
+ install -m 644 "$RELEASE/$APP.service" "/etc/systemd/system/$APP.service"
45
+ install -m 644 "$RELEASE/nginx.conf" "/etc/nginx/sites-available/$APP"
46
+ ln -sf "/etc/nginx/sites-available/$APP" "/etc/nginx/sites-enabled/$APP"
47
+ rm -f /etc/nginx/sites-enabled/default
48
+
49
+ systemctl daemon-reload
50
+ systemctl enable "$APP" >/dev/null 2>&1
51
+ systemctl restart "$APP"
52
+ nginx -t
53
+ systemctl reload nginx || systemctl restart nginx
54
+
55
+ sleep 2
56
+ systemctl is-active "$APP"
57
+ curl -sS -o /dev/null -w 'api %{http_code}\n' http://127.0.0.1:{{apiPort}}/api/
58
+ curl -fsS -o /dev/null -w 'host %{http_code}\n' http://127.0.0.1/