mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
refactor(tooling): [MOD-DEV-01] own dev server lifecycle and report port failures
This commit is contained in:
1 parent
401d4cdd65
commit
772d79a671
24 files changed
+1249
-132
No files matched your search
+4
-2
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
@@ -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<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)
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
-9
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([])
|
||||
}
|
||||
})
|
||||
@@ -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, [])
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user