From 18ba1ea483de88febd4cb7235d398c92db7d713a Mon Sep 17 00:00:00 2001 From: eric8810 Date: Wed, 22 Jul 2026 23:47:29 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(server):=20=E6=97=A7=E8=88=9F=E5=BD=92?= =?UTF-8?q?=E6=B8=AF=EF=BC=8C=E4=BA=91=E9=97=A8=E5=88=9D=E5=90=AF=20=C2=B7?= =?UTF-8?q?=20integrate=20server=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the REST and Docker implementation promised by PR #24 into the N2 workspace. Keep the package private at 0.1.0 while its versioned HTTP contract is evaluated. Bound uploads before buffering, preserve OcrError semantics, and reuse the representative native build for one integration request instead of duplicating the CI matrix. Co-authored-by: chatre7 --- .github/workflows/pull-request.yml | 5 + docker-compose.yml | 9 + docs/implementation-status.md | 12 +- docs/monorepo-design.md | 35 +- package-lock.json | 1026 +++++++++++++++++ package.json | 2 +- packages/light-ocr-server/Dockerfile | 23 + .../light-ocr-server/Dockerfile.dockerignore | 19 + packages/light-ocr-server/LICENSE | 202 ++++ packages/light-ocr-server/NOTICE | 7 + packages/light-ocr-server/README.md | 63 + packages/light-ocr-server/package.json | 35 + packages/light-ocr-server/src/app.js | 19 + packages/light-ocr-server/src/config.js | 29 + packages/light-ocr-server/src/engine.js | 25 + packages/light-ocr-server/src/errors.js | 37 + .../light-ocr-server/src/routes/health.js | 13 + packages/light-ocr-server/src/routes/info.js | 17 + packages/light-ocr-server/src/routes/ocr.js | 55 + packages/light-ocr-server/src/server.js | 38 + packages/light-ocr-server/test/app.test.js | 75 ++ packages/light-ocr-server/test/config.test.js | 28 + packages/light-ocr-server/test/engine.test.js | 26 + packages/light-ocr-server/test/errors.test.js | 68 ++ .../test/fixtures/hello-123.png | Bin 0 -> 11530 bytes .../test/ocr-concurrency.test.js | 78 ++ packages/light-ocr-server/test/server.test.js | 77 ++ 27 files changed, 2007 insertions(+), 16 deletions(-) create mode 100644 docker-compose.yml create mode 100644 packages/light-ocr-server/Dockerfile create mode 100644 packages/light-ocr-server/Dockerfile.dockerignore create mode 100644 packages/light-ocr-server/LICENSE create mode 100644 packages/light-ocr-server/NOTICE create mode 100644 packages/light-ocr-server/README.md create mode 100644 packages/light-ocr-server/package.json create mode 100644 packages/light-ocr-server/src/app.js create mode 100644 packages/light-ocr-server/src/config.js create mode 100644 packages/light-ocr-server/src/engine.js create mode 100644 packages/light-ocr-server/src/errors.js create mode 100644 packages/light-ocr-server/src/routes/health.js create mode 100644 packages/light-ocr-server/src/routes/info.js create mode 100644 packages/light-ocr-server/src/routes/ocr.js create mode 100644 packages/light-ocr-server/src/server.js create mode 100644 packages/light-ocr-server/test/app.test.js create mode 100644 packages/light-ocr-server/test/config.test.js create mode 100644 packages/light-ocr-server/test/engine.test.js create mode 100644 packages/light-ocr-server/test/errors.test.js create mode 100644 packages/light-ocr-server/test/fixtures/hello-123.png create mode 100644 packages/light-ocr-server/test/ocr-concurrency.test.js create mode 100644 packages/light-ocr-server/test/server.test.js diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index dfb497a..e8459dd 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -68,6 +68,11 @@ jobs: LIGHT_OCR_RUNTIME_DESCRIPTOR: ${{ github.workspace }}/build-pr/node-runtime/native/runtime-descriptor.json LIGHT_OCR_MODEL_BUNDLE: ${{ github.workspace }}/models/generated/ppocrv6-small-onnx-20260714.2 run: npm test + - name: Exercise the server through one real OCR request + env: + LIGHT_OCR_NODE_BINARY: ${{ github.workspace }}/build-pr/node-runtime/native/light_ocr_node.node + LIGHT_OCR_RUNTIME_DESCRIPTOR: ${{ github.workspace }}/build-pr/node-runtime/native/runtime-descriptor.json + run: npm run test:integration --workspace @arcships/light-ocr-server - name: Prove runtime operation without network access shell: bash run: >- diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a345448 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + light-ocr-server: + build: + context: . + dockerfile: packages/light-ocr-server/Dockerfile + ports: + - "3000:3000" + environment: + EXECUTION_MODE: cpu diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 7222079..d7ea6f7 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,7 +1,7 @@ # C++ Core 与 Node-API 实施状态 更新时间:2026-07-22
-结论:`@arcships/light-ocr@0.3.4` 与七个依赖包已发布,npm `next`/`latest` 均指向 `0.3.4`。`0.3.0` 交付的 Direct Core ML、Native WebGPU、D112 Auto 与自包含 payload 保持有效;`0.3.4` 修复 N1 CLI ROI 的 native option contract。N2 阶段 1 已按 D107 启动内部 workspace 迁移,但尚未发布 runtime、tiny 或 medium。 +结论:`@arcships/light-ocr@0.3.4` 与七个依赖包已发布,npm `next`/`latest` 均指向 `0.3.4`。`0.3.0` 交付的 Direct Core ML、Native WebGPU、D112 Auto 与自包含 payload 保持有效;`0.3.4` 修复 N1 CLI ROI 的 native option contract。N2 workspace 已包含 runtime、Small facade 与私有 server preview,但尚未发布 runtime、server、tiny 或 medium。 状态含义: @@ -19,6 +19,16 @@ - `bindings/node/` 暂时保留为 `0.3.x` 兼容发布源。切换前只运行一条 workspace 语义检查,不复制整套原生矩阵。 - tiny 尚未接入;下一步是消除旧 facade 与 workspace facade 的源码重复,并让 release assembler 能独立产出 runtime + Small meta tarball。 +## N2 阶段 2:Server preview + +状态:In progress + +- 历史 PR #24 的 REST API 已按原约定从 `chatre7/light-ocr-server` 迁回 `packages/light-ocr-server/`,保留来源说明与后续并发上传、优雅停机修复。 +- private `0.1.0` 精确依赖 workspace `@arcships/light-ocr@0.3.4`;当前不发布,也不要求新的 `light-ocr` patch 版本。 +- `/api/v1` 提供 health、info 与 multipart OCR;20 MiB 输入上限、HTTP 层并发拒绝和 `OcrError` HTTP 映射已有 contract tests。 +- Docker preview 复用根 workspace lock,支持 Linux x64/arm64,不再保留历史 `linux/amd64` 强制平台。 +- PR 只新增纯 HTTP contract 和代表性构建后的单次真实 OCR,不复制 Core/native 验证矩阵。 + ## 需求验收矩阵 | `requirements.md` §19 条目 | 状态 | 当前证据或缺口 | diff --git a/docs/monorepo-design.md b/docs/monorepo-design.md index 40a4cf5..97dbc47 100644 --- a/docs/monorepo-design.md +++ b/docs/monorepo-design.md @@ -1,6 +1,6 @@ # light-ocr Monorepo 设计 -状态:已接受用于 N2 阶段 1(2026-07-22)。 +状态:阶段 1 已落地,server preview 已提前进入阶段 2(2026-07-22)。 受众:维护者、贡献者。本文定义 monorepo 的目录结构、包依赖关系、迁移路径和约束,不替代各包自身的实现设计。 ## 1. 动机 @@ -158,10 +158,11 @@ bindings/node/ # @arcships/light-ocr,JS + native addon 混合 - `bindings/node/` 标记为 deprecated,保留到确认迁移稳定后删除。 - 此阶段保留 `bindings/node/` 作为 `0.3.x` 发布兼容入口,但只增加一条 workspace 契约检查,不重复运行两套完整原生矩阵;切换发布源之前必须证明 workspace facade 与旧入口的 API、错误类型和真实 OCR 语义一致。 -### 5.3 阶段 2:加入 server(N2 完成或 N3 前后) +### 5.3 阶段 2:加入 server(N2,已启动) -- `packages/light-ocr-server/` 加入 workspace。 -- Dockerfile 使用两阶段构建或从 npm registry 安装依赖。 +- `packages/light-ocr-server/` 已从历史 PR #24 及其独立仓库迁回 workspace,保留原贡献者归属。 +- Server 先以 private `0.1.0` preview 验证 HTTP 契约,不推动 `light-ocr` 版本,也不进入当前 npm release。 +- Preview Dockerfile 从根 workspace lock 安装精确依赖;server 与 runtime 正式发布后再切换为 registry-only production image。 - 本地开发时 `npm install` 自动 symlink workspace 内的 `light-ocr`,无需先发布。 ### 5.4 阶段 3:加入 tiny、medium(N2 GA) @@ -175,7 +176,7 @@ bindings/node/ # @arcships/light-ocr,JS + native addon 混合 ## 6. Server Docker 构建 -Server 是特殊的包:它既作为 npm 包发布,也作为 Docker 镜像分发。 +Server 是特殊的包:preview 期作为私有 workspace 和 Docker 镜像验证,契约稳定后再同时发布 npm 包与 Docker 镜像。 ### 6.1 Dockerfile 策略 @@ -185,11 +186,16 @@ FROM node:22-trixie-slim WORKDIR /app -# 从 npm registry 安装(生产模式不需要 workspace) +# Preview 从根 workspace lock 安装,正式发布后可缩为 registry-only image COPY package.json package-lock.json ./ -RUN npm ci --omit=dev +COPY packages/runtime/package.json ./packages/runtime/package.json +COPY packages/light-ocr/package.json ./packages/light-ocr/package.json +COPY packages/light-ocr-server/package.json ./packages/light-ocr-server/package.json +RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund -COPY src/ ./src/ +COPY packages/runtime/src ./packages/runtime/src +COPY packages/light-ocr/src ./packages/light-ocr/src +COPY packages/light-ocr-server/src ./packages/light-ocr-server/src RUN groupadd -r ocr && useradd -r -g ocr ocr USER ocr @@ -198,14 +204,15 @@ EXPOSE 3000 ENV EXECUTION_MODE=cpu ENV QUEUE_CAPACITY=4 -CMD ["node", "src/server.js"] +CMD ["node", "packages/light-ocr-server/src/server.js"] ``` ### 6.2 本地开发 vs 生产构建 -- **本地开发**:`npm install`(workspace 解析为 `packages/light-ocr/` 的 symlink),直接 `node src/server.js`,改动即时生效。 -- **生产 Docker 构建**:`npm install` 走 npm registry,拉取已发布的 `@arcships/light-ocr`。镜像构建独立于 monorepo。 -- **CI 集成测试**:先 `npm install`(workspace),再 `npm test --workspace packages/light-ocr-server`,验证 server + engine 的端到端行为。 +- **本地开发**:根目录执行 `npm install`,workspace 自动链接三个包;使用 `npm start --workspace @arcships/light-ocr-server`。 +- **Preview Docker 构建**:使用根 `package-lock.json`,把 runtime、Small facade 和 server 源码装入同一镜像;Linux x64/arm64 都使用对应的已发布 native package,不钉死平台。 +- **正式生产构建**:runtime、Small facade 与 server 发布后,可切为 registry-only image,不再复制 workspace 源码。 +- **CI**:前段只跑无 native 的 HTTP contract;代表性原生构建完成后只补一条真实 OCR 请求,不增加第二套原生矩阵。 ### 6.3 与 docker-compose 的关系 @@ -213,14 +220,14 @@ CMD ["node", "src/server.js"] ```yaml services: - light-ocr-api: + light-ocr-server: build: context: . dockerfile: packages/light-ocr-server/Dockerfile ports: - "3000:3000" environment: - - EXECUTION_MODE=cpu + EXECUTION_MODE: cpu ``` ## 7. 约束 diff --git a/package-lock.json b/package-lock.json index 9e66899..817c43b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,10 @@ "resolved": "packages/runtime", "link": true }, + "node_modules/@arcships/light-ocr-server": { + "resolved": "packages/light-ocr-server", + "link": true + }, "node_modules/@arcships/light-ocr-win32-arm64": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@arcships/light-ocr-win32-arm64/-/light-ocr-win32-arm64-0.3.4.tgz", @@ -133,6 +137,985 @@ "undici-types": "~6.21.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -154,6 +1137,36 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "packages/light-ocr": { "name": "@arcships/light-ocr", "version": "0.3.4", @@ -169,6 +1182,19 @@ "node": "^22.0.0 || ^24.0.0" } }, + "packages/light-ocr-server": { + "name": "@arcships/light-ocr-server", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@arcships/light-ocr": "0.3.4", + "express": "5.2.1", + "multer": "2.2.0" + }, + "engines": { + "node": "^22.0.0 || ^24.0.0" + } + }, "packages/runtime": { "name": "@arcships/light-ocr-runtime", "version": "0.1.0", diff --git a/package.json b/package.json index 5d99b93..1eeae31 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ ], "scripts": { "test": "npm test --workspaces --if-present", - "test:workspace": "npm test --workspace @arcships/light-ocr-runtime && npm test --workspace @arcships/light-ocr", + "test:workspace": "npm test --workspace @arcships/light-ocr-runtime && npm test --workspace @arcships/light-ocr && npm run test:contract --workspace @arcships/light-ocr-server", "typecheck": "tsc --strict --noEmit --skipLibCheck false --target ES2022 --module NodeNext --moduleResolution NodeNext --types node tools/npm/smoke.ts packages/runtime/test/types.ts" }, "devDependencies": { diff --git a/packages/light-ocr-server/Dockerfile b/packages/light-ocr-server/Dockerfile new file mode 100644 index 0000000..abd957a --- /dev/null +++ b/packages/light-ocr-server/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-trixie-slim + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY packages/runtime/package.json ./packages/runtime/package.json +COPY packages/light-ocr/package.json ./packages/light-ocr/package.json +COPY packages/light-ocr-server/package.json ./packages/light-ocr-server/package.json + +RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund + +COPY packages/runtime/src ./packages/runtime/src +COPY packages/light-ocr/src ./packages/light-ocr/src +COPY packages/light-ocr-server/src ./packages/light-ocr-server/src + +RUN groupadd -r ocr && useradd -r -g ocr ocr +USER ocr + +EXPOSE 3000 +ENV EXECUTION_MODE=cpu +ENV QUEUE_CAPACITY=4 + +CMD ["node", "packages/light-ocr-server/src/server.js"] diff --git a/packages/light-ocr-server/Dockerfile.dockerignore b/packages/light-ocr-server/Dockerfile.dockerignore new file mode 100644 index 0000000..de3cbae --- /dev/null +++ b/packages/light-ocr-server/Dockerfile.dockerignore @@ -0,0 +1,19 @@ +**/node_modules +.git +.github +.cache +build +build-* +corpus +docs +models +oracle +reports +tests +dist +*.md +!package.json +!package-lock.json +!packages/runtime/package.json +!packages/light-ocr/package.json +!packages/light-ocr-server/package.json diff --git a/packages/light-ocr-server/LICENSE b/packages/light-ocr-server/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/light-ocr-server/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/light-ocr-server/NOTICE b/packages/light-ocr-server/NOTICE new file mode 100644 index 0000000..474ea2e --- /dev/null +++ b/packages/light-ocr-server/NOTICE @@ -0,0 +1,7 @@ +light-ocr +Copyright 2026 light-ocr contributors + +This product includes software developed by the light-ocr contributors. + +Third-party licenses and notices for packaged dependencies and model +artifacts are distributed with their respective release artifacts. diff --git a/packages/light-ocr-server/README.md b/packages/light-ocr-server/README.md new file mode 100644 index 0000000..5e5f912 --- /dev/null +++ b/packages/light-ocr-server/README.md @@ -0,0 +1,63 @@ +# @arcships/light-ocr-server + +Preview HTTP API for [`@arcships/light-ocr`](https://github.com/arcships/light-ocr). It runs the default PP-OCRv6 Small engine behind a bounded multipart upload endpoint and can be started directly or as a Docker container. + +This package is private while its HTTP contract is evaluated. It is not part of the `0.3.x` npm release line yet. + +## Run locally + +From the monorepo root: + +```bash +npm install +npm start --workspace @arcships/light-ocr-server +``` + +The server listens on `PORT` (default `3000`). + +## Run with Docker + +From the monorepo root: + +```bash +docker build -f packages/light-ocr-server/Dockerfile -t light-ocr-server . +docker run --rm -p 3000:3000 light-ocr-server +``` + +Or run `docker compose up --build`. Native packages are available for both Linux x64 and Linux arm64; no Docker platform override is required. + +## API + +All endpoints use the `/api/v1` prefix. + +- `GET /api/v1/health` returns `{ "status": "ok" }`. +- `GET /api/v1/info` returns the server version and engine execution information. +- `POST /api/v1/ocr` accepts `multipart/form-data` with an `image` file field (JPEG or PNG, at most 20 MiB). + +```bash +curl -F "image=@sample.jpg" http://localhost:3000/api/v1/ocr +``` + +Successful OCR returns `{ "lines": [...] }`. Stable engine failures retain their `OcrError.code` in the JSON `error` field and are mapped to an HTTP status by category. + +## Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `PORT` | `3000` | TCP port, from 1 through 65535 | +| `EXECUTION_MODE` | `cpu` | `auto`, `cpu`, `apple`, or `webgpu` | +| `QUEUE_CAPACITY` | `4` | Engine queue and concurrent upload limit, from 1 through 64 | + +The HTTP concurrency check rejects excess uploads with `429` before Multer buffers their bodies. The native engine remains the final authority for image and resource validation. + +## Test + +```bash +npm test --workspace @arcships/light-ocr-server +``` + +The suite covers routing, upload backpressure, error mapping, shutdown ordering, configuration, and one real OCR fixture. + +## Provenance + +The server originated in [arcships/light-ocr#24](https://github.com/arcships/light-ocr/pull/24) by [chatre7](https://github.com/chatre7), then continued in [`chatre7/light-ocr-server`](https://github.com/chatre7/light-ocr-server) while the main repository prepared its N2 monorepo. It was migrated back when that prerequisite was met, retaining the later upload-bound and graceful-shutdown fixes. diff --git a/packages/light-ocr-server/package.json b/packages/light-ocr-server/package.json new file mode 100644 index 0000000..ea0ab53 --- /dev/null +++ b/packages/light-ocr-server/package.json @@ -0,0 +1,35 @@ +{ + "name": "@arcships/light-ocr-server", + "version": "0.1.0", + "private": true, + "description": "Preview REST API server for light-ocr", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/arcships/light-ocr.git", + "directory": "packages/light-ocr-server" + }, + "type": "commonjs", + "main": "src/server.js", + "files": [ + "src/", + "README.md", + "LICENSE", + "NOTICE", + "Dockerfile" + ], + "engines": { + "node": "^22.0.0 || ^24.0.0" + }, + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js", + "test:contract": "node --test test/config.test.js test/errors.test.js test/ocr-concurrency.test.js test/server.test.js", + "test:integration": "node --test test/app.test.js" + }, + "dependencies": { + "@arcships/light-ocr": "0.3.4", + "express": "5.2.1", + "multer": "2.2.0" + } +} diff --git a/packages/light-ocr-server/src/app.js b/packages/light-ocr-server/src/app.js new file mode 100644 index 0000000..c1f0567 --- /dev/null +++ b/packages/light-ocr-server/src/app.js @@ -0,0 +1,19 @@ +'use strict'; + +const express = require('express'); + +const healthRouter = require('./routes/health'); +const infoRouter = require('./routes/info'); +const ocrRouter = require('./routes/ocr'); +const { errorHandler } = require('./errors'); + +function createApp(engine, options = {}) { + const app = express(); + app.use('/api/v1', healthRouter()); + app.use('/api/v1', infoRouter(engine)); + app.use('/api/v1', ocrRouter(engine, options)); + app.use(errorHandler); + return app; +} + +module.exports = { createApp }; diff --git a/packages/light-ocr-server/src/config.js b/packages/light-ocr-server/src/config.js new file mode 100644 index 0000000..32b872f --- /dev/null +++ b/packages/light-ocr-server/src/config.js @@ -0,0 +1,29 @@ +'use strict'; + +const EXECUTION_MODES = new Set(['auto', 'cpu', 'apple', 'webgpu']); + +function readInteger(name, value, fallback, minimum, maximum) { + const raw = value ?? String(fallback); + if (!/^\d+$/.test(raw)) { + throw new Error(`${name} must be an integer from ${minimum} through ${maximum}`); + } + const parsed = Number(raw); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(`${name} must be an integer from ${minimum} through ${maximum}`); + } + return parsed; +} + +function readConfig(env = process.env) { + const executionMode = env.EXECUTION_MODE ?? 'cpu'; + if (!EXECUTION_MODES.has(executionMode)) { + throw new Error('EXECUTION_MODE must be one of: auto, cpu, apple, webgpu'); + } + return Object.freeze({ + port: readInteger('PORT', env.PORT, 3000, 1, 65535), + executionMode, + queueCapacity: readInteger('QUEUE_CAPACITY', env.QUEUE_CAPACITY, 4, 1, 64), + }); +} + +module.exports = { readConfig }; diff --git a/packages/light-ocr-server/src/engine.js b/packages/light-ocr-server/src/engine.js new file mode 100644 index 0000000..4d3a609 --- /dev/null +++ b/packages/light-ocr-server/src/engine.js @@ -0,0 +1,25 @@ +'use strict'; + +const { createEngine } = require('@arcships/light-ocr'); +const { readConfig } = require('./config'); + +let enginePromise = null; + +function initEngine(config = readConfig()) { + if (!enginePromise) { + enginePromise = createEngine({ + queueCapacity: config.queueCapacity, + execution: { provider: config.executionMode }, + }); + } + return enginePromise; +} + +function getEngine() { + if (!enginePromise) { + throw new Error('Engine not initialized; call initEngine() first'); + } + return enginePromise; +} + +module.exports = { initEngine, getEngine }; diff --git a/packages/light-ocr-server/src/errors.js b/packages/light-ocr-server/src/errors.js new file mode 100644 index 0000000..ac9123d --- /dev/null +++ b/packages/light-ocr-server/src/errors.js @@ -0,0 +1,37 @@ +'use strict'; + +const multer = require('multer'); + +const STATUS_BY_CODE = { + queue_full: 429, + resource_limit_exceeded: 413, + invalid_argument: 400, + invalid_image: 422, + unsupported_pixel_format: 400, + environment_closing: 503, +}; + +function statusForOcrError(code) { + return STATUS_BY_CODE[code] ?? 500; +} + +function errorHandler(error, request, response, next) { + if (error instanceof multer.MulterError) { + if (error.code === 'LIMIT_FILE_SIZE') { + response.status(413).json({ error: 'file_too_large' }); + return; + } + response.status(400).json({ error: 'invalid_upload', message: error.message }); + return; + } + if (error && error.name === 'OcrError') { + response.status(statusForOcrError(error.code)).json({ + error: error.code, + message: error.message, + }); + return; + } + response.status(500).json({ error: 'internal_error' }); +} + +module.exports = { statusForOcrError, errorHandler }; diff --git a/packages/light-ocr-server/src/routes/health.js b/packages/light-ocr-server/src/routes/health.js new file mode 100644 index 0000000..7ac31e4 --- /dev/null +++ b/packages/light-ocr-server/src/routes/health.js @@ -0,0 +1,13 @@ +'use strict'; + +const express = require('express'); + +function healthRouter() { + const router = express.Router(); + router.get('/health', (request, response) => { + response.status(200).json({ status: 'ok' }); + }); + return router; +} + +module.exports = healthRouter; diff --git a/packages/light-ocr-server/src/routes/info.js b/packages/light-ocr-server/src/routes/info.js new file mode 100644 index 0000000..460c429 --- /dev/null +++ b/packages/light-ocr-server/src/routes/info.js @@ -0,0 +1,17 @@ +'use strict'; + +const express = require('express'); +const packageJson = require('../../package.json'); + +function infoRouter(engine) { + const router = express.Router(); + router.get('/info', (request, response) => { + response.status(200).json({ + execution: engine.info.execution, + version: packageJson.version, + }); + }); + return router; +} + +module.exports = infoRouter; diff --git a/packages/light-ocr-server/src/routes/ocr.js b/packages/light-ocr-server/src/routes/ocr.js new file mode 100644 index 0000000..ee193bf --- /dev/null +++ b/packages/light-ocr-server/src/routes/ocr.js @@ -0,0 +1,55 @@ +'use strict'; + +const express = require('express'); +const multer = require('multer'); + +const MAX_FILE_BYTES = 20 * 1024 * 1024; +const DEFAULT_MAX_CONCURRENT_UPLOADS = 4; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_FILE_BYTES }, +}); + +function ocrRouter(engine, options = {}) { + const maxConcurrentUploads = + options.maxConcurrentUploads ?? DEFAULT_MAX_CONCURRENT_UPLOADS; + let activeUploads = 0; + + const router = express.Router(); + router.post( + '/ocr', + (request, response, next) => { + if (activeUploads >= maxConcurrentUploads) { + response.status(429).json({ error: 'too_many_uploads' }); + return; + } + activeUploads += 1; + let released = false; + const release = () => { + if (released) return; + released = true; + activeUploads -= 1; + }; + response.on('finish', release); + response.on('close', release); + next(); + }, + upload.single('image'), + async (request, response, next) => { + if (!request.file || request.file.buffer.length === 0) { + response.status(400).json({ error: 'missing_image' }); + return; + } + try { + const result = await engine.recognizeEncoded(request.file.buffer); + response.status(200).json({ lines: result.lines }); + } catch (error) { + next(error); + } + }, + ); + return router; +} + +module.exports = ocrRouter; diff --git a/packages/light-ocr-server/src/server.js b/packages/light-ocr-server/src/server.js new file mode 100644 index 0000000..9d76cd3 --- /dev/null +++ b/packages/light-ocr-server/src/server.js @@ -0,0 +1,38 @@ +'use strict'; + +const { createApp } = require('./app'); +const { readConfig } = require('./config'); +const { initEngine } = require('./engine'); + +function createShutdownHandler(server, engine, exit = process.exit) { + let shuttingDown = false; + return async function shutdown() { + if (shuttingDown) return; + shuttingDown = true; + await new Promise((resolve) => server.close(resolve)); + await engine.close(); + exit(0); + }; +} + +async function main() { + const config = readConfig(); + const engine = await initEngine(config); + const app = createApp(engine, { maxConcurrentUploads: config.queueCapacity }); + const server = app.listen(config.port, () => { + console.log(`light-ocr-server listening on port ${config.port}`); + }); + + const shutdown = createShutdownHandler(server, engine); + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} + +module.exports = { createShutdownHandler, main }; diff --git a/packages/light-ocr-server/test/app.test.js b/packages/light-ocr-server/test/app.test.js new file mode 100644 index 0000000..abd461c --- /dev/null +++ b/packages/light-ocr-server/test/app.test.js @@ -0,0 +1,75 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { after, before, test } = require('node:test'); + +const { createApp } = require('../src/app'); +const { initEngine } = require('../src/engine'); + +let engine; +let server; +let baseUrl; + +before(async () => { + engine = await initEngine({ executionMode: 'cpu', queueCapacity: 4 }); + const app = createApp(engine, { maxConcurrentUploads: 4 }); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + await new Promise((resolve) => server.close(resolve)); + await engine.close(); +}); + +test('GET /api/v1/health returns 200 ok', async () => { + const response = await fetch(`${baseUrl}/api/v1/health`); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { status: 'ok' }); +}); + +test('GET /api/v1/info returns execution info and version', async () => { + const response = await fetch(`${baseUrl}/api/v1/info`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.execution.requestedProvider, 'cpu'); + assert.equal(body.version, '0.1.0'); +}); + +test('POST /api/v1/ocr recognizes text in a real image', async () => { + const imagePath = path.resolve(__dirname, 'fixtures/hello-123.png'); + const imageBuffer = fs.readFileSync(imagePath); + const form = new FormData(); + form.set('image', new Blob([imageBuffer], { type: 'image/png' }), 'hello-123.png'); + + const response = await fetch(`${baseUrl}/api/v1/ocr`, { method: 'POST', body: form }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.ok(Array.isArray(body.lines)); + assert.ok(body.lines.some((line) => /HELLO/i.test(line.text))); +}); + +test('POST /api/v1/ocr without a file returns 400', async () => { + const response = await fetch(`${baseUrl}/api/v1/ocr`, { + method: 'POST', + body: new FormData(), + }); + assert.equal(response.status, 400); + assert.equal((await response.json()).error, 'missing_image'); +}); + +test('POST /api/v1/ocr with non-image data returns 422', async () => { + const form = new FormData(); + form.set( + 'image', + new Blob([Buffer.from('not an image')], { type: 'application/octet-stream' }), + 'garbage.bin', + ); + + const response = await fetch(`${baseUrl}/api/v1/ocr`, { method: 'POST', body: form }); + assert.equal(response.status, 422); + assert.equal((await response.json()).error, 'invalid_image'); +}); diff --git a/packages/light-ocr-server/test/config.test.js b/packages/light-ocr-server/test/config.test.js new file mode 100644 index 0000000..727b5db --- /dev/null +++ b/packages/light-ocr-server/test/config.test.js @@ -0,0 +1,28 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { readConfig } = require('../src/config'); + +test('readConfig supplies conservative defaults', () => { + assert.deepEqual(readConfig({}), { + port: 3000, + executionMode: 'cpu', + queueCapacity: 4, + }); +}); + +test('readConfig accepts supported explicit values', () => { + assert.deepEqual( + readConfig({ PORT: '8080', EXECUTION_MODE: 'auto', QUEUE_CAPACITY: '8' }), + { port: 8080, executionMode: 'auto', queueCapacity: 8 }, + ); +}); + +test('readConfig rejects invalid integer and provider values', () => { + assert.throws(() => readConfig({ PORT: '0' }), /PORT/); + assert.throws(() => readConfig({ PORT: '3000.5' }), /PORT/); + assert.throws(() => readConfig({ QUEUE_CAPACITY: '65' }), /QUEUE_CAPACITY/); + assert.throws(() => readConfig({ EXECUTION_MODE: 'cuda' }), /EXECUTION_MODE/); +}); diff --git a/packages/light-ocr-server/test/engine.test.js b/packages/light-ocr-server/test/engine.test.js new file mode 100644 index 0000000..47eaca6 --- /dev/null +++ b/packages/light-ocr-server/test/engine.test.js @@ -0,0 +1,26 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { after, test } = require('node:test'); + +const { initEngine, getEngine } = require('../src/engine'); + +after(async () => { + const engine = await getEngine(); + await engine.close(); +}); + +test('getEngine throws before initEngine has been called', () => { + assert.throws(() => getEngine(), /Engine not initialized/); +}); + +test('initEngine resolves a CPU engine from explicit configuration', async () => { + const engine = await initEngine({ executionMode: 'cpu', queueCapacity: 4 }); + assert.equal(engine.info.execution.requestedProvider, 'cpu'); +}); + +test('getEngine returns the initialized engine', async () => { + const first = await initEngine(); + const second = await getEngine(); + assert.equal(first, second); +}); diff --git a/packages/light-ocr-server/test/errors.test.js b/packages/light-ocr-server/test/errors.test.js new file mode 100644 index 0000000..5ceefe2 --- /dev/null +++ b/packages/light-ocr-server/test/errors.test.js @@ -0,0 +1,68 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const multer = require('multer'); + +const { statusForOcrError, errorHandler } = require('../src/errors'); + +function responseRecorder() { + return { + statusCode: undefined, + body: undefined, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + }, + }; +} + +test('statusForOcrError maps stable engine error codes', () => { + assert.equal(statusForOcrError('queue_full'), 429); + assert.equal(statusForOcrError('resource_limit_exceeded'), 413); + assert.equal(statusForOcrError('invalid_argument'), 400); + assert.equal(statusForOcrError('invalid_image'), 422); + assert.equal(statusForOcrError('unsupported_pixel_format'), 400); + assert.equal(statusForOcrError('environment_closing'), 503); + assert.equal(statusForOcrError('something_new'), 500); +}); + +test('errorHandler preserves an OcrError code and message', () => { + const error = Object.assign(new Error('boom'), { + name: 'OcrError', + code: 'invalid_argument', + }); + const response = responseRecorder(); + errorHandler(error, {}, response, () => {}); + assert.equal(response.statusCode, 400); + assert.deepEqual(response.body, { error: 'invalid_argument', message: 'boom' }); +}); + +test('errorHandler maps Multer file-size failures to 413', () => { + const response = responseRecorder(); + errorHandler(new multer.MulterError('LIMIT_FILE_SIZE'), {}, response, () => {}); + assert.equal(response.statusCode, 413); + assert.deepEqual(response.body, { error: 'file_too_large' }); +}); + +test('errorHandler maps other Multer failures to 400', () => { + const response = responseRecorder(); + errorHandler( + new multer.MulterError('LIMIT_UNEXPECTED_FILE', 'image'), + {}, + response, + () => {}, + ); + assert.equal(response.statusCode, 400); + assert.equal(response.body.error, 'invalid_upload'); +}); + +test('errorHandler hides unknown internal errors', () => { + const response = responseRecorder(); + errorHandler(new Error('secret detail'), {}, response, () => {}); + assert.equal(response.statusCode, 500); + assert.deepEqual(response.body, { error: 'internal_error' }); +}); diff --git a/packages/light-ocr-server/test/fixtures/hello-123.png b/packages/light-ocr-server/test/fixtures/hello-123.png new file mode 100644 index 0000000000000000000000000000000000000000..da41b4284053ba91af3d411b8b5eedaff5f944ba GIT binary patch literal 11530 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 literal 0 HcmV?d00001 diff --git a/packages/light-ocr-server/test/ocr-concurrency.test.js b/packages/light-ocr-server/test/ocr-concurrency.test.js new file mode 100644 index 0000000..012c020 --- /dev/null +++ b/packages/light-ocr-server/test/ocr-concurrency.test.js @@ -0,0 +1,78 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { after, before, test } = require('node:test'); +const express = require('express'); + +const { errorHandler } = require('../src/errors'); +const ocrRouter = require('../src/routes/ocr'); + +let server; +let baseUrl; +let recognizeStarted; +let releaseRecognize; + +function armRecognition() { + return new Promise((resolve) => { + recognizeStarted = resolve; + }); +} + +function imageForm(name) { + const form = new FormData(); + form.set( + 'image', + new Blob([Buffer.from('fake-image-bytes')], { type: 'image/png' }), + name, + ); + return form; +} + +before(async () => { + const fakeEngine = { + recognizeEncoded: () => { + recognizeStarted(); + return new Promise((resolve) => { + releaseRecognize = () => resolve({ lines: [] }); + }); + }, + }; + const app = express(); + app.use(ocrRouter(fakeEngine, { maxConcurrentUploads: 1 })); + app.use(errorHandler); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +test('POST /ocr rejects excess uploads and releases the slot', async () => { + let started = armRecognition(); + const firstRequest = fetch(`${baseUrl}/ocr`, { + method: 'POST', + body: imageForm('a.png'), + }); + await started; + + const secondResponse = await fetch(`${baseUrl}/ocr`, { + method: 'POST', + body: imageForm('b.png'), + }); + assert.equal(secondResponse.status, 429); + assert.equal((await secondResponse.json()).error, 'too_many_uploads'); + + releaseRecognize(); + assert.equal((await firstRequest).status, 200); + + started = armRecognition(); + const thirdRequest = fetch(`${baseUrl}/ocr`, { + method: 'POST', + body: imageForm('c.png'), + }); + await started; + releaseRecognize(); + assert.equal((await thirdRequest).status, 200); +}); diff --git a/packages/light-ocr-server/test/server.test.js b/packages/light-ocr-server/test/server.test.js new file mode 100644 index 0000000..2784ec8 --- /dev/null +++ b/packages/light-ocr-server/test/server.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { createShutdownHandler } = require('../src/server'); + +test('shutdown drains HTTP, closes the engine, then exits', async () => { + const calls = []; + const fakeServer = { + close(callback) { + calls.push('server.close'); + callback(); + }, + }; + const fakeEngine = { close: async () => calls.push('engine.close') }; + const shutdown = createShutdownHandler( + fakeServer, + fakeEngine, + (code) => calls.push(`exit(${code})`), + ); + + await shutdown(); + assert.deepEqual(calls, ['server.close', 'engine.close', 'exit(0)']); +}); + +test('shutdown only runs once when called concurrently', async () => { + const calls = []; + const fakeServer = { + close(callback) { + calls.push('server.close'); + callback(); + }, + }; + const fakeEngine = { close: async () => calls.push('engine.close') }; + const shutdown = createShutdownHandler( + fakeServer, + fakeEngine, + (code) => calls.push(`exit(${code})`), + ); + + await Promise.all([shutdown(), shutdown()]); + assert.deepEqual(calls, ['server.close', 'engine.close', 'exit(0)']); +}); + +test('shutdown waits for HTTP drain before closing the engine', async () => { + const calls = []; + let finishDrain; + const fakeServer = { + close(callback) { + calls.push('server.close:start'); + finishDrain = () => { + calls.push('server.close:done'); + callback(); + }; + }, + }; + const fakeEngine = { close: async () => calls.push('engine.close') }; + const shutdown = createShutdownHandler( + fakeServer, + fakeEngine, + (code) => calls.push(`exit(${code})`), + ); + + const shutdownPromise = shutdown(); + await Promise.resolve(); + assert.deepEqual(calls, ['server.close:start']); + + finishDrain(); + await shutdownPromise; + assert.deepEqual(calls, [ + 'server.close:start', + 'server.close:done', + 'engine.close', + 'exit(0)', + ]); +}); From e0edf4addb54e8c9a79683dccc34f16142b29ce4 Mon Sep 17 00:00:00 2001 From: eric8810 Date: Wed, 22 Jul 2026 23:49:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20=E9=95=BF=E9=AA=8C=E9=9A=8F=E6=BA=90?= =?UTF-8?q?=EF=BC=8C=E8=BD=BB=E8=88=9F=E7=96=BE=E8=BF=87=20=C2=B7=20route?= =?UTF-8?q?=20checks=20by=20changed=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the full Core job only when native, model, corpus, or release tooling paths change. Give workspace and server changes a focused Node job that still performs a real OCR request with exact published native dependencies. --- .github/workflows/pull-request.yml | 23 +++++++++++--------- .github/workflows/workspace.yml | 35 ++++++++++++++++++++++++++++++ docs/implementation-status.md | 2 +- docs/monorepo-design.md | 2 +- 4 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/workspace.yml diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index e8459dd..8872ed8 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -2,6 +2,19 @@ name: pull request on: pull_request: + paths: + - "CMakeLists.txt" + - "CMakePresets.json" + - "bindings/node/**" + - "cmake/**" + - "contracts/**" + - "corpus/**" + - "include/**" + - "models/**" + - "oracle/**" + - "src/**" + - "tests/**" + - "tools/**" permissions: contents: read @@ -22,11 +35,6 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: "22" - - name: Check the N2 workspace contracts - run: | - npm ci --ignore-scripts --no-audit --no-fund --omit=optional - npm run typecheck - npm run test:workspace - name: Validate repository and release tooling contracts run: python -m unittest discover -s tests/python -p 'test_*.py' - name: Bootstrap pinned inputs and prove the dependency cache is complete @@ -68,11 +76,6 @@ jobs: LIGHT_OCR_RUNTIME_DESCRIPTOR: ${{ github.workspace }}/build-pr/node-runtime/native/runtime-descriptor.json LIGHT_OCR_MODEL_BUNDLE: ${{ github.workspace }}/models/generated/ppocrv6-small-onnx-20260714.2 run: npm test - - name: Exercise the server through one real OCR request - env: - LIGHT_OCR_NODE_BINARY: ${{ github.workspace }}/build-pr/node-runtime/native/light_ocr_node.node - LIGHT_OCR_RUNTIME_DESCRIPTOR: ${{ github.workspace }}/build-pr/node-runtime/native/runtime-descriptor.json - run: npm run test:integration --workspace @arcships/light-ocr-server - name: Prove runtime operation without network access shell: bash run: >- diff --git a/.github/workflows/workspace.yml b/.github/workflows/workspace.yml new file mode 100644 index 0000000..166f688 --- /dev/null +++ b/.github/workflows/workspace.yml @@ -0,0 +1,35 @@ +name: workspace + +on: + pull_request: + paths: + - ".github/workflows/**" + - "docker-compose.yml" + - "package.json" + - "package-lock.json" + - "packages/**" + +permissions: + contents: read + +concurrency: + group: workspace-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + node: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "22" + - name: Install the exact workspace + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Check types and package contracts + run: | + npm run typecheck + npm test + - name: Parse the server Compose definition + run: docker compose config --quiet diff --git a/docs/implementation-status.md b/docs/implementation-status.md index d7ea6f7..07894cd 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -27,7 +27,7 @@ - private `0.1.0` 精确依赖 workspace `@arcships/light-ocr@0.3.4`;当前不发布,也不要求新的 `light-ocr` patch 版本。 - `/api/v1` 提供 health、info 与 multipart OCR;20 MiB 输入上限、HTTP 层并发拒绝和 `OcrError` HTTP 映射已有 contract tests。 - Docker preview 复用根 workspace lock,支持 Linux x64/arm64,不再保留历史 `linux/amd64` 强制平台。 -- PR 只新增纯 HTTP contract 和代表性构建后的单次真实 OCR,不复制 Core/native 验证矩阵。 +- Workspace PR 使用已发布的 native/model 依赖完成全部包测试和真实 OCR;只有 native/Core 相关路径才进入完整构建,server 不复制原生验证矩阵。 ## 需求验收矩阵 diff --git a/docs/monorepo-design.md b/docs/monorepo-design.md index 97dbc47..6d1819c 100644 --- a/docs/monorepo-design.md +++ b/docs/monorepo-design.md @@ -212,7 +212,7 @@ CMD ["node", "packages/light-ocr-server/src/server.js"] - **本地开发**:根目录执行 `npm install`,workspace 自动链接三个包;使用 `npm start --workspace @arcships/light-ocr-server`。 - **Preview Docker 构建**:使用根 `package-lock.json`,把 runtime、Small facade 和 server 源码装入同一镜像;Linux x64/arm64 都使用对应的已发布 native package,不钉死平台。 - **正式生产构建**:runtime、Small facade 与 server 发布后,可切为 registry-only image,不再复制 workspace 源码。 -- **CI**:前段只跑无 native 的 HTTP contract;代表性原生构建完成后只补一条真实 OCR 请求,不增加第二套原生矩阵。 +- **CI**:workspace 路径使用已发布的 native/model 精确依赖跑类型、HTTP contract 和一条真实 OCR;只有 C++、CMake、native bindings、模型或发布工具变更才进入完整 Core job。 ### 6.3 与 docker-compose 的关系