From 772d79a67196ff61dc1e90499c275fee936cb942 Mon Sep 17 00:00:00 2001 From: Harvey Zhao Date: Mon, 14 Sep 2026 11:29:46 +0800 Subject: [PATCH] refactor(tooling): [MOD-DEV-01] own dev server lifecycle and report port failures --- package.json | 6 +- refactor/baselines/dev-server-validation.json | 247 ++++++++++++++++++ refactor/build-development.md | 13 +- .../2026-09-14-MOD-DEV-01-dev-server.md | 110 ++++++++ refactor/ci-setup.md | 1 + refactor/plan.md | 5 +- refactor/progress.md | 15 ++ refactor/risk-table.md | 2 +- refactor/risks.json | 11 +- refactor/tasks.json | 7 +- refactor/typechecking.md | 4 +- scripts/dev.js | 27 +- scripts/library/README.md | 82 ++++-- scripts/library/assets.ts | 134 ++++++++++ scripts/library/config.ts | 2 +- scripts/library/development.ts | 170 ++++++++---- scripts/library/reload.ts | 60 +++++ scripts/library/server.ts | 121 +++++++++ scripts/library/vendor.d.ts | 9 - test/README.md | 7 + test/browser/README.md | 10 +- test/browser/library-development.spec.js | 100 +++++-- test/dev-server.test.js | 233 +++++++++++++++++ yarn.lock | 5 + 24 files changed, 1249 insertions(+), 132 deletions(-) create mode 100644 refactor/baselines/dev-server-validation.json create mode 100644 refactor/changes/2026-09-14-MOD-DEV-01-dev-server.md create mode 100644 scripts/library/assets.ts create mode 100644 scripts/library/reload.ts create mode 100644 scripts/library/server.ts create mode 100644 test/dev-server.test.js 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); `) - 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, 'Docs edit reloaded') + 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([]) } }) diff --git a/test/dev-server.test.js b/test/dev-server.test.js new file mode 100644 index 000000000..460c6af86 --- /dev/null +++ b/test/dev-server.test.js @@ -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'), 'Home首页') + 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'), '

Docs

') + 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(), /

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, []) +}) diff --git a/yarn.lock b/yarn.lock index 188d445a3..933ae83ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"