diff --git a/package.json b/package.json
index aeb08fa6d..38c7a6921 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,7 @@
"check:toolchain": "node scripts/check-toolchain.mjs",
"lint:fix": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"packages/artplayer-vitepress/{browser,build}/**/*.ts\" \"scripts/{docs-smoke,editor-declarations,documentation,site-build,library}/**/*.ts\" \"scripts/plugin/*.{js,ts}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --fix",
"check:plan": "node refactor/scripts/plan.mjs --check",
- "test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js test/media-gate.test.js test/ci-summary.test.js test/package-runtime.test.js test/library-build.test.js test/site-loading.test.js test/documentation-pipeline.test.js test/site-build.test.js test/site-markdown.test.js test/site-editor.test.js test/plugin-scaffold.test.js",
+ "test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js test/package-check.test.js test/declarations.test.js test/editor-types.test.js test/coverage.test.js test/performance-report.test.js test/media-gate.test.js test/ci-summary.test.js test/package-runtime.test.js test/library-build.test.js test/site-loading.test.js test/documentation-pipeline.test.js test/site-build.test.js test/site-markdown.test.js test/site-editor.test.js test/plugin-scaffold.test.js test/dev-server.test.js",
"test:baseline": "node --test refactor/scripts/*.test.mjs",
"ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:impact --report && yarn check:ci && yarn test:contracts && yarn check:contracts --report && yarn check:plan && yarn lint && yarn typecheck:library && yarn typecheck:scaffold && yarn typecheck:docs-tools && yarn check:docs-smoke && yarn check:editor-types && yarn check:llm && yarn typecheck:site-assets && yarn check:site-assets && yarn check:types && yarn typecheck && yarn typecheck:react && yarn lint:react && yarn typecheck:vue && yarn lint:vue && yarn test",
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:llm && yarn build:test && yarn build:docs && yarn test:imports",
@@ -131,7 +131,8 @@
"typecheck:scaffold": "node node_modules/typescript/bin/tsc -p scripts/tsconfig.scaffold.json --noEmit",
"test:scaffold": "node --test test/plugin-scaffold.test.js",
"typecheck:library": "node node_modules/typescript/bin/tsc -p scripts/tsconfig.library.json --noEmit",
- "test:library": "node --test refactor/scripts/build.test.mjs test/library-build.test.js test/performance-report.test.js test/plugin-scaffold.test.js"
+ "test:library": "node --test refactor/scripts/build.test.mjs test/library-build.test.js test/performance-report.test.js test/plugin-scaffold.test.js test/dev-server.test.js",
+ "test:dev-server": "node --test test/dev-server.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -159,6 +160,7 @@
"less": "4.5.1",
"linkedom": "0.18.13",
"monaco-editor": "0.30.1",
+ "mrmime": "2.0.1",
"pngjs": "7.0.0",
"prompts": "2.4.2",
"react": "19.1.1",
diff --git a/refactor/baselines/dev-server-validation.json b/refactor/baselines/dev-server-validation.json
new file mode 100644
index 000000000..f93d15db7
--- /dev/null
+++ b/refactor/baselines/dev-server-validation.json
@@ -0,0 +1,247 @@
+{
+ "task": "MOD-DEV-01",
+ "date": "2026-09-14",
+ "baselineCommit": "401d4cdd6557a69f9cfaf564f2b68f8589e526e2",
+ "node": "v24.21.0",
+ "yarn": "1.22.22",
+ "platform": "win32",
+ "dependency": {
+ "name": "mrmime",
+ "version": "2.0.1",
+ "category": "root devDependency",
+ "license": "MIT",
+ "purpose": "MIME lookup only",
+ "lockEntriesAdded": 1,
+ "frozenInstall": "--frozen-lockfile --ignore-scripts",
+ "postinstallExecuted": false
+ },
+ "historicalFailure": {
+ "task": "MOD-DEV-01",
+ "node": "v24.21.0",
+ "servor": "4.0.2",
+ "occupiedPort": 12671,
+ "code": 0,
+ "signal": null,
+ "output": "[ERR] The port you have specified is already in use!\n",
+ "interpretation": "Historical upstream defect reproduction, not candidate success: an occupied requested port exits the entire process with status zero."
+ },
+ "candidate": {
+ "occupiedPortExit": 1,
+ "originalServiceStillResponds": true,
+ "repeatedSamePortLifetimes": 3,
+ "newNodeTests": 8,
+ "cancelledDownloadBytes": 16777216,
+ "heartbeatTest": "Controlled clock; other HTTP and SSE lifecycle tests use real sockets"
+ },
+ "results": [
+ {
+ "file": "refactor/.cache/dev-server-library.log",
+ "passed": 26,
+ "failed": 0,
+ "skipped": 0,
+ "sha256": "abd6fca7249b64a63f9351b1b53f207dac1083fd8d713e23b39bf6e9c1e3f9ee"
+ },
+ {
+ "file": "refactor/.cache/dev-server-ci.log",
+ "passed": 50,
+ "failed": 0,
+ "skipped": 0,
+ "sha256": "04e732cfccb07be92ccdedf5ddc8133b4861f0b631128419c2319183f1aee515"
+ },
+ {
+ "file": "refactor/.cache/dev-server-baseline.log",
+ "passed": 522,
+ "failed": 0,
+ "skipped": 0,
+ "sha256": "a9e44d6c3487319a9b7fbb9058df7a79e3625daf3abb3e4e9cd23c00508e54bd"
+ }
+ ],
+ "logs": [
+ {
+ "file": "refactor/.cache/dev-server-tests-final.log",
+ "sha256": "6cd260af733aa6220d4bf5aeea2307d9140aed7cee9ddb85040c1540c4bcabd3"
+ },
+ {
+ "file": "refactor/.cache/dev-server-types.log",
+ "sha256": "b8ce65e8a25624581da20e526f95117a989ea5c5c3033cc1a3a6861e6cec5f36"
+ },
+ {
+ "file": "refactor/.cache/dev-server-lint-final.log",
+ "sha256": "ccc1f36e43545ccdfb7aef1d55d40496c343ff10c6036c8fd961f378efc48d48"
+ },
+ {
+ "file": "refactor/.cache/dev-server-toolchain.log",
+ "sha256": "1059b56fdfb592beff8f39768d64ad13df19bc9a550a9444a943133278aefb3a"
+ },
+ {
+ "file": "refactor/.cache/dev-server-frozen-install.log",
+ "sha256": "4d0bee94ffb5dd5ee805368049bf45d89de83d9edfbb8fa46deee26977902a33"
+ },
+ {
+ "file": "refactor/.cache/dev-server-browser-verified.log",
+ "sha256": "eda334fa0841c12e815aedfe5aad7c92efaeb0afdb5c01864fdc358ac332829a"
+ },
+ {
+ "file": "refactor/.cache/dev-server-final.log",
+ "sha256": "9062d987689bda3d5285bf51c175350bd938bcbb92db3b86f994d9174a72bf00"
+ }
+ ],
+ "sources": [
+ {
+ "file": "scripts/dev.js",
+ "sha256": "4ef37e14e38ef1219c1f0dd9f477c91142bc222449805b560ed3b210b068655e"
+ },
+ {
+ "file": "scripts/library/development.ts",
+ "sha256": "202d1cfa5cc5ba7a5ee474a5a7e36afaf028b7be834516ab0be5cc97a3a53f71"
+ },
+ {
+ "file": "scripts/library/server.ts",
+ "sha256": "389303abf965e5b8a1138de194b766457f625bf06438b50e48fb388d8a537dfd"
+ },
+ {
+ "file": "scripts/library/assets.ts",
+ "sha256": "639c8137ccce15f903267e066e5dc55b4776c76bdfcfe5550599b15dc17e0f2e"
+ },
+ {
+ "file": "scripts/library/reload.ts",
+ "sha256": "0ee863e38c3065826d6432656e7c7d52ab4326733109195ad57ac6583fc6da58"
+ },
+ {
+ "file": "scripts/library/config.ts",
+ "sha256": "2017a781a78e227e8ecfd98af56ea9d3ae91f4f033f36b371e0efac1995a1aec"
+ },
+ {
+ "file": "scripts/library/vendor.d.ts",
+ "sha256": "af37a4c6bf37fde5c8eff862668f188eb1f3e9e3c12c3d46f563af78bd8b385f"
+ },
+ {
+ "file": "test/dev-server.test.js",
+ "sha256": "9cdb6c7911c036fff84b42edc95cfb039430b618f1ac0c48a736da8c2f646595"
+ },
+ {
+ "file": "test/browser/library-development.spec.js",
+ "sha256": "33e3cd93c726b9b8f3a14d7a98ee7962c0bcc749ac56f6bd9b048b28d7440e7d"
+ },
+ {
+ "file": "package.json",
+ "sha256": "85dcb9c9692d823364ca79d501249aa6e017521b4cd5e720cb1fc2561e0571db"
+ },
+ {
+ "file": "yarn.lock",
+ "sha256": "aa33cfc38c7933ab53ce626a911fdafd7b14023e45f93ca37bf7f2f6a1d3ecce"
+ }
+ ],
+ "browser": {
+ "file": "refactor/.cache/dev-server-browser-verified/report.json",
+ "sha256": "f23105cace080f11a97faaa1992f45cf196bd2fd7c5281f9ef7a0ca7930e5aa2",
+ "stats": {
+ "startTime": "2026-09-14T03:27:04.872Z",
+ "duration": 24092.995,
+ "expected": 6,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "actualDocs": [
+ {
+ "engine": "chromium",
+ "browser": "153.0.8010.12",
+ "platform": "win32",
+ "inputs": [
+ {
+ "file": "docs/uncompiled/artplayer/index.js",
+ "sha256": "a7b9baf492208d8b9b8f56644389b90d23f74df204139ea619efdd667da5ea83"
+ },
+ {
+ "file": "docs/assets/sample/video.mp4",
+ "sha256": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980"
+ }
+ ],
+ "frameAlpha": 255,
+ "state": {
+ "version": "5.4.1",
+ "typed": 17,
+ "currentTime": 2,
+ "paused": true,
+ "width": 640,
+ "height": 360,
+ "error": 0,
+ "editorModels": 23
+ }
+ },
+ {
+ "engine": "firefox",
+ "browser": "155.0",
+ "platform": "win32",
+ "inputs": [
+ {
+ "file": "docs/uncompiled/artplayer/index.js",
+ "sha256": "a7b9baf492208d8b9b8f56644389b90d23f74df204139ea619efdd667da5ea83"
+ },
+ {
+ "file": "docs/assets/sample/video.mp4",
+ "sha256": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980"
+ }
+ ],
+ "frameAlpha": 255,
+ "state": {
+ "version": "5.4.1",
+ "typed": 17,
+ "currentTime": 2,
+ "paused": true,
+ "width": 640,
+ "height": 360,
+ "error": 0,
+ "editorModels": 23
+ }
+ },
+ {
+ "engine": "webkit",
+ "browser": "26.6",
+ "platform": "win32",
+ "inputs": [
+ {
+ "file": "docs/uncompiled/artplayer/index.js",
+ "sha256": "a7b9baf492208d8b9b8f56644389b90d23f74df204139ea619efdd667da5ea83"
+ },
+ {
+ "file": "docs/assets/sample/video.mp4",
+ "sha256": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980"
+ }
+ ],
+ "frameAlpha": 255,
+ "state": {
+ "version": "5.4.1",
+ "typed": 17,
+ "currentTime": 2,
+ "paused": true,
+ "width": 538,
+ "height": 303,
+ "error": 0,
+ "editorModels": 23
+ }
+ }
+ ],
+ "cliShutdown": "IPC emits SIGTERM in child, natural process exit0; not native Unix signal delivery",
+ "fixture": "Real CLI TS/Less/SVG/worker, automatic source/docs reload and compiler failure recovery"
+ },
+ "earlierAttempts": [
+ {
+ "file": "refactor/.cache/dev-server-tests-initial.log",
+ "sha256": "31e84f1014fde74a7c3c0f07a399572c0fb0b2a610d8bd6ee7350165ebf4101c"
+ },
+ {
+ "file": "refactor/.cache/dev-server-browser-docs-initial.log",
+ "sha256": "f60dd2a5b1d59d5dd87519e313d20bf0ab34446a7f3f3ef79b62e16da86a88fb"
+ }
+ ],
+ "limits": [
+ "Windows local runs only; remote OS CI and physical devices remain separate gates",
+ "Docs playback uses tracked core5.4.1, not a newly built release candidate",
+ "Analytics and ads inert in browser fixture",
+ "Build/source/config dependency changes outside selected src still require restart",
+ "Frozen install skipped lifecycle scripts",
+ "No push, publication or package artifact regeneration"
+ ]
+}
diff --git a/refactor/build-development.md b/refactor/build-development.md
index 4cf0fd187..53dc63211 100644
--- a/refactor/build-development.md
+++ b/refactor/build-development.md
@@ -6,8 +6,9 @@ MOD-PLUGIN-01 将 `yarn create:plugin` 的实现拆为严格 TS 渲染/文件写
运行 `yarn typecheck:scaffold` 与 `yarn test:scaffold`,具体限制和模块地图见
[生成器维护指南](../scripts/plugin/README.md)。MOD-02 将剩余库构建/开发实现移到
scripts/library 的严格 TS 模块,保留旧命令/模块入口;其模块地图与生命周期限制见
-[库工具维护指南](../scripts/library/README.md)。Servor 端口失败状态和关闭责任由
-MOD-DEV-01 继续处理,不因 TS 迁移而豁免。
+[库工具维护指南](../scripts/library/README.md)。MOD-DEV-01 将 HTTP、静态文件和
+SSE 拆为自有 TS 模块,修复端口失败退出码并明确关闭责任;根开发依赖
+mrmime 2.0.1 只负责 MIME 名称,Servor 仅保留打开浏览器的 helper。
ENG-06 保留原来的 Vite 7.3.6、Terser、三种库产物及路径,增加选包参数和 TS 入口;未切换 bundler 或运行时依赖。固定工具链仍为 Node 24.21.0 / Yarn 1.22.22。
@@ -48,6 +49,14 @@ Vite 支持 TS 转译,但不替代类型检查,源码检查继续运行 yarn
dev 仍监听所选包的 src 目录,包外共享文件/tsconfig/依赖变化需要重启;不宣称已做完整依赖图热更新。错误会打印并保留监听,修复后重建;--no-open 仅禁止自动启动外部浏览器,访问地址不变。实际验证中源码修改和错误恢复后手动刷新页面确认了新结果。
+MOD-DEV-01 的自动化测试直接验证自动刷新、真实 JS CLI 的 SIGTERM 自然退出、
+连续复用同一端口,以及实际 docs/Monaco 的 TS 执行和本地 MP4 播放/跳转。
+默认 8082 不变;可设置 ARTPLAYER_DEV_PORT=0 让测试原子分配端口,或指定其他
+固定端口。冲突/无效值退出非零,不停止原服务。会话 close/done 管理源文件与
+docs 监听器、HTTP 请求/连接、SSE 心跳及正在运行的构建;修改生命周期时先跑
+`yarn test:dev-server`,再跑 `library-development.spec.js` 三引擎。
+详细修正与边界见[记录](changes/2026-09-14-MOD-DEV-01-dev-server.md)。
+
## 本次发现的 AMD 构建缺陷
BUILD-AMD-01:旧插件在压缩 UMD 中把参数 t 写死为全局对象。但构建夹具的包装为 function(e,t),此时 t 是工厂;旧补丁生成 t.artplayerPluginBuildProbe=t(),AMD 返回虽可用,全局导出却不存在。补丁现在读取外层包装的第一个参数。只有实际 outputOptions.format=umd 时处理,避免将内联 worker 的 ES 输出当作 UMD。
diff --git a/refactor/changes/2026-09-14-MOD-DEV-01-dev-server.md b/refactor/changes/2026-09-14-MOD-DEV-01-dev-server.md
new file mode 100644
index 000000000..8098de5d4
--- /dev/null
+++ b/refactor/changes/2026-09-14-MOD-DEV-01-dev-server.md
@@ -0,0 +1,110 @@
+# MOD-DEV-01: Owned development server and failure lifecycle
+
+## Scope and reproduced defect
+
+Starting from MOD-02 commit 401d4cdd6557a69f9cfaf564f2b68f8589e526e2,
+replace Servor's HTTP/watch/reload lifetime with small strict TS modules. This
+changes repository development tooling, not any package runtime, declarations,
+DOM hooks or distribution entrypoints. Default port8082, docs root, package output
+paths, old dev command, --no-open and first-success browser opening remain.
+
+The retained `refactor/scripts/servor-port-probe.mjs` reproduces pinned Servor4.0.2
+printing an occupied-port error then exiting0. It uses a private random port;
+the user's existing8082 service is untouched. Missing close ownership was observed
+in upstream source; this does not assert an independently measured upstream leak.
+
+## Structure and resource ownership
+
+- `server.ts`: bind exactly the requested port, own HTTP sockets/request Promises,
+ recursive docs watcher, debounce and shutdown. Fatal errors notify the session
+ immediately so late builds cannot reopen/reload it, then finish cleanup.
+- `assets.ts`: realpath-contained static files, directory/index/fallback routing,
+ classic JS MIME, HTML reload injection, gzip and single media byte ranges.
+ Node stream pipelines release file/compression resources on disconnect and are
+ awaited by shutdown. Outside symlinks/junctions are rejected.
+- `reload.ts`: SSE client set and one shared heartbeat, removed when the final
+ client disconnects or the channel closes. Browser pagehide closes EventSource.
+- `development.ts`: explicit session `{ url, close, done }`, source watcher,
+ serialized/coalesced builds and AbortSignal. Close stops future work and awaits
+ startup/active build, including abort while asynchronous binding is unfinished.
+- `dev.js`: owns/removes SIGINT/SIGTERM handlers, validates optional
+ ARTPLAYER_DEV_PORT and propagates fatal failures as exit1. Port0 requests atomic
+ allocation for tests. Ordinary compiler failures retain the watcher for retry.
+
+The actual module map and modification guidance live in
+[scripts/library/README.md](../../scripts/library/README.md), with test, browser,
+typechecking, CI and development entry documentation updated together.
+`LibraryConfigOptions.outDir` becomes optional to describe existing write:false
+browser resource builds accurately; runtime production config is unchanged.
+
+## Dependencies and compatibility
+
+Add only root devDependency **mrmime2.0.1** (installed MIT license), using its own
+types for MIME lookup. The root Yarn lock gains its single entry. A brief sirv3.0.2
+evaluation was removed: owning range and stream teardown directly keeps these
+critical media-serving semantics visible. Vite dev transforms are unsuitable for
+the existing classic Monaco scripts, and preview process hooks would compete with
+the session's shutdown ownership. Servor4.0.2 stays only for openBrowser and the
+historical probe; its HTTP API is no longer imported or declared.
+
+Yarn1.22.22/Node24.21.0, Lerna, package runtime dependencies and installation hooks
+remain. Frozen install with --ignore-scripts and strict toolchain validation pass;
+the install check does not claim executing postinstall. `test:dev-server` is added
+to `test:node`/`test:library`; existing CI strict types and three-browser jobs cover
+the new modules/spec automatically. No remote workflow run is claimed.
+
+| Surface | Result |
+| --- | --- |
+| Consumer API/types/events/DOM/exports | No package source or public declaration changes |
+| CLI and URLs | Existing commands/default8082 retained; optional port env is additive |
+| Failure | Occupied port now exit1; never kills the pre-existing server |
+| Build/watch | Serial builds, compiler-error recovery and first-success open retained; successful output alone triggers build reload |
+| HTTP | Valid directory slash redirect/index; missing extensionless route returns fallback200 instead of old malformed301 without Location |
+| Media/static HTTP | Correct body-free HEAD, gzip;q=0, byte0-0/suffix/open ranges and 416 for invalid ranges; files remain untransformed |
+| Shutdown | Idempotent close/done, SIGINT/SIGTERM cleanup, no process.exit or global server signal hooks |
+
+These are development-server corrections, not a new public playback API or a
+production hosting replacement. Shared source/config/dependency edits still need
+restart; only selected src builds and docs asset edits are watched.
+
+## Verification and limits
+
+Authoritative results and fingerprints are in
+[dev-server-validation.json](../baselines/dev-server-validation.json).
+
+- 8 new Node tests: real HTTP/HEAD/gzip/ranges and malformed paths, outside
+ junction, real occupied-port CLI exit1 with the original service still serving,
+ three same-port lifetimes with SSE, aborted16MB download, cancellation during
+ startup/build and a separate controlled-clock heartbeat test.
+- 26 library regression tests pass, including real generated plugin builds and
+ consumers. Strict library types and lint pass with the existing one docs type
+ warning. 50 CI regressions and the full baseline suite are recorded separately.
+- Six final Windows browser cases cover real CLI TS/Less/SVG/worker builds,
+ automatic source/docs reload, syntax-error recovery and natural exit0 after
+ invoking the SIGTERM handler. Windows uses IPC to emit that event; native Unix
+ signal delivery remains a remote OS check.
+- Actual docs/Monaco runs TS, plays local MP4, pauses/seeks and yields opaque
+ decoded canvas pixels in Chromium153.0.8010.12, Firefox155.0 and WebKit26.6.
+ Attachments record the actual tracked core5.4.1 and sample hashes, dimensions,
+ time, editor models and browser versions. This validates the replacement server;
+ it does not certify a newly built npm candidate or all editor type diagnostics.
+ Third-party analytics/ads are inert test routes.
+- Initial Node failure was a message-regex assertion against Node's empty
+ AggregateError message; the test now requires exact ECONNREFUSED code. Initial
+ docs failures were an init script writing storage on about:blank and assuming
+ WebKit supplies requestVideoFrameCallback. Restrict storage setup to the owned
+ origin and assert a real decoded canvas frame alongside actual playback/seek.
+ Failed reports are retained; no production pixel or playback assertion removed.
+
+Linux/macOS remote CI, physical devices and release candidate validation remain
+their own gates. These local results close DEV-SERVOR-PORT-01 within this task's
+occupied-port and resource ownership scope, not the whole modernization plan.
+
+## Delivery and rollback
+
+Commit this task alone with [MOD-DEV-01], including source/tests/docs/lock/status.
+Revert that dedicated commit as a unit to restore the previous tooling; doing so
+also restores the recorded upstream port failure. No package artifacts needed
+regeneration, no version bump, push or publication occurs here. Subsequent
+performance measurements must use fresh artifacts because the lock/tooling
+fingerprint changed; previous measurements cannot silently remain current.
diff --git a/refactor/ci-setup.md b/refactor/ci-setup.md
index 7d4003e63..43a9ba8be 100644
--- a/refactor/ci-setup.md
+++ b/refactor/ci-setup.md
@@ -13,6 +13,7 @@
| `yarn typecheck:docs-tools` / `yarn check:docs-smoke` | 严格检查 TS 示例/声明生成器及 JS/MJS 门面;只读核对确定性生成的 readiness smoke,ci:check 执行 |
| `yarn typecheck:scaffold` / `yarn test:scaffold` | 严格检查插件生成器与旧 JS 入口;生成包真实构建/类型/分发消费和写入失败回归;分别接入 ci:check 与 test:node |
| `yarn typecheck:library` / `yarn test:library` | 严格检查库构建/开发 TS 与旧门面;复用真实资源构建、分发、性能证据和生成器回归;类型检查接入 ci:check |
+| `yarn test:dev-server` | 自有 HTTP/SSE 生命周期、端口冲突退出码、Range/HEAD/gzip、取消与重复启动回归;接入 test:node 和 test:library,真实 docs/CLI 检查进入既有三引擎 browser 矩阵 |
| `yarn check:editor-types` | 只读核对全部编辑器声明、SDK notices 和实际 libUris;主 TS 5.9.3/历史 4.3.5 整组语义检查,ci:check 执行 |
| `yarn build:site-assets` / `yarn check:site-assets` | 从站点 browser/ TS 生成三个经典脚本;check 只读,root build:docs 先生成;完整 VitePress 构建另行验收 |
| `yarn typecheck:site-assets` | 严格检查站点 browser/ 模块;生成脚本由 typecheck:docs-tools 的 checkJs 覆盖;均进入 ci:check |
diff --git a/refactor/plan.md b/refactor/plan.md
index a0bfc4927..4d616588a 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 245 项,范围 22 个包及工作区/示例。
-状态:todo 57 / doing 16 / blocked 0 / done 172 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 56 / doing 16 / blocked 0 / done 173 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -402,7 +402,7 @@
| MOD-01 | workspace
Bun 固定版本干净安装试点 | ENG-09 | 独立目录的 Bun 安装与 Yarn 冻结基线对比,默认仍为用户选定的 Yarn | Node 测试仍通过;安装与资源一致才决定采用;不改 bundler;不自动替换默认 packageManager 或维护锁文件 | H | todo |
| MOD-PLUGIN-01 | workspace
迁移插件生成器与类型化模板 | ENG-06, SITE-03 | 保留 create:plugin CLI;纯渲染/文件发布边界、TS 工厂/声明/测试/示例及维护指南 | 防止覆盖现有包或示例;失败回退;生成包可构建并通过严格类型、旧模块消费及测试;不自动修改既有包或运行安装发布 | M | done |
| MOD-02 | workspace
整理剩余开发/构建脚本与插件模板 | ENG-06, SITE-03, MOD-PLUGIN-01 | dev/build/utils/create-plugin 的 TS 与可测 CLI,模板同时提供旧 API | 旧脚本入口保留、新插件类型/测试/示例齐全,Lerna 改动单独取证 | M | done |
-| MOD-DEV-01 | workspace
修复开发服务器失败状态并明确资源生命周期 | MOD-02 | 修复 Servor 占用端口却退出 0 的已复现问题;明确 HTTP/监听/热刷新连接与定时器的启动、失败和关闭责任 | 保持默认 8082、docs 页面、自动刷新和旧 dev 命令;端口冲突退出非零且不终止其他服务,连续启动/停止及编译错误恢复有实际验证;跨平台监听差异单独留证 | M | todo |
+| MOD-DEV-01 | workspace
修复开发服务器失败状态并明确资源生命周期 | MOD-02 | 修复 Servor 占用端口却退出 0 的已复现问题;明确 HTTP/监听/热刷新连接与定时器的启动、失败和关闭责任 | 保持默认 8082、docs 页面、自动刷新和旧 dev 命令;端口冲突退出非零且不终止其他服务,连续启动/停止及编译错误恢复有实际验证;跨平台监听差异单独留证 | M | done |
| MOD-03 | workspace
测量并优化核心热路径 | CORE-22, ENG-08 | DOM 读写、进度更新、持久化、初始化的测量与改进 | 相同设备媒体多次比较,契约不变,收益及无效尝试记录;复用 BASE-06 的原始样本与测量限制,至少三组同环境旧新配对,不以单次变快宣称收益 | M | todo |
| MOD-04 | workspace
测量并优化重型插件/proxy | PKG-DANMUKU-09, PKG-MASK-06, PKG-MB-10, ENG-08 | 帧/队列/推理/音画同步与资源长期运行比较 | 不改默认算法/阈值,性能改善有证据;无收益则保留旧实现 | M | todo |
| MOD-05 | workspace
完成工具链与性能采用决策 | MOD-01, MOD-02, MOD-03, MOD-04, MOD-DEV-01 | 最终 runtime/packageManager/构建配置及性能台账 | 干净安装和全包检查通过;Bun 未采用有理由,不为状态强行切换 | M | todo |
@@ -624,6 +624,7 @@
- EX-02: [记录](changes/2026-09-14-EX-02-vue-consumer.md) [记录](baselines/vue-consumer-validation.json) [记录](scripts/vue-consumer.mjs)
- MOD-PLUGIN-01: [记录](changes/2026-09-14-MOD-PLUGIN-01-scaffold.md) [记录](baselines/scaffold-validation.json)
- MOD-02: [记录](changes/2026-09-14-MOD-02-library-tooling.md) [记录](baselines/library-tooling-validation.json)
+- MOD-DEV-01: [记录](changes/2026-09-14-MOD-DEV-01-dev-server.md) [记录](baselines/dev-server-validation.json)
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md) [记录](type-compatibility-policy.md) [记录](baselines/factory-compatibility-validation.json) [记录](changes/2026-09-13-PKG-FACTORY-01-compatible-types.md)
- CORE-25: [记录](changes/2026-09-13-CORE-25-defaults-ssr.md) [记录](baselines/defaults-ssr-validation.json)
- ENG-12: [记录](changes/2026-09-13-ENG-12-library-public.md) [记录](baselines/library-public-validation.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index b5c3a2333..a65212b51 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,20 @@
# 进度与证据
+## MOD-DEV-01 自有开发服务器与关闭流程完成
+
+HTTP、静态资源与 SSE 拆为严格 TS 模块,修复 Servor4.0.2 端口占用却退出0的
+已复现问题。旧 dev 命令/默认8082保留,新增可选 ARTPLAYER_DEV_PORT;冲突退出1,
+原服务继续运行。关闭负责监听器、连接、文件流、心跳/防抖与构建中的异步工作;
+编译错误可恢复,成功构建和 docs HTML 修改均验证自动刷新。根开发依赖新增
+mrmime2.0.1 仅查 MIME,Yarn冻结安装通过;Lerna/安装钩子/包源码与产物未改。
+26项库工具、522项baseline、50项CI通过,严格类型与工具链通过,lint 1既有warning。
+Windows三引擎最终6项通过,包括真实JS命令关闭、Monaco运行TS和本地MP4
+播放/暂停/跳转/解码帧;实际tracked核心5.4.1不等于新npm候选,远端OS与真机
+门槛仍保留。失败尝试和最终报告分开存档。详见[记录](changes/2026-09-14-MOD-DEV-01-dev-server.md)
+和[证据](baselines/dev-server-validation.json)。关闭DEV-SERVOR-PORT-01,245项变为
+173 done、16 doing、56 todo。独立本地提交后核实审计;无推送或发布。
+下一步继续 Auto Thumbnail/VAST 等未完成包与组合验证,VAST默认行为选择仍待答复。
+
## MOD-02 库构建与开发脚本 TS 迁移完成
旧 build/dev/utils/projects/rebuild/build-analysis 入口保留,实现拆为严格 TS
diff --git a/refactor/risk-table.md b/refactor/risk-table.md
index 12de9bb9d..38342b4cf 100644
--- a/refactor/risk-table.md
+++ b/refactor/risk-table.md
@@ -262,4 +262,4 @@
| SITE-EDITOR-AMD-01 | resolved / 已复现 | Late Monaco language AMD initialization races with example dependency loading | SITE-03 |
| VTT-CORE-NAME-01 | open / 已复现 | Published VTT 1.0.1 control name conflicts with the core placeholder introduced in 5.1.7 | PKG-VTT-THUMB-05, REL-08 |
| SCAFFOLD-OUTPUT-01 | resolved / 已复现 | Plugin generation overwrites existing examples and links to a different example name | MOD-PLUGIN-01 |
-| DEV-SERVOR-PORT-01 | open / 已复现 | Servor exits the entire dev process with status zero when the requested port is occupied | MOD-DEV-01 |
+| DEV-SERVOR-PORT-01 | resolved / 已复现 | Servor exits the entire dev process with status zero when the requested port is occupied | MOD-DEV-01 |
diff --git a/refactor/risks.json b/refactor/risks.json
index 0331058b2..06ceefaf0 100644
--- a/refactor/risks.json
+++ b/refactor/risks.json
@@ -5919,7 +5919,7 @@
"id": "DEV-SERVOR-PORT-01",
"title": "Servor exits the entire dev process with status zero when the requested port is occupied",
"confirmation": "reproduced",
- "status": "open",
+ "status": "resolved",
"owners": [
"MOD-DEV-01"
],
@@ -5930,7 +5930,14 @@
],
"compatibleResolution": "Preserve the default dev URL and CLI while reporting startup failures with nonzero status, without stopping another service. Define ownership of HTTP, watch and reload resources.",
"closureCriteria": "Real occupied-port and repeated startup/shutdown checks pass for the candidate; build, reload and error recovery remain compatible. Separate source-observed resource ownership limitations from reproduced leaks.",
- "workspaceState": "Pinned Servor4.0.2 probe prints the occupied-port error and exits0. TS migration retains that upstream behavior; MOD-DEV-01 must resolve it before the final tooling decision. Servor has no exposed close handle; this is a source observation, not a measured leak."
+ "workspaceState": "Owned HTTP/watch/SSE session replaces Servor HTTP. Real occupied-port CLI exits1 and leaves original service alive; repeated same-port shutdown/restart, cancellation, automatic reload and compiler recovery pass on Windows. Remote OS validation remains a separate release gate.",
+ "resolutionEvidence": [
+ "refactor/changes/2026-09-14-MOD-DEV-01-dev-server.md",
+ "refactor/baselines/dev-server-validation.json",
+ "test/dev-server.test.js",
+ "test/browser/library-development.spec.js"
+ ],
+ "resolutionRationale": "Exact port binding rejects with EADDRINUSE; CLI propagates exit1. Explicit close/done owns HTTP sockets, file pipelines, source/docs watchers, SSE heartbeat and active builds. Eight node tests and six Windows real-browser cases cover the stated defect and lifecycle scope without stopping unrelated services."
}
]
}
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 941d3b444..49fde8d10 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -4478,11 +4478,14 @@
"dependsOn": [
"MOD-02"
],
- "status": "todo",
+ "status": "done",
"risk": "M",
"deliverable": "修复 Servor 占用端口却退出 0 的已复现问题;明确 HTTP/监听/热刷新连接与定时器的启动、失败和关闭责任",
"acceptance": "保持默认 8082、docs 页面、自动刷新和旧 dev 命令;端口冲突退出非零且不终止其他服务,连续启动/停止及编译错误恢复有实际验证;跨平台监听差异单独留证",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-14-MOD-DEV-01-dev-server.md",
+ "baselines/dev-server-validation.json"
+ ]
},
{
"id": "MOD-03",
diff --git a/refactor/typechecking.md b/refactor/typechecking.md
index 54e374f9a..e1d33eeef 100644
--- a/refactor/typechecking.md
+++ b/refactor/typechecking.md
@@ -2,7 +2,9 @@
`yarn typecheck:library` 检查 scripts/library 的生产构建、开发、配置、banner、
分析、包选择和重建队列,同时检查旧 JS/MJS 门面。Vite 配置使用已安装的官方
-类型;vendor.d.ts 只描述实际消费的 prompts2.4.2/Servor4.0.2 调用,不声明全 SDK。
+类型;vendor.d.ts 只描述实际消费的 prompts2.4.2/Servor4.0.2 openBrowser 调用,
+不声明全 SDK。MOD-DEV-01 的 HTTP/assets/SSE 模块也在同一严格检查内;
+mrmime2.0.1 使用自带声明,Node HTTP/stream/watch 使用固定 Node 类型。
`yarn typecheck:scaffold` 检查 scripts/plugin 的严格 TS 模块及旧 JS 命令入口。
模板本身保留占位符,`yarn test:scaffold` 对实际渲染包执行源码检查和
diff --git a/scripts/dev.js b/scripts/dev.js
index cb8744749..4d133526f 100644
--- a/scripts/dev.js
+++ b/scripts/dev.js
@@ -1,7 +1,26 @@
import process from 'node:process'
import { runDevelop } from './library/development.ts'
-runDevelop().catch((error) => {
- console.error('❌ Development server failed:', error)
- process.exitCode = 1
-})
+const abort = new AbortController()
+const stop = () => abort.abort()
+process.once('SIGINT', stop)
+process.once('SIGTERM', stop)
+
+try {
+ const configured = process.env.ARTPLAYER_DEV_PORT
+ const port = configured === undefined ? 8082 : Number(configured)
+ if (configured?.trim() === '' || !Number.isInteger(port) || port < 0 || port > 65535)
+ throw new Error('ARTPLAYER_DEV_PORT must be an integer from 0 through 65535')
+ const session = await runDevelop({ port, signal: abort.signal })
+ await session?.done
+}
+catch (error) {
+ if (!abort.signal.aborted) {
+ console.error('❌ Development server failed:', error)
+ process.exitCode = 1
+ }
+}
+finally {
+ process.off('SIGINT', stop)
+ process.off('SIGTERM', stop)
+}
diff --git a/scripts/library/README.md b/scripts/library/README.md
index e8aecb25f..91746aa87 100644
--- a/scripts/library/README.md
+++ b/scripts/library/README.md
@@ -2,19 +2,24 @@
Use Node from `.node-version` and Yarn Classic 1.22.22. The historical JS/MJS
entrypoints remain checked compatibility shims; implementation lives in strict TS.
-No runtime/build dependency or bundler was replaced by this migration.
+Vite still owns library bundling. The dev HTTP server is repository-owned; the
+root dev dependency mrmime 2.0.1 supplies MIME names. Servor 4.0.2 remains only for
+its browser-opening helper and the isolated historical failure probe.
-| Module | Responsibility |
-| --- | --- |
-| `projects.ts` | Deterministic workspace discovery, CLI selection, non-TTY failure, one JS/TS entry |
-| `names.ts` | Historical camelCase/PascalCase library global names |
-| `config.ts` | Vite configuration and resource/worker/output targets; typed with installed Vite declarations |
-| `banner.ts` | Version/license/notices, UMD AMD/global identity, inline-worker banner removal |
-| `production.ts` | Sequential package/format builds, checked dist cleanup, docs copy and optional analysis |
-| `analysis.ts` | Per-build module and artifact size/hash reports outside dist |
-| `development.ts` | Servor demo startup, initial IIFE build, source watcher, first-success browser opening |
-| `rebuild.ts` | One active build Promise, coalesced pending changes and later retry after rejection |
-| `vendor.d.ts` | Only the consumed prompts 2.4.2 / Servor 4.0.2 APIs; verified against installed sources |
+| Module | Responsibility |
+| ---------------- | --------------------------------------------------------------------------------------------- |
+| `projects.ts` | Deterministic workspace discovery, CLI selection, non-TTY failure, one JS/TS entry |
+| `names.ts` | Historical camelCase/PascalCase library global names |
+| `config.ts` | Vite configuration and resource/worker/output targets; typed with installed Vite declarations |
+| `banner.ts` | Version/license/notices, UMD AMD/global identity, inline-worker banner removal |
+| `production.ts` | Sequential package/format builds, checked dist cleanup, docs copy and optional analysis |
+| `analysis.ts` | Per-build module and artifact size/hash reports outside dist |
+| `development.ts` | Session startup/close/done, IIFE build queue, source watcher and first-success browser opening |
+| `server.ts` | HTTP binding, docs watcher, request/socket ownership and shutdown |
+| `assets.ts` | Root-contained files, directory indexes, HTML injection, gzip and media ranges |
+| `reload.ts` | SSE clients, shared heartbeat and the browser reload script |
+| `rebuild.ts` | One active build Promise, coalesced pending changes and later retry after rejection |
+| `vendor.d.ts` | Only the consumed prompts 2.4.2 / Servor 4.0.2 openBrowser APIs |
`build.js` and `dev.js` own command execution/error reporting. `utils.js`,
`projects.js`, `rebuild.js` and `build-analysis.mjs` keep their previous named
@@ -33,6 +38,7 @@ yarn build --help
yarn dev --help
yarn typecheck:library
yarn test:library
+yarn test:dev-server
```
No-argument TTY selection uses the same prompts UI. Missing names in non-TTY mode,
@@ -54,13 +60,13 @@ under `scripts/library/`, including additions/removals. Do not reduce that cover
to `config.ts` alone: banners, name mapping, project discovery or analysis may also
affect artifacts. Markdown maintenance changes are not build inputs.
-## Development lifetime and remaining server work
+## Development lifetime
CLI defaults remain port 8082 and docs/index.html, with output at
-docs/uncompiled//index.js. The output directory is created before Servor
-enumerates directories for its Linux file watchers. The internal `runDevelop`
-function accepts a port only for isolated callers/tests; the CLI does not add a
-new flag. Do not stop an unrelated server occupying 8082 to run a test.
+docs/uncompiled//index.js. Optional `ARTPLAYER_DEV_PORT` accepts an integer
+0..65535; 0 atomically binds an available port for isolated callers/tests. Invalid
+values or occupied ports exit nonzero; there is no silent port fallback. Do not
+stop an unrelated server occupying 8082 to run a test.
Build errors are reported while keeping the source watcher alive. Changes during
a build schedule one subsequent build rather than concurrent writes. Browser
@@ -69,12 +75,31 @@ Only the package src tree is watched; external dependencies/configuration change
still require restart. Tests use real automatic reload rather than racing it with
a second manual navigation.
-Servor still owns its HTTP server, docs watchers, reload sockets/timers and SIGINT
-handler until process exit; its API exposes no close handle. MOD-DEV-01 tracks
-replacing/adapting that lifetime and fixing its reproduced occupied-port exit code
-zero. `node refactor/scripts/servor-port-probe.mjs` reproduces that upstream defect
-in an isolated child without touching 8082. It is not a candidate-success test.
-This migration does not claim those server limitations are resolved.
+`startDevelopment` returns `{ url, close, done }`. The CLI owns SIGINT/SIGTERM
+handlers and its AbortController; importing implementation modules installs none.
+`close()` is idempotent: stop queuing builds, remove the abort handler and source
+watcher, close HTTP/docs watching/SSE, then await the active build. A late build
+cannot open a browser or reload a closed session. Aborting during asynchronous
+startup waits for and closes a late-bound server. `done` rejects on fatal watcher
+or server errors; ordinary compiler errors retain the session for correction.
+
+`server.ts` owns every HTTP socket and active asset pipeline, the recursive docs
+watcher and 75ms debounce timer. Its close destroys only its own sockets and waits
+for request pipelines to settle. `reload.ts` has one 30s heartbeat while clients
+exist; the last disconnection or shutdown removes it. The browser closes its SSE
+connection on pagehide. Docs edits reload automatically; the selected output tree
+is ignored by the docs watcher and reloads only after a successful queued build.
+
+`assets.ts` leaves classic JS assets intact for Monaco, injects reload into HTML,
+serves directory index.html and escaped listings, and falls back to root index.html
+for missing extensionless routes. HTTP corrections include valid directory
+redirects, fallback status200, body-free HEAD, explicit gzip;q=0, exact/suffix/open
+media ranges and 416 for malformed/unsatisfiable ranges. Realpath containment
+rejects outside symlinks/junctions. Stream pipelines dispose file/gzip resources on
+disconnect. This local docs server is not a production hosting server.
+
+`node refactor/scripts/servor-port-probe.mjs` preserves the old Servor occupied-port
+exit0 reproduction. Candidate failure/cleanup checks live in test/dev-server.test.js.
## Evidence and regression ownership
@@ -86,13 +111,18 @@ This migration does not claim those server limitations are resolved.
- `test/plugin-scaffold.test.js`: newly generated plugins use these real tools.
- `test/browser/library-development.spec.js`: real dev server in Chromium, Firefox
and WebKit, worker messaging, automatic reload, source edits and error recovery.
- Uses fixture ports and disposes its own child; it does not test media playback.
+ The real JS CLI receives SIGTERM and must exit naturally with status0. A second
+ case serves actual docs/Monaco, executes TypeScript and plays/seeks/decodes local
+ MP4. Its recorded tracked core5.4.1 asset is not a newly built release candidate.
+- `test/dev-server.test.js`: real occupied-port CLI failure, HTTP/HEAD/ranges,
+ repeated same-port startup/shutdown with SSE, aborted download and startup/build
+ cancellation; a controlled clock separately checks heartbeat disposal.
- `node refactor/scripts/library-build-comparison.mjs`: copy current package sources
into a retained fixture, build all libraries with frozen pre-migration scripts
and current scripts at the same paths, compare all artifact hashes. This is an
explicit whole-build validation, not part of every quick unit test.
Remote Linux/macOS/Windows CI remains a separate release gate. The local browser
-evidence for MOD-02 is Windows; source inspection of Linux watcher registration
-does not substitute for a remote Linux run. Lerna and root installation hooks were
+evidence for MOD-02 and MOD-DEV-01 is Windows; it does not substitute for remote
+Linux/macOS runs or real device playback. Lerna and root installation hooks were
not changed. The scaffold's separate guide remains in `scripts/plugin/README.md`.
diff --git a/scripts/library/assets.ts b/scripts/library/assets.ts
new file mode 100644
index 000000000..d1430b22f
--- /dev/null
+++ b/scripts/library/assets.ts
@@ -0,0 +1,134 @@
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { Buffer } from 'node:buffer'
+import fs from 'node:fs'
+import path from 'node:path'
+import { pipeline } from 'node:stream/promises'
+import { createGzip, gzipSync } from 'node:zlib'
+import { lookup } from 'mrmime'
+import { reloadScript } from './reload.ts'
+
+function inside(root: string, file: string): boolean {
+ const relative = path.relative(root, file)
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
+}
+
+function error(response: ServerResponse, status: number) {
+ response.writeHead(status).end(String(status))
+}
+
+function acceptsGzip(request: IncomingMessage): boolean {
+ const encodings = (request.headers['accept-encoding'] || '').split(',').map((entry) => {
+ const [name = '', ...parameters] = entry.toLowerCase().split(';')
+ const quality = parameters.find(value => value.trim().startsWith('q='))
+ return { name: name.trim(), quality: quality ? Number(quality.trim().slice(2)) : 1 }
+ })
+ const quality = (encodings.find(entry => entry.name === 'gzip') || encodings.find(entry => entry.name === '*'))?.quality ?? 0
+ return quality > 0 && quality <= 1
+}
+
+function html(request: IncomingMessage, response: ServerResponse, content: string) {
+ const compressed = acceptsGzip(request)
+ const bytes = Buffer.from(`${content}\n${reloadScript}`)
+ const body = compressed ? gzipSync(bytes) : bytes
+ response.setHeader('Content-Type', 'text/html; charset=utf-8')
+ response.setHeader('Content-Length', body.length)
+ response.setHeader('Vary', 'Accept-Encoding')
+ if (compressed)
+ response.setHeader('Content-Encoding', 'gzip')
+ response.end(request.method === 'HEAD' ? undefined : body)
+}
+
+const escape = (value: string) => value.replace(/[&<>"']/g, value => ({ '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' })[value]!)
+
+export async function serveAsset(root: string, pathname: string, request: IncomingMessage, response: ServerResponse): Promise {
+ const requested = path.resolve(root, `.${pathname}`)
+ if (!inside(root, requested) || /[\\\0:]/.test(pathname)) {
+ error(response, 403)
+ return
+ }
+ let file = requested
+ let stat
+ try {
+ stat = await fs.promises.stat(file)
+ }
+ catch (failure) {
+ if ((failure as NodeJS.ErrnoException).code !== 'ENOENT' && (failure as NodeJS.ErrnoException).code !== 'ENOTDIR')
+ throw failure
+ if (path.extname(pathname)) {
+ error(response, 404)
+ return
+ }
+ file = path.join(root, 'index.html')
+ stat = await fs.promises.stat(file)
+ }
+ if (!inside(root, await fs.promises.realpath(file))) {
+ error(response, 403)
+ return
+ }
+ if (stat.isDirectory()) {
+ if (!pathname.endsWith('/')) {
+ const query = new URL(request.url || '/', 'http://localhost').search
+ response.writeHead(301, { Location: `${pathname.split('/').map(encodeURIComponent).join('/')}/${query}` }).end()
+ return
+ }
+ const index = path.join(file, 'index.html')
+ if (fs.existsSync(index)) {
+ file = index
+ stat = await fs.promises.stat(file)
+ if (!inside(root, await fs.promises.realpath(file))) {
+ error(response, 403)
+ return
+ }
+ }
+ else {
+ const entries = await fs.promises.readdir(file, { withFileTypes: true })
+ const base = pathname.endsWith('/') ? pathname : `${pathname}/`
+ html(request, response, ``)
+ return
+ }
+ }
+ if (!stat.isFile()) {
+ error(response, 404)
+ return
+ }
+ if (path.extname(file).toLowerCase() === '.html') {
+ html(request, response, await fs.promises.readFile(file, 'utf8'))
+ return
+ }
+ const type = lookup(file) || 'application/octet-stream'
+ response.setHeader('Content-Type', type)
+ response.setHeader('Last-Modified', stat.mtime.toUTCString())
+ response.setHeader('Accept-Ranges', 'bytes')
+ let start = 0
+ let end = stat.size - 1
+ if (request.headers.range) {
+ const match = /^bytes=(\d*)-(\d*)$/.exec(request.headers.range)
+ if (!match || (!match[1] && !match[2]) || !stat.size) {
+ response.setHeader('Content-Range', `bytes */${stat.size}`)
+ error(response, 416)
+ return
+ }
+ start = match[1] ? Number(match[1]) : Math.max(0, stat.size - Number(match[2]))
+ end = match[1] && match[2] ? Math.min(Number(match[2]), end) : end
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= stat.size) {
+ response.setHeader('Content-Range', `bytes */${stat.size}`)
+ error(response, 416)
+ return
+ }
+ response.statusCode = 206
+ response.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`)
+ }
+ const compressed = stat.size > 0 && !request.headers.range && acceptsGzip(request) && /^(?:text\/|application\/(?:javascript|json)|image\/svg)/.test(type)
+ response.setHeader('Vary', 'Accept-Encoding')
+ if (compressed)
+ response.setHeader('Content-Encoding', 'gzip')
+ else response.setHeader('Content-Length', stat.size ? end - start + 1 : 0)
+ if (request.method === 'HEAD' || !stat.size) {
+ response.end()
+ return
+ }
+ const stream = fs.createReadStream(file, { start, end })
+ if (compressed)
+ await pipeline(stream, createGzip(), response)
+ else await pipeline(stream, response)
+}
diff --git a/scripts/library/config.ts b/scripts/library/config.ts
index 10be0a335..3d93151d9 100644
--- a/scripts/library/config.ts
+++ b/scripts/library/config.ts
@@ -3,7 +3,7 @@ import { bannerPlugin, workerBannerPlugin } from './banner.ts'
export interface LibraryConfigOptions {
entry: string
- outDir: string
+ outDir?: string
name: string
format: LibraryFormats
fileName: string
diff --git a/scripts/library/development.ts b/scripts/library/development.ts
index bee1399e9..d41831a65 100644
--- a/scripts/library/development.ts
+++ b/scripts/library/development.ts
@@ -1,73 +1,129 @@
import fs from 'node:fs'
import path from 'node:path'
-import servor from 'servor'
+import process from 'node:process'
import openBrowser from 'servor/utils/openBrowser.js'
import { build as viteBuild } from 'vite'
import { getViteBuildConfig } from './config.ts'
import { getGlobalName } from './names.ts'
import { getEntryFile, getProjects, selectProjects } from './projects.ts'
import { createRebuildQueue } from './rebuild.ts'
+import { startDevServer } from './server.ts'
-async function develop(projectPath: string, name: string, open: boolean, port: number) {
- const uncompiledPath = path.resolve(`docs/uncompiled/${name}`)
- let browserOpened = false
-
- // Servor enumerates existing directories when installing its Linux watchers.
- fs.mkdirSync(uncompiledPath, { recursive: true })
-
- const { url } = await servor({
- root: 'docs',
- fallback: 'index.html',
- reload: true,
- port,
- })
-
- async function buildBundle() {
- const startTime = Date.now()
- try {
- const config = getViteBuildConfig({
- entry: getEntryFile(projectPath),
- outDir: uncompiledPath,
- name: getGlobalName(name),
- format: 'iife',
- fileName: 'index.js',
- minify: false,
- emptyOutDir: true,
- })
- config.define['process.env.NODE_ENV'] = JSON.stringify('development')
-
- await viteBuild({ root: projectPath, ...config })
- console.log(`[${name}] ✅ Built in ${Date.now() - startTime}ms`)
-
- if (open && !browserOpened) {
- browserOpened = true
- openBrowser(url)
- }
- }
- catch (error) {
- console.error(`[${name}] ❌ Build error:`, error)
- }
- }
-
- const rebuild = createRebuildQueue(buildBundle)
- await rebuild()
- console.log(`[${name}] Demo: ${url}/?libs=./uncompiled/${name}/index.js`)
-
- const srcPath = path.join(projectPath, 'src')
- console.log(`[${name}] 👀 Watching ${srcPath}...`)
-
- fs.watch(srcPath, { recursive: true }, async (_, filename) => {
- if (filename) {
- console.log(`[${name}] 📝 Changed: ${filename}`)
- await rebuild()
- }
- })
+export interface DevelopmentOptions {
+ root?: string
+ port?: number
+ signal?: AbortSignal
}
-export async function runDevelop({ port = 8082 }: { port?: number } = {}): Promise {
+export async function startDevelopment(projectPath: string, name: string, open: boolean, { root = process.cwd(), port = 8082, signal }: DevelopmentOptions = {}) {
+ const uncompiledPath = path.resolve(root, `docs/uncompiled/${name}`)
+ let browserOpened = false
+ let stopped = false
+ let failure: Error | undefined
+ let watcher: fs.FSWatcher | undefined
+ let activeBuild = Promise.resolve()
+ let closing: Promise | undefined
+ let finish!: () => void
+ let reject!: (error: Error) => void
+ const done = new Promise((resolve, failed) => {
+ finish = resolve
+ reject = failed
+ })
+ // Startup can fail before the caller receives the session; retain rejection for its owner.
+ void done.catch(() => {})
+ const startup = startDevServer({
+ root: path.join(root, 'docs'),
+ port,
+ ignoredDirectory: uncompiledPath,
+ onError: fatal,
+ })
+ function fatal(error: Error) {
+ if (stopped)
+ return
+ failure ??= error
+ void close().catch(() => {})
+ }
+ function aborted() {
+ void close().catch(() => {})
+ }
+ function close(): Promise {
+ if (!closing) {
+ stopped = true
+ watcher?.close()
+ signal?.removeEventListener('abort', aborted)
+ closing = (async () => {
+ const server = await startup.catch(() => undefined)
+ await server?.close()
+ await activeBuild
+ if (failure)
+ throw failure
+ })()
+ void closing.then(finish, reject)
+ }
+ return closing
+ }
+ signal?.addEventListener('abort', aborted, { once: true })
+ try {
+ const server = await startup
+ if (signal?.aborted || stopped) {
+ await close()
+ return { url: server.url, close, done }
+ }
+ fs.mkdirSync(uncompiledPath, { recursive: true })
+ async function buildBundle() {
+ if (stopped)
+ return
+ const startTime = Date.now()
+ try {
+ const config = getViteBuildConfig({ entry: getEntryFile(projectPath), outDir: uncompiledPath, name: getGlobalName(name), format: 'iife', fileName: 'index.js', minify: false, emptyOutDir: true })
+ config.define['process.env.NODE_ENV'] = JSON.stringify('development')
+ await viteBuild({ root: projectPath, ...config })
+ if (stopped)
+ return
+ console.log(`[${name}] ✅ Built in ${Date.now() - startTime}ms`)
+ server.reload()
+ if (open && !browserOpened) {
+ browserOpened = true
+ openBrowser(server.url)
+ }
+ }
+ catch (error) {
+ if (!stopped)
+ console.error(`[${name}] ❌ Build error:`, error)
+ }
+ }
+ const queue = createRebuildQueue(buildBundle)
+ function rebuild() {
+ if (!stopped)
+ activeBuild = queue()
+ return activeBuild
+ }
+ const srcPath = path.join(projectPath, 'src')
+ watcher = fs.watch(srcPath, { recursive: true }, (_, filename) => {
+ if (filename && !stopped) {
+ console.log(`[${name}] 📝 Changed: ${filename}`)
+ void rebuild().catch(fatal)
+ }
+ })
+ watcher.on('error', fatal)
+ void rebuild().then(() => {
+ if (!stopped) {
+ console.log(`[${name}] Demo: ${server.url}/?libs=./uncompiled/${name}/index.js`)
+ console.log(`[${name}] 👀 Watching ${srcPath}...`)
+ }
+ }, fatal)
+ return { url: server.url, close, done }
+ }
+ catch (error) {
+ await close()
+ throw error
+ }
+}
+
+export async function runDevelop({ port = 8082, signal }: DevelopmentOptions = {}) {
const projects = getProjects()
const { names, open } = await selectProjects(projects, 'dev')
const name = names[0]
if (name)
- await develop(projects[name]!, name, open, port)
+ return startDevelopment(projects[name]!, name, open, { port, signal })
}
diff --git a/scripts/library/reload.ts b/scripts/library/reload.ts
new file mode 100644
index 000000000..9689c468c
--- /dev/null
+++ b/scripts/library/reload.ts
@@ -0,0 +1,60 @@
+import type { ServerResponse } from 'node:http'
+
+export const reloadScript = ``
+
+export function createReloadChannel() {
+ const clients = new Set()
+ let heartbeat: ReturnType | undefined
+ let closed = false
+ function stopHeartbeat() {
+ clearInterval(heartbeat)
+ heartbeat = undefined
+ }
+ return {
+ add(response: ServerResponse) {
+ if (closed) {
+ response.writeHead(503).end()
+ return
+ }
+ response.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ })
+ response.write('event: connected\ndata: ready\n\n')
+ clients.add(response)
+ response.once('close', () => {
+ clients.delete(response)
+ if (!clients.size)
+ stopHeartbeat()
+ })
+ if (!heartbeat) {
+ heartbeat = setInterval(() => {
+ for (const client of clients)
+ client.write('event: ping\ndata: waiting\n\n')
+ }, 30000)
+ heartbeat.unref()
+ }
+ },
+ reload() {
+ if (!closed) {
+ for (const client of clients)
+ client.write('data: reload\n\n')
+ }
+ },
+ close() {
+ closed = true
+ stopHeartbeat()
+ for (const client of clients)
+ client.end()
+ clients.clear()
+ },
+ }
+}
diff --git a/scripts/library/server.ts b/scripts/library/server.ts
new file mode 100644
index 000000000..6a4c98f4f
--- /dev/null
+++ b/scripts/library/server.ts
@@ -0,0 +1,121 @@
+import type { Socket } from 'node:net'
+import fs from 'node:fs'
+import http from 'node:http'
+import path from 'node:path'
+import { serveAsset } from './assets.ts'
+import { createReloadChannel } from './reload.ts'
+
+export interface DevServerOptions {
+ root: string
+ port: number
+ ignoredDirectory?: string
+ onError: (error: Error) => void
+}
+
+export async function startDevServer(options: DevServerOptions) {
+ const root = await fs.promises.realpath(options.root)
+ const reload = createReloadChannel()
+ const sockets = new Set()
+ const requests = new Set>()
+ let watcher: fs.FSWatcher | undefined
+ let debounce: ReturnType | undefined
+ let closing: Promise | undefined
+ const server = http.createServer((request, response) => {
+ if (closing) {
+ response.writeHead(503).end()
+ return
+ }
+ response.setHeader('Access-Control-Allow-Origin', '*')
+ response.setHeader('Cache-Control', 'no-store')
+ if (request.method === 'OPTIONS') {
+ response.writeHead(204, { 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS' }).end()
+ return
+ }
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
+ response.writeHead(405, { Allow: 'GET, HEAD, OPTIONS' }).end()
+ return
+ }
+ let pathname: string
+ try {
+ pathname = decodeURIComponent(new URL(request.url || '/', 'http://localhost').pathname)
+ }
+ catch {
+ response.writeHead(400).end()
+ return
+ }
+ if (pathname === '/livereload') {
+ if (request.method === 'HEAD')
+ response.writeHead(200, { 'Content-Type': 'text/event-stream' }).end()
+ else reload.add(response)
+ return
+ }
+ const active = serveAsset(root, pathname, request, response).catch((error: NodeJS.ErrnoException) => {
+ if (!response.destroyed && !response.headersSent)
+ response.writeHead(error.code === 'ENOENT' ? 404 : 500).end()
+ else response.destroy()
+ }).finally(() => requests.delete(active))
+ requests.add(active)
+ })
+ server.on('connection', (socket) => {
+ sockets.add(socket)
+ socket.once('close', () => sockets.delete(socket))
+ })
+ function close(): Promise {
+ if (!closing) {
+ watcher?.close()
+ clearTimeout(debounce)
+ reload.close()
+ closing = new Promise((resolve, reject) => {
+ server.close((error?: NodeJS.ErrnoException) => {
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING')
+ reject(error)
+ else void Promise.all(requests).then(() => resolve(), reject)
+ })
+ for (const socket of sockets)
+ socket.destroy()
+ })
+ }
+ return closing
+ }
+ function fail(error: Error) {
+ if (closing)
+ return
+ const pending = close()
+ options.onError(error)
+ void pending.catch(options.onError)
+ }
+ try {
+ await new Promise((resolve, reject) => {
+ function failed(error: Error) {
+ server.off('listening', ready)
+ reject(error)
+ }
+ function ready() {
+ server.off('error', failed)
+ resolve()
+ }
+ server.once('error', failed)
+ server.once('listening', ready)
+ server.listen(options.port)
+ })
+ server.on('error', fail)
+ watcher = fs.watch(root, { recursive: true }, (_, filename) => {
+ if (closing)
+ return
+ const file = filename && path.resolve(root, filename)
+ if (file && options.ignoredDirectory && (file === options.ignoredDirectory || file.startsWith(`${options.ignoredDirectory}${path.sep}`)))
+ return
+ clearTimeout(debounce)
+ debounce = setTimeout(() => reload.reload(), 75)
+ })
+ watcher.on('error', fail)
+ const address = server.address()
+ if (!address || typeof address === 'string')
+ throw new Error('Development server did not bind a TCP port')
+ return { url: `http://localhost:${address.port}`, reload: reload.reload, close }
+ }
+ catch (error) {
+ await close()
+ throw error
+ }
+}
diff --git a/scripts/library/vendor.d.ts b/scripts/library/vendor.d.ts
index 1a2b52b16..b121c2712 100644
--- a/scripts/library/vendor.d.ts
+++ b/scripts/library/vendor.d.ts
@@ -8,15 +8,6 @@ declare module 'prompts' {
}): Promise<{ value?: string }>
}
-declare module 'servor' {
- export default function servor(options: {
- root: string
- fallback: string
- reload: boolean
- port: number
- }): Promise<{ url: string }>
-}
-
declare module 'servor/utils/openBrowser.js' {
export default function openBrowser(url: string): true | undefined
}
diff --git a/test/README.md b/test/README.md
index 337c3ed89..aef253e24 100644
--- a/test/README.md
+++ b/test/README.md
@@ -5,6 +5,13 @@ scaffold's generated consumers. The new build modules live in `scripts/library/`
`yarn typecheck:library` checks them and the retained JS/MJS entrypoints. Browser
development/watch/error-recovery coverage is `browser/library-development.spec.js`.
+`yarn test:dev-server` covers actual HTTP ranges/HEAD/gzip, root containment,
+occupied-port CLI failure without stopping the existing server, repeated same-port
+reuse with SSE connections, aborted downloads and startup/build cancellation.
+A controlled-clock test checks heartbeat cleanup separately from real sockets.
+It is included in `test:node` and `test:library`; the browser suite also verifies
+actual docs/Monaco TypeScript execution and local media playback through this server.
+
`yarn test:scaffold` covers old plugin-generator defects, exclusive writes and
rollback, the historical CLI path, and actual generated package builds and type
consumers. It runs in ignored fixtures without adding a workspace or real demo;
diff --git a/test/browser/README.md b/test/browser/README.md
index dadd77264..942ae7e82 100644
--- a/test/browser/README.md
+++ b/test/browser/README.md
@@ -1,10 +1,14 @@
# Browser regression entry
-`library-development.spec.js` starts the actual typed development runner on an
-isolated fixture port. It checks generated TS/Less/SVG/inline-worker assets,
+`library-development.spec.js` starts the actual JS dev CLI and typed runner on an
+atomically assigned fixture port. It checks generated TS/Less/SVG/inline-worker assets,
automatic reload on edits and recovery after a compiler error in all three
engines. It owns and stops only its child server, never the user's port 8082
-session. This is development-tool validation, not media playback acceptance.
+session. SIGTERM must settle the child naturally with exit0. Its second case serves
+actual docs/Monaco, executes TypeScript and plays/pauses/seeks a local MP4, checking
+a decoded canvas frame. The attachment records browser/version, OS and actual
+core/media hashes; the tracked core5.4.1 asset is not a new release candidate.
+Analytics/ads are inert routes; real-device and remote OS checks remain separate.
`site-loading.spec.js` covers actual desktop/mobile loading, dependency order,
failure/retry/AMD restoration, example priority, repeated Run and stale responses,
diff --git a/test/browser/library-development.spec.js b/test/browser/library-development.spec.js
index a1b140a6a..2ad2fed01 100644
--- a/test/browser/library-development.spec.js
+++ b/test/browser/library-development.spec.js
@@ -1,10 +1,11 @@
import { spawn } from 'node:child_process'
+import { createHash } from 'node:crypto'
import fs from 'node:fs'
-import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { expect, test } from '@playwright/test'
+import { startDevServer } from '../../scripts/library/server.ts'
const root = fileURLToPath(new URL('../../', import.meta.url))
const cache = path.join(root, 'refactor/.cache')
@@ -15,17 +16,6 @@ function removeFixture(directory) {
fs.rmSync(directory, { recursive: true, force: true })
}
-async function freePort() {
- const server = net.createServer()
- await new Promise((resolve, reject) => {
- server.once('error', reject)
- server.listen(0, resolve)
- })
- const port = server.address().port
- await new Promise(resolve => server.close(resolve))
- return port
-}
-
test('typed development builds real assets and workers, watches edits and recovers from compilation errors', async ({ page }, testInfo) => {
test.setTimeout(60000)
const directory = fs.mkdtempSync(path.join(cache, 'library-development-'))
@@ -40,15 +30,14 @@ test('typed development builds real assets and workers, watches edits and recove
window.probe = artplayerPluginBuildProbe();
document.querySelector('main').textContent = String(probe.value);