From 21a8972d644e053f36ce3ebb28cd2e6d6d377557 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 10:37:26 +0300 Subject: [PATCH 1/6] fix(effect): harden v4 runtime boundaries --- AGENTS.md | 10 ++ package.json | 3 +- pnpm-lock.yaml | 224 ++++++++++++++++++++++--------- pnpm-workspace.yaml | 4 + scripts/prepare-effect-source.sh | 9 +- scripts/smoke.ts | 7 +- src/cli.ts | 135 +++++++++++-------- src/errors.ts | 11 +- src/manifest.ts | 197 ++++++++++++--------------- src/output.ts | 9 +- src/serve.ts | 129 +++++++++++++++--- test/vref.test.ts | 48 ++++++- 12 files changed, 512 insertions(+), 274 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9007d7e..520edc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,16 @@ - `vref manifest add --json '{"id":"home",...}' --dry-run --output json` - `vref describe --output json` +## Learning more about the Effect + +This repository uses the Effect Typescript library. + +Before writing any Effect code, first read `node_modules/effect/AGENTS.md` +**completely**, and follow the links in the file when required. + +If you need to learn more about particular Effect apis and concepts that the +guide doesn't cover, search through the source code in `node_modules/effect/src`. + ## Repo-Specific Guidance - Keep `vref` platform-neutral: it renders, validates, and serves visual references; app repos capture and curate screenshots diff --git a/package.json b/package.json index 771b43e..c6f8d0e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "verify": "vp check . && vp run typecheck && vp pack src/index.ts src/cli.ts --dts && vp run smoke && vp test && npm pack --dry-run" }, "dependencies": { - "effect": "beta" + "@effect/platform-node": "4.0.0-beta.107", + "effect": "4.0.0-beta.107" }, "devDependencies": { "@types/node": "^26.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20071c9..f5e754b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,12 @@ importers: .: dependencies: + '@effect/platform-node': + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1) effect: - specifier: beta - version: 4.0.0-beta.102 + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107 devDependencies: '@types/node': specifier: ^26.1.0 @@ -23,10 +26,10 @@ importers: version: 7.0.2 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.6 - version: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)' vite-plus: specifier: 0.2.7 - version: 0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(typescript@7.0.2)(yaml@2.9.0) + version: 0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(typescript@7.0.2) packages: @@ -45,6 +48,22 @@ packages: '@blazediff/core@1.9.1': resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@effect/platform-node-shared@4.0.0-beta.107': + resolution: {integrity: sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-beta.107 + + '@effect/platform-node@4.0.0-beta.107': + resolution: {integrity: sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-beta.107 + ioredis: '>=5.7.0 <6.0.0' + + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -394,6 +413,9 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -863,9 +885,26 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -877,8 +916,8 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - effect@4.0.0-beta.102: - resolution: {integrity: sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w==} + effect@4.0.0-beta.107: + resolution: {integrity: sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ==} es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} @@ -903,17 +942,14 @@ packages: picomatch: optional: true - find-my-way-ts@0.1.6: - resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - ini@7.0.0: - resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1002,10 +1038,18 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true @@ -1013,9 +1057,6 @@ packages: msgpackr@2.0.5: resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} - multipasta@0.2.8: - resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} - nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1091,6 +1132,14 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1105,6 +1154,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -1127,10 +1179,6 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} - toml@4.3.0: - resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} - engines: {node: '>=20'} - totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -1143,6 +1191,10 @@ packages: undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + uuid@14.0.1: resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true @@ -1218,11 +1270,6 @@ packages: utf-8-validate: optional: true - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} - engines: {node: '>= 14.6'} - hasBin: true - yuku-codegen@0.5.48: resolution: {integrity: sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw==} @@ -1243,6 +1290,28 @@ snapshots: '@blazediff/core@1.9.1': {} + '@effect/platform-node-shared@4.0.0-beta.107(effect@4.0.0-beta.107)': + dependencies: + '@types/ws': 8.18.1 + effect: 4.0.0-beta.107 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1)': + dependencies: + '@effect/platform-node-shared': 4.0.0-beta.107(effect@4.0.0-beta.107) + effect: 4.0.0-beta.107 + ioredis: 5.11.1 + mime: 4.1.0 + undici: 8.10.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@ioredis/commands@1.10.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': @@ -1435,6 +1504,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.1.2 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1495,28 +1568,28 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.10)': + '@vitest/browser-preview@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(vitest@4.1.10)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.10) - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)) + '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(vitest@4.1.10) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.10)': + '@vitest/browser@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(vitest@4.1.10)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)) '@vitest/utils': 4.1.10 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)) ws: 8.21.1 transitivePeerDependencies: - bufferutil @@ -1533,13 +1606,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)' '@vitest/pretty-format@4.1.10': dependencies: @@ -1565,7 +1638,7 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 - '@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)': dependencies: '@oxc-project/runtime': 0.141.0 '@oxc-project/types': 0.141.0 @@ -1577,9 +1650,8 @@ snapshots: '@types/node': 26.1.2 fsevents: 2.3.3 typescript: 7.0.2 - yaml: 2.9.0 - '@voidzero-dev/vite-plus-core@0.2.7(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-core@0.2.7(@types/node@26.1.2)(typescript@7.0.2)': dependencies: '@oxc-project/runtime': 0.141.0 '@oxc-project/types': 0.141.0 @@ -1591,7 +1663,6 @@ snapshots: '@types/node': 26.1.2 fsevents: 2.3.3 typescript: 7.0.2 - yaml: 2.9.0 '@voidzero-dev/vite-plus-darwin-arm64@0.2.7': optional: true @@ -1697,26 +1768,29 @@ snapshots: chai@6.2.2: {} + cluster-key-slot@1.1.1: {} + convert-source-map@2.0.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + denque@2.1.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} dom-accessibility-api@0.5.16: {} - effect@4.0.0-beta.102: + effect@4.0.0-beta.107: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 - find-my-way-ts: 0.1.6 - ini: 7.0.0 kubernetes-types: 1.30.0 msgpackr: 2.0.5 - multipasta: 0.2.8 - toml: 4.3.0 uuid: 14.0.1 - yaml: 2.9.0 es-module-lexer@2.3.1: {} @@ -1734,12 +1808,20 @@ snapshots: optionalDependencies: picomatch: 4.0.5 - find-my-way-ts@0.1.6: {} - fsevents@2.3.3: optional: true - ini@7.0.0: {} + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color js-tokens@4.0.0: {} @@ -1800,8 +1882,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mime@4.1.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} + msgpackr-extract@3.0.4: dependencies: node-gyp-build-optional-packages: 5.2.2 @@ -1818,8 +1904,6 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.4 - multipasta@0.2.8: {} - nanoid@3.3.16: {} node-gyp-build-optional-packages@5.2.2: @@ -1829,7 +1913,7 @@ snapshots: obug@2.1.4: {} - oxfmt@0.60.0(vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(typescript@7.0.2)(yaml@2.9.0)): + oxfmt@0.60.0(vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(typescript@7.0.2)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -1852,7 +1936,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.60.0 '@oxfmt/binding-win32-ia32-msvc': 0.60.0 '@oxfmt/binding-win32-x64-msvc': 0.60.0 - vite-plus: 0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(typescript@7.0.2)(yaml@2.9.0) + vite-plus: 0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(typescript@7.0.2) oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -1863,7 +1947,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(typescript@7.0.2)(yaml@2.9.0)): + oxlint@1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(typescript@7.0.2)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.75.0 '@oxlint/binding-android-arm64': 1.75.0 @@ -1885,7 +1969,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.75.0 '@oxlint/binding-win32-x64-msvc': 1.75.0 oxlint-tsgolint: 7.0.2001 - vite-plus: 0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(typescript@7.0.2)(yaml@2.9.0) + vite-plus: 0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(typescript@7.0.2) pathe@2.0.3: {} @@ -1917,6 +2001,12 @@ snapshots: react-is@17.0.2: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + siginfo@2.0.0: {} sirv@3.0.2: @@ -1929,6 +2019,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@4.2.0: {} tinybench@2.9.0: {} @@ -1944,8 +2036,6 @@ snapshots: tinyrainbow@3.1.1: {} - toml@4.3.0: {} - totalist@3.0.1: {} typescript@7.0.2: @@ -1973,26 +2063,28 @@ snapshots: undici-types@8.3.0: {} + undici@8.10.0: {} + uuid@14.0.1: {} - vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(typescript@7.0.2)(yaml@2.9.0): + vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(typescript@7.0.2): dependencies: '@oxc-project/types': 0.141.0 '@oxlint/plugins': 1.73.0 - '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.10) - '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(vitest@4.1.10) + '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(vitest@4.1.10) '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - '@voidzero-dev/vite-plus-core': 0.2.7(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0) - oxfmt: 0.60.0(vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(typescript@7.0.2)(yaml@2.9.0)) - oxlint: 1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(typescript@7.0.2)(yaml@2.9.0)) + '@voidzero-dev/vite-plus-core': 0.2.7(@types/node@26.1.2)(typescript@7.0.2) + oxfmt: 0.60.0(vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(typescript@7.0.2)) + oxlint: 1.75.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.7(@types/node@26.1.2)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(typescript@7.0.2)) oxlint-tsgolint: 7.0.2001 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.7 '@voidzero-dev/vite-plus-darwin-x64': 0.2.7 @@ -2033,10 +2125,10 @@ snapshots: - vite - yaml - vitest@4.1.10(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.1.2)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2053,11 +2145,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0)' + vite: '@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.2 - '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser-preview': 4.1.10(@voidzero-dev/vite-plus-core@0.2.6(@types/node@26.1.2)(typescript@7.0.2))(vitest@4.1.10) transitivePeerDependencies: - msw @@ -2068,8 +2160,6 @@ snapshots: ws@8.21.1: {} - yaml@2.9.0: {} - yuku-codegen@0.5.48: dependencies: '@yuku-toolchain/types': 0.5.43 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c5b8c7f..d17074c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,3 +7,7 @@ peerDependencyRules: vite: "0.2.6" allowBuilds: msgpackr-extract: false +minimumReleaseAgeExclude: + - "@effect/platform-node-shared@4.0.0-beta.107" + - "@effect/platform-node@4.0.0-beta.107" + - effect@4.0.0-beta.107 diff --git a/scripts/prepare-effect-source.sh b/scripts/prepare-effect-source.sh index 6b0e88c..a1b0437 100755 --- a/scripts/prepare-effect-source.sh +++ b/scripts/prepare-effect-source.sh @@ -2,9 +2,16 @@ set -euo pipefail readonly effect_upstream="https://github.com/Effect-TS/effect.git" -readonly effect_ref="effect@4.0.0-beta.102" readonly repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" readonly checkout="$repo_root/.repos/effect" +readonly effect_version="$(node -p 'require(process.argv[1]).dependencies.effect' "$repo_root/package.json")" + +if [[ ! "$effect_version" =~ ^4\.0\.0-beta\.[0-9]+$ ]]; then + echo "package.json must pin effect to an exact v4 beta version, got: $effect_version" >&2 + exit 1 +fi + +readonly effect_ref="effect@$effect_version" if [[ -e "$checkout" && ! -d "$checkout/.git" ]]; then echo "Effect source path exists but is not a Git checkout: $checkout" >&2 diff --git a/scripts/smoke.ts b/scripts/smoke.ts index f0d86f7..7719e23 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { Predicate } from "effect"; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const cliPath = join(repoRoot, "dist/cli.mjs"); @@ -108,14 +109,10 @@ function parseRecord(source: string, label: string): Record { } function requireRecord(value: unknown, label: string): Record { - assert.ok(isRecord(value), `${label} is invalid`); + assert.ok(Predicate.isObject(value), `${label} is invalid`); return value; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function failureMessage(check: string, result: SpawnSyncReturns): string { return [ `${check} smoke check failed`, diff --git a/src/cli.ts b/src/cli.ts index 31c6999..b5baede 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { NodeRuntime } from "@effect/platform-node"; import { realpathSync } from "node:fs"; import process from "node:process"; import { pathToFileURL } from "node:url"; @@ -33,8 +34,8 @@ export const runCli = Effect.fn("vref.cli")(function* ( cwd: string, options: RunCliOptions = {}, ) { - const args = parseArgs(argv, options.isInteractiveTerminal ?? true); - validateBooleanFlags(args, ["check", "dry-run", "help"]); + const args = yield* syncBoundary(() => parseArgs(argv, options.isInteractiveTerminal ?? true)); + yield* syncBoundary(() => validateBooleanFlags(args, ["check", "dry-run", "help"])); if (getBoolean(args, "help")) { yield* Effect.sync(() => printHelp(args.command)); @@ -44,7 +45,9 @@ export const runCli = Effect.fn("vref.cli")(function* ( switch (args.command) { case "build": { if (getBoolean(args, "check") || getBoolean(args, "dry-run")) { - validateFields(args, ["manifestPath", "screenshotCount", "groupCount", "deviceCount"]); + yield* syncBoundary(() => + validateFields(args, ["manifestPath", "screenshotCount", "groupCount", "deviceCount"]), + ); const result = yield* promiseBoundary(() => validateGallery({ cwd, @@ -57,13 +60,15 @@ export const runCli = Effect.fn("vref.cli")(function* ( return; } - validateFields(args, [ - "manifestPath", - "outputPath", - "screenshotCount", - "groupCount", - "deviceCount", - ]); + yield* syncBoundary(() => + validateFields(args, [ + "manifestPath", + "outputPath", + "screenshotCount", + "groupCount", + "deviceCount", + ]), + ); const result = yield* promiseBoundary(() => buildGallery({ cwd, @@ -76,7 +81,9 @@ export const runCli = Effect.fn("vref.cli")(function* ( } case "validate": { - validateFields(args, ["manifestPath", "screenshotCount", "groupCount", "deviceCount"]); + yield* syncBoundary(() => + validateFields(args, ["manifestPath", "screenshotCount", "groupCount", "deviceCount"]), + ); const result = yield* promiseBoundary(() => validateGallery({ cwd, @@ -90,38 +97,42 @@ export const runCli = Effect.fn("vref.cli")(function* ( } case "serve": { - validateFields(args, ["dir", "host", "port", "url"]); + yield* syncBoundary(() => validateFields(args, ["dir", "host", "port", "url"])); const port = yield* optionalPositiveInteger(args, "port"); - const result = yield* promiseBoundary(() => - serve({ - cwd, - dir: getString(args, "dir") ?? DEFAULT_SERVE_DIR, - host: getString(args, "host") ?? DEFAULT_HOST, - port: port ?? DEFAULT_PORT, + return yield* Effect.scoped( + Effect.gen(function* () { + const result = yield* serve({ + cwd, + dir: getString(args, "dir") ?? DEFAULT_SERVE_DIR, + host: getString(args, "host") ?? DEFAULT_HOST, + port: port ?? DEFAULT_PORT, + }); + yield* Effect.sync(() => { + if (args.output === "json") { + console.log(renderJsonResult(result, args.fields)); + } else { + console.log(`serving ${result.dir} at ${result.url}`); + console.log("press Ctrl+C to stop"); + } + }); + return yield* Effect.never; }), ); - yield* Effect.sync(() => { - if (args.output === "json") { - console.log(renderJsonResult(result, args.fields)); - } else { - console.log(`serving ${result.dir} at ${result.url}`); - console.log("press Ctrl+C to stop"); - } - }); - return; } case "describe": { - validateFields(args, [ - "name", - "package", - "version", - "defaults", - "output", - "automation", - "commands", - "manifest", - ]); + yield* syncBoundary(() => + validateFields(args, [ + "name", + "package", + "version", + "defaults", + "output", + "automation", + "commands", + "manifest", + ]), + ); const result = describeCli(); yield* Effect.sync(() => print(args, result, "vref: build, validate, serve, describe")); return; @@ -145,14 +156,16 @@ export const runCli = Effect.fn("vref.cli")(function* ( ); } - validateFields(args, [ - "assetExists", - "dryRun", - "manifestPath", - "screenshot", - "screenshotCount", - ]); - const screenshot = decodeScreenshotJson(rawJson); + yield* syncBoundary(() => + validateFields(args, [ + "assetExists", + "dryRun", + "manifestPath", + "screenshot", + "screenshotCount", + ]), + ); + const screenshot = yield* syncBoundary(() => decodeScreenshotJson(rawJson)); const result = yield* promiseBoundary(() => addScreenshot({ cwd, @@ -370,6 +383,13 @@ function promiseBoundary(run: () => Promise): Effect.Effect }); } +function syncBoundary(run: () => A): Effect.Effect { + return Effect.try({ + try: run, + catch: normalizeError, + }); +} + /** * Whether this module is the process entry point. * @@ -405,17 +425,20 @@ export function isDirectInvocation(moduleUrl: string, entryPath: string | undefi export function main(argv: string[], cwd: string): void { const isInteractiveTerminal = process.stdout.isTTY === true; - void Effect.runPromise(runCli(argv, cwd, { isInteractiveTerminal })).catch((error: unknown) => { - const wantsJson = wantsJsonOutput(argv, isInteractiveTerminal); - if (wantsJson) { - console.error(renderJsonError(error)); - } else if (error instanceof Error) { - console.error(error.message); - } else { - console.error(String(error)); - } - process.exitCode = 1; - }); + const program = runCli(argv, cwd, { isInteractiveTerminal }).pipe( + Effect.catch((error) => + Effect.sync(() => { + if (wantsJsonOutput(argv, isInteractiveTerminal)) { + console.error(renderJsonError(error)); + } else { + console.error(error.message); + } + process.exitCode = 1; + }), + ), + ); + + NodeRuntime.runMain(program); } if (isDirectInvocation(import.meta.url, process.argv[1])) { diff --git a/src/errors.ts b/src/errors.ts index 7f399be..0d61b8a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,10 +1,11 @@ -export class VrefError extends Error { - readonly code: string; +import { Schema } from "effect"; +export class VrefError extends Schema.TaggedError()("VrefError", { + code: Schema.String, + message: Schema.String, +}) { constructor(code: string, message: string) { - super(message); - this.name = "VrefError"; - this.code = code; + super({ code, message }); } } diff --git a/src/manifest.ts b/src/manifest.ts index 057736e..211c232 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,7 +1,55 @@ import { readFile, writeFile } from "node:fs/promises"; +import { DateTime, Option, Predicate, Schema } from "effect"; import { VrefError } from "./errors.js"; import { assertSupportedImage, safeManifestAssetPath } from "./path-safety.js"; -import type { VrefManifest, VrefScreenshot, VrefViewport } from "./types.js"; +import type { VrefManifest, VrefScreenshot } from "./types.js"; + +const Identifier = Schema.NonEmptyString.check( + Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/u, { + expected: "lowercase letters, numbers, dots, underscores, or hyphens", + }), +); +const NonBlankString = Schema.String.check( + Schema.makeFilter((value) => value.trim().length > 0, { + expected: "a non-empty string", + }), +); +const PositiveFinite = Schema.Finite.check(Schema.isGreaterThan(0)); +const IsoDateTimeString = NonBlankString.check( + Schema.makeFilter( + (value) => + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) && + Option.isSome(DateTime.make(value)), + { expected: "an ISO date-time string with a timezone" }, + ), +); +const VrefViewportSchema = Schema.Struct({ + width: PositiveFinite, + height: PositiveFinite, +}); +const VrefScreenshotSchema = Schema.Struct({ + id: Identifier, + title: NonBlankString, + group: NonBlankString, + platform: NonBlankString, + device: NonBlankString, + viewport: VrefViewportSchema, + file: NonBlankString, + capturedAt: IsoDateTimeString, + sizeBytes: PositiveFinite, + tags: Schema.Array(Identifier), + notes: Schema.Array(NonBlankString), +}); +const VrefManifestSchema = Schema.Struct({ + version: Schema.Literal(1), + title: NonBlankString, + description: NonBlankString, + updatedAt: IsoDateTimeString, + screenshots: Schema.Array(VrefScreenshotSchema), +}); + +const decodeManifest = Schema.decodeUnknownSync(VrefManifestSchema); +const decodeScreenshot = Schema.decodeUnknownSync(VrefScreenshotSchema); export async function readManifest(path: string): Promise { const { manifest } = await readManifestDocument(path); @@ -55,15 +103,19 @@ export function screenshotFromJson(value: unknown, path: string): VrefScreenshot } function manifestFromUnknown(value: unknown, path: string): VrefManifest { - const record = requireRecord(value, path); - const version = requireNumber(record.version, `${path}:version`); - - if (version !== 1) { + if (Predicate.isObject(value) && typeof value.version === "number" && value.version !== 1) { throw new VrefError("VREF_MANIFEST_UNSUPPORTED_VERSION", `${path}:version must be 1`); } - const screenshots = requireArray(record.screenshots, `${path}:screenshots`).map( - (screenshot, index) => screenshotFromUnknown(screenshot, `${path}:screenshots[${index}]`), + let decoded: typeof VrefManifestSchema.Type; + try { + decoded = decodeManifest(value, { errors: "all" }); + } catch (error) { + throw new VrefError("VREF_MANIFEST_SCHEMA_INVALID", `${path}: ${messageFrom(error)}`); + } + + const screenshots = decoded.screenshots.map((screenshot, index) => + validatedScreenshot(screenshot, `${path}:screenshots[${index}]`), ); const ids = new Set(); for (const screenshot of screenshots) { @@ -78,129 +130,54 @@ function manifestFromUnknown(value: unknown, path: string): VrefManifest { return { version: 1, - title: requireString(record.title, `${path}:title`), - description: requireString(record.description, `${path}:description`), - updatedAt: requireIsoDateString(record.updatedAt, `${path}:updatedAt`), + title: decoded.title, + description: decoded.description, + updatedAt: decoded.updatedAt, screenshots, }; } function screenshotFromUnknown(value: unknown, path: string): VrefScreenshot { - const record = requireRecord(value, path); - const file = safeManifestAssetPath(requireString(record.file, `${path}:file`), `${path}:file`); - assertSupportedImage(file); + let decoded: typeof VrefScreenshotSchema.Type; + try { + decoded = decodeScreenshot(value, { errors: "all" }); + } catch (error) { + throw new VrefError("VREF_MANIFEST_SCHEMA_INVALID", `${path}: ${messageFrom(error)}`); + } - return { - id: requireIdentifier(record.id, `${path}:id`), - title: requireString(record.title, `${path}:title`), - group: requireString(record.group, `${path}:group`), - platform: requireString(record.platform, `${path}:platform`), - device: requireString(record.device, `${path}:device`), - viewport: viewportFromUnknown(record.viewport, `${path}:viewport`), - file, - capturedAt: requireIsoDateString(record.capturedAt, `${path}:capturedAt`), - sizeBytes: requirePositiveNumber(record.sizeBytes, `${path}:sizeBytes`), - tags: requireStringArray(record.tags, `${path}:tags`).map((tag, index) => - requireTag(tag, `${path}:tags[${index}]`), - ), - notes: requireStringArray(record.notes, `${path}:notes`), - }; + return validatedScreenshot(decoded, path); } -function viewportFromUnknown(value: unknown, path: string): VrefViewport { - const record = requireRecord(value, path); +function validatedScreenshot( + screenshot: typeof VrefScreenshotSchema.Type, + path: string, +): VrefScreenshot { + const file = safeManifestAssetPath(screenshot.file, `${path}:file`); + assertSupportedImage(file); return { - width: requirePositiveNumber(record.width, `${path}:width`), - height: requirePositiveNumber(record.height, `${path}:height`), + id: screenshot.id, + title: screenshot.title, + group: screenshot.group, + platform: screenshot.platform, + device: screenshot.device, + viewport: { width: screenshot.viewport.width, height: screenshot.viewport.height }, + file, + capturedAt: screenshot.capturedAt, + sizeBytes: screenshot.sizeBytes, + tags: [...screenshot.tags], + notes: [...screenshot.notes], }; } function requireRecord(value: unknown, path: string): Record { - if (isRecord(value)) { + if (Predicate.isObject(value)) { return value; } throw new VrefError("VREF_MANIFEST_SCHEMA_INVALID", `${path} must be an object`); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function requireArray(value: unknown, path: string): unknown[] { - if (Array.isArray(value)) { - return value; - } - - throw new VrefError("VREF_MANIFEST_SCHEMA_INVALID", `${path} must be an array`); -} - -function requireStringArray(value: unknown, path: string): string[] { - return requireArray(value, path).map((item, index) => requireString(item, `${path}[${index}]`)); -} - -function requireString(value: unknown, path: string): string { - if (typeof value === "string" && value.trim().length > 0) { - return value; - } - - throw new VrefError("VREF_MANIFEST_SCHEMA_INVALID", `${path} must be a non-empty string`); -} - -function requireNumber(value: unknown, path: string): number { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - - throw new VrefError("VREF_MANIFEST_SCHEMA_INVALID", `${path} must be a finite number`); -} - -function requirePositiveNumber(value: unknown, path: string): number { - const numberValue = requireNumber(value, path); - - if (numberValue > 0) { - return numberValue; - } - - throw new VrefError("VREF_MANIFEST_SCHEMA_INVALID", `${path} must be greater than 0`); -} - -function requireIsoDateString(value: unknown, path: string): string { - const stringValue = requireString(value, path); - const parsed = Date.parse(stringValue); - - if (Number.isFinite(parsed)) { - return stringValue; - } - - throw new VrefError("VREF_MANIFEST_SCHEMA_INVALID", `${path} must be an ISO date string`); -} - -function requireIdentifier(value: unknown, path: string): string { - const stringValue = requireString(value, path); - - if (/^[a-z0-9][a-z0-9._-]*$/u.test(stringValue)) { - return stringValue; - } - - throw new VrefError( - "VREF_MANIFEST_SCHEMA_INVALID", - `${path} must use lowercase letters, numbers, dots, underscores, or hyphens`, - ); -} - -function requireTag(value: string, path: string): string { - if (/^[a-z0-9][a-z0-9._-]*$/u.test(value)) { - return value; - } - - throw new VrefError( - "VREF_MANIFEST_SCHEMA_INVALID", - `${path} must use lowercase letters, numbers, dots, underscores, or hyphens`, - ); -} - function messageFrom(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/output.ts b/src/output.ts index 3c0817d..bd9e0f5 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,3 +1,4 @@ +import { Predicate } from "effect"; import { VrefError, errorToJson } from "./errors.js"; export type OutputFormat = "human" | "json"; @@ -66,7 +67,7 @@ function selectTopLevelFields(value: unknown, fields: readonly string[]): unknow return value; } - if (!isRecord(value)) { + if (!Predicate.isObject(value)) { return value; } @@ -105,7 +106,7 @@ function collectUntrustedTextPaths(value: unknown, path: string, paths: string[] return; } - if (!isRecord(value)) { + if (!Predicate.isObject(value)) { return; } @@ -130,10 +131,6 @@ function isUntrustedTextPath(path: string): boolean { ); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function hasControlCharacter(value: string): boolean { return [...value].some((character) => { const codePoint = character.codePointAt(0); diff --git a/src/serve.ts b/src/serve.ts index 6fd1c41..184021d 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -1,7 +1,9 @@ import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; -import { createServer } from "node:http"; +import { createServer, type Server } from "node:http"; import { extname, join, normalize } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Effect } from "effect"; import { VrefError } from "./errors.js"; import { realPathInside, resolveInsideCwd } from "./path-safety.js"; @@ -19,12 +21,27 @@ export type ServeResult = { url: string; }; -export async function serve(options: ServeOptions): Promise { - const root = resolveInsideCwd(options.cwd, options.dir, "serve dir"); - const rootStats = await stat(root); +type RunningServer = { + result: ServeResult; + server: Server; +}; + +export const serve = Effect.fn("vref.serve")(function* (options: ServeOptions) { + const root = yield* Effect.try({ + try: () => resolveInsideCwd(options.cwd, options.dir, "serve dir"), + catch: normalizeServeError, + }); + const rootStats = yield* Effect.tryPromise({ + try: () => stat(root), + catch: (cause) => + new VrefError( + "VREF_SERVE_DIR_READ_FAILED", + `serve dir could not be read: ${messageFrom(cause)}`, + ), + }); if (!rootStats.isDirectory()) { - throw new VrefError( + return yield* new VrefError( "VREF_SERVE_DIR_NOT_DIRECTORY", `serve dir is not a directory: ${options.dir}`, ); @@ -53,34 +70,92 @@ export async function serve(options: ServeOptions): Promise { } response.writeHead(200, { "content-type": contentType(filePath) }); - createReadStream(filePath).pipe(response); + await pipeline(createReadStream(filePath), response); } catch (error) { if (error instanceof VrefError && error.code === "VREF_BAD_SERVE_PATH") { response.writeHead(400); response.end("Bad request"); return; } - response.writeHead(404); - response.end("Not found"); + if (response.headersSent) { + response.destroy(); + } else { + response.writeHead(404); + response.end("Not found"); + } } }); - await new Promise((resolvePromise, rejectPromise) => { - server.once("error", rejectPromise); - server.listen(options.port, options.host, () => { - server.off("error", rejectPromise); - resolvePromise(); - }); - }); + const running = yield* Effect.acquireRelease(listen(server, root, options), ({ server }) => + closeServer(server), + ); + + return running.result; +}); + +const listen = Effect.fn("vref.serve.listen")( + (server: Server, root: string, options: ServeOptions) => + Effect.callback((resume) => { + const onError = (cause: Error): void => { + tryCloseServer(server); + resume( + Effect.fail( + new VrefError("VREF_SERVE_LISTEN_FAILED", `server could not listen: ${cause.message}`), + ), + ); + }; + + server.once("error", onError); + server.listen(options.port, options.host, () => { + server.off("error", onError); + const address = server.address(); + if (typeof address !== "object" || address === null) { + server.close(); + resume( + Effect.fail( + new VrefError("VREF_SERVE_LISTEN_FAILED", "server did not expose a TCP address"), + ), + ); + return; + } - const url = `http://${options.host}:${options.port}/`; + const result = { + dir: root, + host: options.host, + port: address.port, + url: `http://${options.host}:${address.port}/`, + }; + resume(Effect.succeed({ result, server })); + }); - return { - dir: root, - host: options.host, - port: options.port, - url, - }; + return Effect.sync(() => { + server.off("error", onError); + tryCloseServer(server); + }); + }), +); + +function closeServer(server: Server): Effect.Effect { + return Effect.callback((resume) => { + if (!server.listening) { + resume(Effect.void); + return; + } + + try { + server.close(() => resume(Effect.void)); + } catch { + resume(Effect.void); + } + }); +} + +function tryCloseServer(server: Server): void { + try { + server.close(); + } catch { + return; + } } export async function resolveServableFile(root: string, relativePath: string): Promise { @@ -121,3 +196,13 @@ function contentType(filePath: string): string { return "application/octet-stream"; } } + +function normalizeServeError(error: unknown): VrefError { + return error instanceof VrefError + ? error + : new VrefError("VREF_SERVE_START_FAILED", messageFrom(error)); +} + +function messageFrom(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/test/vref.test.ts b/test/vref.test.ts index 57e3c38..1ba0654 100644 --- a/test/vref.test.ts +++ b/test/vref.test.ts @@ -7,7 +7,8 @@ import { describe, expect, it } from "vite-plus/test"; import { buildGallery, validateGallery } from "../src/build.js"; import { isDirectInvocation, runCli } from "../src/cli.js"; import { describeCli } from "../src/describe.js"; -import { resolveServableFile } from "../src/serve.js"; +import { VrefError } from "../src/errors.js"; +import { resolveServableFile, serve } from "../src/serve.js"; import type { VrefManifest } from "../src/types.js"; describe("vref", () => { @@ -143,6 +144,19 @@ describe("vref", () => { await expect(readFile(join(root, ".vref/index.html"), "utf8")).rejects.toThrow(); }); + it("rejects date-like strings that are not ISO date-times", async () => { + const root = await makeFixture(); + const manifest = makeManifest("screenshots/roku-720p/home.jpg", ["home"]); + await writeFile( + join(root, ".vref/manifest.json"), + `${JSON.stringify({ ...manifest, updatedAt: "2026" }, null, 2)}\n`, + ); + + await expect( + validateGallery({ cwd: root, manifestPath: ".vref/manifest.json" }), + ).rejects.toThrow("ISO date-time"); + }); + it("renders tag filter buttons only for tags used by multiple screenshots", async () => { const root = await mkdtemp(join(tmpdir(), "vref-")); await mkdir(join(root, ".vref/screenshots/roku-720p"), { recursive: true }); @@ -271,6 +285,27 @@ describe("vref", () => { ).rejects.toThrow("serve root"); }); + it("closes the HTTP server when its Effect scope ends", async () => { + const root = await makeFixture(); + let url = ""; + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const result = yield* serve({ cwd: root, dir: ".vref", host: "127.0.0.1", port: 0 }); + url = result.url; + const response = yield* Effect.tryPromise(() => + fetch(`${result.url}screenshots/roku-720p/home.jpg`), + ); + expect(response.status).toBe(200); + expect(yield* Effect.tryPromise(() => response.text())).toBe("image"); + }), + ), + ); + + await expect(fetch(url)).rejects.toThrow(); + }); + it("rejects manifest asset paths that escape the vref directory", async () => { const root = await makeFixture("../secret.jpg"); @@ -475,6 +510,17 @@ describe("vref", () => { expect(manifest).not.toContain('"settings"'); }); + it("keeps synchronous argument failures in the typed Effect error channel", async () => { + const root = await makeFixture(); + + const error = await Effect.runPromise( + Effect.flip(runCli(["build", "--output", "json", "--fields", "nope"], root)), + ); + + expect(error).toBeInstanceOf(VrefError); + expect(error.code).toBe("VREF_UNKNOWN_FIELD"); + }); + it("treats explicit true as a boolean dry-run value", async () => { const root = await makeFixture(); const screenshot = { From 9237095cdf78563e604f768703864cb6a01ac0ee Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 10:42:48 +0300 Subject: [PATCH 2/6] chore(effect): remove source readiness workflow --- .gitignore | 1 - .worktreeinclude | 1 - CONTRIBUTING.md | 4 +-- package.json | 1 - scripts/prepare-effect-source.sh | 48 -------------------------------- 5 files changed, 1 insertion(+), 54 deletions(-) delete mode 100644 .worktreeinclude delete mode 100755 scripts/prepare-effect-source.sh diff --git a/.gitignore b/.gitignore index 3263bee..8fa2750 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,4 @@ dist/ coverage/ .claude/worktrees/ .vref/index.html -.repos/effect .DS_Store diff --git a/.worktreeinclude b/.worktreeinclude deleted file mode 100644 index c8bc571..0000000 --- a/.worktreeinclude +++ /dev/null @@ -1 +0,0 @@ -/.repos/effect/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a83793..8f1878c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,12 +6,10 @@ Use Node `>=24.18.0` and pnpm. ```bash pnpm install -pnpm run effect:prepare pnpm run hooks:install ``` -The Effect preparation command installs the exact dependency-matched upstream source under -`.repos/effect`. The checked-in pre-push hook runs the full verification gate before each push. +The checked-in pre-push hook runs the full verification gate before each push. ## Run Locally diff --git a/package.json b/package.json index c6f8d0e..ad1354b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "check": "vp check .", "clean": "rm -rf coverage dist", "describe": "node ./dist/cli.mjs describe --output json", - "effect:prepare": "./scripts/prepare-effect-source.sh", "hooks:install": "git config core.hooksPath .git-hooks", "pack:dry": "vp pack src/index.ts src/cli.ts --dts && npm pack --dry-run", "prepack": "vp pack src/index.ts src/cli.ts --dts", diff --git a/scripts/prepare-effect-source.sh b/scripts/prepare-effect-source.sh deleted file mode 100755 index a1b0437..0000000 --- a/scripts/prepare-effect-source.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -readonly effect_upstream="https://github.com/Effect-TS/effect.git" -readonly repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -readonly checkout="$repo_root/.repos/effect" -readonly effect_version="$(node -p 'require(process.argv[1]).dependencies.effect' "$repo_root/package.json")" - -if [[ ! "$effect_version" =~ ^4\.0\.0-beta\.[0-9]+$ ]]; then - echo "package.json must pin effect to an exact v4 beta version, got: $effect_version" >&2 - exit 1 -fi - -readonly effect_ref="effect@$effect_version" - -if [[ -e "$checkout" && ! -d "$checkout/.git" ]]; then - echo "Effect source path exists but is not a Git checkout: $checkout" >&2 - exit 1 -fi - -if [[ ! -d "$checkout/.git" ]]; then - mkdir -p "$checkout" - git -C "$checkout" init --quiet -fi - -if [[ -n "$(git -C "$checkout" status --porcelain=v1)" ]]; then - echo "Effect source checkout has local changes: $checkout" >&2 - echo "Commit, stash, or remove those changes before running this command again." >&2 - exit 1 -fi - -readonly current_remote="$(git -C "$checkout" remote get-url origin 2>/dev/null || true)" - -if [[ -z "$current_remote" ]]; then - git -C "$checkout" remote add origin "$effect_upstream" -elif [[ "$current_remote" != "$effect_upstream" ]]; then - git -C "$checkout" remote set-url origin "$effect_upstream" -fi - -git -C "$checkout" fetch --depth 1 --force origin "refs/tags/$effect_ref:refs/tags/$effect_ref" -readonly target_commit="$(git -C "$checkout" rev-list -n 1 "$effect_ref")" -readonly current_commit="$(git -C "$checkout" rev-parse HEAD 2>/dev/null || true)" - -if [[ "$current_commit" != "$target_commit" ]]; then - git -C "$checkout" checkout --detach "$target_commit" -fi - -printf 'Effect source ready at %s (%s)\n' "$checkout" "$effect_ref" From d95f18255d4171e500c18ffd6c1639ebb73d72d2 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 10:52:49 +0300 Subject: [PATCH 3/6] fix(cli): preserve structured runtime failures --- src/cli.ts | 39 +++++++++++++++++++++++-------- test/vref.test.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b5baede..435783a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,9 @@ #!/usr/bin/env node -import { NodeRuntime } from "@effect/platform-node"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import { realpathSync } from "node:fs"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { Effect } from "effect"; +import { Cause, Effect } from "effect"; import { buildGallery, validateGallery } from "./build.js"; import { describeCli } from "./describe.js"; import { normalizeError, VrefError } from "./errors.js"; @@ -425,20 +425,39 @@ export function isDirectInvocation(moduleUrl: string, entryPath: string | undefi export function main(argv: string[], cwd: string): void { const isInteractiveTerminal = process.stdout.isTTY === true; - const program = runCli(argv, cwd, { isInteractiveTerminal }).pipe( - Effect.catch((error) => - Effect.sync(() => { + const program = recoverCliProgram( + runCli(argv, cwd, { isInteractiveTerminal }), + argv, + isInteractiveTerminal, + ); + + NodeRuntime.runMain(program); +} + +export function recoverCliProgram( + program: Effect.Effect, + argv: string[], + isInteractiveTerminal: boolean, +): Effect.Effect { + return program.pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterrupts(cause)) { + return Effect.failCause(cause); + } + + return Effect.sync(() => { + const error = Cause.squash(cause); if (wantsJsonOutput(argv, isInteractiveTerminal)) { console.error(renderJsonError(error)); - } else { + } else if (error instanceof Error) { console.error(error.message); + } else { + console.error(String(error)); } process.exitCode = 1; - }), - ), + }); + }), ); - - NodeRuntime.runMain(program); } if (isDirectInvocation(import.meta.url, process.argv[1])) { diff --git a/test/vref.test.ts b/test/vref.test.ts index 1ba0654..31103de 100644 --- a/test/vref.test.ts +++ b/test/vref.test.ts @@ -2,10 +2,10 @@ import { mkdir, mkdtemp, readFile, realpath, symlink, unlink, writeFile } from " import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { Effect } from "effect"; +import { Cause, Effect } from "effect"; import { describe, expect, it } from "vite-plus/test"; import { buildGallery, validateGallery } from "../src/build.js"; -import { isDirectInvocation, runCli } from "../src/cli.js"; +import { isDirectInvocation, recoverCliProgram, runCli } from "../src/cli.js"; import { describeCli } from "../src/describe.js"; import { VrefError } from "../src/errors.js"; import { resolveServableFile, serve } from "../src/serve.js"; @@ -521,6 +521,44 @@ describe("vref", () => { expect(error.code).toBe("VREF_UNKNOWN_FIELD"); }); + it("formats unexpected main-boundary defects as structured JSON", async () => { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + + try { + const result = await captureConsoleError(() => + Effect.runPromise( + recoverCliProgram(Effect.die(new Error("unexpected defect")), ["--output", "json"], true), + ), + ); + + expect(result.logs.join("\n")).toContain('"code": "VREF_UNEXPECTED_ERROR"'); + expect(result.logs.join("\n")).toContain("unexpected defect"); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + } + }); + + it("preserves main-boundary interruption for NodeRuntime", async () => { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + + try { + const exit = await Effect.runPromiseExit( + recoverCliProgram(Effect.interrupt, ["--output", "json"], true), + ); + + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(Cause.hasInterrupts(exit.cause)).toBe(true); + } + expect(process.exitCode).toBeUndefined(); + } finally { + process.exitCode = previousExitCode; + } + }); + it("treats explicit true as a boolean dry-run value", async () => { const root = await makeFixture(); const screenshot = { @@ -632,6 +670,23 @@ async function captureConsoleLog( } } +async function captureConsoleError( + run: () => Promise, +): Promise<{ logs: string[]; result: Result }> { + const originalError = console.error; + const logs: string[] = []; + console.error = (...values: unknown[]) => { + logs.push(values.map(String).join(" ")); + }; + + try { + const result = await run(); + return { logs, result }; + } finally { + console.error = originalError; + } +} + async function makeFixture( file = "screenshots/roku-720p/home.jpg", tags = ["home", "navigation"], From c0ad22d151b32fc0b06584a94f388e03a25e8f12 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 11:07:25 +0300 Subject: [PATCH 4/6] test(effect): cover compatibility and listen failures --- src/manifest.ts | 13 ++++-------- test/vref.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/manifest.ts b/src/manifest.ts index 211c232..6c0b28c 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -15,13 +15,8 @@ const NonBlankString = Schema.String.check( }), ); const PositiveFinite = Schema.Finite.check(Schema.isGreaterThan(0)); -const IsoDateTimeString = NonBlankString.check( - Schema.makeFilter( - (value) => - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) && - Option.isSome(DateTime.make(value)), - { expected: "an ISO date-time string with a timezone" }, - ), +const DateString = NonBlankString.check( + Schema.makeFilter((value) => Option.isSome(DateTime.make(value)), { expected: "a date string" }), ); const VrefViewportSchema = Schema.Struct({ width: PositiveFinite, @@ -35,7 +30,7 @@ const VrefScreenshotSchema = Schema.Struct({ device: NonBlankString, viewport: VrefViewportSchema, file: NonBlankString, - capturedAt: IsoDateTimeString, + capturedAt: DateString, sizeBytes: PositiveFinite, tags: Schema.Array(Identifier), notes: Schema.Array(NonBlankString), @@ -44,7 +39,7 @@ const VrefManifestSchema = Schema.Struct({ version: Schema.Literal(1), title: NonBlankString, description: NonBlankString, - updatedAt: IsoDateTimeString, + updatedAt: DateString, screenshots: Schema.Array(VrefScreenshotSchema), }); diff --git a/test/vref.test.ts b/test/vref.test.ts index 31103de..1518fa3 100644 --- a/test/vref.test.ts +++ b/test/vref.test.ts @@ -1,4 +1,5 @@ import { mkdir, mkdtemp, readFile, realpath, symlink, unlink, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -144,17 +145,38 @@ describe("vref", () => { await expect(readFile(join(root, ".vref/index.html"), "utf8")).rejects.toThrow(); }); - it("rejects date-like strings that are not ISO date-times", async () => { + it("preserves date-only and offset-less manifest timestamps", async () => { const root = await makeFixture(); const manifest = makeManifest("screenshots/roku-720p/home.jpg", ["home"]); await writeFile( join(root, ".vref/manifest.json"), - `${JSON.stringify({ ...manifest, updatedAt: "2026" }, null, 2)}\n`, + `${JSON.stringify( + { + ...manifest, + updatedAt: "2026-05-19", + screenshots: [{ ...manifest.screenshots[0], capturedAt: "2026-05-19T13:35:00" }], + }, + null, + 2, + )}\n`, ); await expect( validateGallery({ cwd: root, manifestPath: ".vref/manifest.json" }), - ).rejects.toThrow("ISO date-time"); + ).resolves.toEqual(expect.objectContaining({ screenshotCount: 1 })); + }); + + it("rejects invalid manifest timestamps", async () => { + const root = await makeFixture(); + const manifest = makeManifest("screenshots/roku-720p/home.jpg", ["home"]); + await writeFile( + join(root, ".vref/manifest.json"), + `${JSON.stringify({ ...manifest, updatedAt: "not-a-date" }, null, 2)}\n`, + ); + + await expect( + validateGallery({ cwd: root, manifestPath: ".vref/manifest.json" }), + ).rejects.toThrow("date string"); }); it("renders tag filter buttons only for tags used by multiple screenshots", async () => { @@ -306,6 +328,29 @@ describe("vref", () => { await expect(fetch(url)).rejects.toThrow(); }); + it("surfaces a typed error when the serve port is already in use", async () => { + const root = await makeFixture(); + const blocker = createServer(); + await new Promise((resolve, reject) => { + blocker.once("error", reject); + blocker.listen(0, "127.0.0.1", resolve); + }); + const address = blocker.address(); + if (typeof address !== "object" || address === null) { + throw new Error("blocking server did not expose a TCP address"); + } + + try { + await expect( + Effect.runPromise( + Effect.scoped(serve({ cwd: root, dir: ".vref", host: "127.0.0.1", port: address.port })), + ), + ).rejects.toMatchObject({ code: "VREF_SERVE_LISTEN_FAILED" }); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + it("rejects manifest asset paths that escape the vref directory", async () => { const root = await makeFixture("../secret.jpg"); From f9ca8c84356d4a3f43153f91f371374c25f3a5de Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 11:23:34 +0300 Subject: [PATCH 5/6] fix(cli): report mixed interruption failures --- src/cli.ts | 2 +- test/vref.test.ts | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 435783a..ced850b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -441,7 +441,7 @@ export function recoverCliProgram( ): Effect.Effect { return program.pipe( Effect.catchCause((cause) => { - if (Cause.hasInterrupts(cause)) { + if (Cause.hasInterruptsOnly(cause)) { return Effect.failCause(cause); } diff --git a/test/vref.test.ts b/test/vref.test.ts index 1518fa3..82ad9b1 100644 --- a/test/vref.test.ts +++ b/test/vref.test.ts @@ -604,6 +604,27 @@ describe("vref", () => { } }); + it("formats a main-boundary defect mixed with interruption", async () => { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + const cause = Cause.fromReasons([ + Cause.makeDieReason(new Error("mixed defect")), + Cause.makeInterruptReason(), + ]); + + try { + const result = await captureConsoleError(() => + Effect.runPromise(recoverCliProgram(Effect.failCause(cause), ["--output", "json"], true)), + ); + + expect(result.logs.join("\n")).toContain('"code": "VREF_UNEXPECTED_ERROR"'); + expect(result.logs.join("\n")).toContain("mixed defect"); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + } + }); + it("treats explicit true as a boolean dry-run value", async () => { const root = await makeFixture(); const screenshot = { From d0c897585c5a9d252cd8bd2e505bcb0d1ec78963 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 11:33:05 +0300 Subject: [PATCH 6/6] fix(serve): bound shutdown with active clients --- src/serve.ts | 13 ++++++++++-- test/vref.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/serve.ts b/src/serve.ts index 184021d..84323d8 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -142,10 +142,19 @@ function closeServer(server: Server): Effect.Effect { return; } + let completed = false; + const complete = (): void => { + if (!completed) { + completed = true; + resume(Effect.void); + } + }; + try { - server.close(() => resume(Effect.void)); + server.close(complete); + server.closeAllConnections(); } catch { - resume(Effect.void); + complete(); } }); } diff --git a/test/vref.test.ts b/test/vref.test.ts index 82ad9b1..70194e7 100644 --- a/test/vref.test.ts +++ b/test/vref.test.ts @@ -1,5 +1,5 @@ import { mkdir, mkdtemp, readFile, realpath, symlink, unlink, writeFile } from "node:fs/promises"; -import { createServer } from "node:net"; +import { connect, createServer, type Socket } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -328,6 +328,56 @@ describe("vref", () => { await expect(fetch(url)).rejects.toThrow(); }); + it("does not wait for a stalled response when the server scope ends", async () => { + const root = await makeFixture(); + await writeFile( + join(root, ".vref/screenshots/roku-720p/home.jpg"), + Buffer.alloc(8 * 1024 * 1024), + ); + let socket: Socket | undefined; + let timeout: ReturnType | undefined; + + try { + const scopedServer = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const result = yield* serve({ + cwd: root, + dir: ".vref", + host: "127.0.0.1", + port: 0, + }); + yield* Effect.tryPromise( + () => + new Promise((resolve, reject) => { + socket = connect(result.port, result.host, () => { + socket?.write( + "GET /screenshots/roku-720p/home.jpg HTTP/1.1\r\nHost: localhost\r\n\r\n", + ); + }); + socket.once("error", reject); + socket.once("data", () => { + socket?.pause(); + resolve(); + }); + }), + ); + }), + ), + ); + const deadline = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error("server scope did not close")), 1_000); + }); + + await expect(Promise.race([scopedServer, deadline])).resolves.toBeUndefined(); + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + socket?.destroy(); + } + }); + it("surfaces a typed error when the serve port is already in use", async () => { const root = await makeFixture(); const blocker = createServer();