@h-ai/cli 0.1.0-alpha.51 → 0.1.0-alpha.53

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@h-ai/cli",
3
3
  "type": "module",
4
- "version": "0.1.0-alpha.51",
4
+ "version": "0.1.0-alpha.53",
5
5
  "description": "Hai Framework CLI for project scaffolding and code generation.",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -34,7 +34,7 @@
34
34
  "ora": "^9.1.0",
35
35
  "prompts": "^2.4.2",
36
36
  "yaml": "^2.7.0",
37
- "@h-ai/core": "0.1.0-alpha.51"
37
+ "@h-ai/core": "0.1.0-alpha.53"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/fs-extra": "^11.0.4",
@@ -3,13 +3,16 @@ FROM node:22-bookworm-slim AS build
3
3
 
4
4
  ENV PNPM_HOME=/pnpm
5
5
  ENV PATH=$PNPM_HOME:$PATH
6
+ ENV CI=true
6
7
  WORKDIR /workspace
7
8
 
8
9
  RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
10
+ COPY pnpm-lock.yaml pnpm-workspace.yaml ./
11
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch --frozen-lockfile
9
12
  COPY . .
10
- RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --frozen-lockfile
13
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
11
14
  RUN pnpm build
12
- RUN pnpm --filter {{api.servicePackageName}} --prod deploy /prod/app
15
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm --filter {{api.servicePackageName}} --prod deploy /prod/app
13
16
 
14
17
  FROM node:22-bookworm-slim AS runtime-base
15
18
 
@@ -29,6 +32,6 @@ CMD ["node", "dist/index.js"]
29
32
 
30
33
  FROM runtime-base AS runtime-with-data
31
34
 
32
- COPY --from=embedded-data --chown=node:node . /app/data
35
+ COPY --from=embedded-data --chown=node:node / /app/data/
33
36
 
34
37
  FROM runtime-base AS runtime
@@ -1,4 +1,5 @@
1
1
  .git
2
+ Dockerfile
2
3
  .github
3
4
  .agents
4
5
  .claude
@@ -8,6 +9,7 @@
8
9
  **/build
9
10
  **/dist
10
11
  **/node_modules
12
+ node_modules
11
13
  playwright-report
12
14
  test-results
13
15
  **/data
@@ -1,17 +1,21 @@
1
1
  # syntax=docker/dockerfile:1.7
2
2
  FROM node:22-bookworm-slim AS build
3
3
 
4
- ARG PUBLIC_API_BASE
5
4
  ENV PNPM_HOME=/pnpm
6
5
  ENV PATH=$PNPM_HOME:$PATH
7
- ENV PUBLIC_API_BASE=$PUBLIC_API_BASE
6
+ ENV CI=true
8
7
  WORKDIR /workspace
9
8
 
10
9
  RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
10
+ COPY pnpm-lock.yaml pnpm-workspace.yaml ./
11
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch --frozen-lockfile
11
12
  COPY . .
12
- RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --frozen-lockfile
13
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
13
14
  RUN pnpm build
14
- RUN pnpm --filter {{fullstack.servPackageName}} --prod deploy /prod/service
15
+
16
+ FROM build AS service-build
17
+
18
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm --filter {{fullstack.servPackageName}} --prod deploy /prod/service
15
19
 
16
20
  FROM node:22-bookworm-slim AS service
17
21
 
@@ -20,7 +24,7 @@ ENV HOST=0.0.0.0
20
24
  ENV PORT=3000
21
25
  WORKDIR /app
22
26
 
23
- COPY --from=build --chown=node:node /prod/service ./
27
+ COPY --from=service-build --chown=node:node /prod/service ./
24
28
  RUN mkdir -p /app/data && chown node:node /app/data
25
29
 
26
30
  USER node
@@ -29,43 +33,26 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
29
33
  CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
30
34
  CMD ["node", "dist/index.js"]
31
35
 
36
+ FROM service AS runtime-with-data
37
+
38
+ COPY --from=embedded-data --chown=node:node / /app/data/
39
+
40
+ FROM service AS runtime
41
+
32
42
  {{#if fullstack.frontends.web}}
33
43
  FROM nginxinc/nginx-unprivileged:1.29-alpine AS web
34
44
 
45
+ ENV PUBLIC_API_BASE=
46
+ ENV NGINX_ENVSUBST_FILTER=PUBLIC_API_BASE_BASE64
47
+ ENV NGINX_ENVSUBST_OUTPUT_DIR=/tmp/hai-runtime-config
48
+
49
+ RUN mkdir -p /tmp/hai-runtime-config
35
50
  COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
51
+ COPY --chmod=755 docker/19-runtime-config.envsh /docker-entrypoint.d/19-runtime-config.envsh
52
+ COPY docker/runtime-config.js.template /etc/nginx/templates/runtime-config.js.template
36
53
  COPY --from=build /workspace/apps/{{projectName}}-web/dist /usr/share/nginx/html
37
54
 
38
55
  EXPOSE 8080
39
56
  HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
40
- CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1
41
-
42
- FROM node:22-bookworm-slim AS runtime-base
43
-
44
- ENV NODE_ENV=production
45
- ENV HAI_WEB_PORT=8080
46
- ENV HAI_SERV_PORT=3000
47
- WORKDIR /app
48
-
49
- COPY --from=build --chown=node:node /prod/service ./service
50
- COPY --from=build --chown=node:node /workspace/apps/{{projectName}}-web/dist ./web
51
- COPY --chown=node:node docker/fullstack-server.mjs ./fullstack-server.mjs
52
- RUN mkdir -p /app/service/data && chown node:node /app/service/data
53
-
54
- USER node
55
- EXPOSE 8080
56
- HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
57
- CMD node -e "fetch('http://127.0.0.1:'+process.env.HAI_WEB_PORT+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
58
- CMD ["node", "fullstack-server.mjs"]
59
-
60
- FROM runtime-base AS runtime-with-data
61
-
62
- COPY --from=embedded-data --chown=node:node . /app/service/data
63
-
64
- FROM runtime-base AS runtime
65
- {{else}}
66
- FROM service AS runtime-with-data
67
-
68
- COPY --from=embedded-data --chown=node:node . /app/data
69
-
70
- FROM service AS runtime
57
+ CMD wget -Y off -q -O /dev/null http://127.0.0.1:8080/health || exit 1
71
58
  {{/if}}
@@ -6,10 +6,11 @@
6
6
  <meta charset="UTF-8" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1" />
8
8
  <title>{{projectName}} Web</title>
9
+ <script src="/runtime-config.js"></script>
9
10
  </head>
10
11
  <body>
11
12
  <div id="app"></div>
12
13
  <script type="module" src="/src/main.ts"></script>
13
14
  </body>
14
15
  </html>
15
- {{/if}}
16
+ {{/if}}
@@ -0,0 +1 @@
1
+ globalThis.__HAI_PUBLIC_CONFIG__ = {}
@@ -24,5 +24,32 @@ describe('fetchAppInfo', () => {
24
24
 
25
25
  expect(info.name).toBe('{{fullstack.servPackageName}}')
26
26
  })
27
+
28
+ it('uses API origin injected at container startup', async () => {
29
+ Object.assign(globalThis, {
30
+ __HAI_PUBLIC_CONFIG__: { apiBase: 'https://api.example.com' },
31
+ })
32
+ let requestedUrl = ''
33
+ const fetchFn: typeof fetch = async (input) => {
34
+ requestedUrl = input instanceof Request ? input.url : String(input)
35
+ return new Response(JSON.stringify({
36
+ success: true,
37
+ data: {
38
+ name: '{{fullstack.servPackageName}}',
39
+ version: '0.1.0',
40
+ startedAt: '2026-01-01T00:00:00.000Z',
41
+ frontends: ['web'],
42
+ },
43
+ }), { headers: { 'content-type': 'application/json' } })
44
+ }
45
+
46
+ try {
47
+ await fetchAppInfo({ fetchFn })
48
+ expect(requestedUrl).toContain('https://api.example.com/api/v1')
49
+ }
50
+ finally {
51
+ Reflect.deleteProperty(globalThis, '__HAI_PUBLIC_CONFIG__')
52
+ }
53
+ })
27
54
  })
28
55
  {{/if}}
@@ -0,0 +1,5 @@
1
+ #!/bin/sh
2
+
3
+ # 使用 Base64 跨越 shell、envsubst 与 JavaScript 字符串边界,避免 URL 字符改变脚本语义。
4
+ PUBLIC_API_BASE_BASE64="$(printf '%s' "${PUBLIC_API_BASE:-}" | base64 | tr -d '\n')"
5
+ export PUBLIC_API_BASE_BASE64
@@ -9,6 +9,12 @@ server {
9
9
  try_files $uri $uri/ /index.html;
10
10
  }
11
11
 
12
+ location = /runtime-config.js {
13
+ alias /tmp/hai-runtime-config/runtime-config.js;
14
+ add_header Cache-Control "no-store";
15
+ default_type application/javascript;
16
+ }
17
+
12
18
  location = /health {
13
19
  access_log off;
14
20
  add_header Content-Type text/plain;
@@ -0,0 +1,3 @@
1
+ globalThis.__HAI_PUBLIC_CONFIG__ = {
2
+ apiBase: new TextDecoder().decode(Uint8Array.from(atob('${PUBLIC_API_BASE_BASE64}'), value => value.charCodeAt(0))),
3
+ }
@@ -12,12 +12,10 @@
12
12
  "compile": "pnpm typecheck && pnpm build",
13
13
  "build": "pnpm --filter {{fullstack.contractPackageName}} build && pnpm i18n:compile && pnpm -r --if-present build",
14
14
  "docker:build": "docker build --target runtime -t {{projectName}}:latest .",
15
- "docker:build:data": "docker build --target runtime-with-data --build-context embedded-data=./packages/{{projectName}}-serv/data -t {{projectName}}:latest .",
16
- "docker:build:service": "docker build --target service -t {{projectName}}-serv:latest ."{{#if fullstack.frontends.web}},
15
+ "docker:build:data": "docker build --target runtime-with-data --build-context embedded-data=./packages/{{projectName}}-serv/data -t {{projectName}}:latest ."{{#if fullstack.frontends.web}},
17
16
  "docker:build:web": "docker build --target web -t {{projectName}}-web:latest ."{{/if}},
18
17
  "podman:build": "podman build --format docker --jobs=1 --target runtime -t {{projectName}}:latest .",
19
- "podman:build:data": "podman build --format docker --jobs=1 --target runtime-with-data --build-context embedded-data=./packages/{{projectName}}-serv/data -t {{projectName}}:latest .",
20
- "podman:build:service": "podman build --format docker --jobs=1 --target service -t {{projectName}}-serv:latest ."{{#if fullstack.frontends.web}},
18
+ "podman:build:data": "podman build --format docker --jobs=1 --target runtime-with-data --build-context embedded-data=./packages/{{projectName}}-serv/data -t {{projectName}}:latest ."{{#if fullstack.frontends.web}},
21
19
  "podman:build:web": "podman build --format docker --jobs=1 --target web -t {{projectName}}-web:latest ."{{/if}},
22
20
  "package": "pnpm build{{#if fullstack.frontends.web}} && pnpm --filter {{projectName}}-web package{{/if}}{{#if fullstack.frontends.app}} && pnpm --filter {{projectName}}-app package{{/if}}{{#if fullstack.frontends.desktop}} && pnpm --filter {{projectName}}-desktop package{{/if}}",
23
21
  "deploy": "pnpm package",
@@ -18,7 +18,8 @@
18
18
 
19
19
  ## 容器运行
20
20
 
21
- - 根目录 `pnpm docker:build` / `pnpm podman:build` 生成 Web + Service 同源镜像;`pnpm docker:build:service` / `pnpm podman:build:service` 只生成本服务镜像。
22
- - 合并镜像的配置挂载点为 `/app/service/config`,数据目录为 `/app/service/data`;service-only 镜像对应 `/app/config` 与 `/app/data`。
21
+ - 根目录 `pnpm docker:build` / `pnpm podman:build` 直接生成本服务镜像;Web 使用独立的 `docker:build:web` / `podman:build:web` 镜像。
22
+ - 服务镜像直接由 `@h-ai/serv` 对外监听,配置挂载点为 `/app/config`,数据目录为 `/app/data`,不包含额外转发进程。
23
+ - Web 镜像不烘焙 API 地址;启动时使用 `-e PUBLIC_API_BASE=https://api.example.com` 注入,同一镜像可部署到不同环境。未设置时 Web 默认请求自身 origin。
23
24
  - YAML 配置使用 `${ENV_NAME:default}` 接收运行时环境变量;不要把密钥写入镜像。
24
25
  - 将 SQLite/本地存储放入本目录 `data/` 后运行 `pnpm docker:build:data` / `pnpm podman:build:data`,可生成带初始数据的镜像,持续写入仍应使用 volume。
@@ -38,10 +38,18 @@ export async function fetchAppInfo(options: FetchAppInfoOptions = {}): Promise<A
38
38
  }
39
39
 
40
40
  function resolveDefaultApiOrigin(): string {
41
- return import.meta.env.PUBLIC_API_BASE
41
+ return resolveRuntimeApiOrigin()
42
+ || import.meta.env.PUBLIC_API_BASE
42
43
  || (typeof globalThis.location === 'undefined' ? DEFAULT_API_ORIGIN : globalThis.location.origin)
43
44
  }
44
45
 
46
+ function resolveRuntimeApiOrigin(): string | undefined {
47
+ const runtimeConfig = (globalThis as typeof globalThis & {
48
+ __HAI_PUBLIC_CONFIG__?: { apiBase?: string }
49
+ }).__HAI_PUBLIC_CONFIG__
50
+ return runtimeConfig?.apiBase
51
+ }
52
+
45
53
  function normalizeApiBaseUrl(value: string): string {
46
54
  const trimmed = trimTrailingSlash(value)
47
55
  return trimmed.endsWith('/api/v1')
@@ -13,4 +13,5 @@ ios
13
13
  .DS_Store
14
14
  test-results/
15
15
  playwright-report/
16
- data/
16
+ data/*
17
+ !**/data/.gitkeep
@@ -4,11 +4,14 @@ FROM node:22-bookworm-slim AS build
4
4
 
5
5
  ENV PNPM_HOME=/pnpm
6
6
  ENV PATH=$PNPM_HOME:$PATH
7
+ ENV CI=true
7
8
  WORKDIR /workspace
8
9
 
9
10
  RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
11
+ COPY pnpm-lock.yaml pnpm-workspace.yaml ./
12
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch --frozen-lockfile
10
13
  COPY . .
11
- RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --frozen-lockfile
14
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
12
15
  RUN pnpm build
13
16
 
14
17
  FROM nginxinc/nginx-unprivileged:1.29-alpine AS runtime
@@ -24,13 +27,16 @@ FROM node:22-bookworm-slim AS build
24
27
 
25
28
  ENV PNPM_HOME=/pnpm
26
29
  ENV PATH=$PNPM_HOME:$PATH
30
+ ENV CI=true
27
31
  WORKDIR /workspace
28
32
 
29
33
  RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
34
+ COPY pnpm-lock.yaml pnpm-workspace.yaml ./
35
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm fetch --frozen-lockfile
30
36
  COPY . .
31
- RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --frozen-lockfile
37
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm install --offline --frozen-lockfile --ignore-scripts
32
38
  RUN pnpm build
33
- RUN pnpm --filter {{projectName}} --prod deploy /prod/app
39
+ RUN --mount=type=cache,id={{projectName}}-pnpm,target=/pnpm/store pnpm --filter {{projectName}} --prod deploy /prod/app
34
40
 
35
41
  FROM node:22-bookworm-slim AS runtime-base
36
42
 
@@ -50,7 +56,7 @@ CMD ["node", "build"]
50
56
 
51
57
  FROM runtime-base AS runtime-with-data
52
58
 
53
- COPY --from=embedded-data --chown=node:node . /app/data
59
+ COPY --from=embedded-data --chown=node:node / /app/data/
54
60
 
55
61
  FROM runtime-base AS runtime
56
62
  {{/if}}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "{{projectName}}",
3
- "version": "0.1.0-alpha.51",
3
+ "version": "0.1.0-alpha.53",
4
4
  "private": true,
5
5
  "description": "{{#if_eq appType 'admin'}}Enterprise management system built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'website'}}Enterprise website built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'h5'}}Mobile H5 app built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'api'}}REST API service built with hai Agent Framework{{/if_eq}}{{#if_eq appType 'mobile-app'}}Mobile app built with hai Agent Framework{{/if_eq}}",
6
6
  "keywords": ["hai", {{#unless isSvelteOnlyApp}}"sveltekit", {{/unless}}"svelte5", "{{appType}}"],
@@ -337,6 +337,10 @@ if (!playable.success)
337
337
  - 平台不支持的输入方式(如 OpenAI/MiMo 的持续音频输入)会抛 `AUDIO_UNSUPPORTED_INPUT`,不伪装成实时。
338
338
  - 浏览器/移动端经 `@h-ai/serv` 统一语音 WebSocket 入口访问;客户端用已登录 HTTP 请求获取短期一次性 ticket,`@h-ai/ai/client` 通过 `audio.getTicket` 建连,不把 IAM access token 放入 URL。浏览器客户端区分正常结束、取消(`AUDIO_CANCELLED`)与 `end` 前异常断连(`AUDIO_CONNECTION_FAILED`);服务端 error 帧保留领域错误码,`synthesize` 不返回未完成的部分音频。
339
339
 
340
+ ### 本地 CPU 模型服务
341
+
342
+ hai-framework 源码仓库提供 `pnpm model <name> --device cpu` 与 `pnpm model:run <name>`,默认优先使用 Podman,可构建/启动 `faster-whisper-large-v3`、`indextts-2.5`、`qwen3-4b`。三套权重均优先从清单锁定的 ModelScope revision 下载并复用容器 volume,Hugging Face 只作回退。Whisper/IndexTTS 仍配置为 `audio.models[].baseUrl`,Qwen3-4B 仍配置为 OpenAI-compatible `llm.models[].baseUrl`;部署位置不进入 Provider 名称。离线部署使用准备权重后的 bundled 镜像。
343
+
340
344
  ## 文生图(Image)
341
345
 
342
346
  `ai.image` 只暴露稳定的提示词、模型别名、像素尺寸、可选参考图和图片字节;OpenAI、Google、Qwen、Seedream 与 Pollinations 的请求结构、鉴权、响应解析和临时 URL 下载均封装在内部 Provider。
@@ -358,7 +358,9 @@ core.init() → reldb.init() → cache.init() → iam.init() → createBusinessT
358
358
 
359
359
  - 保持脚手架根目录 `Dockerfile`、`.dockerignore` 与 `package.json` 中 `docker:build*` / `podman:build*` 命令同步。
360
360
  - Podman 多阶段构建保持 `--format docker --jobs=1`;前者保留 `HEALTHCHECK`,后者避免部分 Podman 5.8/Buildah 组合在并行 stage 构建时触发内部 semaphore panic。
361
- - SvelteKit 服务使用 Node adapter;纯 Vite 前端使用带 SPA fallback 的静态镜像;fullstack 默认支持 Web + Service 同源镜像。
361
+ - 先复制 lockfile 与 workspace 配置执行 `pnpm fetch`,再复制源码并离线安装、构建;避免源码变化重复下载依赖,同时兼容新增 workspace。
362
+ - SvelteKit 服务使用 Node adapter;纯 Vite 前端使用带 SPA fallback 的静态镜像;fullstack 的 Service 与 Web 分别交付,Service 直接监听且不增加转发进程。
363
+ - Web 的 `PUBLIC_*` 部署配置在容器启动时生成静态运行时配置,不作为 build arg 烘焙,确保同一镜像可跨环境部署。
362
364
  - 配置文件随镜像提供,并允许只读挂载覆盖;密钥只通过环境变量或 `--env-file` 注入。
363
365
  - 普通构建不携带 `data/`;只有用户明确运行 `docker:build:data` / `podman:build:data` 时才通过 named build context 嵌入 SQLite/本地存储初始快照。
364
366
  - 数据库和本地存储的持续写入必须落到 volume,不能依赖镜像可写层。
@@ -1,194 +0,0 @@
1
- {{! @skipIfEmpty }}
2
- {{#if fullstack.frontends.web}}
3
- import { spawn } from 'node:child_process'
4
- import { createReadStream } from 'node:fs'
5
- import { readFile, stat } from 'node:fs/promises'
6
- import { createServer, request as createProxyRequest } from 'node:http'
7
- import { extname, relative, resolve, sep } from 'node:path'
8
- import process from 'node:process'
9
-
10
- const serviceRoot = '/app/service'
11
- const webRoot = '/app/web'
12
- const publicPort = readPort('HAI_WEB_PORT', 8080)
13
- const servicePort = readPort('HAI_SERV_PORT', 3000)
14
- const indexHtml = await readFile(resolve(webRoot, 'index.html'), 'utf8')
15
- const indexWithRuntimeApi = injectRuntimeApiOrigin(indexHtml)
16
- let shuttingDown = false
17
-
18
- // 服务进程只监听容器回环地址,所有外部流量统一经过同源 Web 入口。
19
- const service = spawn(process.execPath, ['dist/index.js'], {
20
- cwd: serviceRoot,
21
- env: {
22
- ...process.env,
23
- HOST: '127.0.0.1',
24
- PORT: String(servicePort),
25
- },
26
- stdio: 'inherit',
27
- })
28
-
29
- const server = createServer(async (request, response) => {
30
- const requestUrl = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`)
31
-
32
- if (isServicePath(requestUrl.pathname)) {
33
- proxyToService(request, response)
34
- return
35
- }
36
-
37
- if (request.method !== 'GET' && request.method !== 'HEAD') {
38
- response.writeHead(405, { Allow: 'GET, HEAD' })
39
- response.end()
40
- return
41
- }
42
-
43
- await serveWebAsset(requestUrl.pathname, request.method === 'HEAD', response)
44
- })
45
-
46
- server.listen(publicPort, '0.0.0.0', () => {
47
- process.stdout.write(`Fullstack container listening on 0.0.0.0:${publicPort}\n`)
48
- })
49
-
50
- service.once('error', (error) => {
51
- process.stderr.write(`Service process failed to start: ${error.message}\n`)
52
- void shutdown('service-error')
53
- })
54
-
55
- service.once('exit', (code, signal) => {
56
- if (shuttingDown)
57
- return
58
- process.stderr.write(`Service process exited unexpectedly (code=${String(code)}, signal=${String(signal)})\n`)
59
- process.exitCode = code ?? 1
60
- server.close()
61
- })
62
-
63
- for (const signal of ['SIGINT', 'SIGTERM']) {
64
- process.once(signal, () => {
65
- void shutdown(signal)
66
- })
67
- }
68
-
69
- function readPort(name, fallback) {
70
- const raw = process.env[name]
71
- if (raw === undefined || raw === '')
72
- return fallback
73
- const value = Number(raw)
74
- if (!Number.isInteger(value) || value < 1 || value > 65535)
75
- throw new Error(`${name} must be an integer between 1 and 65535`)
76
- return value
77
- }
78
-
79
- function isServicePath(pathname) {
80
- return pathname === '/health'
81
- || pathname === '/ready'
82
- || pathname === '/openapi.json'
83
- || pathname.startsWith('/api/')
84
- || pathname.startsWith('/docs')
85
- || pathname.startsWith('/_hai/')
86
- }
87
-
88
- function proxyToService(request, response) {
89
- const proxyRequest = createProxyRequest({
90
- host: '127.0.0.1',
91
- port: servicePort,
92
- method: request.method,
93
- path: request.url,
94
- headers: {
95
- ...request.headers,
96
- host: `127.0.0.1:${servicePort}`,
97
- 'x-forwarded-host': request.headers.host ?? '',
98
- 'x-forwarded-proto': request.headers['x-forwarded-proto'] ?? 'http',
99
- },
100
- }, (proxyResponse) => {
101
- response.writeHead(proxyResponse.statusCode ?? 502, proxyResponse.headers)
102
- proxyResponse.pipe(response)
103
- })
104
-
105
- proxyRequest.once('error', (error) => {
106
- if (response.headersSent) {
107
- response.destroy(error)
108
- return
109
- }
110
- response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
111
- response.end('Service unavailable')
112
- })
113
- request.pipe(proxyRequest)
114
- }
115
-
116
- async function serveWebAsset(pathname, headOnly, response) {
117
- let decodedPath
118
- try {
119
- decodedPath = decodeURIComponent(pathname)
120
- }
121
- catch {
122
- response.writeHead(400)
123
- response.end()
124
- return
125
- }
126
-
127
- const candidate = resolve(webRoot, `.${decodedPath}`)
128
- const relativePath = relative(webRoot, candidate)
129
- if (relativePath.startsWith(`..${sep}`) || relativePath === '..') {
130
- response.writeHead(403)
131
- response.end()
132
- return
133
- }
134
-
135
- try {
136
- const file = await stat(candidate)
137
- if (file.isFile()) {
138
- response.writeHead(200, {
139
- 'content-length': file.size,
140
- 'content-type': contentType(candidate),
141
- 'x-content-type-options': 'nosniff',
142
- })
143
- if (headOnly)
144
- response.end()
145
- else
146
- createReadStream(candidate).pipe(response)
147
- return
148
- }
149
- }
150
- catch {
151
- // SPA 路由没有对应静态文件时回退到 index.html。
152
- }
153
-
154
- const body = Buffer.from(indexWithRuntimeApi)
155
- response.writeHead(200, {
156
- 'content-length': body.length,
157
- 'content-type': 'text/html; charset=utf-8',
158
- 'x-content-type-options': 'nosniff',
159
- })
160
- response.end(headOnly ? undefined : body)
161
- }
162
-
163
- function injectRuntimeApiOrigin(html) {
164
- const script = `<script>(()=>{const p=new URLSearchParams(location.search);if(!p.has('apiBase')){p.set('apiBase',location.origin);history.replaceState(null,'',location.pathname+'?'+p+location.hash)}})()</script>`
165
- return html.includes('<head>') ? html.replace('<head>', `<head>${script}`) : `${script}${html}`
166
- }
167
-
168
- function contentType(file) {
169
- return {
170
- '.css': 'text/css; charset=utf-8',
171
- '.html': 'text/html; charset=utf-8',
172
- '.ico': 'image/x-icon',
173
- '.js': 'text/javascript; charset=utf-8',
174
- '.json': 'application/json; charset=utf-8',
175
- '.map': 'application/json; charset=utf-8',
176
- '.png': 'image/png',
177
- '.svg': 'image/svg+xml',
178
- '.webp': 'image/webp',
179
- '.woff': 'font/woff',
180
- '.woff2': 'font/woff2',
181
- }[extname(file).toLowerCase()] ?? 'application/octet-stream'
182
- }
183
-
184
- async function shutdown(signal) {
185
- if (shuttingDown)
186
- return
187
- shuttingDown = true
188
- process.stdout.write(`Stopping fullstack container (${signal})\n`)
189
- server.close()
190
- service.kill('SIGTERM')
191
- const forceExit = setTimeout(() => service.kill('SIGKILL'), 10_000)
192
- forceExit.unref()
193
- }
194
- {{/if}}