refactor(tooling): [MOD-DEV-01] own dev server lifecycle and report port failures

This commit is contained in:
Harvey Zhao committed 2026-09-14 11:29:46 +08:00
1 parent 401d4cdd65
commit 772d79a671
24 files changed
+1249 -132

No files matched your search

+4 -2
View File
@@ -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",
@@ -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"
]
}
+11 -2
View File
@@ -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。
@@ -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.
+1
View File
@@ -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 |
+3 -2
View File
@@ -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<br>Bun 固定版本干净安装试点 | ENG-09 | 独立目录的 Bun 安装与 Yarn 冻结基线对比,默认仍为用户选定的 Yarn | Node 测试仍通过;安装与资源一致才决定采用;不改 bundler;不自动替换默认 packageManager 或维护锁文件 | H | todo |
| MOD-PLUGIN-01 | workspace<br>迁移插件生成器与类型化模板 | ENG-06, SITE-03 | 保留 create:plugin CLI;纯渲染/文件发布边界、TS 工厂/声明/测试/示例及维护指南 | 防止覆盖现有包或示例;失败回退;生成包可构建并通过严格类型、旧模块消费及测试;不自动修改既有包或运行安装发布 | M | done |
| MOD-02 | workspace<br>整理剩余开发/构建脚本与插件模板 | ENG-06, SITE-03, MOD-PLUGIN-01 | dev/build/utils/create-plugin 的 TS 与可测 CLI,模板同时提供旧 API | 旧脚本入口保留、新插件类型/测试/示例齐全,Lerna 改动单独取证 | M | done |
| MOD-DEV-01 | workspace<br>修复开发服务器失败状态并明确资源生命周期 | MOD-02 | 修复 Servor 占用端口却退出 0 的已复现问题;明确 HTTP/监听/热刷新连接与定时器的启动、失败和关闭责任 | 保持默认 8082、docs 页面、自动刷新和旧 dev 命令;端口冲突退出非零且不终止其他服务,连续启动/停止及编译错误恢复有实际验证;跨平台监听差异单独留证 | M | todo |
| MOD-DEV-01 | workspace<br>修复开发服务器失败状态并明确资源生命周期 | MOD-02 | 修复 Servor 占用端口却退出 0 的已复现问题;明确 HTTP/监听/热刷新连接与定时器的启动、失败和关闭责任 | 保持默认 8082、docs 页面、自动刷新和旧 dev 命令;端口冲突退出非零且不终止其他服务,连续启动/停止及编译错误恢复有实际验证;跨平台监听差异单独留证 | M | done |
| MOD-03 | workspace<br>测量并优化核心热路径 | CORE-22, ENG-08 | DOM 读写、进度更新、持久化、初始化的测量与改进 | 相同设备媒体多次比较,契约不变,收益及无效尝试记录;复用 BASE-06 的原始样本与测量限制,至少三组同环境旧新配对,不以单次变快宣称收益 | M | todo |
| MOD-04 | workspace<br>测量并优化重型插件/proxy | PKG-DANMUKU-09, PKG-MASK-06, PKG-MB-10, ENG-08 | 帧/队列/推理/音画同步与资源长期运行比较 | 不改默认算法/阈值,性能改善有证据;无收益则保留旧实现 | M | todo |
| MOD-05 | workspace<br>完成工具链与性能采用决策 | 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)
+15
View File
@@ -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
+1 -1
View File
@@ -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 |
+9 -2
View File
@@ -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."
}
]
}
+5 -2
View File
@@ -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",
+3 -1
View File
@@ -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` 对实际渲染包执行源码检查和
+23 -4
View File
@@ -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)
}
+56 -26
View File
@@ -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/<package>/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/<package>/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`.
+134
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' })[value]!)
export async function serveAsset(root: string, pathname: string, request: IncomingMessage, response: ServerResponse): Promise<void> {
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, `<!doctype html><meta charset="utf-8"><ul>${entries.map(entry => `<li><a href="${escape(base + encodeURIComponent(entry.name) + (entry.isDirectory() ? '/' : ''))}">${escape(entry.name)}</a></li>`).join('')}</ul>`)
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)
}
+1 -1
View File
@@ -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
+113 -57
View File
@@ -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<void> {
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<void> | undefined
let finish!: () => void
let reject!: (error: Error) => void
const done = new Promise<void>((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<void> {
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 })
}
+60
View File
@@ -0,0 +1,60 @@
import type { ServerResponse } from 'node:http'
export const reloadScript = `<script>(() => {
const source = new EventSource('/livereload');
let reloading = false;
const reload = () => { if (!reloading) { reloading = true; location.reload(); } };
source.onmessage = reload;
source.onerror = () => { source.onopen = reload; };
addEventListener('pagehide', () => source.close(), { once: true });
})();</script>`
export function createReloadChannel() {
const clients = new Set<ServerResponse>()
let heartbeat: ReturnType<typeof setInterval> | 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()
},
}
}
+121
View File
@@ -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<Socket>()
const requests = new Set<Promise<void>>()
let watcher: fs.FSWatcher | undefined
let debounce: ReturnType<typeof setTimeout> | undefined
let closing: Promise<void> | 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<void> {
if (!closing) {
watcher?.close()
clearTimeout(debounce)
reload.close()
closing = new Promise<void>((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<void>((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
}
}
-9
View File
@@ -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
}
+7
View File
@@ -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;
+7 -3
View File
@@ -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,
+80 -20
View File
@@ -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);
</script></body></html>`)
const port = await freePort()
const url = `http://localhost:${port}`
const module = pathToFileURL(path.join(root, 'scripts/library/development.ts')).href
const code = `import { runDevelop } from ${JSON.stringify(module)};
const module = pathToFileURL(path.join(root, 'scripts/dev.js')).href
const code = `process.on('message', () => process.emit('SIGTERM'));
process.argv = [process.execPath, 'fixture.js', '${name}', '--no-open'];
await runDevelop({port:${port}});`
await import(${JSON.stringify(module)});
process.disconnect();`
const launcher = path.join(directory, 'launch.mjs')
fs.writeFileSync(launcher, code)
const child = spawn(process.execPath, [launcher], { cwd: directory, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const child = spawn(process.execPath, [launcher], { cwd: directory, env: { ...process.env, ARTPLAYER_DEV_PORT: '0' }, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe', 'ipc'] })
let output = ''
child.stdout.on('data', (data) => {
output += data
@@ -60,6 +49,7 @@ await runDevelop({port:${port}});`
try {
await expect.poll(() => output, { timeout: 20000 }).toContain('Watching')
expect(child.exitCode).toBeNull()
const url = /Demo: (http:\/\/localhost:\d+)/.exec(output)[1]
await page.goto(url)
await expect(page.locator('main')).toHaveText('42')
expect(await page.evaluate(() => window.probe.css)).toContain('#123abc')
@@ -91,11 +81,81 @@ await runDevelop({port:${port}});`
fs.writeFileSync(entry, original.replace('add(20, 22)', 'add(20, 24)'))
await expect.poll(() => output.slice(failed), { timeout: 15000 }).toContain('Built in')
await expect(page.locator('main')).toHaveText('44')
const html = path.join(directory, 'docs/index.html')
fs.appendFileSync(html, '<title>Docs edit reloaded</title>')
await expect(page).toHaveTitle('Docs edit reloaded')
await expect(page.locator('main')).toHaveText('44')
}
finally {
await testInfo.attach('development-server.log', { body: output, contentType: 'text/plain' })
child.kill()
await exited
const watchdog = setTimeout(() => child.kill(), 5000)
if (child.connected)
child.send('close')
const exitCode = await exited
clearTimeout(watchdog)
removeFixture(directory)
expect(exitCode, 'Dev CLI must close its resources and exit naturally').toBe(0)
}
})
test('actual docs editor runs TypeScript and plays and seeks local media through the owned server', async ({ page, browser }, testInfo) => {
test.setTimeout(60000)
const errors = []
const pageErrors = []
const service = await startDevServer({ root: path.join(root, 'docs'), port: 0, onError: error => errors.push(error.message) })
page.on('pageerror', error => pageErrors.push(error.message))
await page.route('https://**/*', route => route.fulfill({ status: 200, body: '' }))
await page.addInitScript((origin) => {
if (location.origin === origin)
localStorage.setItem('ts', 'true')
}, service.url)
try {
const code = 'const typed: number = 17; window.devServerTyped = typed; var art = new Artplayer({container:".artplayer-app",url:"/assets/sample/video.mp4",muted:true});'
await page.goto(`${service.url}/?code=${encodeURIComponent(code)}`)
await page.waitForFunction(() => window.art?.isReady && window.devServerTyped === 17)
await page.evaluate(() => window.art.play())
await page.waitForFunction(() => window.art.currentTime > 0.1)
const target = await page.evaluate(() => {
window.art.pause()
const target = Math.min(2, window.art.duration / 2)
window.art.seek = target
return target
})
await page.waitForFunction(target => Math.abs(window.art.currentTime - target) < 0.3 && !window.art.video.seeking, target)
const frameAlpha = await page.evaluate(() => {
const video = window.art.video
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const context = canvas.getContext('2d')
context.drawImage(video, 0, 0)
return context.getImageData(0, 0, 1, 1).data[3]
})
expect(frameAlpha).toBe(255)
const state = await page.evaluate(() => ({
version: window.Artplayer.version,
typed: window.devServerTyped,
currentTime: window.art.currentTime,
paused: window.art.video.paused,
width: window.art.video.videoWidth,
height: window.art.video.videoHeight,
error: window.art.video.error?.code || 0,
editorModels: window.monaco.editor.getModels().length,
}))
expect(state.typed).toBe(17)
expect(state.paused).toBe(true)
expect(state.width).toBeGreaterThan(0)
expect(state.height).toBeGreaterThan(0)
expect(state.error).toBe(0)
expect(state.editorModels).toBeGreaterThan(1)
const inputs = ['docs/uncompiled/artplayer/index.js', 'docs/assets/sample/video.mp4'].map(file => ({ file, sha256: createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') }))
await testInfo.attach('actual-docs-server.json', { body: JSON.stringify({ browser: browser.version(), platform: process.platform, inputs, frameAlpha, state }, null, 2), contentType: 'application/json' })
await page.screenshot({ path: testInfo.outputPath('actual-docs.png') })
await page.goto('about:blank')
expect(pageErrors).toEqual([])
}
finally {
await service.close()
expect(errors).toEqual([])
}
})
+233
View File
@@ -0,0 +1,233 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import { spawnSync } from 'node:child_process'
import { EventEmitter, once } from 'node:events'
import fs from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Verify real HTTP, shutdown and startup failure ownership.
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { gunzipSync } from 'node:zlib'
import { startDevelopment } from '../scripts/library/development.ts'
import { createReloadChannel } from '../scripts/library/reload.ts'
import { startDevServer } from '../scripts/library/server.ts'
const repository = fileURLToPath(new URL('../', import.meta.url))
const cache = path.join(repository, 'refactor/.cache')
const cleanup = new WeakMap()
function fixture(t) {
cleanup.set(t, [])
const root = fs.mkdtempSync(path.join(cache, 'dev-server-'))
fs.mkdirSync(path.join(root, 'docs'))
fs.writeFileSync(path.join(root, 'docs/index.html'), '<!doctype html><title>Home</title>首页')
const project = path.join(root, 'packages/artplayer-plugin-probe')
fs.mkdirSync(path.join(project, 'src'), { recursive: true })
fs.writeFileSync(path.join(project, 'package.json'), '{"name":"artplayer-plugin-probe","version":"1.0.0"}')
fs.writeFileSync(path.join(project, 'src/index.ts'), 'export default () => 42')
t.after(async () => {
for (const close of cleanup.get(t).reverse())
await close()
assert.equal(fs.realpathSync(root), root)
assert.equal(path.dirname(root), fs.realpathSync(cache))
fs.rmSync(root, { recursive: true, force: true })
})
return { root, docs: path.join(root, 'docs'), project }
}
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const req = http.request(url, options, (res) => {
const chunks = []
res.on('data', chunk => chunks.push(chunk))
res.on('error', reject)
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }))
})
req.on('error', reject)
req.end()
})
}
async function server(t, docs) {
const failures = []
const service = await startDevServer({ root: docs, port: 0, onError: error => failures.push(error) })
cleanup.get(t).push(async () => {
await service.close()
assert.deepEqual(failures, [])
})
return service
}
test('static pages, fallback, directory indexes, gzip negotiation and HEAD preserve actual content', async (t) => {
const { docs } = fixture(t)
fs.writeFileSync(path.join(docs, 'module.js'), 'window.label = "中文";')
fs.writeFileSync(path.join(docs, 'empty.css'), '')
fs.mkdirSync(path.join(docs, 'document'))
fs.writeFileSync(path.join(docs, 'document/index.html'), '<h1>Docs</h1>')
const { url } = await server(t, docs)
const home = await request(url, { headers: { 'Accept-Encoding': 'gzip' } })
assert.equal(home.status, 200)
assert.equal(home.headers['content-encoding'], 'gzip')
assert.match(gunzipSync(home.body).toString(), /首页/)
assert.match(gunzipSync(home.body).toString(), /EventSource\('\/livereload'\)/)
const identity = await request(`${url}/module.js`, { headers: { 'Accept-Encoding': 'gzip;q=0, *;q=1' } })
assert.equal(identity.headers['content-encoding'], undefined)
assert.equal(identity.body.toString(), 'window.label = "中文";')
const zipped = await request(`${url}/module.js`, { headers: { 'Accept-Encoding': 'gzip' } })
assert.equal(gunzipSync(zipped.body).toString(), identity.body.toString())
const head = await request(`${url}/module.js`, { method: 'HEAD' })
assert.equal(Number(head.headers['content-length']), identity.body.length)
assert.equal(head.body.length, 0)
const empty = await request(`${url}/empty.css`, { headers: { 'Accept-Encoding': 'gzip' } })
assert.equal(empty.body.length, 0)
assert.equal(empty.headers['content-encoding'], undefined)
assert.equal((await request(`${url}/document?language=en`)).headers.location, '/document/?language=en')
assert.match((await request(`${url}/document/`)).body.toString(), /<h1>Docs/)
assert.match((await request(`${url}/unknown-route`)).body.toString(), /首页/)
assert.equal((await request(`${url}/missing.js`)).status, 404)
assert.equal((await request(url, { method: 'POST' })).status, 405)
assert.equal((await request(url, { method: 'OPTIONS' })).headers['access-control-allow-origin'], '*')
})
test('media ranges support exact zero, bounded, suffix, open-ended and unsatisfiable requests', async (t) => {
const { docs } = fixture(t)
fs.writeFileSync(path.join(docs, 'media.mp4'), '0123456789')
const { url } = await server(t, docs)
for (const [range, body, contentRange] of [
['bytes=0-0', '0', 'bytes 0-0/10'],
['bytes=2-5', '2345', 'bytes 2-5/10'],
['bytes=7-999', '789', 'bytes 7-9/10'],
['bytes=-3', '789', 'bytes 7-9/10'],
['bytes=8-', '89', 'bytes 8-9/10'],
]) {
const response = await request(`${url}/media.mp4`, { headers: { 'Range': range, 'Accept-Encoding': 'gzip' } })
assert.equal(response.status, 206)
assert.equal(response.body.toString(), body)
assert.equal(response.headers['content-range'], contentRange)
assert.equal(response.headers['content-encoding'], undefined)
}
for (const range of ['bytes=10-', 'bytes=5-2', 'bytes=-0', 'bytes=1-2,5-7', 'bytes=-', 'invalid']) {
const response = await request(`${url}/media.mp4`, { headers: { Range: range } })
assert.equal(response.status, 416)
assert.equal(response.headers['content-range'], 'bytes */10')
}
})
test('malformed paths and symlink escapes never serve files outside the docs root', async (t) => {
const { root, docs } = fixture(t)
fs.mkdirSync(path.join(root, 'private'))
fs.writeFileSync(path.join(root, 'private/secret.txt'), 'private sentinel')
fs.symlinkSync(path.join(root, 'private'), path.join(docs, 'outside'), 'junction')
const { url } = await server(t, docs)
assert.equal((await request(`${url}/outside/secret.txt`)).status, 403)
assert.equal((await request(`${url}/%2e%2e%2fprivate/secret.txt`)).status, 403)
assert.equal((await request(`${url}/%E0%A4%A`)).status, 400)
fs.unlinkSync(path.join(docs, 'outside'))
})
test('an occupied port rejects and the real CLI exits nonzero without stopping the existing service', async (t) => {
const { root, docs } = fixture(t)
const service = await server(t, docs)
const port = Number(new URL(service.url).port)
await assert.rejects(startDevServer({ root: docs, port, onError() {} }), { code: 'EADDRINUSE' })
const result = spawnSync(process.execPath, [path.join(repository, 'scripts/dev.js'), 'artplayer-plugin-probe', '--no-open'], { cwd: root, env: { ...process.env, ARTPLAYER_DEV_PORT: String(port) }, encoding: 'utf8', timeout: 10000 })
assert.equal(result.status, 1, result.stderr)
assert.match(result.stderr, /EADDRINUSE/)
assert.equal((await request(service.url)).status, 200)
const invalid = spawnSync(process.execPath, [path.join(repository, 'scripts/dev.js'), '--help'], { cwd: root, env: { ...process.env, ARTPLAYER_DEV_PORT: 'invalid' }, encoding: 'utf8', timeout: 10000 })
assert.equal(invalid.status, 1)
assert.match(invalid.stderr, /ARTPLAYER_DEV_PORT/)
})
test('SSE clients and HTTP sockets close before the same port is reused repeatedly', async (t) => {
const { docs } = fixture(t)
let port = 0
for (let index = 0; index < 3; index++) {
const service = await startDevServer({ root: docs, port, onError: error => assert.fail(error) })
cleanup.get(t).push(() => service.close())
port = Number(new URL(service.url).port)
const client = http.get(`${service.url}/livereload`)
const [response] = await once(client, 'response')
response.on('error', () => {})
const [connected] = await once(response, 'data')
assert.match(connected.toString(), /event: connected/)
service.reload()
const [reload] = await once(response, 'data')
assert.match(reload.toString(), /data: reload/)
const disconnected = new Promise(resolve => response.once('close', resolve))
const close = service.close()
assert.equal(service.close(), close)
await Promise.all([close, disconnected])
}
})
test('disconnecting a large download does not retain a stream or block shutdown', async (t) => {
const { docs } = fixture(t)
const large = path.join(docs, 'large.bin')
fs.closeSync(fs.openSync(large, 'w'))
fs.truncateSync(large, 16 * 1024 * 1024)
const service = await server(t, docs)
const client = http.get(`${service.url}/large.bin`)
const [response] = await once(client, 'response')
response.on('error', () => {})
await once(response, 'data')
client.destroy()
await service.close()
fs.unlinkSync(large)
})
test('development close settles a running build and abort during startup cannot leave a listening server', async (t) => {
const { root, project } = fixture(t)
const session = await startDevelopment(project, 'artplayer-plugin-probe', false, { root, port: 0 })
await session.close()
await session.done
await assert.rejects(request(session.url), { code: 'ECONNREFUSED' })
const abort = new AbortController()
const starting = startDevelopment(project, 'artplayer-plugin-probe', false, { root, port: 0, signal: abort.signal })
abort.abort()
const aborted = await starting
await aborted.done
await assert.rejects(request(aborted.url), { code: 'ECONNREFUSED' })
await assert.rejects(startDevelopment(path.join(root, 'missing'), 'missing', false, { root, port: 0 }), { code: 'ENOENT' })
})
test('reload heartbeats share one clock and stop after disconnection or channel shutdown', (t) => {
t.mock.timers.enable({ apis: ['setInterval'] })
class Response extends EventEmitter {
writes = []
writeHead(status) {
this.status = status
return this
}
write(value) {
this.writes.push(value)
}
end() {
this.emit('close')
}
}
const channel = createReloadChannel()
const first = new Response()
const second = new Response()
channel.add(first)
channel.add(second)
t.mock.timers.tick(30000)
assert.equal(first.writes.length, 2)
assert.equal(second.writes.length, 2)
first.emit('close')
t.mock.timers.tick(30000)
assert.equal(first.writes.length, 2)
assert.equal(second.writes.length, 3)
channel.close()
t.mock.timers.tick(90000)
channel.reload()
assert.equal(second.writes.length, 3)
const late = new Response()
channel.add(late)
assert.equal(late.status, 503)
assert.deepEqual(late.writes, [])
})
+5
View File
@@ -6341,6 +6341,11 @@ monaco-editor@0.30.1:
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.30.1.tgz#47f8d18a0aa2264fc5654581741ab8d7bec01689"
integrity sha512-B/y4+b2O5G2gjuxIFtCE2EkM17R2NM7/3F8x0qcPsqy4V83bitJTIO4TIeZpYlzu/xy6INiY/+84BEm6+7Cmzg==
mrmime@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"
integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==
ms@^2.1.3:
version "2.1.3"
resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"