diff --git a/deno.json b/deno.json index 63f365d067..26ebabd0d3 100644 --- a/deno.json +++ b/deno.json @@ -140,6 +140,7 @@ "./extensions/compat": "./src/extensions/compat/index.ts", "./extensions/first-party-import": "./src/extensions/first-party-import.ts", "./extensions/css": "./src/extensions/css/index.ts", + "./extensions/image": "./src/extensions/image/index.ts", "./extensions/database": "./src/extensions/database/index.ts", "./extensions/eval": "./src/extensions/eval/index.ts", "./extensions/parser": "./src/extensions/parser/index.ts", @@ -252,6 +253,7 @@ "veryfront/extensions/contracts": "./src/extensions/contracts.ts", "veryfront/extensions/first-party-import": "./src/extensions/first-party-import.ts", "veryfront/extensions/css": "./src/extensions/css/index.ts", + "veryfront/extensions/image": "./src/extensions/image/index.ts", "veryfront/extensions/database": "./src/extensions/database/index.ts", "veryfront/extensions/eval": "./src/extensions/eval/index.ts", "veryfront/extensions/parser": "./src/extensions/parser/index.ts", diff --git a/deno.lock b/deno.lock index 34798b0c58..5dffd2f2f5 100644 --- a/deno.lock +++ b/deno.lock @@ -58,6 +58,7 @@ "npm:bash-tool@1.3.18": "1.3.18_ai@7.0.41__zod@3.25.76_just-bash@3.0.1", "npm:better-sqlite3@9.6.0": "9.6.0", "npm:brace-expansion@5.0.8": "5.0.8", + "npm:browserslist@4.28.7": "4.28.7", "npm:es-module-lexer@2.3.1": "2.3.1", "npm:esbuild@0.28.1": "0.28.1", "npm:gaxios@7.2.0": "7.2.0", @@ -67,11 +68,13 @@ "npm:jsdom@28.0.0": "28.0.0", "npm:jszip@3.10.1": "3.10.1", "npm:just-bash@3.0.1": "3.0.1", + "npm:lightningcss@1.29.2": "1.29.2", "npm:mdast-util-to-string@4.0.0": "4.0.0", "npm:pdf-lib@1.17.1": "1.17.1", "npm:playwright@*": "1.60.0", "npm:playwright@1.60.0": "1.60.0", "npm:protobufjs@7.6.5": "7.6.5", + "npm:purgecss@8.0.0": "8.0.0", "npm:redis@5.11.0": "5.11.0", "npm:rehype-highlight@7.0.2": "7.0.2", "npm:rehype-raw@7.0.0": "7.0.0", @@ -83,6 +86,7 @@ "npm:remark-gfm@4.0.1": "4.0.1", "npm:remark-parse@11.0.0": "11.0.0", "npm:remark-rehype@11.1.2": "11.1.2", + "npm:sharp@0.34.5": "0.34.5", "npm:tailwindcss@4.2.2": "4.2.2", "npm:unified@11.0.5": "11.0.5", "npm:unist-util-visit@5.1.0": "5.1.0", @@ -2689,6 +2693,10 @@ "base64-js@1.5.1": { "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "baseline-browser-mapping@2.11.8": { + "integrity": "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==", + "bin": true + }, "bash-tool@1.3.18_ai@7.0.41__zod@3.25.76_just-bash@3.0.1": { "integrity": "sha512-Z5pLu6GP9C07wMpmDTSMqzdC2yJF71r9L1Ar1mqRCVwZesH6jQ0IN6v1NF5Ynt2rPCBawDuWDcJE3ciqqxhz5A==", "dependencies": [ @@ -2752,6 +2760,17 @@ "fill-range" ] }, + "browserslist@4.28.7": { + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dependencies": [ + "baseline-browser-mapping", + "caniuse-lite", + "electron-to-chromium", + "node-releases", + "update-browserslist-db" + ], + "bin": true + }, "buffer@5.6.0": { "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", "dependencies": [ @@ -2759,6 +2778,16 @@ "ieee754" ] }, + "buffer@5.7.1": { + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, + "caniuse-lite@1.0.30001806": { + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==" + }, "ccount@2.0.1": { "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" }, @@ -2809,6 +2838,9 @@ "comma-separated-tokens@2.0.3": { "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==" }, + "commander@12.1.0": { + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==" + }, "commander@6.2.1": { "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==" }, @@ -2822,6 +2854,10 @@ "source-map-js" ] }, + "cssesc@3.0.0": { + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": true + }, "cssstyle@5.3.7": { "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "dependencies": [ @@ -2902,6 +2938,9 @@ "diff@8.0.4": { "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==" }, + "electron-to-chromium@1.5.398": { + "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==" + }, "emoji-regex@8.0.0": { "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, @@ -3561,6 +3600,74 @@ "immediate" ] }, + "lightningcss-darwin-arm64@1.29.2": { + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-x64@1.29.2": { + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "lightningcss-freebsd-x64@1.29.2": { + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "lightningcss-linux-arm-gnueabihf@1.29.2": { + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "lightningcss-linux-arm64-gnu@1.29.2": { + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-arm64-musl@1.29.2": { + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-x64-gnu@1.29.2": { + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-linux-x64-musl@1.29.2": { + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-win32-arm64-msvc@1.29.2": { + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "lightningcss-win32-x64-msvc@1.29.2": { + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "lightningcss@1.29.2": { + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dependencies": [ + "detect-libc" + ], + "optionalDependencies": [ + "lightningcss-darwin-arm64", + "lightningcss-darwin-x64", + "lightningcss-freebsd-x64", + "lightningcss-linux-arm-gnueabihf", + "lightningcss-linux-arm64-gnu", + "lightningcss-linux-arm64-musl", + "lightningcss-linux-x64-gnu", + "lightningcss-linux-x64-musl", + "lightningcss-win32-arm64-msvc", + "lightningcss-win32-x64-msvc" + ] + }, "lodash.camelcase@4.3.0": { "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" }, @@ -4162,6 +4269,10 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "nanoid@3.3.16": { + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "bin": true + }, "napi-build-utils@2.0.0": { "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" }, @@ -4199,6 +4310,9 @@ "scripts": true, "bin": true }, + "node-releases@2.0.51": { + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==" + }, "object-keys@1.1.1": { "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" }, @@ -4316,6 +4430,21 @@ ], "bin": true }, + "postcss-selector-parser@7.1.4": { + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dependencies": [ + "cssesc", + "util-deprecate" + ] + }, + "postcss@8.5.25": { + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, "postgres-array@2.0.0": { "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" }, @@ -4383,6 +4512,16 @@ "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, + "purgecss@8.0.0": { + "integrity": "sha512-QFJyps9y5oHeXnNA3Ql1EaAqWBivNwQn19Pw1lt9RxfB+4e+bIyqCyuombk79D6Fxe+lPXggVfI1WtRGEBwgbQ==", + "dependencies": [ + "commander@12.1.0", + "fast-glob", + "postcss", + "postcss-selector-parser" + ], + "bin": true + }, "queue-microtask@1.2.3": { "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, @@ -4647,7 +4786,7 @@ "seek-bzip@2.0.0": { "integrity": "sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==", "dependencies": [ - "commander" + "commander@6.2.1" ], "bin": true }, @@ -4964,6 +5103,15 @@ "unist-util-visit-parents" ] }, + "update-browserslist-db@1.2.3_browserslist@4.28.7": { + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dependencies": [ + "browserslist", + "escalade", + "picocolors" + ], + "bin": true + }, "util-deprecate@1.0.2": { "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, diff --git a/docs/api-reference/veryfront/extensions.md b/docs/api-reference/veryfront/extensions.md index 84c454a88d..45a6243543 100644 --- a/docs/api-reference/veryfront/extensions.md +++ b/docs/api-reference/veryfront/extensions.md @@ -8,12 +8,12 @@ order: 9 ```ts import { + assertImageOptimizationEngine, + assertSystemReadCapability, auditCapabilities, + captureImageOptimizationEngine, composeAbortSignals, detectConflicts, - discoverLocalExtensions, - discoverPackageExtensions, - discoverProjectExtensions, } from "veryfront/extensions"; ``` @@ -41,6 +41,8 @@ await loader.teardownAll(); | `CIRCULAR_DEPENDENCY_ERROR` | Shared circular dependency error value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/errors.ts#L28) | | `EXTENSION_CONFLICT_ERROR` | Shared extension conflict error value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/errors.ts#L37) | | `EXTENSION_VALIDATION_ERROR` | Shared extension validation error value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/errors.ts#L19) | +| `ImageOptimizationEngineName` | Registry name used for the image optimization extension contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L22) | +| `MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS` | Maximum stable implementation identity accepted across the boundary. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L25) | | `MISSING_EXTENSION_ERROR` | Shared missing extension error value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/errors.ts#L10) | | `SandboxShellToolsProviderName` | Render sandbox shell tools provider name. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/sandbox/shell-tools.ts#L5) | @@ -48,22 +50,26 @@ await loader.teardownAll(); | Name | Description | Source | |------|-------------|--------| -| `auditCapabilities` | Log capabilities for a named extension at startup. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L84) | +| `assertImageOptimizationEngine` | Validate an implementation received through the dynamic contract registry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L129) | +| `assertSystemReadCapability` | Validate the bounded scope required by a `system:read` capability. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L144) | +| `auditCapabilities` | Log capabilities for a named extension at startup. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L222) | +| `captureImageOptimizationEngine` | Capture dynamic properties once so one run cannot split across mutations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L136) | | `composeAbortSignals` | Compose cancellation sources without depending on `AbortSignal.any`, which is absent from early Node 18 releases still covered by the npm engine range. The first source to abort owns the exact propagated reason, and listeners on every remaining source are detached immediately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/abort-signal.ts#L7) | -| `detectConflicts` | Detect contract conflicts between resolved extensions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/validation.ts#L148) | +| `detectConflicts` | Detect contract conflicts between resolved extensions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/validation.ts#L155) | | `discoverLocalExtensions` | Find `*.extension.ts` files in the project root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/discovery.ts#L241) | | `discoverPackageExtensions` | Scan `node_modules` (including `@scoped` packages) for packages that declare veryfront extension metadata in their `package.json`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/discovery.ts#L160) | | `discoverProjectExtensions` | Discover project extensions living under `extensions/` in the project root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/discovery.ts#L216) | -| `formatCapabilities` | Format capabilities as human-readable strings for logging. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L12) | -| `getRecommendation` | Return recommendation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/recommendations.ts#L30) | +| `formatCapabilities` | Format capabilities as human-readable strings for logging. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L23) | +| `getRecommendation` | Return recommendation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/recommendations.ts#L33) | +| `isSupportedDenoSystemReadApi` | Return whether a Deno system permission name is explicitly read-only. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L68) | | `loadExtensionFactory` | Dynamically import an extension factory from `path` and resolve it. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/factory-loader.ts#L32) | -| `mapToDenoPermissions` | Map capabilities to Deno CLI permission flags. Skips capabilities without a Deno permission mapping. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L54) | +| `mapToDenoPermissions` | Map capabilities to Deno CLI permission flags. Skips capabilities without a Deno permission mapping. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/capabilities.ts#L181) | | `mergeExtensions` | Merge extensions from all four sources in priority order. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/discovery.ts#L93) | | `orchestrateExtensions` | Run the full extension pipeline against a resolved project config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/orchestrate.ts#L103) | | `parsePackageMetadata` | Parse veryfront extension metadata from a package.json-like object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/discovery.ts#L59) | | `resolve` | Resolve path segments to an absolute path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/contracts.ts#L13) | | `tryResolve` | Try to resolve. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/contracts.ts#L28) | -| `validateExtension` | Validate the shape of an extension object. Returns an array of issue descriptions (empty array = valid). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/validation.ts#L84) | +| `validateExtension` | Validate the shape of an extension object. Returns an array of issue descriptions (empty array = valid). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/validation.ts#L85) | ### Classes @@ -76,7 +82,7 @@ await loader.teardownAll(); | Name | Description | Source | |------|-------------|--------| | `Capability` | Declares a system capability an extension requires. Object-based for extensibility -- scoping fields vary by type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/types.ts#L11) | -| `ConflictInfo` | Information about a contract conflict between extensions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/validation.ts#L12) | +| `ConflictInfo` | Information about a contract conflict between extensions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/validation.ts#L13) | | `CreateSandboxShellToolsInput` | Input payload for create sandbox shell tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/sandbox/shell-tools.ts#L38) | | `Extension` | Public API contract for extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/types.ts#L46) | | `ExtensionConfigEntry` | Entry shape for extension config. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/types.ts#L61) | @@ -85,6 +91,11 @@ await loader.teardownAll(); | `ExtensionFactory` | Public API contract for extension factory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/types.ts#L58) | | `ExtensionLogger` | Public API contract for extension logger. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/types.ts#L38) | | `ExtensionSource` | Public API contract for extension source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/types.ts#L66) | +| `ImageOptimizationEngine` | Image decoder, resizer, and encoder implemented by an explicit extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L63) | +| `ImageOptimizationFormat` | Formats core can request from an image optimization engine. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L29) | +| `ImageOptimizationRequest` | Immutable byte-oriented request supplied by core. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L32) | +| `ImageOptimizationResult` | Portable result returned by an image optimization engine. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L56) | +| `ImageOptimizationVariantResult` | One encoded output returned by an image optimization engine. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L48) | | `OrchestrateOptions` | Options for `orchestrateExtensions`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/orchestrate.ts#L29) | | `PackageMetadata` | Metadata extracted from a package.json that declares itself as a veryfront extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/discovery.ts#L17) | | `ResolvedExtension` | Public API contract for resolved extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/types.ts#L74) | @@ -248,9 +259,27 @@ import { register, reset, resolve } from "veryfront/extensions/contracts"; CSS category barrel - CSS processor and compiler contracts. ```ts -import "veryfront/extensions/css"; +import { assertCSSOptimizationEngine, assertCSSPurgingEngine, captureCSSOptimizationEngine } from "veryfront/extensions/css"; ``` +#### Components + +| Name | Description | Source | +|------|-------------|--------| +| `CSSOptimizationEngineName` | Registry name used for the CSS optimization extension contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-optimization-engine.ts#L21) | +| `CSSPurgingEngineName` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L20) | +| `MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS` | Maximum stable implementation identity accepted across the runtime boundary. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-optimization-engine.ts#L24) | +| `MAX_CSS_PURGING_ENGINE_IDENTITY_CHARACTERS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L21) | + +#### Functions + +| Name | Description | Source | +|------|-------------|--------| +| `assertCSSOptimizationEngine` | Validate an implementation received through the dynamic contract registry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-optimization-engine.ts#L113) | +| `assertCSSPurgingEngine` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L94) | +| `captureCSSOptimizationEngine` | Capture dynamic properties once so later mutation or accessors cannot change the implementation that core invokes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-optimization-engine.ts#L123) | +| `captureCSSPurgingEngine` | Capture identity and method once so registry mutation cannot split a run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L99) | + #### Types | Name | Description | Source | @@ -258,7 +287,14 @@ import "veryfront/extensions/css"; | `CSSCompileOptions` | Options passed to `CSSProcessor.compile`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L33) | | `CSSCompiler` | Stateful compiler returned by `CSSProcessor.compile`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L43) | | `CSSModuleSource` | A loaded module (Tailwind plugin). `module` is the plugin's default export. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L26) | +| `CSSOptimizationEngine` | Parser-backed CSS optimization contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-optimization-engine.ts#L46) | +| `CSSOptimizationRequest` | Immutable optimization request supplied by core. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-optimization-engine.ts#L27) | +| `CSSOptimizationResult` | Portable output returned by a CSS optimization engine. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-optimization-engine.ts#L35) | | `CSSProcessor` | CSSProcessor contract interface. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L59) | +| `CSSPurgeContentSource` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L23) | +| `CSSPurgingEngine` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L40) | +| `CSSPurgingRequest` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L28) | +| `CSSPurgingResult` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L35) | | `CSSStylesheetSource` | A loaded stylesheet body with the base path used to resolve relative imports. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L19) | ### `veryfront/extensions/database` @@ -328,6 +364,38 @@ import { firstPartyExtensionSourceSpecifiers, importFirstPartyExtensionModule, i | `importFirstPartyExtensionModule` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/first-party-import.ts#L17) | | `isMissingFirstPartyExtensionModule` | Classify a dynamic-import failure as "the extension module itself is not installed" as opposed to a real load failure inside an installed extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/first-party-import.ts#L75) | +### `veryfront/extensions/image` + +Image extension contracts. + +```ts +import { assertImageOptimizationEngine, captureImageOptimizationEngine, ImageOptimizationEngineName } from "veryfront/extensions/image"; +``` + +#### Components + +| Name | Description | Source | +|------|-------------|--------| +| `ImageOptimizationEngineName` | Registry name used for the image optimization extension contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L22) | +| `MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS` | Maximum stable implementation identity accepted across the boundary. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L25) | + +#### Functions + +| Name | Description | Source | +|------|-------------|--------| +| `assertImageOptimizationEngine` | Validate an implementation received through the dynamic contract registry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L129) | +| `captureImageOptimizationEngine` | Capture dynamic properties once so one run cannot split across mutations. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L136) | + +#### Types + +| Name | Description | Source | +|------|-------------|--------| +| `ImageOptimizationEngine` | Image decoder, resizer, and encoder implemented by an explicit extension. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L63) | +| `ImageOptimizationFormat` | Formats core can request from an image optimization engine. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L29) | +| `ImageOptimizationRequest` | Immutable byte-oriented request supplied by core. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L32) | +| `ImageOptimizationResult` | Portable result returned by an image optimization engine. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L56) | +| `ImageOptimizationVariantResult` | One encoded output returned by an image optimization engine. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/image/image-optimization-engine.ts#L48) | + ### `veryfront/extensions/llm` LLM category barrel - provider, embedding, and registry contracts. Interfaces re-exported with `export type { ... }` because Deno `--no-check` transpiles each file in isolation and would otherwise emit a runtime value re-export that fails ESM resolution. Reserve plain `export { ... }` for runtime values. diff --git a/docs/api-reference/veryfront/fs.md b/docs/api-reference/veryfront/fs.md index 1b8e49014a..2814732ab9 100644 --- a/docs/api-reference/veryfront/fs.md +++ b/docs/api-reference/veryfront/fs.md @@ -53,21 +53,21 @@ const configPath = resolve(cwd(), "veryfront.config.ts"); | Name | Description | Source | |------|-------------|--------| | `basename` | Return the last path segment. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/path/basic-operations.ts#L36) | -| `createFileSystem` | Create file system. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L316) | +| `createFileSystem` | Create file system. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L328) | | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L21) | | `dirname` | Return the parent directory path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/path/basic-operations.ts#L29) | -| `exists` | Check whether a path exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L348) | +| `exists` | Check whether a path exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L360) | | `extname` | Return the file extension for a path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/path/basic-operations.ts#L44) | -| `isNotFoundError` | Error shape for is not found. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L447) | +| `isNotFoundError` | Error shape for is not found. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L459) | | `join` | Join and normalize path segments using their detected path flavor. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/path/basic-operations.ts#L18) | -| `lstat` | Read file metadata without following a terminal symbolic link. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L358) | -| `mkdir` | Create a directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L382) | -| `readDir` | Read directory entries. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L392) | -| `readTextFile` | Read a file as text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L328) | -| `realPath` | Resolve a path to its canonical absolute form, following symlinks. Throws if the path does not exist. Useful for containment checks where a symlink could otherwise escape an intended directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L428) | -| `remove` | Remove a file or directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L387) | +| `lstat` | Read file metadata without following a terminal symbolic link. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L370) | +| `mkdir` | Create a directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L394) | +| `readDir` | Read directory entries. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L404) | +| `readTextFile` | Read a file as text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L340) | +| `realPath` | Resolve a path to its canonical absolute form, following symlinks. Throws if the path does not exist. Useful for containment checks where a symlink could otherwise escape an intended directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L440) | +| `remove` | Remove a file or directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L399) | | `resolve` | Resolve path segments to an absolute, normalized path. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/path/resolution.ts#L18) | -| `writeTextFile` | Write text to a file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L338) | +| `writeTextFile` | Write text to a file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L350) | ### Types diff --git a/docs/api-reference/veryfront/testing.md b/docs/api-reference/veryfront/testing.md index 7a79e53df7..eec8bf2089 100644 --- a/docs/api-reference/veryfront/testing.md +++ b/docs/api-reference/veryfront/testing.md @@ -55,44 +55,44 @@ describe("math", () => { | `assertThrows` | Assert that a synchronous function throws. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L293) | | `beforeAll` | Register a hook before all BDD tests in a group. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L630) | | `beforeEach` | Register a hook before each BDD test. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L611) | -| `chmod` | Change file permissions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L409) | -| `createFileSystem` | Create file system. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L316) | +| `chmod` | Change file permissions. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L421) | +| `createFileSystem` | Create file system. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L328) | | `cwd` | Return the current working directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L21) | | `deepEquals` | ********************* Shared utility functions for cross-runtime testing. ********************* | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L5) | | `delay` | Wait for a duration in milliseconds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L123) | | `deleteEnv` | Delete a process environment variable. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L213) | | `describe` | Group related BDD tests. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L497) | | `env` | Read and write process environment variables. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L32) | -| `exists` | Check whether a path exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L348) | +| `exists` | Check whether a path exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L360) | | `exit` | Exit the current process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L128) | | `fail` | Fail the current assertion immediately. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/assert.ts#L333) | | `getArgs` | Get command-line arguments (cross-runtime: Deno.args or process.argv). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/lifecycle.ts#L5) | | `getEnv` | Read an environment variable from the active project scope. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L117) | | `getTestTimeScale` | Return test time scale. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L4) | -| `isAlreadyExistsError` | Error shape for is already exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L469) | -| `isNotFoundError` | Error shape for is not found. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L447) | +| `isAlreadyExistsError` | Error shape for is already exists. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L481) | +| `isNotFoundError` | Error shape for is not found. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L459) | | `it` | Define a BDD test case. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/bdd.ts#L553) | -| `makeTempDir` | Create temp dir. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L404) | +| `makeTempDir` | Create temp dir. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L416) | | `makeTempDirWithOptions` | Options accepted by make temp dir with. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L73) | | `makeTempFile` | Create temp file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L48) | -| `mkdir` | Create a directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L382) | -| `readDir` | Read directory entries. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L392) | -| `readFile` | Read a file as bytes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L333) | -| `readTextFile` | Read a file as text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L328) | +| `mkdir` | Create a directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L394) | +| `readDir` | Read directory entries. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L404) | +| `readFile` | Read a file as bytes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L345) | +| `readTextFile` | Read a file as text. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L340) | | `registerTestCleanup` | Registers test cleanup. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/isolation.ts#L22) | -| `remove` | Remove a file or directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L387) | +| `remove` | Remove a file or directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L399) | | `resetAllTestState` | Comprehensive reset of ALL test state across the application. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/isolation.ts#L64) | | `safeStringify` | Serialize unknown values safely for test output. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/utils.ts#L34) | | `scaleMs` | Scale a duration for the current test runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L9) | | `setEnv` | Sets env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/process/env.ts#L193) | -| `stat` | Read file metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L353) | +| `stat` | Read file metadata. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L365) | | `testDelay` | Wait for a test-scaled duration. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/timing.ts#L15) | | `waitFor` | Wait until a condition succeeds. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L99) | | `withEnv` | Applies env. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L186) | | `withTempDir` | Applies temp dir. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L138) | | `withTempFile` | Applies temp file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/testing/deno-compat.ts#L162) | -| `writeFile` | Write bytes to a file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L343) | -| `writeTextFile` | Write text to a file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L338) | +| `writeFile` | Write bytes to a file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L355) | +| `writeTextFile` | Write text to a file. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/fs.ts#L350) | ### Types diff --git a/src/build/asset-pipeline/css-optimizer.test.ts b/src/build/asset-pipeline/css-optimizer.test.ts index 03aaebce53..716eb4cb1e 100644 --- a/src/build/asset-pipeline/css-optimizer.test.ts +++ b/src/build/asset-pipeline/css-optimizer.test.ts @@ -1,956 +1,204 @@ import "#veryfront/schemas/_test-setup.ts"; -/** - * Tests for CSS Optimizer - */ - -import { - assert, - assertEquals, - assertExists, - assertStringIncludes, -} from "#veryfront/testing/assert.ts"; +import { join } from "#veryfront/compat/path/index.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { join } from "#veryfront/compat/path"; -import { readTextFile, remove, writeTextFile } from "#veryfront/compat/fs.ts"; -import { ensureDir } from "#veryfront/compat/std/fs.ts"; -import { - type CSSOptimizationOptions, - CSSOptimizer, - loadCSSManifest, - optimizeCSS, -} from "./css-optimizer/index.ts"; - -const TEST_DIR = "./.veryfront/test-css"; -const OUTPUT_DIR = "./.veryfront/test-output-css"; - -async function removeDir(path: string): Promise { - try { - await remove(path, { recursive: true }); - } catch { - // Directory doesn't exist - } -} - -async function cleanupTestDirs(): Promise { - await removeDir(TEST_DIR); - await removeDir(OUTPUT_DIR); -} - -async function setupTestCSS(filename: string, content: string): Promise { - await ensureDir(TEST_DIR); - await writeTextFile(join(TEST_DIR, filename), content); -} +import { CSSOptimizer, loadCSSManifest, optimizeCSS } from "./css-optimizer/index.ts"; -async function withCleanDirs(fn: () => Promise): Promise { - await cleanupTestDirs(); +async function withProject( + callback: (projectDir: string) => Promise, +): Promise { + const projectDir = await Deno.makeTempDir(); try { - await fn(); + await Deno.mkdir(join(projectDir, "styles"), { recursive: true }); + await callback(projectDir); } finally { - await cleanupTestDirs(); + await Deno.remove(projectDir, { recursive: true }); } } -const TEST_CSS = ` -.button { - padding: 12px 24px; - background: #007bff; - color: white; - border-radius: 4px; - transition: all 0.3s ease; -} - -.button:hover { - background: #0056b3; - transform: translateY(-2px); -} - -/* This is a comment */ -.unused-class { - display: none; -} -`; - -describe("CSSOptimizer", () => { - describe("initialization", () => { - it("should initialize and return boolean for readiness", async () => { - await withCleanDirs(async () => { - const optimizer = new CSSOptimizer({ - enabled: true, - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - }); - - const isReady = await optimizer.init(); - - // Should return false if Lightning CSS is not available (graceful degradation) - // or true if Lightning CSS is installed - assertEquals(typeof isReady, "boolean"); - }); - }); - }); - - describe("disabled optimization", () => { - it("should return empty manifest when disabled", async () => { - await withCleanDirs(async () => { - const optimizer = new CSSOptimizer({ enabled: false }); - const manifest = await optimizer.optimize(); - - assertEquals(manifest.size, 0); - }); - }); - }); - - describe("minification", () => { - it("should perform basic minification", async () => { - await withCleanDirs(async () => { - await setupTestCSS("test.css", TEST_CSS); - - const optimizer = new CSSOptimizer({ - enabled: true, - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - const manifest = await optimizer.optimize(); - - // Should process CSS (with Lightning CSS or fallback) - assertEquals(typeof manifest.size, "number"); - - const bundle = manifest.get("test.css"); - if (bundle) assertEquals(bundle.minifiedSize < bundle.size, true); - }); - }); - - it("should use fallback minification", async () => { - await withCleanDirs(async () => { - await setupTestCSS("test.css", TEST_CSS); - - const optimizer = new CSSOptimizer({ - enabled: true, - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - const manifest = await optimizer.optimize(); - - // Even without Lightning CSS, fallback should work - const bundle = manifest.get("test.css"); - if (!bundle) return; - - // Fallback minification should at least remove whitespace - assertEquals(bundle.minifiedSize <= bundle.size, true); - }); - }); - - it("should remove comments when minifying", async () => { - await withCleanDirs(async () => { - const cssWithComments = ` -/* Header comment */ -.header { - /* Inline comment */ - padding: 1rem; -} - -/* Another comment */ -.footer { - padding: 2rem; /* Trailing comment */ -} -`; - - await setupTestCSS("commented.css", cssWithComments); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - const manifest = await optimizer.optimize(); - - const bundle = manifest.get("commented.css"); - if (!bundle) return; - - // Minified version should not contain comments - assertEquals(bundle.content.includes("/*"), false); - }); - }); - }); - - describe("getStats", () => { - it("should return stats with expected properties", async () => { - await withCleanDirs(async () => { - await setupTestCSS("test.css", TEST_CSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - }); - - await optimizer.optimize(); - - const stats = await optimizer.getStats(); - - assertEquals(typeof stats.totalFiles, "number"); - assertEquals(typeof stats.originalSize, "number"); - assertEquals(typeof stats.minifiedSize, "number"); - assertEquals(typeof stats.averageSavings, "number"); - }); - }); - - it("should calculate total savings correctly", async () => { - await withCleanDirs(async () => { - await setupTestCSS("file1.css", TEST_CSS); - await setupTestCSS("file2.css", TEST_CSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - await optimizer.optimize(); - - const stats = await optimizer.getStats(); - - if (stats.totalFiles <= 0) return; - - assertEquals(stats.totalSavings, stats.originalSize - stats.minifiedSize); - assertEquals( - stats.averageSavings, - (stats.totalSavings / stats.originalSize) * 100, - ); - }); - }); - - it("should handle stats with no savings", async () => { - await withCleanDirs(async () => { - await setupTestCSS("minimal.css", ".a{color:red}"); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: false, - }); - - await optimizer.optimize(); - - const stats = await optimizer.getStats(); - - assertEquals(typeof stats.averageSavings, "number"); - }); - }); - }); - - describe("multiple files", () => { - it("should process all CSS files", async () => { - await withCleanDirs(async () => { - await setupTestCSS("main.css", TEST_CSS); - await setupTestCSS("components.css", ".card { padding: 1rem; }"); - await setupTestCSS("utilities.css", ".mt-4 { margin-top: 1rem; }"); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - const manifest = await optimizer.optimize(); - - // Should process all CSS files - assertEquals(manifest.size >= 0, true); - }); - }); - }); - - describe("source maps", () => { - it("should handle source map generation", async () => { - await withCleanDirs(async () => { - await setupTestCSS("test.css", TEST_CSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - sourceMap: true, - }); - - const manifest = await optimizer.optimize(); - - const bundle = manifest.get("test.css"); - if (!bundle) return; - - // Source maps may or may not be generated depending on Lightning CSS availability - const sourceMapType = typeof bundle.sourceMap; - assert(sourceMapType === "string" || sourceMapType === "undefined"); - }); - }); - }); - - describe("critical CSS extraction", () => { - it("should extract critical CSS from HTML content", async () => { - await withCleanDirs(async () => { - await setupTestCSS("test.css", TEST_CSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - }); - - await optimizer.optimize(); - - const htmlContent = ''; - const cssPath = join(TEST_DIR, "test.css"); - - const result = await optimizer.extractCriticalCSS(cssPath, htmlContent); - - assertExists(result.critical); - assertExists(result.remaining); - assertEquals(typeof result.criticalSize, "number"); - assertEquals(typeof result.remainingSize, "number"); - }); - }); - - it("should extract critical CSS with HTML content selectors", async () => { - await withCleanDirs(async () => { - const css = ` -.above-fold { display: flex; } -.below-fold { margin-top: 100vh; } -.header { position: sticky; } -.footer { margin-top: auto; } -`; - - await setupTestCSS("critical.css", css); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - }); - - await optimizer.optimize(); - - const html = '
Visible content
'; - const result = await optimizer.extractCriticalCSS( - join(TEST_DIR, "critical.css"), - html, - ); - - assertExists(result.critical); - assertExists(result.remaining); - assertEquals(typeof result.criticalSize, "number"); - assertEquals(typeof result.remainingSize, "number"); - }); - }); - - it("should minify critical CSS", async () => { - await withCleanDirs(async () => { - const css = ` -/* Header styles */ -.header { - padding: 1rem; - background: white; -} - -/* Content styles */ -.content { - margin: 2rem; -} -`; - - await setupTestCSS("minify-critical.css", css); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - await optimizer.optimize(); - - const html = '
Title
'; - const result = await optimizer.extractCriticalCSS( - join(TEST_DIR, "minify-critical.css"), - html, - ); - - // Critical CSS should be minified - assertEquals(result.critical.includes("/*"), false); - }); - }); - }); - - describe("purge unused CSS", () => { - it("should purge unused CSS", async () => { - await withCleanDirs(async () => { - await setupTestCSS("test.css", TEST_CSS); - - await ensureDir(join(TEST_DIR, "pages")); - await writeTextFile( - join(TEST_DIR, "pages", "index.tsx"), - '', - ); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - purge: true, - purgeContent: [`${TEST_DIR}/pages/**/*.tsx`], - }); - - const manifest = await optimizer.optimize(); - - // Purging should work (with or without Lightning CSS) - assertEquals(typeof manifest.size, "number"); - }); - }); - - it("should purge CSS with className extraction", async () => { - await withCleanDirs(async () => { - const css = ` -.used-button { background: blue; } -.used-card { padding: 1rem; } -.unused-section { display: block; } -.another-unused { color: red; } -`; - - await setupTestCSS("purgeable.css", css); - - await ensureDir(join(TEST_DIR, "pages")); - await writeTextFile( - join(TEST_DIR, "pages", "index.tsx"), - '
Content
', - ); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - purge: true, - purgeContent: [`${TEST_DIR}/pages/**/*.tsx`], - }); - - const manifest = await optimizer.optimize(); - - assertEquals(typeof manifest.size, "number"); - }); - }); - - it("should purge CSS with class attribute", async () => { - await withCleanDirs(async () => { - const css = ` -.header { font-size: 2rem; } -.footer { margin-top: 2rem; } -.sidebar { width: 300px; } -`; - - await setupTestCSS("classes.css", css); - - await ensureDir(join(TEST_DIR, "app")); - await writeTextFile( - join(TEST_DIR, "app", "layout.tsx"), - '', - ); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - purge: true, - purgeContent: [`${TEST_DIR}/app/**/*.tsx`], - }); - - await optimizer.optimize(); - }); - }); - - it("should purge CSS with ID selectors", async () => { - await withCleanDirs(async () => { - const css = ` -#main { max-width: 1200px; } -#sidebar { width: 250px; } -#unused { display: none; } -`; - - await setupTestCSS("ids.css", css); - - await ensureDir(join(TEST_DIR, "components")); - await writeTextFile( - join(TEST_DIR, "components", "app.tsx"), - '
', - ); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - purge: true, - purgeContent: [`${TEST_DIR}/components/**/*.tsx`], - }); - - await optimizer.optimize(); - }); +describe("build/asset-pipeline/CSSOptimizer", () => { + it("rejects malformed facade options synchronously", () => { + assertThrows( + () => new CSSOptimizer(null as unknown as Record), + TypeError, + "must be an object", + ); + assertThrows( + () => + new CSSOptimizer({ + inputFiles: "styles/main.css" as unknown as string[], + }), + TypeError, + "inputFiles must be an array", + ); + }); + + it("initializes the required compiler and honors disabled mode", async () => { + await withProject(async (projectDir) => { + assertEquals( + await new CSSOptimizer({ projectDir, enabled: true }).init(), + true, + ); + assertEquals( + (await new CSSOptimizer({ projectDir, enabled: false }).optimize()).size, + 0, + ); }); }); - describe("autoprefixer", () => { - it("should add vendor prefixes", async () => { - await withCleanDirs(async () => { - const modernCSS = ` -.grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); -} -`; - - await setupTestCSS("modern.css", modernCSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - autoprefixer: true, - browsers: ["last 2 versions"], - }); - - const manifest = await optimizer.optimize(); - - // Should process CSS (prefixes added if Lightning CSS available) - assertEquals(typeof manifest.size, "number"); + it("minifies valid CSS and reports exact byte statistics", async () => { + await withProject(async (projectDir) => { + const input = "/* comment */ .button { color: red; padding: 1rem; }"; + await Deno.writeTextFile(join(projectDir, "styles/main.css"), input); + const optimizer = new CSSOptimizer({ + projectDir, + inputDir: "styles", + outputDir: ".veryfront/css", }); - }); - - it("should handle modern browser targets", async () => { - await withCleanDirs(async () => { - const modernCSS = ` -.container { - display: grid; - gap: 1rem; - aspect-ratio: 16 / 9; -} -`; - - await setupTestCSS("modern-features.css", modernCSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - autoprefixer: true, - browsers: ["last 2 Chrome versions"], - }); - - const manifest = await optimizer.optimize(); - - assertEquals(typeof manifest.size, "number"); - }); - }); - - it("should handle legacy browser support", async () => { - await withCleanDirs(async () => { - const legacyCSS = ` -.flexbox { - display: flex; - flex-direction: column; -} -`; - - await setupTestCSS("legacy.css", legacyCSS); - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - autoprefixer: true, - browsers: ["> 1%", "last 2 versions"], - }); - - const manifest = await optimizer.optimize(); - - assertEquals(typeof manifest.size, "number"); - }); + const bundle = (await optimizer.optimize()).get("main.css")!; + assertEquals(bundle.content, ".button{color:red;padding:1rem}"); + assertEquals(bundle.size, new TextEncoder().encode(input).length); + assertEquals( + bundle.minifiedSize, + new TextEncoder().encode(bundle.content).length, + ); + assertEquals((await optimizer.getStats()).totalFiles, 1); }); }); - describe("edge cases", () => { - it("should handle empty CSS file", async () => { - await withCleanDirs(async () => { - await setupTestCSS("empty.css", ""); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - }); - - const manifest = await optimizer.optimize(); - - // Should handle empty files gracefully - assertEquals(typeof manifest.size, "number"); - }); - }); - - it("should handle invalid CSS", async () => { - await withCleanDirs(async () => { - await setupTestCSS("invalid.css", ".broken { color: }"); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - }); - - // Should not throw, should log error - const manifest = await optimizer.optimize(); - - assertEquals(typeof manifest.size, "number"); - }); - }); - - it("should handle CSS with only whitespace", async () => { - await withCleanDirs(async () => { - await setupTestCSS("whitespace.css", " \n\n \t\t \n "); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - const manifest = await optimizer.optimize(); - - const bundle = manifest.get("whitespace.css"); - if (bundle) assertEquals(bundle.content.trim(), ""); - }); - }); - - it("should handle missing input directory", async () => { - await withCleanDirs(async () => { - const optimizer = new CSSOptimizer({ - inputDir: "/nonexistent/directory", - outputDir: OUTPUT_DIR, - }); - - // Should not throw - const manifest = await optimizer.optimize(); - - assertEquals(manifest.size, 0); - }); - }); - - it("should handle malformed @media queries", async () => { - await withCleanDirs(async () => { - const malformedCSS = ` -@media (min-width: 768px { - .container { width: 750px; } -} - -.valid { color: blue; } -`; - - await setupTestCSS("malformed.css", malformedCSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - }); - - // Should handle gracefully - const manifest = await optimizer.optimize(); - - assertEquals(typeof manifest.size, "number"); - }); + it("uses Browserslist targets only when prefixing is enabled", async () => { + await withProject(async (projectDir) => { + await Deno.writeTextFile( + join(projectDir, "styles/main.css"), + ".field { user-select: none; appearance: none; }", + ); + const prefixed = await optimizeCSS({ + projectDir, + inputDir: "styles", + outputDir: ".veryfront/prefixed", + browsers: ["ie 11"], + autoprefixer: true, + }); + const unprefixed = await optimizeCSS({ + projectDir, + inputDir: "styles", + outputDir: ".veryfront/unprefixed", + browsers: ["ie 11"], + autoprefixer: false, + }); + + assertEquals( + prefixed.get("main.css")!.content.includes("-ms-user-select"), + true, + ); + assertEquals( + unprefixed.get("main.css")!.content.includes("-ms-user-select"), + false, + ); }); }); - describe("Lightning CSS integration", () => { - it("should use fallback minification for complex CSS", async () => { - await withCleanDirs(async () => { - const complexCSS = ` -/* Complex CSS with modern features */ -.container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - padding: clamp(1rem, 5vw, 3rem); -} - -@media (prefers-color-scheme: dark) { - .container { - background: #1a1a1a; - color: #ffffff; - } -} - -.card:has(> .active) { - border: 2px solid blue; -} -`; - - await setupTestCSS("modern.css", complexCSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - const manifest = await optimizer.optimize(); - - const bundle = manifest.get("modern.css"); - if (bundle) { - // Should minify even with fallback - assertEquals(bundle.minifiedSize <= bundle.size, true); - } - }); - }); - - it("should use fallback when Lightning unavailable", async () => { - await withCleanDirs(async () => { - await setupTestCSS("fallback.css", TEST_CSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - // Even if Lightning CSS fails, fallback should work - const manifest = await optimizer.optimize(); + it("writes usable source-map links and rejects malformed CSS", async () => { + await withProject(async (projectDir) => { + await Deno.writeTextFile( + join(projectDir, "styles/main.css"), + ".main { color: red; }", + ); + const optimizer = new CSSOptimizer({ + projectDir, + inputDir: "styles", + outputDir: ".veryfront/css", + sourceMap: true, + }); + const bundle = (await optimizer.optimize()).get("main.css")!; + assertEquals( + bundle.content.includes("sourceMappingURL=main.min.css.map"), + true, + ); + assertEquals(JSON.parse(bundle.sourceMap!).version, 3); - assertEquals(typeof manifest.size, "number"); - }); + await Deno.writeTextFile( + join(projectDir, "styles/main.css"), + "@media ( { .broken { color: red; }", + ); + await assertRejects(() => optimizer.optimize()); + assertEquals( + await Deno.readTextFile(join(projectDir, ".veryfront/css/main.min.css")), + bundle.content, + ); }); }); - describe("Tailwind processing", () => { - it("should process Tailwind utility classes", async () => { - await withCleanDirs(async () => { - const tailwindCSS = ` -.flex { display: flex; } -.items-center { align-items: center; } -.justify-between { justify-content: space-between; } -.p-4 { padding: 1rem; } -.mt-2 { margin-top: 0.5rem; } -.bg-blue-500 { background-color: #3b82f6; } -.text-white { color: #ffffff; } -`; - - await setupTestCSS("tailwind.css", tailwindCSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - const manifest = await optimizer.optimize(); - - const bundle = manifest.get("tailwind.css"); - if (bundle) assert(bundle.minifiedSize < bundle.size); - }); - }); - - it("should preserve utility classes with purge", async () => { - await withCleanDirs(async () => { - const utilities = ` -.flex { display: flex; } -.grid { display: grid; } -.hidden { display: none; } -.block { display: block; } -`; - - await setupTestCSS("utilities.css", utilities); - - await ensureDir(join(TEST_DIR, "src")); - await writeTextFile( - join(TEST_DIR, "src", "component.tsx"), - '
Content
', - ); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - purge: true, - purgeContent: [`${TEST_DIR}/src/**/*.tsx`], - }); + it("extracts critical and remaining CSS with parser-backed rules", async () => { + await withProject(async (projectDir) => { + const cssPath = join(projectDir, "styles/main.css"); + await Deno.writeTextFile( + cssPath, + ".header { color: red; } " + + ".footer { color: blue; } " + + "@media (min-width: 40rem) { .header { display: flex; } .aside { display: block; } }", + ); + const optimizer = new CSSOptimizer({ projectDir, minify: true }); + const result = await optimizer.extractCriticalCSS( + cssPath, + '
Title
', + ); - await optimizer.optimize(); - }); + assertEquals(result.critical.includes(".header"), true); + assertEquals(result.critical.includes(".footer"), false); + assertEquals(result.remaining.includes(".footer"), true); + assertEquals(result.remaining.includes(".aside"), true); + assertEquals( + result.criticalSize, + new TextEncoder().encode(result.critical).length, + ); }); }); - describe("manifest handling", () => { - it("should exclude content from manifest", async () => { - await withCleanDirs(async () => { - await setupTestCSS("manifest-test.css", TEST_CSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - }); - - await optimizer.optimize(); - - try { - const manifestPath = join(OUTPUT_DIR, "css-manifest.json"); - const content = await readTextFile(manifestPath); - const parsed = JSON.parse(content); - - // Manifest should not include full content (for file size) - for (const key in parsed) { - const entry = parsed[key]; - assertEquals(entry.content, undefined); - assertEquals(entry.sourceMap, undefined); - } - } catch { - // Manifest may not exist if optimization disabled - } - }); - }); - - it("should include bundle metadata in manifest", async () => { - await withCleanDirs(async () => { - await setupTestCSS("metadata.css", TEST_CSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - await optimizer.optimize(); - - try { - const manifestPath = join(OUTPUT_DIR, "css-manifest.json"); - const content = await readTextFile(manifestPath); - const parsed = JSON.parse(content); - - for (const key in parsed) { - const entry = parsed[key]; - assertExists(entry.file); - assertEquals(typeof entry.size, "number"); - assertEquals(typeof entry.minifiedSize, "number"); - assertEquals(typeof entry.savings, "number"); - } - } catch { - // OK if manifest doesn't exist - } - }); + it("supports explicit project-contained input files", async () => { + await withProject(async (projectDir) => { + await Deno.writeTextFile( + join(projectDir, "standalone.css"), + ".standalone { color: purple; }", + ); + const bundles = await optimizeCSS({ + projectDir, + inputDir: "styles", + inputFiles: ["standalone.css"], + outputDir: ".veryfront/css", + }); + + assertEquals(bundles.has("standalone.css"), true); + assertEquals( + await Deno.readTextFile( + join(projectDir, ".veryfront/css/standalone.min.css"), + ), + ".standalone{color:purple}", + ); }); }); - describe("import resolution", () => { - it("should handle @import with URL", async () => { - await withCleanDirs(async () => { - const cssWithImports = ` -@import url("https://fonts.googleapis.com/css2?family=Roboto"); -@import "normalize.css"; - -.container { - font-family: "Roboto", sans-serif; -} -`; - - await setupTestCSS("imports.css", cssWithImports); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }); - - const manifest = await optimizer.optimize(); - - const bundle = manifest.get("imports.css"); - if (bundle) { - // Should preserve imports - assertStringIncludes(bundle.content, "@import"); - } - }); - }); - - it("should handle relative @import paths", async () => { - await withCleanDirs(async () => { - const mainCSS = ` -@import "./variables.css"; -@import "../base/reset.css"; - -.app { - color: var(--primary); -} -`; - - await setupTestCSS("main.css", mainCSS); - - const optimizer = new CSSOptimizer({ - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - }); - - const manifest = await optimizer.optimize(); - - assertEquals(typeof manifest.size, "number"); + it("loads the published complete manifest", async () => { + await withProject(async (projectDir) => { + await Deno.writeTextFile( + join(projectDir, "styles/main.css"), + ".main { color: red; }", + ); + await optimizeCSS({ + projectDir, + inputDir: "styles", + outputDir: ".veryfront/css", }); - }); - }); -}); - -describe("optimizeCSS", () => { - it("should work as a helper function", async () => { - await withCleanDirs(async () => { - await setupTestCSS("test.css", TEST_CSS); - - const options: CSSOptimizationOptions = { - enabled: true, - inputDir: TEST_DIR, - outputDir: OUTPUT_DIR, - minify: true, - }; - - const manifest = await optimizeCSS(options); - - assertExists(manifest); - assertEquals(typeof manifest.size, "number"); - }); - }); -}); - -describe("loadCSSManifest", () => { - it("should return empty manifest for missing file", async () => { - await withCleanDirs(async () => { - const manifest = await loadCSSManifest(OUTPUT_DIR); - - assertEquals(manifest.size, 0); - }); - }); - - it("should load valid manifest file", async () => { - await withCleanDirs(async () => { - await ensureDir(OUTPUT_DIR); - - const testManifest = { - "test.css": { - file: "test.css", - size: 1000, - minifiedSize: 500, - savings: 50, - }, - }; - await writeTextFile( - join(OUTPUT_DIR, "css-manifest.json"), - JSON.stringify(testManifest, null, 2), + const manifest = await loadCSSManifest( + join(projectDir, ".veryfront/css"), + ); + assertEquals(manifest.get("main.css")?.content, ".main{color:red}"); + assertEquals( + manifest.get("main.css")?.outputFile, + ".veryfront/css/main.min.css", ); - - const manifest = await loadCSSManifest(OUTPUT_DIR); - - assertEquals(manifest.size, 1); - assertExists(manifest.get("test.css")); - }); - }); - - it("should return empty manifest for corrupted file", async () => { - await withCleanDirs(async () => { - await ensureDir(OUTPUT_DIR); - - await writeTextFile(join(OUTPUT_DIR, "css-manifest.json"), "invalid{json}"); - - const manifest = await loadCSSManifest(OUTPUT_DIR); - - // Should return empty map on error - assertEquals(manifest.size, 0); }); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/ARCHITECTURE.md b/src/build/asset-pipeline/css-optimizer/ARCHITECTURE.md index fb84664252..2e85281fd6 100644 --- a/src/build/asset-pipeline/css-optimizer/ARCHITECTURE.md +++ b/src/build/asset-pipeline/css-optimizer/ARCHITECTURE.md @@ -1,465 +1,88 @@ -# CSS Optimizer Architecture - -## Overview - -The CSS Optimizer uses a **Strategy Pattern** architecture that separates concerns into independent, testable modules. This design enables easy extension, graceful degradation, and comprehensive testing. - -## Architecture Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Public API Layer │ -│ │ -│ ┌────────────────┐ ┌──────────────────┐ │ -│ │ CSSOptimizer │ │ optimizeCSS() │ │ -│ │ (Facade) │◄─────────│ (Helper) │ │ -│ └────────┬───────┘ └──────────────────┘ │ -│ │ │ -└───────────┼──────────────────────────────────────────────────────┘ - │ - │ delegates to - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Orchestration Layer │ -│ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ CSSOptimizerService │ │ -│ │ ┌──────────────────────────────────────────────┐ │ │ -│ │ │ • init() - Initialize strategies │ │ │ -│ │ │ • optimize() - Run optimization │ │ │ -│ │ │ • optimizeFile() - Process single file │ │ │ -│ │ │ • selectStrategy() - Choose best strategy │ │ │ -│ │ │ • getStats() - Get statistics │ │ │ -│ │ └──────────────────────────────────────────────┘ │ │ -│ └───────┬──────────────────────────────────────────────┘ │ -│ │ │ -└──────────┼───────────────────────────────────────────────────────┘ - │ - ├─── uses ───┐ - │ │ - ▼ ▼ -┌──────────────────┐ ┌─────────────────────────────────────────┐ -│ CacheManager │ │ Strategy Layer │ -│ │ │ │ -│ • addBundle() │ │ ┌─────────────────────────────────┐ │ -│ • getBundle() │ │ │ CSSOptimizationStrategy │ │ -│ • getAllBundles()│ │ │ (interface) │ │ -│ • getStats() │ │ │ • name: string │ │ -│ • writeManifest()│ │ │ • priority: number │ │ -│ • clear() │ │ │ • canProcess(options): boolean │ │ -│ └────────────────┘ │ │ • process(...): Promise<...> │ │ -│ │ │ └───────┬─────────────────────┘ │ -│ ┌────────────────┐ │ │ │ │ -│ │ Manifest File │ │ │ │ implements │ -│ │ (JSON) │ │ │ │ │ -│ └────────────────┘ │ │ ┌───────┴──────┬─────────────┬────┤ -│ │ │ │ │ │ │ -└─────────────────────┘ │ ▼ ▼ ▼ │ - │ ┌──────────┐ ┌────────────┐ ┌──────┐│ - │ │Lightning │ │ Purge │ │Basic ││ - │ │ CSS │ │ Strategy │ │Minif ││ - │ │ Strategy │ │ │ │ ││ - │ │ │ │ │ │ ││ - │ │Priority │ │Priority 50 │ │Prior ││ - │ │ 100 │ │ │ │ 10 ││ - │ └──────────┘ └────────────┘ └──────┘│ - └─────────────────────────────────────┘ - │ - │ uses - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ Utility Layer │ -│ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Utils Module │ │ -│ │ • findCSSFiles() - File discovery │ │ -│ │ • globFiles() - Pattern matching │ │ -│ │ • extractSelectors() - HTML/JSX analysis │ │ -│ │ • basicMinify() - Fallback minification │ │ -│ │ • shouldKeepSelector() - Purge rules logic │ │ -│ │ • calculateSavings() - Stats calculation │ │ -│ │ • parseBrowserTargets() - Lightning CSS config │ │ -│ │ • getOutputPath() - Path resolution │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ Critical CSS Module │ │ -│ │ • extractCriticalCSS() - Above-fold extraction │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ -└───────────────────────────────────────────────────────────────────┘ - - ▲ - │ - │ defines - │ -┌─────────────────────────────────────────────────────────────────┐ -│ Type Layer │ -│ │ -│ • CSSOptimizationOptions - Configuration │ -│ • CSSBundle - Result bundle │ -│ • CSSOptimizationStrategy - Strategy interface │ -│ • CSSProcessingResult - Strategy output │ -│ • CriticalCSSResult - Critical CSS output │ -│ • CSSOptimizerStats - Statistics │ -│ • LightningCSSModule - External library types │ -│ • BrowserTargets - Browser configuration │ -│ │ -└───────────────────────────────────────────────────────────────────┘ -``` - -## Component Responsibilities - -### 1. Public API Layer - -#### CSSOptimizer (Facade) - -- **Purpose**: Provides backward-compatible API for existing code -- **Pattern**: Facade Pattern -- **Responsibility**: Delegates to CSSOptimizerService -- **File**: `index.ts` - -#### optimizeCSS() - -- **Purpose**: Helper function for one-shot optimization -- **Usage**: `await optimizeCSS({ inputDir: './styles' })` - -### 2. Orchestration Layer - -#### CSSOptimizerService - -- **Purpose**: Main orchestrator coordinating all optimization -- **Pattern**: Strategy Pattern (Context) -- **Responsibilities**: - - Initialize and manage strategies - - Select best strategy based on options - - Process CSS files through pipeline - - Coordinate with CacheManager - - Collect and report statistics -- **File**: `optimizer-service.ts` - -### 3. Strategy Layer - -All strategies implement the `CSSOptimizationStrategy` interface: - -```typescript -interface CSSOptimizationStrategy { - readonly name: string; - readonly priority: number; - canProcess(options: CSSOptimizationOptions): boolean; - process( - content: string, - filename: string, - options: CSSOptimizationOptions, - ): Promise; -} -``` - -#### LightningCSSStrategy (Priority: 100) - -- **Purpose**: Advanced CSS optimization using Lightning CSS -- **Features**: - - Minification - - Autoprefixing - - Modern CSS compilation - - Source map generation -- **File**: `strategies/lightning-strategy.ts` -- **Availability**: Optional (graceful degradation) - -#### PurgeStrategy (Priority: 50) - -- **Purpose**: Remove unused CSS rules -- **Features**: - - Content analysis for used selectors - - Intelligent rule filtering - - Preserves universal rules -- **File**: `strategies/purge-strategy.ts` -- **Use Case**: Production builds to reduce CSS size - -#### MinificationStrategy (Priority: 10) - -- **Purpose**: Basic CSS minification -- **Features**: - - Comment removal - - Whitespace reduction - - Always available fallback -- **File**: `strategies/minification-strategy.ts` -- **Availability**: Always (no external dependencies) - -### 4. Cache Layer - -#### CacheManager - -- **Purpose**: Bundle storage and manifest management -- **Pattern**: Repository Pattern -- **Responsibilities**: - - Store optimization results in memory - - Calculate statistics - - Write/load manifest files - - Exclude content from manifest for efficiency -- **File**: `cache-manager.ts` - -### 5. Utility Layer - -#### Utils Module - -- **Purpose**: Shared utility functions -- **Responsibilities**: - - File system operations - - Pattern matching - - Selector extraction - - Basic minification - - Path resolution -- **File**: `utils.ts` - -#### Critical CSS Module - -- **Purpose**: Extract above-the-fold CSS -- **Responsibilities**: - - Parse HTML content for selectors - - Split CSS into critical/remaining - - Optional minification -- **File**: `critical-css.ts` - -### 6. Type Layer - -#### Types Module - -- **Purpose**: TypeScript type definitions -- **Responsibilities**: - - Define all public interfaces - - Define strategy interfaces - - Define result types - - External library type definitions -- **File**: `types/index.ts` - -## Data Flow - -### Optimization Pipeline - -``` -1. User creates CSSOptimizer - ↓ -2. CSSOptimizer delegates to CSSOptimizerService - ↓ -3. Service initializes strategies - ↓ -4. Service finds CSS files (Utils.findCSSFiles) - ↓ -5. For each file: - a. Service selects best strategy (priority-based) - b. Strategy processes CSS content - c. Service writes optimized file - d. Service stores result in CacheManager - ↓ -6. Service writes manifest (CacheManager.writeManifest) - ↓ -7. Service returns bundles Map - ↓ -8. User receives optimization results -``` - -### Strategy Selection - -``` -Options: { minify: true, purge: false, autoprefixer: true } - ↓ -1. Sort strategies by priority (desc) - [LightningCSS(100), PurgeCSS(50), Minification(10)] - ↓ -2. For each strategy, call canProcess(options) - - LightningCSS.canProcess() → true (if loaded) - - PurgeCSS.canProcess() → false (purge: false) - - Minification.canProcess() → true (always) - ↓ -3. Select first matching strategy - → LightningCSSStrategy (if available) - → MinificationStrategy (fallback) -``` - -## Extension Points - -### Adding a New Strategy - -1. **Create strategy file**: `strategies/my-strategy.ts` -2. **Implement interface**: `CSSOptimizationStrategy` -3. **Export from**: `strategies/index.ts` -4. **Register in service**: `optimizer-service.ts` -5. **Add tests**: `tests/css-optimizer/strategies.test.ts` - -Example: - -```typescript -// strategies/postcss-strategy.ts -export class PostCSSStrategy implements CSSOptimizationStrategy { - readonly name = "postcss"; - readonly priority = 75; // Between Lightning and Purge - - canProcess(options: CSSOptimizationOptions): boolean { - return options.postcss === true; - } - - async process( - content: string, - filename: string, - options: CSSOptimizationOptions, - ): Promise { - // PostCSS implementation - } -} -``` - -### Adding Utility Functions - -Add to `utils.ts` and export: - -```typescript -export function myUtility(input: string): string { - // Implementation -} -``` - -### Extending Types - -Add to `types/index.ts`: - -```typescript -export interface MyNewType { - // Type definition -} -``` - -## Design Patterns - -### 1. Strategy Pattern - -- **Context**: `CSSOptimizerService` -- **Strategy Interface**: `CSSOptimizationStrategy` -- **Concrete Strategies**: Lightning, Purge, Minification -- **Benefit**: Easy to add new optimization strategies - -### 2. Facade Pattern - -- **Facade**: `CSSOptimizer` class -- **Subsystem**: `CSSOptimizerService` + Strategies -- **Benefit**: Simple API for common cases - -### 3. Repository Pattern - -- **Repository**: `CacheManager` -- **Entity**: `CSSBundle` -- **Benefit**: Abstracted storage with clean interface - -## Error Handling - -### Graceful Degradation - -1. Lightning CSS fails to load → Use MinificationStrategy -2. Strategy processing fails → Log warning, use fallback -3. File read/write fails → Log error, continue with other files -4. Manifest write fails → Log warning (not critical) - -### Error Flow - -``` -Strategy.process() throws - ↓ -Service catches error - ↓ -Service logs warning with strategy name - ↓ -Service tries basicMinify() fallback - ↓ -Service continues with next file -``` - -## Testing Strategy - -### Unit Tests (36 tests) - -- **strategies.test.ts**: Test each strategy independently -- **utils.test.ts**: Test utility functions -- **cache-manager.test.ts**: Test cache operations - -### Integration Tests (37 tests) - -- **css-optimizer.test.ts**: Test full optimization pipeline -- Test all features end-to-end -- Test error cases and edge cases - -### Test Structure - -``` -tests/build/asset-pipeline/ -├── css-optimizer.test.ts # Integration tests -└── css-optimizer/ - ├── strategies.test.ts # Strategy unit tests - ├── utils.test.ts # Utility unit tests - └── cache-manager.test.ts # Cache unit tests -``` - -## Performance Characteristics - -### Strategy Priority System - -- **High Priority (100)**: Fast, feature-rich (Lightning CSS) -- **Medium Priority (50)**: Targeted optimization (Purge) -- **Low Priority (10)**: Always-available fallback (Basic) - -### Caching Strategy - -- In-memory during optimization (fast access) -- Manifest written once at end (batch I/O) -- Manifest excludes content (smaller files) - -### File Processing - -- Sequential file processing (predictable behavior) -- Error isolation (one file failure doesn't stop others) -- Efficient I/O with Deno APIs - -## Dependencies - -### Required (Deno Standard Library) - -- `std/path/mod.ts` - Path operations -- `std/fs/mod.ts` - File system operations -- `std/assert/mod.ts` - Testing utilities - -### Optional (External) - -- `https://esm.sh/lightningcss@1.29.2` - Advanced CSS optimization - -### Internal - -- `#veryfront/internal` - Logging utilities -- `../../../shared/constants/network.ts` - Constants - -## Compatibility - -### Backward Compatibility - -- All existing APIs preserved -- Original CSSOptimizer class works unchanged -- All options supported -- Return types unchanged - -### Deno Compatibility - -- Uses Deno standard library -- File operations via Deno APIs -- Test with `deno test` -- Type check with `deno check` -- Import maps supported - -## Conclusion - -This architecture provides: - -- **Modularity**: Clear separation of concerns -- **Extensibility**: Easy to add new strategies -- **Testability**: Each module independently testable -- **Maintainability**: Small, focused modules -- **Reliability**: Comprehensive error handling -- **Performance**: Efficient strategy selection and caching -- **Compatibility**: 100% backward compatible +# CSS optimization pipeline + +This document explains the design and operating contract of the CSS optimizer. +Public option and result shapes remain defined in +[`types/index.ts`](./types/index.ts); resource bounds and pinned dependencies +remain defined in [`constants.ts`](./constants.ts). + +## Contract + +`CSSOptimizer` is the public facade. It resolves the runtime adapter once and +delegates to `CSSOptimizerService`. The service treats an optimization as one +publication: + +1. Validate the project boundary, configuration, and required filesystem + capabilities. +2. Discover regular `.css` inputs deterministically within configured bounds. +3. If enabled, remove unused rules with PurgeCSS using validated project + content. +4. Transform every stylesheet with Lightning CSS, including browser-target + compilation, minification, and optional source maps. +5. Write all CSS files, maps, and the complete manifest into an isolated + staging directory. +6. Atomically replace the prior output only after every file and the manifest + succeed. + +Concurrent calls on one optimizer instance share the same in-flight run and +receive defensive copies of its result. A later failed run leaves the last +published output and in-memory cache unchanged. + +## Why the stages have a fixed order + +Purging and CSS compilation are complementary transformations rather than +alternative strategies. PurgeCSS must see the uncompiled rule structure and +content evidence first. Lightning CSS then parses and emits the final syntax. +The service therefore uses a fixed `purge -> compile` pipeline; strategy +priority values remain exported only for compatibility with callers that +instantiate the strategy classes directly. + +Lightning CSS and Browserslist are required for batch optimization. PurgeCSS is +required when purging or critical-CSS extraction is requested. Missing, +malformed, or failing dependencies reject the operation. There is no CDN +import, regex minifier, or partial-success fallback. + +## Filesystem and publication safety + +The project directory is an absolute trust boundary. Input, output, and content +patterns must resolve within it; input and output trees cannot overlap. +Discovery skips symlinks, sorts paths, and enforces file-count, depth, entry, +per-file, and aggregate byte limits. + +Output paths are derived from normalized project-relative input paths and +checked for case-folded and Unicode-normalized collisions. Source maps are +validated before staging. The publication helper serializes writers targeting +the same output and restores the previous directory if promotion fails. + +The generated `css-manifest.json` contains complete bundle records, including +the emitted CSS and optional source map. `loadCSSManifest()` validates +structure, paths, sizes, collision freedom, and statistics. It can hydrate the +older content-free manifest format from the corresponding generated CSS file; +all other malformed manifests reject. + +## Purging and critical CSS + +Batch purging requires at least one matching content file. Missing optional +static roots contribute no matches, while permission and I/O failures remain +fatal. Dynamic selectors must be declared with `purgeSafelist`; silent +retention guesses are not made. + +Purge output cannot currently be composed with a trustworthy source map, so +`purge: true` with `sourceMap: true` rejects explicitly. + +Critical CSS depends on a specific HTML document and is therefore exposed only +through `CSSOptimizer.extractCriticalCSS(cssPath, html)`. Setting the legacy +`criticalCSS` batch option rejects with migration guidance. The extraction API +uses PurgeCSS's parsed retained/rejected outputs so nested at-rules stay +structurally valid. + +## Failure model + +Configuration errors, unsafe paths, missing inputs, invalid CSS, dependency +failures, malformed source maps, exhausted resource bounds, write failures, and +manifest failures all reject the run. Work already written during that run is +confined to staging and cleaned up. Cleanup failures are reported together with +the original failure instead of masking it. + +Disabled optimization is the only successful no-op. An enabled batch with no +CSS inputs rejects because publishing an apparently successful empty result +would conceal a configuration error. diff --git a/src/build/asset-pipeline/css-optimizer/cache-manager.test.ts b/src/build/asset-pipeline/css-optimizer/cache-manager.test.ts index 33592c1cd4..0618aa8585 100644 --- a/src/build/asset-pipeline/css-optimizer/cache-manager.test.ts +++ b/src/build/asset-pipeline/css-optimizer/cache-manager.test.ts @@ -1,197 +1,138 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { join } from "#veryfront/compat/path/index.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { join } from "#veryfront/compat/path"; -import { readTextFile, remove, writeTextFile } from "#veryfront/compat/fs.ts"; -import { ensureDir } from "#veryfront/compat/std/fs.ts"; import { CacheManager, loadCSSManifest } from "./css-bundle-cache.ts"; -import type { CSSBundle } from "#veryfront/types"; +import type { CSSBundle } from "./types/index.ts"; +import { calculateSavings } from "./utils.ts"; -const TEST_DIR = "./.veryfront/test-cache"; - -async function cleanupTestDir(): Promise { - try { - await remove(TEST_DIR, { recursive: true }); - } catch { - // Directory doesn't exist - } -} +const encoder = new TextEncoder(); function createBundle( file: string, - size: number, - minifiedSize: number, content: string, - sourceMap?: string, + originalSize = encoder.encode(content).length, ): CSSBundle { + const minifiedSize = encoder.encode(content).length; return { file, + outputFile: `.veryfront/css/${file.replace(/\.css$/i, ".min.css")}`, content, - sourceMap, - size, + size: originalSize, minifiedSize, - savings: 50, + savings: calculateSavings(originalSize, minifiedSize), }; } -describe("CacheManager", () => { - it("addBundle and getBundle", () => { - const cache = new CacheManager(); - - cache.addBundle( - "test.css", - createBundle("test.css", 100, 50, ".test { color: red; }"), - ); - - const retrieved = cache.getBundle("test.css"); - assertExists(retrieved); - assertEquals(retrieved.file, "test.css"); - assertEquals(retrieved.size, 100); - }); - - it("getAllBundles", () => { - const cache = new CacheManager(); - - cache.addBundle("a.css", createBundle("a.css", 10, 5, ".a {}")); - cache.addBundle("b.css", createBundle("b.css", 20, 10, ".b {}")); - - const bundles = cache.getAllBundles(); - assertEquals(bundles.size, 2); - }); - - it("clear", () => { - const cache = new CacheManager(); - - cache.addBundle("test.css", createBundle("test.css", 10, 5, ".test {}")); - - assertEquals(cache.size(), 1); - - cache.clear(); - assertEquals(cache.size(), 0); - }); - - it("getStats", () => { - const cache = new CacheManager(); - - cache.addBundle("test.css", createBundle("test.css", 1000, 500, ".test {}")); - - const stats = cache.getStats(); - - assertEquals(stats.totalFiles, 1); - assertEquals(stats.originalSize, 1000); - assertEquals(stats.minifiedSize, 500); - assertEquals(stats.totalSavings, 500); - assertEquals(stats.averageSavings, 50); - }); +async function withTempDir( + callback: (directory: string) => Promise, +): Promise { + const directory = await Deno.makeTempDir(); + try { + await callback(directory); + } finally { + await Deno.remove(directory, { recursive: true }); + } +} - it("getStats with multiple bundles", () => { +describe("build/asset-pipeline/css-optimizer/cache-manager", () => { + it("stores defensive copies and invalidates statistics", () => { const cache = new CacheManager(); - - cache.addBundle("a.css", createBundle("a.css", 1000, 500, ".a {}")); - cache.addBundle("b.css", createBundle("b.css", 2000, 1000, ".b {}")); - - const stats = cache.getStats(); - - assertEquals(stats.totalFiles, 2); - assertEquals(stats.originalSize, 3000); - assertEquals(stats.minifiedSize, 1500); - assertEquals(stats.totalSavings, 1500); - assertEquals(stats.averageSavings, 50); + const bundle = createBundle("a.css", "a".repeat(50), 100); + cache.addBundle("a.css", bundle); + assertEquals(cache.getStats().averageSavings, 50); + + bundle.content = "mutated"; + assertEquals(cache.getBundle("a.css")?.content, "a".repeat(50)); + const exposed = cache.getAllBundles(); + exposed.get("a.css")!.content = "mutated"; + assertEquals(cache.getBundle("a.css")?.content, "a".repeat(50)); + + cache.addBundle("b.css", createBundle("b.css", "b".repeat(25), 100)); + assertEquals(cache.getStats(), { + totalFiles: 2, + originalSize: 200, + minifiedSize: 75, + totalSavings: 125, + averageSavings: 62.5, + }); }); - it("writeManifest", async () => { - await cleanupTestDir(); - + it("serializes deterministic, complete manifests", () => { const cache = new CacheManager(); + cache.addBundle("z.css", createBundle("z.css", ".z{}")); + cache.addBundle("a.css", createBundle("a.css", ".a{}")); - cache.addBundle( - "test.css", - createBundle( - "test.css", - 100, - 50, - ".test { color: red; }", - "source-map-content", - ), - ); - - await cache.writeManifest(TEST_DIR); - - const manifestPath = join(TEST_DIR, "css-manifest.json"); - const parsed = JSON.parse(await readTextFile(manifestPath)); - - assertExists(parsed["test.css"]); - assertEquals(parsed["test.css"].file, "test.css"); - assertEquals(parsed["test.css"].size, 100); - - // Content and sourceMap should be excluded - assertEquals(parsed["test.css"].content, undefined); - assertEquals(parsed["test.css"].sourceMap, undefined); - - await cleanupTestDir(); + const serialized = cache.serializeManifest(); + assertEquals(serialized.indexOf('"a.css"') < serialized.indexOf('"z.css"'), true); + assertEquals(serialized.includes('"content": ".a{}"'), true); + assertEquals(serialized.endsWith("\n"), true); }); - it("getTotalSavings format", () => { - const cache = new CacheManager(); - - cache.addBundle("test.css", createBundle("test.css", 10240, 5120, ".test {}")); + it("round-trips complete manifests", async () => { + await withTempDir(async (directory) => { + const cache = new CacheManager(); + cache.addBundle("test.css", createBundle("test.css", ".test{}")); + await cache.writeManifest(directory); - const savings = cache.getTotalSavings(); - - assertEquals(typeof savings, "string"); - assertEquals(savings.includes("KB"), true); - assertEquals(savings.includes("→"), true); - assertEquals(savings.includes("%"), true); + const loaded = await loadCSSManifest(directory); + assertEquals(loaded.get("test.css")?.content, ".test{}"); + assertEquals(loaded.get("test.css")?.minifiedSize, 7); + }); }); -}); - -describe("loadCSSManifest", () => { - it("loads valid manifest", async () => { - await cleanupTestDir(); - await ensureDir(TEST_DIR); - - const manifest = { - "test.css": { - file: "test.css", - size: 100, - minifiedSize: 50, - savings: 50, - }, - }; - - await writeTextFile( - join(TEST_DIR, "css-manifest.json"), - JSON.stringify(manifest, null, 2), - ); - - const loaded = await loadCSSManifest(TEST_DIR); - assertEquals(loaded.size, 1); - const bundle = loaded.get("test.css"); - assertExists(bundle); - assertEquals(bundle.size, 100); - - await cleanupTestDir(); + it("hydrates legacy manifests from their generated CSS file", async () => { + await withTempDir(async (directory) => { + const content = ".legacy{}"; + await Deno.writeTextFile(join(directory, "legacy.min.css"), content); + await Deno.writeTextFile( + join(directory, "css-manifest.json"), + JSON.stringify({ + "legacy.css": { + file: "legacy.css", + size: 20, + minifiedSize: encoder.encode(content).length, + savings: calculateSavings(20, encoder.encode(content).length), + }, + }), + ); + + const loaded = await loadCSSManifest(directory); + assertEquals(loaded.get("legacy.css")?.content, content); + }); }); - it("handles missing manifest", async () => { - await cleanupTestDir(); - - const loaded = await loadCSSManifest(TEST_DIR); - assertEquals(loaded.size, 0); - - await cleanupTestDir(); + it("returns empty only for an absent manifest", async () => { + await withTempDir(async (directory) => { + assertEquals((await loadCSSManifest(directory)).size, 0); + await Deno.writeTextFile(join(directory, "css-manifest.json"), "{"); + await assertRejects( + () => loadCSSManifest(directory), + TypeError, + "not valid JSON", + ); + }); }); - it("handles corrupted manifest", async () => { - await cleanupTestDir(); - await ensureDir(TEST_DIR); - - await writeTextFile(join(TEST_DIR, "css-manifest.json"), "invalid{json}"); - - const loaded = await loadCSSManifest(TEST_DIR); - assertEquals(loaded.size, 0); - - await cleanupTestDir(); + it("rejects malformed entries instead of trusting casts", async () => { + await withTempDir(async (directory) => { + await Deno.writeTextFile( + join(directory, "css-manifest.json"), + JSON.stringify({ + "../escape.css": { + file: "../escape.css", + content: ".x{}", + size: 4, + minifiedSize: 4, + savings: 0, + }, + }), + ); + await assertRejects( + () => loadCSSManifest(directory), + TypeError, + "malformed", + ); + }); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/constants.ts b/src/build/asset-pipeline/css-optimizer/constants.ts new file mode 100644 index 0000000000..e4bc2b894d --- /dev/null +++ b/src/build/asset-pipeline/css-optimizer/constants.ts @@ -0,0 +1,42 @@ +import type { CSSOptimizationOptions } from "./types/index.ts"; +import { CSS_OPTIMIZATION } from "#veryfront/utils/constants/build.ts"; + +export const LIGHTNING_CSS_MODULE_SPECIFIER = "npm:lightningcss@1.29.2"; +export const PURGE_CSS_MODULE_SPECIFIER = "npm:purgecss@8.0.0"; + +export const CSS_MANIFEST_FILENAME = "css-manifest.json"; +export const MAX_CSS_FILES = CSS_OPTIMIZATION.MAX_FILES; +export const MAX_CSS_DIRECTORY_DEPTH = 64; +export const MAX_CSS_DIRECTORY_ENTRIES = 100_000; +export const MAX_CSS_FILE_BYTES = 16 * 1024 * 1024; +export const MAX_CSS_TOTAL_BYTES = 64 * 1024 * 1024; +export const MAX_CSS_OUTPUT_FILE_BYTES = 32 * 1024 * 1024; +export const MAX_CSS_TOTAL_OUTPUT_BYTES = 128 * 1024 * 1024; +export const MAX_CSS_MANIFEST_BYTES = 256 * 1024 * 1024; +export const MAX_CSS_SELECTOR_TOKENS = 100_000; +export const MAX_CSS_SELECTOR_TOKEN_CHARACTERS = 1_024; +export const MAX_CSS_BROWSER_QUERIES = CSS_OPTIMIZATION.MAX_BROWSER_QUERIES; +export const MAX_CSS_BROWSER_QUERY_CHARACTERS = CSS_OPTIMIZATION.MAX_BROWSER_QUERY_CHARACTERS; +export const MAX_CSS_PURGE_PATTERNS = CSS_OPTIMIZATION.MAX_PURGE_PATTERNS; +export const MAX_CSS_PURGE_SAFELIST_ENTRIES = CSS_OPTIMIZATION.MAX_PURGE_SAFELIST_ENTRIES; + +export const DEFAULT_CSS_OPTIONS: Omit< + Required, + "projectDir" +> = { + enabled: true, + minify: true, + autoprefixer: true, + purge: false, + criticalCSS: false, + inputFiles: [], + inputDir: "./styles", + outputDir: "./.veryfront/optimized-css", + browsers: ["defaults", "not IE 11"], + purgeContent: [ + "./app/**/*.{tsx,jsx,ts,js}", + "./pages/**/*.{tsx,jsx,ts,js}", + ], + purgeSafelist: [], + sourceMap: false, +}; diff --git a/src/build/asset-pipeline/css-optimizer/critical-css.ts b/src/build/asset-pipeline/css-optimizer/critical-css.ts index 2ae5a0cc06..c83aff2525 100644 --- a/src/build/asset-pipeline/css-optimizer/critical-css.ts +++ b/src/build/asset-pipeline/css-optimizer/critical-css.ts @@ -1,49 +1,197 @@ -import { logger } from "#veryfront/utils"; -import type { CriticalCSSResult, CSSOptimizationOptions } from "#veryfront/types"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { DEPENDENCY_MISSING } from "#veryfront/errors"; +import { createFileSystem, isNotFoundError } from "#veryfront/platform/compat/fs.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { basicMinify, extractSelectorsFromHTML } from "./utils.ts"; +import { logger } from "#veryfront/utils"; +import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; +import { hasControlCharacters } from "../../utils/string-validation.ts"; +import { + MAX_CSS_FILE_BYTES, + MAX_CSS_OUTPUT_FILE_BYTES, + MAX_CSS_PURGE_SAFELIST_ENTRIES, + MAX_CSS_SELECTOR_TOKEN_CHARACTERS, + MAX_CSS_TOTAL_OUTPUT_BYTES, + PURGE_CSS_MODULE_SPECIFIER, +} from "./constants.ts"; +import type { CriticalCSSResult, CSSOptimizationOptions } from "./types/index.ts"; +import { basicMinify } from "./utils.ts"; + +interface CriticalPurgeResult { + css: string; + rejectedCss?: string; +} + +interface CriticalPurgeCSSModule { + PurgeCSS: new () => { + purge(options: { + content: Array<{ raw: string; extension: string }>; + css: Array<{ raw: string }>; + rejectedCss: true; + safelist?: string[]; + }): Promise; + }; +} -const fs = createFileSystem(); const encoder = new TextEncoder(); +let purgeCSSModule: Promise | null = null; + +function safelistToken(value: string): string { + return value.startsWith(".") || value.startsWith("#") ? value.slice(1) : value; +} + +async function loadPurgeCSS(): Promise { + const pending = purgeCSSModule ??= import(PURGE_CSS_MODULE_SPECIFIER).then( + (module) => { + if (!module || typeof module.PurgeCSS !== "function") { + throw new TypeError("PurgeCSS module did not export PurgeCSS"); + } + return module; + }, + ); + try { + return await pending; + } catch (error) { + if (purgeCSSModule === pending) purgeCSSModule = null; + throw DEPENDENCY_MISSING.create({ + detail: `Critical CSS extraction requires ${PURGE_CSS_MODULE_SPECIFIER}`, + cause: error, + }); + } +} export function extractCriticalCSS( cssPath: string, htmlContent: string, options: CSSOptimizationOptions, ): Promise { + if ( + typeof options !== "object" || + options === null || + Array.isArray(options) + ) { + throw new TypeError("Critical CSS options must be an object"); + } + if ( + typeof cssPath !== "string" || + cssPath.length === 0 || + cssPath.length > MAX_PATH_LENGTH_CHARS || + hasControlCharacters(cssPath) + ) { + throw new TypeError("Critical CSS input path must be a safe non-empty path"); + } + if (options.minify !== undefined && typeof options.minify !== "boolean") { + throw new TypeError("Critical CSS minify must be a boolean"); + } const shouldMinify = options.minify ?? true; return withSpan( "build.asset.extractCriticalCSS", async (): Promise => { - logger.debug(`Extracting critical CSS from ${cssPath}`); + if (typeof htmlContent !== "string") { + throw new TypeError("Critical CSS HTML content must be a string"); + } + if (encoder.encode(htmlContent).length > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `Critical CSS HTML content exceeds ${MAX_CSS_FILE_BYTES} bytes`, + ); + } + if ( + options.purgeSafelist !== undefined && + !Array.isArray(options.purgeSafelist) + ) { + throw new TypeError("Critical CSS safelist must be an array"); + } + const safelist = Array.from(options.purgeSafelist ?? []); + if ( + safelist.length > MAX_CSS_PURGE_SAFELIST_ENTRIES || + safelist.some((entry) => + typeof entry !== "string" || + entry.length === 0 || + entry.length > MAX_CSS_SELECTOR_TOKEN_CHARACTERS || + hasControlCharacters(entry) || + /\s/u.test(entry) + ) + ) { + throw new TypeError( + `Critical CSS safelist must contain at most ${MAX_CSS_PURGE_SAFELIST_ENTRIES} non-empty strings`, + ); + } + const safelistTokens = safelist.map(safelistToken); + if ( + safelistTokens.some((entry) => + entry.length === 0 || + entry.length > MAX_CSS_SELECTOR_TOKEN_CHARACTERS || + hasControlCharacters(entry) + ) + ) { + throw new TypeError("Critical CSS safelist contains an unsafe selector token"); + } + logger.debug(`Extracting critical CSS from ${cssPath}`); + const fs = createFileSystem(); + let info; + try { + info = fs.lstat ? await fs.lstat(cssPath) : await fs.stat(cssPath); + } catch (error) { + if (isNotFoundError(error)) { + throw new TypeError(`Critical CSS input does not exist: ${cssPath}`, { + cause: error, + }); + } + throw error; + } + if ( + !info.isFile || + info.isSymlink || + !Number.isSafeInteger(info.size) || + info.size < 0 + ) { + throw new TypeError("Critical CSS input must be a regular file"); + } + if (info.size > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `Critical CSS input exceeds ${MAX_CSS_FILE_BYTES} bytes`, + ); + } const css = await fs.readTextFile(cssPath); - const criticalSelectors = extractSelectorsFromHTML(htmlContent); - - const critical: string[] = []; - const remaining: string[] = []; - - for (const rule of css.split("}")) { - if (!rule.trim()) continue; - - const fullRule = `${rule}}`; - const selector = fullRule.match(/^([^{]+)\{/)?.[1]?.trim(); - if (!selector) continue; - - const isCritical = criticalSelectors.some((s) => selector.includes(s)); - (isCritical ? critical : remaining).push(fullRule); + if (encoder.encode(css).length > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `Critical CSS input exceeds ${MAX_CSS_FILE_BYTES} bytes`, + ); } - const criticalCSS = critical.join("\n"); - const remainingCSS = remaining.join("\n"); + const module = await loadPurgeCSS(); + const results = await new module.PurgeCSS().purge({ + content: [{ raw: htmlContent, extension: "html" }], + css: [{ raw: css }], + rejectedCss: true, + safelist: safelistTokens, + }); + const result = results[0]; + if ( + results.length !== 1 || + !result || + typeof result.css !== "string" || + typeof result.rejectedCss !== "string" + ) { + throw new TypeError("PurgeCSS returned an invalid critical CSS result"); + } + const critical = shouldMinify ? basicMinify(result.css) : result.css; + const remaining = shouldMinify ? basicMinify(result.rejectedCss) : result.rejectedCss; + const criticalSize = encoder.encode(critical).length; + const remainingSize = encoder.encode(remaining).length; + if ( + criticalSize > MAX_CSS_OUTPUT_FILE_BYTES || + remainingSize > MAX_CSS_OUTPUT_FILE_BYTES || + criticalSize + remainingSize > MAX_CSS_TOTAL_OUTPUT_BYTES + ) { + throw new TypeError("Critical CSS output exceeds the configured resource limits"); + } return { - critical: shouldMinify ? basicMinify(criticalCSS) : criticalCSS, - remaining: shouldMinify ? basicMinify(remainingCSS) : remainingCSS, - criticalSize: encoder.encode(criticalCSS).length, - remainingSize: encoder.encode(remainingCSS).length, + critical, + remaining, + criticalSize, + remainingSize, }; }, { diff --git a/src/build/asset-pipeline/css-optimizer/css-bundle-cache.test.ts b/src/build/asset-pipeline/css-optimizer/css-bundle-cache.test.ts index d7bd62890d..f9b12ce346 100644 --- a/src/build/asset-pipeline/css-optimizer/css-bundle-cache.test.ts +++ b/src/build/asset-pipeline/css-optimizer/css-bundle-cache.test.ts @@ -2,153 +2,54 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { CacheManager } from "./css-bundle-cache.ts"; +import type { CSSBundle } from "./types/index.ts"; +import { calculateSavings } from "./utils.ts"; + +function bundle( + file: string, + originalSize: number, + content: string, +): CSSBundle { + const minifiedSize = new TextEncoder().encode(content).length; + return { + file, + content, + size: originalSize, + minifiedSize, + savings: calculateSavings(originalSize, minifiedSize), + }; +} describe("build/asset-pipeline/css-optimizer/css-bundle-cache", () => { - describe("CacheManager", () => { - function createBundle( - overrides: Partial<{ - file: string; - content: string; - size: number; - minifiedSize: number; - savings: number; - sourceMap: string | undefined; - }> = {}, - ) { - return { - file: overrides.file ?? "test.css", - content: overrides.content ?? "body { color: red; }", - size: overrides.size ?? 100, - minifiedSize: overrides.minifiedSize ?? 80, - savings: overrides.savings ?? 20, - sourceMap: overrides.sourceMap, - }; - } - - it("should start empty", () => { - const cache = new CacheManager(); - assertEquals(cache.size(), 0); - }); - - it("should add and retrieve bundles", () => { - const cache = new CacheManager(); - const bundle = createBundle({ file: "style.css" }); - - cache.addBundle("style.css", bundle); - - assertEquals(cache.size(), 1); - assertEquals(cache.getBundle("style.css"), bundle); - }); - - it("should return undefined for missing key", () => { - const cache = new CacheManager(); - assertEquals(cache.getBundle("missing.css"), undefined); - }); - - it("should clear all bundles", () => { - const cache = new CacheManager(); - - cache.addBundle("a.css", createBundle({ file: "a.css" })); - cache.addBundle("b.css", createBundle({ file: "b.css" })); - - assertEquals(cache.size(), 2); - - cache.clear(); - - assertEquals(cache.size(), 0); - }); - - it("should return all bundles as a map", () => { - const cache = new CacheManager(); - - cache.addBundle("a.css", createBundle({ file: "a.css" })); - cache.addBundle("b.css", createBundle({ file: "b.css" })); - - const all = cache.getAllBundles(); - - assertEquals(all.size, 2); - assertEquals(all.has("a.css"), true); - assertEquals(all.has("b.css"), true); - }); - - describe("getStats", () => { - it("should compute stats from bundles", () => { - const cache = new CacheManager(); - - cache.addBundle("a.css", createBundle({ size: 200, minifiedSize: 150 })); - cache.addBundle("b.css", createBundle({ size: 300, minifiedSize: 200 })); - - const stats = cache.getStats(); - - assertEquals(stats.totalFiles, 2); - assertEquals(stats.originalSize, 500); - assertEquals(stats.minifiedSize, 350); - assertEquals(stats.totalSavings, 150); - assertEquals(stats.averageSavings, 30); // 150/500*100 = 30% - }); - - it("should return zero stats for empty cache", () => { - const cache = new CacheManager(); - const stats = cache.getStats(); - - assertEquals(stats.totalFiles, 0); - assertEquals(stats.originalSize, 0); - assertEquals(stats.minifiedSize, 0); - assertEquals(stats.totalSavings, 0); - assertEquals(stats.averageSavings, 0); - }); - - it("should cache stats and invalidate on add", () => { - const cache = new CacheManager(); - - cache.addBundle("a.css", createBundle({ size: 100, minifiedSize: 80 })); - - const stats1 = cache.getStats(); - const stats2 = cache.getStats(); - - assertEquals(stats1.totalFiles, 1); - assertEquals(stats1, stats2); - - cache.addBundle("b.css", createBundle({ size: 200, minifiedSize: 100 })); - - const stats3 = cache.getStats(); - assertEquals(stats3.totalFiles, 2); - }); - - it("should invalidate stats cache on clear", () => { - const cache = new CacheManager(); - - cache.addBundle("a.css", createBundle({ size: 100, minifiedSize: 80 })); - cache.getStats(); // populate cache - - cache.clear(); + it("starts empty and clears all state", () => { + const cache = new CacheManager(); + assertEquals(cache.size(), 0); + cache.addBundle("a.css", bundle("a.css", 10, "12345")); + assertEquals(cache.size(), 1); + cache.clear(); + assertEquals(cache.size(), 0); + assertEquals(cache.getStats().totalFiles, 0); + }); - const stats = cache.getStats(); - assertEquals(stats.totalFiles, 0); - }); + it("computes aggregate byte savings", () => { + const cache = new CacheManager(); + cache.addBundle("a.css", bundle("a.css", 100, "a".repeat(50))); + cache.addBundle("b.css", bundle("b.css", 100, "b".repeat(75))); + + assertEquals(cache.getStats(), { + totalFiles: 2, + originalSize: 200, + minifiedSize: 125, + totalSavings: 75, + averageSavings: 37.5, }); + assertEquals(cache.getTotalSavings().includes("37.5%"), true); + }); - describe("getTotalSavings", () => { - it("should format savings string correctly", () => { - const cache = new CacheManager(); - - cache.addBundle("a.css", createBundle({ size: 1024, minifiedSize: 512 })); - - const result = cache.getTotalSavings(); - - assertEquals(result.includes("1.0KB"), true); - assertEquals(result.includes("0.5KB"), true); - assertEquals(result.includes("50.0%"), true); - }); - - it("should handle zero original size", () => { - const cache = new CacheManager(); - - cache.addBundle("a.css", createBundle({ size: 0, minifiedSize: 0 })); - - const result = cache.getTotalSavings(); - assertEquals(result.includes("0.0%"), true); - }); - }); + it("reports negative savings when transformation expands output", () => { + const cache = new CacheManager(); + cache.addBundle("a.css", bundle("a.css", 2, "expanded")); + assertEquals(cache.getStats().totalSavings, -6); + assertEquals(cache.getStats().averageSavings, -300); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/css-bundle-cache.ts b/src/build/asset-pipeline/css-optimizer/css-bundle-cache.ts index 11d87b9ebd..1e568f3b14 100644 --- a/src/build/asset-pipeline/css-optimizer/css-bundle-cache.ts +++ b/src/build/asset-pipeline/css-optimizer/css-bundle-cache.ts @@ -1,33 +1,156 @@ import { join } from "#veryfront/compat/path/index.ts"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { + createFileSystem, + type FileSystem, + isNotFoundError, +} from "#veryfront/platform/compat/fs.ts"; import { BYTES_PER_KB, logger } from "#veryfront/utils"; -import type { CSSBundle } from "#veryfront/types"; +import { + CSS_MANIFEST_FILENAME, + MAX_CSS_FILE_BYTES, + MAX_CSS_FILES, + MAX_CSS_MANIFEST_BYTES, + MAX_CSS_OUTPUT_FILE_BYTES, + MAX_CSS_TOTAL_OUTPUT_BYTES, +} from "./constants.ts"; +import type { CSSBundle, CSSOptimizerStats } from "./types/index.ts"; +import { + calculateSavings, + getOutputPath, + isSafeCSSRelativePath, + validateCSSSourceMap, +} from "./utils.ts"; -const fs = createFileSystem(); +const encoder = new TextEncoder(); -type CacheStats = { - totalFiles: number; - originalSize: number; - minifiedSize: number; - totalSavings: number; - averageSavings: number; -}; +function comparePaths(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function cloneBundle(bundle: CSSBundle): CSSBundle { + return { ...bundle }; +} + +function cloneBundles( + bundles: Map, +): Map { + return new Map( + [...bundles].map(([key, bundle]) => [key, cloneBundle(bundle)]), + ); +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function assertBundle( + key: string, + value: unknown, + requireContent: boolean, +): asserts value is CSSBundle { + if ( + !isSafeCSSRelativePath(key) || + typeof value !== "object" || + value === null || + Array.isArray(value) + ) { + throw new TypeError(`CSS manifest entry ${JSON.stringify(key)} is malformed`); + } + const bundle = value as Partial; + if ( + bundle.file !== key || + (bundle.outputFile !== undefined && + !isSafeCSSRelativePath(bundle.outputFile)) || + (requireContent && typeof bundle.content !== "string") || + (bundle.content !== undefined && typeof bundle.content !== "string") || + (bundle.sourceMap !== undefined && typeof bundle.sourceMap !== "string") || + !isNonNegativeSafeInteger(bundle.size) || + !isNonNegativeSafeInteger(bundle.minifiedSize) || + bundle.size > MAX_CSS_FILE_BYTES || + bundle.minifiedSize > MAX_CSS_OUTPUT_FILE_BYTES || + typeof bundle.savings !== "number" || + !Number.isFinite(bundle.savings) || + bundle.savings !== calculateSavings(bundle.size, bundle.minifiedSize) + ) { + throw new TypeError(`CSS manifest entry ${JSON.stringify(key)} is malformed`); + } + if ( + typeof bundle.content === "string" && + encoder.encode(bundle.content).length !== bundle.minifiedSize + ) { + throw new TypeError( + `CSS manifest entry ${JSON.stringify(key)} has an invalid content size`, + ); + } + if ( + typeof bundle.sourceMap === "string" && + encoder.encode(bundle.sourceMap).length > MAX_CSS_OUTPUT_FILE_BYTES + ) { + throw new TypeError( + `CSS manifest entry ${JSON.stringify(key)} has an oversized source map`, + ); + } + if (typeof bundle.sourceMap === "string") { + validateCSSSourceMap(bundle.sourceMap, key); + } +} + +function assertBundles( + entries: Array<[string, unknown]>, + requireContent: boolean, +): asserts entries is Array<[string, CSSBundle]> { + if (entries.length > MAX_CSS_FILES) { + throw new TypeError(`CSS manifest exceeds ${MAX_CSS_FILES} entries`); + } + const normalizedKeys = new Set(); + const normalizedOutputs = new Set(); + let totalOutputBytes = 0; + for (const [key, value] of entries) { + assertBundle(key, value, requireContent); + const normalizedKey = key.normalize("NFC").toLocaleLowerCase("en-US"); + const output = value.outputFile ?? + getOutputPath(key, ".").replaceAll("\\", "/").replace(/^\.\//, ""); + const normalizedOutput = output.normalize("NFC").toLocaleLowerCase("en-US"); + if ( + normalizedKeys.has(normalizedKey) || + normalizedOutputs.has(normalizedOutput) + ) { + throw new TypeError( + `CSS manifest contains a path collision: ${JSON.stringify(key)}`, + ); + } + normalizedKeys.add(normalizedKey); + normalizedOutputs.add(normalizedOutput); + totalOutputBytes += value.minifiedSize + + (value.sourceMap === undefined ? 0 : encoder.encode(value.sourceMap).length); + if (totalOutputBytes > MAX_CSS_TOTAL_OUTPUT_BYTES) { + throw new TypeError( + `CSS manifest outputs exceed ${MAX_CSS_TOTAL_OUTPUT_BYTES} total bytes`, + ); + } + } +} export class CacheManager { private bundles = new Map(); - private cachedStats: CacheStats | null = null; + private cachedStats: CSSOptimizerStats | null = null; addBundle(key: string, bundle: CSSBundle): void { - this.bundles.set(key, bundle); + assertBundle(key, bundle, true); + const next = new Map(this.bundles); + next.set(key, cloneBundle(bundle)); + assertBundles([...next], true); + this.bundles = next; this.cachedStats = null; } getBundle(key: string): CSSBundle | undefined { - return this.bundles.get(key); + const bundle = this.bundles.get(key); + return bundle ? cloneBundle(bundle) : undefined; } getAllBundles(): Map { - return this.bundles; + return cloneBundles(this.bundles); } clear(): void { @@ -39,45 +162,41 @@ export class CacheManager { return this.bundles.size; } - async writeManifest(outputDir: string): Promise { - await fs.mkdir(outputDir, { recursive: true }); - - const manifestPath = join(outputDir, "css-manifest.json"); - const manifest = Object.fromEntries(this.bundles); - - await fs.writeTextFile( - manifestPath, - JSON.stringify( - manifest, - (_key, value) => { - if (typeof value !== "object" || value === null || !("content" in value)) return value; - const { content: _content, sourceMap: _sourceMap, ...rest } = value; - return rest; - }, - 2, - ), - ); + serializeManifest(): string { + const entries = [...this.bundles].sort(([left], [right]) => comparePaths(left, right)); + assertBundles(entries, true); + const content = `${JSON.stringify(Object.fromEntries(entries), null, 2)}\n`; + if (encoder.encode(content).length > MAX_CSS_MANIFEST_BYTES) { + throw new TypeError( + `CSS manifest exceeds ${MAX_CSS_MANIFEST_BYTES} bytes`, + ); + } + return content; + } + async writeManifest( + outputDir: string, + fs: FileSystem = createFileSystem(), + ): Promise { + await fs.mkdir(outputDir, { recursive: true }); + const manifestPath = join(outputDir, CSS_MANIFEST_FILENAME); + await fs.writeTextFile(manifestPath, this.serializeManifest()); logger.debug(`Wrote CSS manifest to ${manifestPath}`); } getTotalSavings(): string { const { originalSize, minifiedSize } = this.getStats(); const savings = originalSize > 0 ? ((originalSize - minifiedSize) / originalSize) * 100 : 0; - return `${(originalSize / BYTES_PER_KB).toFixed(1)}KB → ${ - (minifiedSize / BYTES_PER_KB).toFixed( - 1, - ) + (minifiedSize / BYTES_PER_KB).toFixed(1) }KB (${savings.toFixed(1)}%)`; } - getStats(): CacheStats { - if (this.cachedStats) return this.cachedStats; + getStats(): CSSOptimizerStats { + if (this.cachedStats) return { ...this.cachedStats }; let originalSize = 0; let minifiedSize = 0; - for (const bundle of this.bundles.values()) { originalSize += bundle.size; minifiedSize += bundle.minifiedSize; @@ -85,7 +204,6 @@ export class CacheManager { const totalSavings = originalSize - minifiedSize; const averageSavings = originalSize > 0 ? (totalSavings / originalSize) * 100 : 0; - this.cachedStats = { totalFiles: this.bundles.size, originalSize, @@ -93,24 +211,89 @@ export class CacheManager { totalSavings, averageSavings, }; - - return this.cachedStats; + return { ...this.cachedStats }; } } export async function loadCSSManifest( outputDir: string = "./.veryfront/optimized-css", + fs: FileSystem = createFileSystem(), ): Promise> { - const manifestPath = join(outputDir, "css-manifest.json"); + const manifestPath = join(outputDir, CSS_MANIFEST_FILENAME); + let content: string; + try { + const info = fs.lstat ? await fs.lstat(manifestPath) : await fs.stat(manifestPath); + if ( + !info.isFile || + info.isSymlink || + !Number.isSafeInteger(info.size) || + info.size < 0 + ) { + throw new TypeError("CSS manifest must be a regular file"); + } + if (info.size > MAX_CSS_MANIFEST_BYTES) { + throw new TypeError( + `CSS manifest exceeds ${MAX_CSS_MANIFEST_BYTES} bytes`, + ); + } + content = await fs.readTextFile(manifestPath); + if (encoder.encode(content).length > MAX_CSS_MANIFEST_BYTES) { + throw new TypeError( + `CSS manifest exceeds ${MAX_CSS_MANIFEST_BYTES} bytes`, + ); + } + } catch (error) { + if (isNotFoundError(error)) return new Map(); + throw error; + } + let parsed: unknown; try { - const content = await fs.readTextFile(manifestPath); - const data = JSON.parse(content) as Record; - return new Map(Object.entries(data)); + parsed = JSON.parse(content); } catch (error) { - logger.warn("Failed to load CSS manifest", { - error: error instanceof Error ? error.message : String(error), - }); - return new Map(); + throw new TypeError("CSS manifest is not valid JSON", { cause: error }); + } + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new TypeError("CSS manifest must be a JSON object"); + } + + const entries = Object.entries(parsed); + assertBundles(entries, false); + for (const [key, bundle] of entries) { + if (typeof bundle.content === "string") continue; + const outputPath = getOutputPath(key, outputDir); + try { + const info = fs.lstat ? await fs.lstat(outputPath) : await fs.stat(outputPath); + if ( + !info.isFile || + info.isSymlink || + !Number.isSafeInteger(info.size) || + info.size < 0 || + info.size > MAX_CSS_OUTPUT_FILE_BYTES + ) { + throw new TypeError( + `Legacy CSS manifest output is invalid for ${JSON.stringify(key)}`, + ); + } + bundle.content = await fs.readTextFile(outputPath); + } catch (error) { + throw new TypeError( + `Legacy CSS manifest output is missing for ${JSON.stringify(key)}`, + { cause: error }, + ); + } + if (encoder.encode(bundle.content).length !== bundle.minifiedSize) { + throw new TypeError( + `Legacy CSS manifest output size does not match ${JSON.stringify(key)}`, + ); + } } + assertBundles(entries, true); + return new Map( + entries.map(([key, bundle]) => [key, cloneBundle(bundle)]), + ); } diff --git a/src/build/asset-pipeline/css-optimizer/index.ts b/src/build/asset-pipeline/css-optimizer/index.ts index 584401d0f0..6a19cbdb55 100644 --- a/src/build/asset-pipeline/css-optimizer/index.ts +++ b/src/build/asset-pipeline/css-optimizer/index.ts @@ -11,7 +11,12 @@ export type { CSSOptimizationOptions, CSSOptimizationStrategy, CSSOptimizerStats, -} from "#veryfront/types"; + CSSProcessingResult, + LightningCSSModule, + LightningCSSTransformOptions, + LightningCSSTransformResult, + SelectorExtractionResult, +} from "./types/index.ts"; export { CSSOptimizerService } from "./optimizer-service.ts"; export { CacheManager, loadCSSManifest } from "./css-bundle-cache.ts"; @@ -19,29 +24,76 @@ export { extractCriticalCSS } from "./critical-css.ts"; export { LightningCSSStrategy, MinificationStrategy, PurgeStrategy } from "./strategies/index.ts"; export * as CSSUtils from "./utils.ts"; -import type { CriticalCSSResult, CSSBundle, CSSOptimizationOptions } from "#veryfront/types"; +import type { + CriticalCSSResult, + CSSBundle, + CSSOptimizationOptions, + CSSOptimizerStats, +} from "./types/index.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { runtime } from "#veryfront/platform/adapters/detect.ts"; import { cwd } from "#veryfront/platform/compat/process.ts"; import { CSSOptimizerService } from "./optimizer-service.ts"; import { extractCriticalCSS as extractCriticalCSSImpl } from "./critical-css.ts"; +import { nativeBuildPublicationLock } from "../../production-build/build/build-publication.ts"; export class CSSOptimizer { private service: CSSOptimizerService | null = null; + private serviceInitialization: Promise | null = null; private adapter: RuntimeAdapter | null = null; - private baseDir: string; + private readonly baseDir: string; + private readonly options: CSSOptimizationOptions; - constructor(private options: CSSOptimizationOptions = {}, baseDir?: string) { - this.baseDir = baseDir ?? cwd(); + constructor(options: CSSOptimizationOptions = {}, baseDir?: string) { + if ( + typeof options !== "object" || + options === null || + Array.isArray(options) + ) { + throw new TypeError("CSS optimization options must be an object"); + } + for ( + const [name, value] of [ + ["inputFiles", options.inputFiles], + ["browsers", options.browsers], + ["purgeContent", options.purgeContent], + ["purgeSafelist", options.purgeSafelist], + ] as const + ) { + if (value !== undefined && !Array.isArray(value)) { + throw new TypeError(`CSS ${name} must be an array`); + } + } + this.options = { + ...options, + inputFiles: options.inputFiles ? [...options.inputFiles] : undefined, + browsers: options.browsers ? [...options.browsers] : undefined, + purgeContent: options.purgeContent ? [...options.purgeContent] : undefined, + purgeSafelist: options.purgeSafelist ? [...options.purgeSafelist] : undefined, + }; + this.baseDir = baseDir ?? options.projectDir ?? cwd(); } private async ensureService(): Promise { if (this.service) return this.service; + if (this.serviceInitialization) return await this.serviceInitialization; - this.adapter ??= await runtime.get(); - this.service = new CSSOptimizerService(this.adapter, this.baseDir, this.options); - - return this.service; + this.serviceInitialization = (async () => { + this.adapter ??= await runtime.get(); + const service = new CSSOptimizerService( + this.adapter, + this.baseDir, + this.options, + { publicationLock: nativeBuildPublicationLock }, + ); + this.service = service; + return service; + })(); + try { + return await this.serviceInitialization; + } finally { + this.serviceInitialization = null; + } } async init(): Promise { @@ -57,13 +109,7 @@ export class CSSOptimizer { return extractCriticalCSSImpl(cssPath, htmlContent, service.getOptions()); } - async getStats(): Promise<{ - totalFiles: number; - originalSize: number; - minifiedSize: number; - totalSavings: number; - averageSavings: number; - }> { + async getStats(): Promise { return (await this.ensureService()).getStats(); } } diff --git a/src/build/asset-pipeline/css-optimizer/optimizer-service.test.ts b/src/build/asset-pipeline/css-optimizer/optimizer-service.test.ts index 3312587d3c..206088c6c9 100644 --- a/src/build/asset-pipeline/css-optimizer/optimizer-service.test.ts +++ b/src/build/asset-pipeline/css-optimizer/optimizer-service.test.ts @@ -1,94 +1,254 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { join } from "#veryfront/compat/path/index.ts"; +import { runtime } from "#veryfront/platform/adapters/detect.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { CSSOptimizerService } from "./optimizer-service.ts"; -import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; - -function createMockAdapter(_baseDir: string): RuntimeAdapter { - return { - name: "test", - fs: { - readFile: (path: string) => Deno.readTextFile(path), - writeFile: (path: string, content: string) => Deno.writeTextFile(path, content), - exists: async (path: string) => { - try { - await Deno.stat(path); - return true; - } catch { - return false; - } - }, - mkdir: (path: string, opts?: { recursive?: boolean }) => Deno.mkdir(path, opts), - readDir: (path: string) => Deno.readDir(path), - stat: (path: string) => Deno.stat(path), - remove: (path: string, opts?: { recursive?: boolean }) => Deno.remove(path, opts), - readTextFile: (path: string) => Deno.readTextFile(path), - writeTextFile: (path: string, content: string) => Deno.writeTextFile(path, content), +import { LightningCSSStrategy } from "./strategies/lightning-strategy.ts"; +import { nativeBuildPublicationLock } from "../../production-build/build/build-publication.ts"; + +async function withProject( + callback: (projectDir: string) => Promise, +): Promise { + const projectDir = await Deno.makeTempDir(); + try { + await Deno.mkdir(join(projectDir, "styles"), { recursive: true }); + await callback(projectDir); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } +} + +async function createService( + projectDir: string, + options: ConstructorParameters[2] = {}, + dependencies: ConstructorParameters[3] = {}, +): Promise { + return new CSSOptimizerService( + await runtime.get(), + projectDir, + { + inputDir: "styles", + outputDir: ".veryfront/css", + ...options, }, - } as unknown as RuntimeAdapter; + { publicationLock: nativeBuildPublicationLock, ...dependencies }, + ); } describe("build/asset-pipeline/css-optimizer/optimizer-service", () => { - describe("CSSOptimizerService", () => { - it("should construct with default options", () => { - const tmpDir = "/tmp/test-css-optimizer"; - const adapter = createMockAdapter(tmpDir); - const service = new CSSOptimizerService(adapter, tmpDir); - const options = service.getOptions(); - assertEquals(options.enabled, true); - assertEquals(options.minify, true); - assertEquals(options.autoprefixer, true); - assertEquals(options.purge, false); - assertEquals(options.criticalCSS, false); + it("publishes nested outputs, source maps, and a complete manifest", async () => { + await withProject(async (projectDir) => { + await Deno.mkdir(join(projectDir, "styles/nested")); + await Deno.writeTextFile( + join(projectDir, "styles/nested/main.css"), + "/* comment */ .button { user-select: none; color: red; }", + ); + const service = await createService(projectDir, { sourceMap: true }); + + const bundles = await service.optimize(); + const bundle = bundles.get("nested/main.css")!; + assertEquals(bundle.content.includes("/* comment */"), false); + assertEquals(bundle.content.includes("sourceMappingURL=main.min.css.map"), true); + assertEquals(bundle.outputFile, ".veryfront/css/nested/main.min.css"); + assertEquals( + await Deno.readTextFile( + join(projectDir, ".veryfront/css/nested/main.min.css"), + ), + bundle.content, + ); + assertEquals( + JSON.parse( + await Deno.readTextFile( + join(projectDir, ".veryfront/css/css-manifest.json"), + ), + )["nested/main.css"].content, + bundle.content, + ); + assertEquals(service.getStats().totalFiles, 1); }); + }); - it("should merge user options with defaults", () => { - const tmpDir = "/tmp/test-css-optimizer"; - const adapter = createMockAdapter(tmpDir); - const service = new CSSOptimizerService(adapter, tmpDir, { - minify: false, - purge: true, - }); - const options = service.getOptions(); - assertEquals(options.minify, false); - assertEquals(options.purge, true); - assertEquals(options.enabled, true); // default kept + it("replaces stale output only after every CSS file succeeds", async () => { + await withProject(async (projectDir) => { + await Deno.writeTextFile( + join(projectDir, "styles/valid.css"), + ".valid { color: green; }", + ); + await Deno.writeTextFile( + join(projectDir, "styles/invalid.css"), + "@media ( { .broken { color: red; }", + ); + const outputDir = join(projectDir, ".veryfront/css"); + await Deno.mkdir(outputDir, { recursive: true }); + await Deno.writeTextFile(join(outputDir, "sentinel.txt"), "known good"); + const service = await createService(projectDir); + + await assertRejects(() => service.optimize()); + assertEquals( + await Deno.readTextFile(join(outputDir, "sentinel.txt")), + "known good", + ); + assertEquals(service.getStats().totalFiles, 0); + + const parentEntries = []; + for await (const entry of Deno.readDir(join(projectDir, ".veryfront"))) { + parentEntries.push(entry.name); + } + assertEquals( + parentEntries.some((name) => + name.includes(".css.veryfront-stage-") || + name.includes(".css.veryfront-build.lock") + ), + false, + ); + }); + }); + + it("removes stale output after a successful publication", async () => { + await withProject(async (projectDir) => { + await Deno.writeTextFile( + join(projectDir, "styles/main.css"), + ".main { color: red; }", + ); + const outputDir = join(projectDir, ".veryfront/css"); + await Deno.mkdir(outputDir, { recursive: true }); + await Deno.writeTextFile(join(outputDir, "stale.css"), "stale"); + + await (await createService(projectDir)).optimize(); + await assertRejects( + () => Deno.readTextFile(join(outputDir, "stale.css")), + Deno.errors.NotFound, + ); }); + }); - it("should return empty stats initially", () => { - const tmpDir = "/tmp/test-css-optimizer"; - const adapter = createMockAdapter(tmpDir); - const service = new CSSOptimizerService(adapter, tmpDir); - const stats = service.getStats(); - assertExists(stats); + it("fails closed on empty input and missing compiler dependencies", async () => { + await withProject(async (projectDir) => { + const emptyService = await createService(projectDir); + await assertRejects( + () => emptyService.optimize(), + TypeError, + "found no .css files", + ); + + await Deno.writeTextFile( + join(projectDir, "styles/main.css"), + ".main { color: red; }", + ); + const missingDependency = new LightningCSSStrategy(() => + Promise.reject(new Error("compiler unavailable")) + ); + const dependencyService = await createService( + projectDir, + {}, + { lightningStrategy: missingDependency }, + ); + await assertRejects( + () => dependencyService.optimize(), + Error, + "requires npm:lightningcss", + ); }); + }); - it("should return empty map when disabled during optimize", async () => { - const tmpDir = await Deno.makeTempDir(); - try { - const adapter = createMockAdapter(tmpDir); - const service = new CSSOptimizerService(adapter, tmpDir, { enabled: false }); - const result = await service.optimize(); - assertEquals(result.size, 0); - } finally { - await Deno.remove(tmpDir, { recursive: true }); - } + it("runs parser-backed purge before minification", async () => { + await withProject(async (projectDir) => { + await Deno.mkdir(join(projectDir, "app")); + await Deno.writeTextFile( + join(projectDir, "app/page.tsx"), + '
content
', + ); + await Deno.writeTextFile( + join(projectDir, "styles/main.css"), + ".used { color: green; } .unused { color: red; }", + ); + const service = await createService(projectDir, { + purge: true, + purgeContent: ["app/**/*.tsx"], + }); + + const content = (await service.optimize()).get("main.css")!.content; + assertEquals(content.includes(".used"), true); + assertEquals(content.includes(".unused"), false); }); + }); + + it("rejects unsafe configurations and unsupported batch combinations", async () => { + await withProject(async (projectDir) => { + const adapter = await runtime.get(); + assertThrows( + () => + new CSSOptimizerService(adapter, projectDir, { + inputDir: "styles", + outputDir: "styles/output", + }), + TypeError, + "must not overlap", + ); + assertThrows( + () => + new CSSOptimizerService(adapter, projectDir, { + inputDir: "../outside", + }), + TypeError, + "inside the project", + ); + assertThrows( + () => + new CSSOptimizerService(adapter, projectDir, { + projectDir: "relative", + }), + TypeError, + "must be absolute", + ); + assertThrows( + () => + new CSSOptimizerService(adapter, projectDir, { + inputFiles: [".veryfront/css/generated.css"], + outputDir: ".veryfront/css", + }), + TypeError, + "outside the output directory", + ); - it("should provide cache manager", () => { - const tmpDir = "/tmp/test-css-optimizer"; - const adapter = createMockAdapter(tmpDir); - const service = new CSSOptimizerService(adapter, tmpDir); - const cache = service.getCacheManager(); - assertExists(cache); + const critical = await createService(projectDir, { criticalCSS: true }); + await assertRejects( + () => critical.optimize(), + TypeError, + "call extractCriticalCSS", + ); + const unmappable = await createService(projectDir, { + purge: true, + sourceMap: true, + }); + await assertRejects( + () => unmappable.optimize(), + TypeError, + "cannot be composed", + ); }); + }); - it("should provide purge strategy", () => { - const tmpDir = "/tmp/test-css-optimizer"; - const adapter = createMockAdapter(tmpDir); - const service = new CSSOptimizerService(adapter, tmpDir); - const purge = service.getPurgeStrategy(); - assertExists(purge); + it("coalesces concurrent runs and does not expose mutable result state", async () => { + await withProject(async (projectDir) => { + await Deno.writeTextFile( + join(projectDir, "styles/main.css"), + ".main { color: red; }", + ); + const service = await createService(projectDir); + const [first, second] = await Promise.all([ + service.optimize(), + service.optimize(), + ]); + first.get("main.css")!.content = "mutated"; + assertEquals(second.get("main.css")!.content.includes(".main"), true); + assertEquals( + service.getCacheManager().getBundle("main.css")!.content.includes( + ".main", + ), + true, + ); }); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/optimizer-service.ts b/src/build/asset-pipeline/css-optimizer/optimizer-service.ts index 4b1518e19a..9733660e99 100644 --- a/src/build/asset-pipeline/css-optimizer/optimizer-service.ts +++ b/src/build/asset-pipeline/css-optimizer/optimizer-service.ts @@ -1,61 +1,346 @@ -import { dirname, relative } from "#veryfront/compat/path/index.ts"; -import { logger } from "#veryfront/utils"; -import type { - CSSBundle, - CSSOptimizationOptions, - CSSOptimizationStrategy, - CSSOptimizerStats, -} from "#veryfront/types"; +import { + basename, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, +} from "#veryfront/compat/path/index.ts"; +import { INITIALIZATION_ERROR } from "#veryfront/errors"; +import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { createSecureFs, type SecureFs } from "#veryfront/security/secure-fs.ts"; -import { LightningCSSStrategy, MinificationStrategy, PurgeStrategy } from "./strategies/index.ts"; +import { DEFAULT_BUILD_CONCURRENCY, logger } from "#veryfront/utils"; +import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; +import { + type BuildPublicationLock, + createBuildPublication, +} from "../../production-build/build/build-publication.ts"; +import { isContainedAssetPath } from "../../utils/asset-utils.ts"; +import { hasControlCharacters } from "../../utils/string-validation.ts"; +import { + CSS_MANIFEST_FILENAME, + DEFAULT_CSS_OPTIONS, + MAX_CSS_BROWSER_QUERIES, + MAX_CSS_BROWSER_QUERY_CHARACTERS, + MAX_CSS_FILE_BYTES, + MAX_CSS_FILES, + MAX_CSS_OUTPUT_FILE_BYTES, + MAX_CSS_PURGE_PATTERNS, + MAX_CSS_PURGE_SAFELIST_ENTRIES, + MAX_CSS_TOTAL_BYTES, + MAX_CSS_TOTAL_OUTPUT_BYTES, +} from "./constants.ts"; import { CacheManager } from "./css-bundle-cache.ts"; -import { basicMinify, calculateSavings, findCSSFiles, getOutputPath } from "./utils.ts"; -import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; +import { LightningCSSStrategy } from "./strategies/lightning-strategy.ts"; +import { type PurgeContentSource, PurgeStrategy } from "./strategies/purge-strategy.ts"; +import type { CSSBundle, CSSOptimizationOptions, CSSOptimizerStats } from "./types/index.ts"; +import { + calculateSavings, + findCSSFiles, + getOutputPath, + globFiles, + isSafeCSSRelativePath, + validateCSSSourceMap, +} from "./utils.ts"; + +const encoder = new TextEncoder(); + +interface PlannedCSSFile { + sourcePath: string; + logicalPath: string; + outputRelativePath: string; + outputProjectPath: string; +} + +export interface CSSOptimizerServiceDependencies { + lightningStrategy?: LightningCSSStrategy; + purgeStrategy?: PurgeStrategy; + publicationLock?: BuildPublicationLock; +} + +function portablePath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function normalizedCollisionKey(path: string): string { + return portablePath(path).normalize("NFC").toLocaleLowerCase("en-US"); +} + +function requireSafeConfiguredPath(path: string, label: string): void { + if ( + typeof path !== "string" || + path.length === 0 || + path.length > MAX_PATH_LENGTH_CHARS || + hasControlCharacters(path) + ) { + throw new TypeError(`${label} must be a safe non-empty path`); + } +} + +function cloneBundles( + bundles: Map, +): Map { + return new Map( + [...bundles].map(([key, bundle]) => [key, { ...bundle }]), + ); +} -const DEFAULT_OPTIONS: Required = { - enabled: true, - minify: true, - autoprefixer: true, - purge: false, - criticalCSS: false, - inputFiles: [], - inputDir: "./styles", - outputDir: "./.veryfront/optimized-css", - browsers: ["defaults", "not IE 11"], - purgeContent: ["./app/**/*.{tsx,jsx,ts,js}", "./pages/**/*.{tsx,jsx,ts,js}"], - sourceMap: false, -}; +function assertStringList( + value: string[], + label: string, + maximumEntries: number, + maximumCharacters = MAX_PATH_LENGTH_CHARS, + allowEmpty = true, +): void { + if ( + !Array.isArray(value) || + (!allowEmpty && value.length === 0) || + value.length > maximumEntries || + value.some((entry) => + typeof entry !== "string" || + entry.length === 0 || + entry.length > maximumCharacters || + hasControlCharacters(entry) + ) + ) { + throw new TypeError( + `${label} must be a bounded list of safe non-empty strings`, + ); + } +} export class CSSOptimizerService { private options: Required; - private strategies: CSSOptimizationStrategy[]; - private cacheManager: CacheManager; - private lightningStrategy: LightningCSSStrategy; - private minificationStrategy: MinificationStrategy; - private purgeStrategy: PurgeStrategy; - private adapter: RuntimeAdapter; - private secureFs: SecureFs; - private baseDir: string; - - constructor(adapter: RuntimeAdapter, baseDir: string, options: CSSOptimizationOptions = {}) { - this.adapter = adapter; - this.baseDir = baseDir; - this.options = { ...DEFAULT_OPTIONS, ...options }; - this.cacheManager = new CacheManager(); + private cacheManager = new CacheManager(); + private readonly lightningStrategy: LightningCSSStrategy; + private readonly purgeStrategy: PurgeStrategy; + private readonly publicationLock?: BuildPublicationLock; + private readonly adapter: RuntimeAdapter; + private readonly secureFs: SecureFs; + private readonly baseDir: string; + private optimization: Promise> | null = null; + + constructor( + adapter: RuntimeAdapter, + baseDir: string, + options: CSSOptimizationOptions = {}, + dependencies: CSSOptimizerServiceDependencies = {}, + ) { + if (!adapter || typeof adapter !== "object") { + throw new TypeError("CSS optimizer requires a runtime adapter"); + } + if (typeof options !== "object" || options === null || Array.isArray(options)) { + throw new TypeError("CSS optimization options must be an object"); + } + if ( + typeof dependencies !== "object" || + dependencies === null || + Array.isArray(dependencies) + ) { + throw new TypeError("CSS optimizer dependencies must be an object"); + } + if ( + dependencies.lightningStrategy !== undefined && + (typeof dependencies.lightningStrategy !== "object" || + dependencies.lightningStrategy === null || + typeof dependencies.lightningStrategy.init !== "function" || + typeof dependencies.lightningStrategy.process !== "function") + ) { + throw new TypeError("CSS Lightning strategy dependency is invalid"); + } + if ( + dependencies.purgeStrategy !== undefined && + (typeof dependencies.purgeStrategy !== "object" || + dependencies.purgeStrategy === null || + typeof dependencies.purgeStrategy.analyzeContent !== "function" || + typeof dependencies.purgeStrategy.process !== "function" || + typeof dependencies.purgeStrategy.clearCache !== "function") + ) { + throw new TypeError("CSS purge strategy dependency is invalid"); + } + if ( + dependencies.publicationLock !== undefined && + (typeof dependencies.publicationLock !== "object" || + dependencies.publicationLock === null || + typeof dependencies.publicationLock.acquire !== "function") + ) { + throw new TypeError("CSS publication lock dependency is invalid"); + } + requireSafeConfiguredPath(baseDir, "CSS project directory"); + if (!isAbsolute(baseDir)) { + throw new TypeError("CSS project directory must be absolute"); + } + this.baseDir = resolve(baseDir); + if (options.projectDir !== undefined) { + requireSafeConfiguredPath(options.projectDir, "CSS projectDir"); + if (!isAbsolute(options.projectDir)) { + throw new TypeError("CSS projectDir must be absolute"); + } + if (resolve(options.projectDir) !== this.baseDir) { + throw new TypeError( + "CSS projectDir must match the optimizer project boundary", + ); + } + } + for ( + const [name, value] of [ + ["enabled", options.enabled], + ["minify", options.minify], + ["autoprefixer", options.autoprefixer], + ["purge", options.purge], + ["criticalCSS", options.criticalCSS], + ["sourceMap", options.sourceMap], + ] as const + ) { + if (value !== undefined && typeof value !== "boolean") { + throw new TypeError(`CSS ${name} must be a boolean`); + } + } + if (options.inputFiles !== undefined && !Array.isArray(options.inputFiles)) { + throw new TypeError("CSS inputFiles must be an array"); + } + if (options.browsers !== undefined && !Array.isArray(options.browsers)) { + throw new TypeError("CSS browsers must be an array"); + } + if ( + options.purgeContent !== undefined && + !Array.isArray(options.purgeContent) + ) { + throw new TypeError("CSS purgeContent must be an array"); + } + if ( + options.purgeSafelist !== undefined && + !Array.isArray(options.purgeSafelist) + ) { + throw new TypeError("CSS purgeSafelist must be an array"); + } + + this.options = { + projectDir: this.baseDir, + enabled: options.enabled === undefined ? DEFAULT_CSS_OPTIONS.enabled : options.enabled, + minify: options.minify === undefined ? DEFAULT_CSS_OPTIONS.minify : options.minify, + autoprefixer: options.autoprefixer === undefined + ? DEFAULT_CSS_OPTIONS.autoprefixer + : options.autoprefixer, + purge: options.purge === undefined ? DEFAULT_CSS_OPTIONS.purge : options.purge, + criticalCSS: options.criticalCSS === undefined + ? DEFAULT_CSS_OPTIONS.criticalCSS + : options.criticalCSS, + inputFiles: options.inputFiles === undefined + ? [...DEFAULT_CSS_OPTIONS.inputFiles] + : [...options.inputFiles], + inputDir: options.inputDir === undefined ? DEFAULT_CSS_OPTIONS.inputDir : options.inputDir, + outputDir: options.outputDir === undefined + ? DEFAULT_CSS_OPTIONS.outputDir + : options.outputDir, + browsers: options.browsers === undefined + ? [...DEFAULT_CSS_OPTIONS.browsers] + : [...options.browsers], + purgeContent: options.purgeContent === undefined + ? [...DEFAULT_CSS_OPTIONS.purgeContent] + : [...options.purgeContent], + purgeSafelist: options.purgeSafelist === undefined + ? [...DEFAULT_CSS_OPTIONS.purgeSafelist] + : [...options.purgeSafelist], + sourceMap: options.sourceMap === undefined + ? DEFAULT_CSS_OPTIONS.sourceMap + : options.sourceMap, + }; + this.validateConfiguration(); + this.adapter = adapter; this.secureFs = createSecureFs({ - baseDir, + baseDir: this.baseDir, adapter, context: "build", throwOnError: true, + validationOptions: { followSymlinks: false }, }); + this.lightningStrategy = dependencies.lightningStrategy ?? + new LightningCSSStrategy(); + this.purgeStrategy = dependencies.purgeStrategy ?? + new PurgeStrategy({ + baseDir: this.baseDir, + collectContent: (patterns) => this.collectPurgeContent(patterns), + }); + this.publicationLock = dependencies.publicationLock; + } - this.lightningStrategy = new LightningCSSStrategy(); - this.minificationStrategy = new MinificationStrategy(); - this.purgeStrategy = new PurgeStrategy(); + private validateConfiguration(): void { + requireSafeConfiguredPath(this.options.inputDir, "CSS input directory"); + requireSafeConfiguredPath(this.options.outputDir, "CSS output directory"); + assertStringList( + this.options.inputFiles, + "CSS inputFiles", + MAX_CSS_FILES, + ); + assertStringList( + this.options.browsers, + "CSS browsers", + MAX_CSS_BROWSER_QUERIES, + MAX_CSS_BROWSER_QUERY_CHARACTERS, + false, + ); + assertStringList( + this.options.purgeContent, + "CSS purgeContent", + MAX_CSS_PURGE_PATTERNS, + MAX_PATH_LENGTH_CHARS, + !this.options.purge, + ); + assertStringList( + this.options.purgeSafelist, + "CSS purgeSafelist", + MAX_CSS_PURGE_SAFELIST_ENTRIES, + ); - this.strategies = [this.lightningStrategy, this.purgeStrategy, this.minificationStrategy]; + const inputDir = isAbsolute(this.options.inputDir) + ? resolve(this.options.inputDir) + : resolve(this.baseDir, this.options.inputDir); + const outputDir = isAbsolute(this.options.outputDir) + ? resolve(this.options.outputDir) + : resolve(this.baseDir, this.options.outputDir); + if ( + inputDir === this.baseDir || + outputDir === this.baseDir || + !isContainedAssetPath(this.baseDir, inputDir) || + !isContainedAssetPath(this.baseDir, outputDir) + ) { + throw new TypeError( + "CSS input and output directories must be inside the project", + ); + } + if ( + isContainedAssetPath(inputDir, outputDir) || + isContainedAssetPath(outputDir, inputDir) + ) { + throw new TypeError("CSS input and output directories must not overlap"); + } + + this.options.inputDir = inputDir; + this.options.outputDir = outputDir; + this.options.inputFiles = this.options.inputFiles.map((path) => { + requireSafeConfiguredPath(path, "CSS input file"); + const absolutePath = isAbsolute(path) ? resolve(path) : resolve(this.baseDir, path); + if ( + !isContainedAssetPath(this.baseDir, absolutePath) || + isContainedAssetPath(outputDir, absolutePath) || + extname(absolutePath).toLowerCase() !== ".css" + ) { + throw new TypeError( + `CSS input file must be a .css file inside the project and outside the output directory: ${path}`, + ); + } + return absolutePath; + }); + if ( + new Set(this.options.inputFiles.map(normalizedCollisionKey)).size !== + this.options.inputFiles.length + ) { + throw new TypeError("CSS inputFiles must not contain duplicate paths"); + } } async init(): Promise { @@ -63,131 +348,406 @@ export class CSSOptimizerService { logger.info("CSS optimization is disabled"); return false; } - - const lightningReady = await this.lightningStrategy.init(); - logger.info( - lightningReady ? "Using Lightning CSS for optimization" : "Using fallback CSS minification", - ); - + await this.lightningStrategy.init(); return true; } optimize(): Promise> { + const run = this.optimization ??= this.optimizeOnce().finally(() => { + this.optimization = null; + }); + return run.then(cloneBundles); + } + + private optimizeOnce(): Promise> { return withSpan( "build.cssOptimizer.optimize", async () => { + if (!this.options.enabled) return new Map(); + if (this.options.criticalCSS) { + throw new TypeError( + "criticalCSS cannot run in a batch without HTML; call extractCriticalCSS explicitly", + ); + } + if (this.options.purge && this.options.sourceMap) { + throw new TypeError( + "CSS source maps cannot be composed safely with purge output", + ); + } + await this.validateFilesystemCapabilities(); await this.init(); - if (!this.options.enabled) { - return new Map(); + const plans = await this.planFiles(); + if (plans.length === 0) { + throw new TypeError( + `CSS optimization found no .css files in ${this.options.inputDir}`, + ); + } + if (this.options.purge) { + await this.purgeStrategy.analyzeContent(this.options.purgeContent); + } else { + this.purgeStrategy.clearCache(); } logger.info("Starting CSS optimization", { inputDir: this.options.inputDir, outputDir: this.options.outputDir, + files: plans.length, minify: this.options.minify, autoprefixer: this.options.autoprefixer, purge: this.options.purge, }); - await this.secureFs.mkdir(this.options.outputDir, { recursive: true }); - - const cssFiles = this.options.inputFiles.length - ? this.options.inputFiles - : await findCSSFiles(this.options.inputDir); - - logger.info(`Found ${cssFiles.length} CSS files to optimize`); - - for (const cssFile of cssFiles) { - await this.optimizeFile(cssFile); + const publication = await createBuildPublication( + this.options.outputDir, + false, + { fs: this.adapter.fs, lock: this.publicationLock }, + ); + const stagedCache = new CacheManager(); + let totalInputBytes = 0; + let totalOutputBytes = 0; + let failed = false; + let failure: unknown; + try { + await this.secureFs.mkdir(publication.buildDir, { + recursive: true, + }); + for ( + let start = 0; + start < plans.length; + start += DEFAULT_BUILD_CONCURRENCY + ) { + const settled = await Promise.allSettled( + plans.slice(start, start + DEFAULT_BUILD_CONCURRENCY) + .map((plan) => this.optimizeFile(plan, publication.buildDir)), + ); + const errors = settled.flatMap((result) => + result.status === "rejected" ? [result.reason] : [] + ); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError( + errors, + "Multiple CSS files failed optimization", + ); + } + for (const result of settled) { + if (result.status !== "fulfilled") { + throw new TypeError("Unreachable rejected CSS result"); + } + totalInputBytes += result.value.size; + if (totalInputBytes > MAX_CSS_TOTAL_BYTES) { + throw new TypeError( + `CSS inputs exceed ${MAX_CSS_TOTAL_BYTES} actual bytes`, + ); + } + totalOutputBytes += result.value.minifiedSize + + (result.value.sourceMap === undefined + ? 0 + : encoder.encode(result.value.sourceMap).length); + if (totalOutputBytes > MAX_CSS_TOTAL_OUTPUT_BYTES) { + throw new TypeError( + `CSS outputs exceed ${MAX_CSS_TOTAL_OUTPUT_BYTES} total bytes`, + ); + } + stagedCache.addBundle( + result.value.file, + result.value, + ); + } + } + + await this.secureFs.writeFile( + join(publication.buildDir, CSS_MANIFEST_FILENAME), + stagedCache.serializeManifest(), + ); + await publication.publish(); + this.cacheManager = stagedCache; + } catch (error) { + failed = true; + failure = error; } - await this.cacheManager.writeManifest(this.options.outputDir); + try { + await publication.cleanup(); + } catch (cleanupError) { + if (failed) { + throw new AggregateError( + [failure, cleanupError], + "CSS optimization failed and staging cleanup also failed", + ); + } + throw cleanupError; + } + if (failed) throw failure; logger.info("CSS optimization complete", { totalBundles: this.cacheManager.size(), totalSavings: this.cacheManager.getTotalSavings(), }); - return this.cacheManager.getAllBundles(); }, - { "build.css.inputDir": this.options.inputDir, "build.css.minify": this.options.minify }, + { + "build.css.inputDir": this.options.inputDir, + "build.css.minify": this.options.minify, + }, ); } - private async optimizeFile(cssPath: string): Promise { - const relPath = relative(this.options.inputDir, cssPath); - logger.debug(`Optimizing: ${relPath}`); - - try { - const content = await this.secureFs.readFile(cssPath); - const originalSize = new TextEncoder().encode(content).length; - - const { optimized, sourceMap } = await this.processContent(content, cssPath); + private async validateFilesystemCapabilities(): Promise { + if ( + typeof this.adapter.fs.rename !== "function" || + typeof this.adapter.fs.lstat !== "function" + ) { + throw INITIALIZATION_ERROR.create({ + detail: "Transactional CSS optimization requires rename and lstat filesystem support", + }); + } + const baseInfo = await this.adapter.fs.lstat(this.baseDir); + if (!baseInfo.isDirectory || baseInfo.isSymlink) { + throw new TypeError("CSS project directory must be a real directory"); + } - const outputPath = getOutputPath(relPath, this.options.outputDir); - await this.secureFs.mkdir(dirname(outputPath), { recursive: true }); - await this.secureFs.writeFile(outputPath, optimized); + if (this.options.inputFiles.length === 0) { + const inputInfo = await this.adapter.fs.lstat(this.options.inputDir); + if (!inputInfo.isDirectory || inputInfo.isSymlink) { + throw new TypeError("CSS input directory must be a real directory"); + } + } + await this.secureFs.mkdir(dirname(this.options.outputDir), { + recursive: true, + }); + if (await this.adapter.fs.exists(this.options.outputDir)) { + const outputInfo = await this.adapter.fs.lstat(this.options.outputDir); + if (!outputInfo.isDirectory || outputInfo.isSymlink) { + throw new TypeError("CSS output path must be a real directory"); + } + } + } - if (this.options.sourceMap && sourceMap) { - await this.secureFs.writeFile(`${outputPath}.map`, sourceMap); + private async planFiles(): Promise { + const sourceFiles = this.options.inputFiles.length > 0 + ? [...this.options.inputFiles].sort() + : await findCSSFiles(this.options.inputDir, { + fs: this.secureFs, + maxFiles: MAX_CSS_FILES, + }); + const logicalOwners = new Map(); + const outputOwners = new Map(); + const plans: PlannedCSSFile[] = []; + let totalBytes = 0; + + for (const sourcePath of sourceFiles) { + const info = await this.adapter.fs.lstat!(sourcePath); + if ( + !info.isFile || + info.isSymlink || + !Number.isSafeInteger(info.size) || + info.size < 0 + ) { + throw new TypeError(`CSS input must be a regular file: ${sourcePath}`); + } + if (info.size > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS input exceeds ${MAX_CSS_FILE_BYTES} bytes: ${sourcePath}`, + ); + } + totalBytes += info.size; + if (totalBytes > MAX_CSS_TOTAL_BYTES) { + throw new TypeError( + `CSS inputs exceed ${MAX_CSS_TOTAL_BYTES} total bytes`, + ); } - const minifiedSize = new TextEncoder().encode(optimized).length; - const savings = calculateSavings(originalSize, minifiedSize); + const logicalRoot = isContainedAssetPath( + this.options.inputDir, + sourcePath, + ) + ? this.options.inputDir + : this.baseDir; + const logicalPath = portablePath( + relative(logicalRoot, sourcePath), + ).normalize("NFC"); + if (!isSafeCSSRelativePath(logicalPath)) { + throw new TypeError(`Unsafe CSS input path: ${sourcePath}`); + } + const outputPath = getOutputPath(logicalPath, this.options.outputDir); + const outputRelativePath = portablePath( + relative(this.options.outputDir, outputPath), + ).normalize("NFC"); + const outputProjectPath = portablePath( + relative(this.baseDir, outputPath), + ).normalize("NFC"); + if ( + !isSafeCSSRelativePath(outputRelativePath) || + !isSafeCSSRelativePath(outputProjectPath) + ) { + throw new TypeError(`Unsafe CSS output path: ${outputPath}`); + } - this.cacheManager.addBundle(relPath, { - file: relPath, - content: optimized, - sourceMap, - size: originalSize, - minifiedSize, - savings, + for ( + const [owners, key, label] of [ + [logicalOwners, normalizedCollisionKey(logicalPath), "input"], + [outputOwners, normalizedCollisionKey(outputRelativePath), "output"], + ] as const + ) { + const previous = owners.get(key); + if (previous !== undefined) { + throw new TypeError( + `CSS ${label} collision between ${previous} and ${sourcePath}`, + ); + } + owners.set(key, sourcePath); + } + plans.push({ + sourcePath, + logicalPath, + outputRelativePath, + outputProjectPath, }); + } + return plans; + } - logger.debug( - `Optimized ${relPath}: ${originalSize} → ${minifiedSize} bytes (${ - savings.toFixed( - 1, - ) - }% reduction)`, + private async optimizeFile( + plan: PlannedCSSFile, + stagingDir: string, + ): Promise { + const content = await this.secureFs.readFile(plan.sourcePath); + const originalSize = encoder.encode(content).length; + if (originalSize > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS input exceeds ${MAX_CSS_FILE_BYTES} bytes: ${plan.logicalPath}`, ); - } catch (error) { - logger.error(`Failed to optimize ${relPath}`, { - error: error instanceof Error ? error.message : String(error), - }); } + const { optimized, sourceMap } = await this.processContent( + content, + plan.logicalPath, + ); + + let outputContent = optimized; + if (this.options.sourceMap) { + if (!sourceMap) { + throw new TypeError( + `CSS source map is missing for ${plan.logicalPath}`, + ); + } + validateCSSSourceMap(sourceMap, plan.logicalPath); + const mapName = `${basename(plan.outputRelativePath)}.map`; + outputContent = `${optimized}\n/*# sourceMappingURL=${encodeURIComponent(mapName)} */\n`; + } + const minifiedSize = encoder.encode(outputContent).length; + if (minifiedSize > MAX_CSS_OUTPUT_FILE_BYTES) { + throw new TypeError( + `CSS output exceeds ${MAX_CSS_OUTPUT_FILE_BYTES} bytes: ${plan.logicalPath}`, + ); + } + if ( + sourceMap !== undefined && + encoder.encode(sourceMap).length > MAX_CSS_OUTPUT_FILE_BYTES + ) { + throw new TypeError( + `CSS source map exceeds ${MAX_CSS_OUTPUT_FILE_BYTES} bytes: ${plan.logicalPath}`, + ); + } + + const outputPath = join(stagingDir, plan.outputRelativePath); + await this.secureFs.mkdir(dirname(outputPath), { recursive: true }); + await this.secureFs.writeFile(outputPath, outputContent); + if (this.options.sourceMap && sourceMap) { + await this.secureFs.writeFile(`${outputPath}.map`, sourceMap); + } + + return { + file: plan.logicalPath, + outputFile: plan.outputProjectPath, + content: outputContent, + sourceMap, + size: originalSize, + minifiedSize, + savings: calculateSavings(originalSize, minifiedSize), + }; } private async processContent( content: string, - cssPath: string, + logicalPath: string, ): Promise<{ optimized: string; sourceMap?: string }> { - const strategy = this.selectStrategy(); - const fallback = () => ({ - optimized: this.options.minify ? basicMinify(content) : content, - }); + const purged = this.options.purge + ? (await this.purgeStrategy.process( + content, + logicalPath, + this.options, + )).code + : content; + const result = await this.lightningStrategy.process( + purged, + logicalPath, + this.options, + ); + return { optimized: result.code, sourceMap: result.sourceMap }; + } - if (!strategy) return fallback(); + private async collectPurgeContent( + patterns: string[], + ): Promise { + const paths = new Set(); + for (const pattern of patterns) { + for ( + const path of await globFiles(pattern, { + baseDir: this.baseDir, + fs: this.secureFs, + maxFiles: MAX_CSS_FILES, + }) + ) { + paths.add(path); + if (paths.size > MAX_CSS_FILES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_FILES} files`, + ); + } + } + } - try { - const result = await strategy.process(content, cssPath, this.options); - return { optimized: result.code, sourceMap: result.sourceMap }; - } catch (error) { - logger.warn(`Strategy ${strategy.name} failed, using fallback`, { - error: error instanceof Error ? error.message : String(error), + let totalBytes = 0; + const sources: PurgeContentSource[] = []; + for (const path of [...paths].sort()) { + const info = await this.adapter.fs.lstat!(path); + if ( + !info.isFile || + info.isSymlink || + !Number.isSafeInteger(info.size) || + info.size < 0 + ) { + throw new TypeError( + `CSS purge content must be a regular file: ${path}`, + ); + } + if (info.size > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_FILE_BYTES} bytes: ${path}`, + ); + } + const raw = await this.secureFs.readFile(path); + const actualBytes = encoder.encode(raw).length; + if (actualBytes > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_FILE_BYTES} bytes: ${path}`, + ); + } + totalBytes += actualBytes; + if (totalBytes > MAX_CSS_TOTAL_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_TOTAL_BYTES} total bytes`, + ); + } + sources.push({ + path, + raw, + extension: extname(path).slice(1).toLowerCase() || "html", }); - return { optimized: basicMinify(content) }; } - } - - private selectStrategy(): CSSOptimizationStrategy | null { - const selected = [...this.strategies] - .sort((a, b) => b.priority - a.priority) - .find((s) => s.canProcess(this.options)); - - if (selected) logger.debug(`Selected strategy: ${selected.name}`); - return selected ?? null; + return sources; } getStats(): CSSOptimizerStats { @@ -195,7 +755,13 @@ export class CSSOptimizerService { } getOptions(): Required { - return this.options; + return { + ...this.options, + inputFiles: [...this.options.inputFiles], + browsers: [...this.options.browsers], + purgeContent: [...this.options.purgeContent], + purgeSafelist: [...this.options.purgeSafelist], + }; } getCacheManager(): CacheManager { diff --git a/src/build/asset-pipeline/css-optimizer/strategies.test.ts b/src/build/asset-pipeline/css-optimizer/strategies.test.ts index 3fb8fe4884..1760e75baf 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies.test.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies.test.ts @@ -5,7 +5,7 @@ import { join } from "#veryfront/compat/path"; import { remove, writeTextFile } from "#veryfront/compat/fs.ts"; import { ensureDir } from "#veryfront/compat/std/fs.ts"; import { LightningCSSStrategy, MinificationStrategy, PurgeStrategy } from "./strategies/index.ts"; -import type { CSSOptimizationOptions } from "#veryfront/types"; +import type { CSSOptimizationOptions } from "./types/index.ts"; const TEST_DIR = "./.veryfront/test-strategies"; @@ -155,8 +155,8 @@ describe("PurgeStrategy", () => { }); }); -describe("Strategy priority ordering", () => { - it("strategies have correct priority order", () => { +describe("Legacy strategy metadata", () => { + it("keeps priority values stable for direct strategy consumers", () => { const lightning = new LightningCSSStrategy(); const minification = new MinificationStrategy(); const purge = new PurgeStrategy(); diff --git a/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.test.ts b/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.test.ts index 31df7553a8..48243e8538 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.test.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.test.ts @@ -42,12 +42,11 @@ describe("build/asset-pipeline/css-optimizer/strategies/lightning-strategy", () }); describe("init", () => { - it("should return false when lightningcss is not available", async () => { + it("should load the required pinned compiler", async () => { const strategy = new LightningCSSStrategy(); - // In test environment, the esm.sh import will likely fail const result = await strategy.init(); - // Either true or false is acceptable; we just verify it does not throw - assertEquals(typeof result, "boolean"); + assertEquals(result, true); + assertEquals(strategy.isAvailable(), true); }); it("should not re-initialize on subsequent calls", async () => { @@ -56,6 +55,39 @@ describe("build/asset-pipeline/css-optimizer/strategies/lightning-strategy", () const second = await strategy.init(); assertEquals(first, second); }); + + it("should fail closed and coalesce a missing dependency", async () => { + let attempts = 0; + const strategy = new LightningCSSStrategy(() => { + attempts++; + return Promise.reject(new Error("unavailable")); + }); + const [first, second] = await Promise.allSettled([ + strategy.init(), + strategy.init(), + ]); + assertEquals(first.status, "rejected"); + assertEquals(second.status, "rejected"); + assertEquals(attempts, 1); + await assertRejects(() => strategy.init()); + assertEquals(attempts, 2); + }); + }); + + it("should generate the requested source map", async () => { + const strategy = new LightningCSSStrategy(); + await strategy.init(); + const result = await strategy.process( + ".field { user-select: none; }", + "field.css", + { + browsers: ["ie 11"], + autoprefixer: true, + sourceMap: true, + }, + ); + assertEquals(result.code.includes("-ms-user-select"), true); + assertEquals(JSON.parse(result.sourceMap!).version, 3); }); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.ts b/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.ts index 1cd3604aaf..28472b3f9e 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.ts @@ -1,4 +1,6 @@ +import { DEPENDENCY_MISSING, INITIALIZATION_ERROR } from "#veryfront/errors"; import { logger } from "#veryfront/utils"; +import { LIGHTNING_CSS_MODULE_SPECIFIER } from "../constants.ts"; import type { CSSOptimizationOptions, CSSOptimizationStrategy, @@ -6,30 +8,52 @@ import type { LightningCSSModule, } from "../types/index.ts"; import { parseBrowserTargets } from "../utils.ts"; -import { INITIALIZATION_ERROR } from "#veryfront/errors"; + +type LightningCSSLoader = () => Promise; + +function defaultLoader(): Promise { + return import(LIGHTNING_CSS_MODULE_SPECIFIER); +} export class LightningCSSStrategy implements CSSOptimizationStrategy { readonly name = "lightning-css"; readonly priority = 100; private lightningCSS: LightningCSSModule | null = null; - private initialized = false; - - async init(): Promise { - if (this.initialized) return this.lightningCSS !== null; - - this.initialized = true; + private initialization: Promise | null = null; + private readonly loader: LightningCSSLoader; - try { - this.lightningCSS = await import("https://esm.sh/lightningcss@1.29.2"); - logger.info("Lightning CSS optimizer loaded successfully"); - return true; - } catch (error) { - logger.warn("Lightning CSS not available. Install with: npm install lightningcss", { - error: error instanceof Error ? error.message : String(error), - }); - return false; + constructor(loader: LightningCSSLoader = defaultLoader) { + if (typeof loader !== "function") { + throw new TypeError("Lightning CSS loader must be a function"); } + this.loader = loader; + } + + init(): Promise { + if (this.lightningCSS) return Promise.resolve(true); + const pending = this.initialization ??= (async () => { + try { + const module = await this.loader(); + if (!module || typeof module.transform !== "function") { + throw new TypeError( + "Lightning CSS module did not export a transform function", + ); + } + this.lightningCSS = module; + logger.info("Lightning CSS optimizer loaded successfully"); + return true; + } catch (error) { + throw DEPENDENCY_MISSING.create({ + detail: `CSS optimization requires ${LIGHTNING_CSS_MODULE_SPECIFIER}`, + cause: error, + }); + } + })(); + return pending.catch((error) => { + if (this.initialization === pending) this.initialization = null; + throw error; + }); } canProcess(options: CSSOptimizationOptions): boolean { @@ -42,31 +66,32 @@ export class LightningCSSStrategy implements CSSOptimizationStrategy { options: CSSOptimizationOptions, ): Promise { if (!this.lightningCSS) { - throw INITIALIZATION_ERROR.create({ detail: "Lightning CSS not initialized" }); - } - - try { - const result = this.lightningCSS.transform({ - filename, - code: new TextEncoder().encode(content), - minify: options.minify ?? true, - sourceMap: options.sourceMap ?? false, - targets: parseBrowserTargets(options.browsers), - analyzeDependencies: false, + throw INITIALIZATION_ERROR.create({ + detail: "Lightning CSS not initialized", }); + } - const decoder = new TextDecoder(); - - return { - code: decoder.decode(result.code), - sourceMap: result.map ? decoder.decode(result.map) : undefined, - }; - } catch (error) { - logger.warn(`Lightning CSS processing failed for ${filename}`, { - error: error instanceof Error ? error.message : String(error), - }); - throw error; + const sourceMap = options.sourceMap ?? false; + const result = this.lightningCSS.transform({ + filename, + code: new TextEncoder().encode(content), + minify: options.minify ?? true, + sourceMap, + targets: options.autoprefixer === false ? undefined : parseBrowserTargets(options.browsers), + analyzeDependencies: false, + }); + if (!(result.code instanceof Uint8Array)) { + throw new TypeError("Lightning CSS returned invalid output bytes"); } + if (sourceMap && !(result.map instanceof Uint8Array)) { + throw new TypeError("Lightning CSS did not return the requested source map"); + } + + const decoder = new TextDecoder("utf-8", { fatal: true }); + return { + code: decoder.decode(result.code), + sourceMap: result.map ? decoder.decode(result.map) : undefined, + }; } isAvailable(): boolean { diff --git a/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.ts b/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.ts index b86efe848a..a6be77d637 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.ts @@ -19,7 +19,7 @@ export class MinificationStrategy implements CSSOptimizationStrategy { filename: string, _options: CSSOptimizationOptions, ): Promise { - logger.debug(`Using basic minification for ${filename}`); + logger.debug(`Using parser-backed minification for ${filename}`); return { code: basicMinify(content), diff --git a/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.test.ts b/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.test.ts index daa7e4b6cd..3ac5adef08 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.test.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { PurgeStrategy } from "./purge-strategy.ts"; @@ -87,6 +87,64 @@ describe("build/asset-pipeline/css-optimizer/strategies/purge-strategy", () => { assertEquals(result.code.includes(".keep"), true); assertEquals(result.code.includes(".remove"), false); }); + + it("should preserve nested at-rules while removing unused rules", async () => { + const strategy = new PurgeStrategy(); + strategy.getUsedSelectors().add(".keep"); + const result = await strategy.process( + "@media (min-width: 40rem) { .keep { display: block; } .remove { display: none; } }", + "test.css", + {}, + ); + assertEquals(result.code.includes("@media"), true); + assertEquals(result.code.includes(".keep"), true); + assertEquals(result.code.includes(".remove"), false); + }); + + it("normalizes selector-form safelist entries", async () => { + const strategy = new PurgeStrategy(); + strategy.getUsedSelectors().add(".used"); + const result = await strategy.process( + ".used { color: green; } .dynamic { color: blue; } .remove { color: red; }", + "test.css", + { purgeSafelist: [".dynamic"] }, + ); + assertEquals(result.code.includes(".dynamic"), true); + assertEquals(result.code.includes(".remove"), false); + }); + + it("should reject missing purge evidence", async () => { + const strategy = new PurgeStrategy(); + await assertRejects( + () => strategy.process(".x {}", "test.css", {}), + TypeError, + "requires non-empty", + ); + }); + + it("validates injected content and mutable selector evidence", async () => { + const malformedContent = new PurgeStrategy({ + collectContent: () => + Promise.resolve([{ + path: "page.tsx", + raw: "
", + extension: "../tsx", + }]), + }); + await assertRejects( + () => malformedContent.analyzeContent(["app/**/*.tsx"]), + TypeError, + "source is malformed", + ); + + const mutatedSelectors = new PurgeStrategy(); + mutatedSelectors.getUsedSelectors().add("bad\nselector"); + await assertRejects( + () => mutatedSelectors.process(".bad {}", "test.css", {}), + TypeError, + "unsafe token", + ); + }); }); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.ts b/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.ts index 1b4c4a0a21..6469e26cd8 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.ts @@ -1,49 +1,248 @@ +import { extname, resolve } from "#veryfront/compat/path/index.ts"; +import { DEPENDENCY_MISSING } from "#veryfront/errors"; +import { createFileSystem, type FileSystem } from "#veryfront/platform/compat/fs.ts"; +import { cwd } from "#veryfront/platform/compat/process.ts"; import { logger } from "#veryfront/utils"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; +import { hasControlCharacters } from "../../../utils/string-validation.ts"; +import { + MAX_CSS_FILE_BYTES, + MAX_CSS_FILES, + MAX_CSS_OUTPUT_FILE_BYTES, + MAX_CSS_PURGE_PATTERNS, + MAX_CSS_PURGE_SAFELIST_ENTRIES, + MAX_CSS_SELECTOR_TOKEN_CHARACTERS, + MAX_CSS_SELECTOR_TOKENS, + MAX_CSS_TOTAL_BYTES, + PURGE_CSS_MODULE_SPECIFIER, +} from "../constants.ts"; import type { CSSOptimizationOptions, CSSOptimizationStrategy, CSSProcessingResult, } from "../types/index.ts"; -import { extractSelectors, globFiles, shouldKeepSelector } from "../utils.ts"; +import { extractSelectors, globFiles } from "../utils.ts"; -const fs = createFileSystem(); +interface PurgeCSSResult { + css: string; +} + +interface PurgeCSSInstance { + purge(options: { + content: Array<{ raw: string; extension: string }>; + css: Array<{ raw: string }>; + safelist?: string[]; + }): Promise; +} + +interface PurgeCSSModule { + PurgeCSS: new () => PurgeCSSInstance; +} + +export interface PurgeContentSource { + path: string; + raw: string; + extension: string; +} + +type PurgeCSSLoader = () => Promise; +type ContentCollector = ( + patterns: string[], +) => Promise; + +export interface PurgeStrategyDependencies { + baseDir?: string; + fs?: FileSystem; + collectContent?: ContentCollector; + loadPurgeCSS?: PurgeCSSLoader; +} + +function defaultPurgeCSSLoader(): Promise { + return import(PURGE_CSS_MODULE_SPECIFIER); +} + +function selectorToken(selector: string): string { + return selector.startsWith(".") || selector.startsWith("#") ? selector.slice(1) : selector; +} + +const encoder = new TextEncoder(); + +function isSafeBoundedString( + value: unknown, + maximumCharacters: number, +): value is string { + return typeof value === "string" && + value.length > 0 && + value.length <= maximumCharacters && + !hasControlCharacters(value); +} + +function validateSelectorEvidence(selectors: Set): string[] { + if (!(selectors instanceof Set) || selectors.size > MAX_CSS_SELECTOR_TOKENS) { + throw new TypeError( + `CSS selector evidence must contain at most ${MAX_CSS_SELECTOR_TOKENS} entries`, + ); + } + const tokens = []; + for (const selector of selectors) { + if ( + !isSafeBoundedString(selector, MAX_CSS_SELECTOR_TOKEN_CHARACTERS) || + /\s/u.test(selector) + ) { + throw new TypeError("CSS selector evidence contains an unsafe token"); + } + const token = selectorToken(selector); + if ( + !isSafeBoundedString(token, MAX_CSS_SELECTOR_TOKEN_CHARACTERS) || + /\s/u.test(token) + ) { + throw new TypeError("CSS selector evidence contains an unsafe token"); + } + tokens.push(token); + } + return tokens.sort(); +} + +function validateContentSources( + value: unknown, +): PurgeContentSource[] { + if (!Array.isArray(value) || value.length > MAX_CSS_FILES) { + throw new TypeError( + `CSS purge content must contain at most ${MAX_CSS_FILES} sources`, + ); + } + + let totalBytes = 0; + const paths = new Set(); + const sources: PurgeContentSource[] = []; + for (let index = 0; index < value.length; index++) { + const candidate: unknown = value[index]; + if ( + typeof candidate !== "object" || + candidate === null || + Array.isArray(candidate) + ) { + throw new TypeError("CSS purge content source is malformed"); + } + const source = candidate as Partial; + if ( + !isSafeBoundedString(source.path, MAX_PATH_LENGTH_CHARS) || + typeof source.raw !== "string" || + !isSafeBoundedString(source.extension, 32) || + !/^[A-Za-z0-9]+$/.test(source.extension) + ) { + throw new TypeError("CSS purge content source is malformed"); + } + if (paths.has(source.path)) { + throw new TypeError(`Duplicate CSS purge content source: ${source.path}`); + } + paths.add(source.path); + + const bytes = encoder.encode(source.raw).length; + if (bytes > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_FILE_BYTES} bytes: ${source.path}`, + ); + } + totalBytes += bytes; + if (totalBytes > MAX_CSS_TOTAL_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_TOTAL_BYTES} total bytes`, + ); + } + sources.push({ + path: source.path, + raw: source.raw, + extension: source.extension.toLowerCase(), + }); + } + return sources; +} export class PurgeStrategy implements CSSOptimizationStrategy { readonly name = "purge-css"; - readonly priority = 50; // Medium priority - runs after Lightning CSS but before basic minification + readonly priority = 50; - private usedSelectors = new Set(); + private readonly baseDir: string; + private readonly fs: FileSystem; + private readonly collectContentDependency: ContentCollector; + private readonly loadPurgeCSSDependency: PurgeCSSLoader; + private readonly usedSelectors = new Set(); + private contentSources: PurgeContentSource[] = []; + private purgeCSSModule: Promise | null = null; + private purgeCSSLoadGeneration = 0; + + constructor(dependencies: PurgeStrategyDependencies = {}) { + if ( + typeof dependencies !== "object" || + dependencies === null || + Array.isArray(dependencies) + ) { + throw new TypeError("Purge strategy dependencies must be an object"); + } + if ( + dependencies.collectContent !== undefined && + typeof dependencies.collectContent !== "function" + ) { + throw new TypeError("Purge content collector must be a function"); + } + if ( + dependencies.loadPurgeCSS !== undefined && + typeof dependencies.loadPurgeCSS !== "function" + ) { + throw new TypeError("PurgeCSS loader must be a function"); + } + this.baseDir = resolve(dependencies.baseDir ?? cwd()); + this.fs = dependencies.fs ?? createFileSystem(); + this.collectContentDependency = dependencies.collectContent ?? + ((patterns) => this.collectContent(patterns)); + this.loadPurgeCSSDependency = dependencies.loadPurgeCSS ?? + defaultPurgeCSSLoader; + } canProcess(options: CSSOptimizationOptions): boolean { return options.enabled !== false && options.purge === true; } async analyzeContent(purgeContent: string[]): Promise { - logger.debug("Analyzing content for CSS purging"); + if ( + !Array.isArray(purgeContent) || + purgeContent.length === 0 || + purgeContent.length > MAX_CSS_PURGE_PATTERNS || + Array.from(purgeContent).some((pattern) => + !isSafeBoundedString(pattern, MAX_PATH_LENGTH_CHARS) + ) + ) { + throw new TypeError( + `CSS purgeContent must contain from 1 through ${MAX_CSS_PURGE_PATTERNS} patterns`, + ); + } - this.usedSelectors.clear(); + logger.debug("Analyzing content for CSS purging"); + const contentSources = validateContentSources( + await this.collectContentDependency([...purgeContent]), + ); + if (contentSources.length === 0) { + throw new TypeError("CSS purgeContent patterns matched no files"); + } - for (const pattern of purgeContent) { - const files = await globFiles(pattern); - - for (const file of files) { - try { - const content = await fs.readTextFile(file); - const { selectors } = extractSelectors(content); - - for (const selector of selectors) { - this.usedSelectors.add(selector); - } - } catch (error) { - logger.warn(`Failed to analyze ${file}`, { - error: error instanceof Error ? error.message : String(error), - }); + const usedSelectors = new Set(); + for (const source of contentSources) { + const { selectors } = extractSelectors(source.raw); + for (const selector of selectors) { + usedSelectors.add(selector); + if (usedSelectors.size > MAX_CSS_SELECTOR_TOKENS) { + throw new TypeError( + `CSS selector evidence exceeds ${MAX_CSS_SELECTOR_TOKENS} entries`, + ); } } } - - logger.debug(`Found ${this.usedSelectors.size} used selectors`); + validateSelectorEvidence(usedSelectors); + this.contentSources = contentSources; + this.usedSelectors.clear(); + for (const selector of usedSelectors) this.usedSelectors.add(selector); + logger.debug(`Found ${this.usedSelectors.size} statically visible selectors`); } async process( @@ -51,35 +250,83 @@ export class PurgeStrategy implements CSSOptimizationStrategy { _filename: string, options: CSSOptimizationOptions, ): Promise { - if (this.usedSelectors.size === 0 && options.purgeContent?.length) { - await this.analyzeContent(options.purgeContent); + if (typeof content !== "string") { + throw new TypeError("CSS purge input must be a string"); } - - return { code: this.purgeUnusedCSS(content), sourceMap: undefined }; - } - - private purgeUnusedCSS(css: string): string { - const lines = css.split("\n"); - const kept: string[] = []; - let currentRule = ""; - let keepRule = false; - - for (const line of lines) { - currentRule += `${line}\n`; - - const selector = line.match(/^([^{]+)\s*\{/)?.[1]?.trim(); - if (selector) { - keepRule = shouldKeepSelector(selector, this.usedSelectors); + const inputBytes = encoder.encode(content).length; + if (inputBytes > MAX_CSS_FILE_BYTES) { + throw new TypeError(`CSS purge input exceeds ${MAX_CSS_FILE_BYTES} bytes`); + } + if (this.contentSources.length === 0) { + if (options.purgeContent?.length) { + await this.analyzeContent(options.purgeContent); + } else if (this.usedSelectors.size === 0) { + throw new TypeError( + "CSS purging requires non-empty purgeContent or an analyzed selector set", + ); } + } - if (!line.includes("}")) continue; - - if (keepRule) kept.push(currentRule); - currentRule = ""; - keepRule = false; + if ( + options.purgeSafelist !== undefined && + !Array.isArray(options.purgeSafelist) + ) { + throw new TypeError("CSS purgeSafelist must be an array"); + } + const configuredSafelist = Array.from(options.purgeSafelist ?? []); + if ( + configuredSafelist.length > MAX_CSS_PURGE_SAFELIST_ENTRIES || + configuredSafelist.some((entry) => + !isSafeBoundedString(entry, MAX_CSS_SELECTOR_TOKEN_CHARACTERS) + ) + ) { + throw new TypeError( + `CSS purgeSafelist must contain at most ${MAX_CSS_PURGE_SAFELIST_ENTRIES} non-empty strings`, + ); + } + const configuredSafelistTokens = configuredSafelist.map(selectorToken); + if ( + configuredSafelistTokens.some((entry) => + !isSafeBoundedString(entry, MAX_CSS_SELECTOR_TOKEN_CHARACTERS) || + /\s/u.test(entry) + ) + ) { + throw new TypeError("CSS purgeSafelist contains an unsafe selector token"); } - return kept.join(""); + const module = await this.loadPurgeCSS(); + const selectorTokens = validateSelectorEvidence(this.usedSelectors); + const safelist = [ + ...new Set([ + ...configuredSafelistTokens, + ...(this.contentSources.length === 0 ? selectorTokens : []), + ]), + ]; + const results = await new module.PurgeCSS().purge({ + content: this.contentSources.length > 0 + ? this.contentSources.map(({ raw, extension }) => ({ + raw, + extension, + })) + : [{ + raw: selectorTokens.join(" "), + extension: "html", + }], + css: [{ raw: content }], + safelist, + }); + if ( + results.length !== 1 || + typeof results[0]?.css !== "string" + ) { + throw new TypeError("PurgeCSS returned an invalid result"); + } + if (encoder.encode(results[0].css).length > MAX_CSS_OUTPUT_FILE_BYTES) { + throw new TypeError( + `PurgeCSS output exceeds ${MAX_CSS_OUTPUT_FILE_BYTES} bytes`, + ); + } + return { code: results[0].css, sourceMap: undefined }; } getUsedSelectors(): Set { @@ -88,5 +335,92 @@ export class PurgeStrategy implements CSSOptimizationStrategy { clearCache(): void { this.usedSelectors.clear(); + this.contentSources = []; + } + + private async loadPurgeCSS(): Promise { + if (this.purgeCSSModule) return await this.purgeCSSModule; + + const generation = ++this.purgeCSSLoadGeneration; + const pending = (async () => { + try { + const module = await this.loadPurgeCSSDependency(); + if (!module || typeof module.PurgeCSS !== "function") { + throw new TypeError("PurgeCSS module did not export PurgeCSS"); + } + return module; + } catch (error) { + throw DEPENDENCY_MISSING.create({ + detail: `CSS purging requires ${PURGE_CSS_MODULE_SPECIFIER}`, + cause: error, + }); + } + })(); + this.purgeCSSModule = pending; + try { + return await pending; + } catch (error) { + if (this.purgeCSSLoadGeneration === generation) this.purgeCSSModule = null; + throw error; + } + } + + private async collectContent( + patterns: string[], + ): Promise { + const files = new Set(); + for (const pattern of patterns) { + for ( + const path of await globFiles(pattern, { + baseDir: this.baseDir, + fs: this.fs, + }) + ) { + files.add(path); + if (files.size > MAX_CSS_FILES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_FILES} files`, + ); + } + } + } + + let totalBytes = 0; + const sources: PurgeContentSource[] = []; + for (const path of [...files].sort()) { + const info = this.fs.lstat ? await this.fs.lstat(path) : await this.fs.stat(path); + if ( + !info.isFile || + info.isSymlink || + !Number.isSafeInteger(info.size) || + info.size < 0 + ) { + throw new TypeError(`CSS purge content must be a regular file: ${path}`); + } + if (info.size > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_FILE_BYTES} bytes: ${path}`, + ); + } + const raw = await this.fs.readTextFile(path); + const actualBytes = encoder.encode(raw).length; + if (actualBytes > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_FILE_BYTES} bytes: ${path}`, + ); + } + totalBytes += actualBytes; + if (totalBytes > MAX_CSS_TOTAL_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_TOTAL_BYTES} total bytes`, + ); + } + sources.push({ + path, + raw, + extension: extname(path).slice(1).toLowerCase() || "html", + }); + } + return sources; } } diff --git a/src/build/asset-pipeline/css-optimizer/types/index.ts b/src/build/asset-pipeline/css-optimizer/types/index.ts index 279a347e01..f47b12a1d8 100644 --- a/src/build/asset-pipeline/css-optimizer/types/index.ts +++ b/src/build/asset-pipeline/css-optimizer/types/index.ts @@ -24,6 +24,7 @@ export interface LightningCSSModule { } export interface BrowserTargets { + [browser: string]: number | undefined; chrome?: number; firefox?: number; safari?: number; @@ -32,20 +33,30 @@ export interface BrowserTargets { export interface CSSOptimizationOptions { enabled?: boolean; + /** Absolute project boundary for CSS inputs, content scans, and outputs. */ + projectDir?: string; minify?: boolean; autoprefixer?: boolean; purge?: boolean; + /** + * @deprecated Batch optimization has no HTML document from which to derive + * critical rules. Use `CSSOptimizer.extractCriticalCSS()` explicitly. + */ criticalCSS?: boolean; inputFiles?: string[]; inputDir?: string; outputDir?: string; browsers?: string[]; purgeContent?: string[]; + /** Literal selectors or tokens that PurgeCSS must retain. */ + purgeSafelist?: string[]; sourceMap?: boolean; } export interface CSSBundle { file: string; + /** Project-relative generated CSS path when known. */ + outputFile?: string; content: string; sourceMap?: string; size: number; diff --git a/src/build/asset-pipeline/css-optimizer/utils.test.ts b/src/build/asset-pipeline/css-optimizer/utils.test.ts index bfb9659688..750f568738 100644 --- a/src/build/asset-pipeline/css-optimizer/utils.test.ts +++ b/src/build/asset-pipeline/css-optimizer/utils.test.ts @@ -3,7 +3,7 @@ import "#veryfront/schemas/_test-setup.ts"; * Tests for CSS Optimizer Utilities */ -import { assert, assertEquals } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { join } from "#veryfront/compat/path"; import { remove, writeTextFile } from "#veryfront/compat/fs.ts"; @@ -16,7 +16,9 @@ import { getOutputPath, globFiles, matchPattern, + parseBrowserTargets, shouldKeepSelector, + validateCSSSourceMap, } from "./utils.ts"; const TEST_DIR = "./.veryfront/test-css-utils"; @@ -46,6 +48,12 @@ describe("CSS Optimizer Utils", () => { await cleanupTestDir(); }); + + it("rejects a missing directory", async () => { + await assertRejects( + () => findCSSFiles(`${TEST_DIR}-${crypto.randomUUID()}`), + ); + }); }); describe("matchPattern", () => { @@ -67,6 +75,14 @@ describe("CSS Optimizer Utils", () => { const result = getOutputPath("styles/main.css", ".output"); assertEquals(result, ".output/styles/main.min.css"); }); + + it("rejects paths that could escape output", () => { + assertThrows( + () => getOutputPath("../main.css", ".output"), + TypeError, + "Invalid relative", + ); + }); }); describe("extractSelectors", () => { @@ -154,6 +170,10 @@ describe("CSS Optimizer Utils", () => { assertEquals(minified, ".button{color:red}"); }); + + it("rejects malformed CSS instead of applying regex rewrites", () => { + assertThrows(() => basicMinify("@media ( { .x { color: red }")); + }); }); describe("calculateSavings", () => { @@ -161,6 +181,7 @@ describe("CSS Optimizer Utils", () => { assertEquals(calculateSavings(1000, 500), 50); assertEquals(calculateSavings(1000, 750), 25); assertEquals(calculateSavings(0, 0), 0); + assertThrows(() => calculateSavings(-1, 0), TypeError); }); }); @@ -181,5 +202,91 @@ describe("CSS Optimizer Utils", () => { await cleanupTestDir(); }); + + it("rejects patterns outside the project boundary", async () => { + const baseDir = await Deno.makeTempDir(); + try { + await assertRejects( + () => globFiles("../outside/**/*.ts", { baseDir }), + TypeError, + "outside the project", + ); + } finally { + await Deno.remove(baseDir, { recursive: true }); + } + }); + + it("treats only a missing static glob root as no matches", async () => { + const baseDir = await Deno.makeTempDir(); + try { + assertEquals( + await globFiles("optional/**/*.tsx", { baseDir }), + [], + ); + await assertRejects( + () => + globFiles("blocked/**/*.tsx", { + baseDir, + fs: { + readDir() { + throw new Deno.errors.PermissionDenied("blocked"); + }, + }, + }), + Deno.errors.PermissionDenied, + ); + } finally { + await Deno.remove(baseDir, { recursive: true }); + } + }); + }); + + describe("parseBrowserTargets", () => { + it("converts real Browserslist queries", () => { + const targets = parseBrowserTargets(["ie 11"]); + assertEquals(typeof targets?.ie, "number"); + }); + + it("rejects empty browser query lists", () => { + assertThrows(() => parseBrowserTargets([]), TypeError, "bounded"); + assertThrows( + () => parseBrowserTargets(null as unknown as string[]), + TypeError, + "queries or a target object", + ); + assertThrows( + () => parseBrowserTargets(new Array(1)), + TypeError, + "bounded", + ); + }); + }); + + describe("validateCSSSourceMap", () => { + it("requires a bounded source-map v3 structure", () => { + validateCSSSourceMap( + JSON.stringify({ + version: 3, + sources: ["main.css"], + names: [], + mappings: "AAAA", + }), + "main.css", + ); + assertThrows( + () => + validateCSSSourceMap( + JSON.stringify({ + version: 3, + sources: ["../outside.css"], + names: [], + mappings: "AAAA", + }), + "main.css", + ), + TypeError, + "invalid", + ); + }); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/utils.ts b/src/build/asset-pipeline/css-optimizer/utils.ts index 9cb7a76535..5d435a5d8f 100644 --- a/src/build/asset-pipeline/css-optimizer/utils.ts +++ b/src/build/asset-pipeline/css-optimizer/utils.ts @@ -1,73 +1,296 @@ -import { dirname, join, relative, SEPARATOR } from "#veryfront/compat/path/index.ts"; -import { walk } from "#veryfront/compat/std/fs.ts"; -import { logger } from "#veryfront/utils"; -import type { BrowserTargets } from "#veryfront/types"; -import { createError, toError } from "#veryfront/errors"; +import { globToRegExp } from "#std/path"; +import { + basename, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, +} from "#veryfront/compat/path/index.ts"; +import { createFileSystem, isNotFoundError } from "#veryfront/platform/compat/fs.ts"; import { cwd } from "#veryfront/platform/compat/process.ts"; +import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; +import { isContainedAssetPath } from "../../utils/asset-utils.ts"; +import { hasControlCharacters } from "../../utils/string-validation.ts"; +import { + MAX_CSS_BROWSER_QUERIES, + MAX_CSS_BROWSER_QUERY_CHARACTERS, + MAX_CSS_DIRECTORY_DEPTH, + MAX_CSS_DIRECTORY_ENTRIES, + MAX_CSS_FILE_BYTES, + MAX_CSS_FILES, + MAX_CSS_OUTPUT_FILE_BYTES, + MAX_CSS_SELECTOR_TOKEN_CHARACTERS, + MAX_CSS_SELECTOR_TOKENS, +} from "./constants.ts"; +import type { BrowserTargets } from "./types/index.ts"; + +import browserslist from "npm:browserslist@4.28.7"; +import { browserslistToTargets, transform as transformCSS } from "npm:lightningcss@1.29.2"; + +interface DirectoryReader { + readDir( + path: string, + ): AsyncIterable<{ + name: string; + isFile: boolean; + isDirectory: boolean; + isSymlink?: boolean; + }>; +} -export async function findCSSFiles(dir: string): Promise { - const cssFiles: string[] = []; +interface DiscoveryOptions { + fs?: DirectoryReader; + maxFiles?: number; +} - try { - for await (const entry of walk(dir, { exts: [".css"], includeDirs: false })) { - cssFiles.push(entry.path); - } - } catch (error) { - logger.warn(`Could not read directory ${dir}`, { error }); +interface GlobOptions extends DiscoveryOptions { + baseDir?: string; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); +const GLOB_MAGIC = /[*?{\[]/; + +function comparePaths(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function portablePath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function requireSafePath(value: string, label: string): void { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_PATH_LENGTH_CHARS || + hasControlCharacters(value) + ) { + throw new TypeError(`${label} must be a safe non-empty path`); } +} - return cssFiles; +function requireDiscoveryLimit(maxFiles: number): void { + if ( + !Number.isInteger(maxFiles) || + maxFiles <= 0 || + maxFiles > MAX_CSS_FILES + ) { + throw new TypeError( + `CSS discovery limit must be an integer from 1 through ${MAX_CSS_FILES}`, + ); + } } -export async function globFiles(pattern: string): Promise { +async function collectMatchingFiles(options: { + rootDir: string; + reader: DirectoryReader; + matches: (path: string) => boolean; + maxFiles: number; + allowMissingRoot?: boolean; +}): Promise { const files: string[] = []; + let visitedEntries = 0; - const baseDir = pattern.split("**")[0] || "."; - const normalizedPattern = pattern.startsWith("./") ? pattern.slice(2) : pattern; + const visit = async (directory: string, depth: number): Promise => { + if (depth > MAX_CSS_DIRECTORY_DEPTH) { + throw new TypeError( + `CSS source tree exceeds ${MAX_CSS_DIRECTORY_DEPTH} directory levels`, + ); + } - try { - for await (const entry of walk(baseDir, { includeDirs: false })) { - const normalizedPath = entry.path.startsWith("./") ? entry.path.slice(2) : entry.path; + const entries = []; + let readAnyEntry = false; + try { + for await (const entry of options.reader.readDir(directory)) { + readAnyEntry = true; + visitedEntries++; + if (visitedEntries > MAX_CSS_DIRECTORY_ENTRIES) { + throw new TypeError( + `CSS source tree exceeds ${MAX_CSS_DIRECTORY_ENTRIES} entries`, + ); + } + if ( + typeof entry.name !== "string" || + entry.name.length === 0 || + entry.name.length > MAX_PATH_LENGTH_CHARS || + entry.name === "." || + entry.name === ".." || + entry.name.includes("/") || + entry.name.includes("\\") || + hasControlCharacters(entry.name) || + typeof entry.isFile !== "boolean" || + typeof entry.isDirectory !== "boolean" || + (entry.isSymlink !== undefined && + typeof entry.isSymlink !== "boolean") + ) { + throw new TypeError("CSS source tree contains an invalid directory entry"); + } + entries.push(entry); + } + } catch (error) { + if ( + depth === 0 && + !readAnyEntry && + options.allowMissingRoot === true && + isNotFoundError(error) + ) { + return; + } + throw error; + } + entries.sort((left, right) => comparePaths(left.name, right.name)); - if (matchPattern(normalizedPath, normalizedPattern)) { - files.push(entry.path); + for (const entry of entries) { + if (entry.isSymlink) continue; + if (entry.isFile === entry.isDirectory) { + throw new TypeError("CSS source tree contains an invalid directory entry"); + } + const path = join(directory, entry.name); + if (entry.isDirectory) { + await visit(path, depth + 1); + continue; + } + if (!entry.isFile || !options.matches(path)) continue; + files.push(path); + if (files.length > options.maxFiles) { + throw new TypeError( + `CSS discovery exceeds the configured limit of ${options.maxFiles} files`, + ); } } - } catch (error) { - logger.warn(`Could not glob pattern ${pattern}`, { error }); + }; + + await visit(options.rootDir, 0); + return files.sort(comparePaths); +} + +export function findCSSFiles( + dir: string, + options: DiscoveryOptions = {}, +): Promise { + requireSafePath(dir, "CSS input directory"); + const maxFiles = options.maxFiles ?? MAX_CSS_FILES; + requireDiscoveryLimit(maxFiles); + + return collectMatchingFiles({ + rootDir: resolve(dir), + reader: options.fs ?? createFileSystem(), + maxFiles, + matches: (path) => extname(path).toLowerCase() === ".css", + }); +} + +function compileProjectGlob( + pattern: string, + baseDir: string, +): { rootDir: string; matches: (path: string) => boolean } { + requireSafePath(pattern, "CSS content pattern"); + const absoluteBase = resolve(baseDir); + const absolutePattern = isAbsolute(pattern) ? resolve(pattern) : resolve(absoluteBase, pattern); + if (!isContainedAssetPath(absoluteBase, absolutePattern)) { + throw new TypeError( + `CSS content pattern resolves outside the project: ${JSON.stringify(pattern)}`, + ); + } + + const relativePattern = portablePath(relative(absoluteBase, absolutePattern)); + if ( + relativePattern.length === 0 || + relativePattern === ".." || + relativePattern.startsWith("../") + ) { + throw new TypeError(`Invalid CSS content pattern: ${JSON.stringify(pattern)}`); } - return files; + const segments = relativePattern.split("/"); + const firstMagicSegment = segments.findIndex((segment) => GLOB_MAGIC.test(segment)); + const staticSegments = firstMagicSegment === -1 + ? segments.slice(0, -1) + : segments.slice(0, firstMagicSegment); + const rootDir = resolve(absoluteBase, ...staticSegments); + const expression = globToRegExp(relativePattern, { + extended: true, + globstar: true, + }); + + return { + rootDir, + matches: (path) => expression.test(portablePath(relative(absoluteBase, resolve(path)))), + }; +} + +export async function globFiles( + pattern: string, + options: GlobOptions = {}, +): Promise { + const baseDir = resolve(options.baseDir ?? cwd()); + const maxFiles = options.maxFiles ?? MAX_CSS_FILES; + requireDiscoveryLimit(maxFiles); + const compiled = compileProjectGlob(pattern, baseDir); + + return await collectMatchingFiles({ + rootDir: compiled.rootDir, + reader: options.fs ?? createFileSystem(), + matches: compiled.matches, + maxFiles, + // A static glob root such as the default `pages/` directory is optional. + // Missing descendants and all other filesystem failures remain fatal. + allowMissingRoot: true, + }); } export function matchPattern(path: string, pattern: string): boolean { - const regexPattern = pattern - .replace(/\{([^}]+)\}/g, (_, group: string) => `(${group.split(",").join("|")})`) - .replace(/\./g, "\\.") - .replace(/\/\*\*\//g, "/(.*/)?") - .replace(/\*/g, "[^/]*"); + requireSafePath(path, "CSS glob candidate"); + requireSafePath(pattern, "CSS glob pattern"); + return globToRegExp(portablePath(pattern), { + extended: true, + globstar: true, + }).test(portablePath(path)); +} - return new RegExp(`^${regexPattern}$`).test(path); +export function isSafeCSSRelativePath(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_PATH_LENGTH_CHARS || + value !== value.normalize("NFC") || + hasControlCharacters(value) || + isAbsolute(value) || + value.startsWith("/") || + value.startsWith("\\") || + /^[A-Za-z]:[\\/]/.test(value) || + value.includes("\\") + ) { + return false; + } + + return !value.split("/").some((segment) => + segment.length === 0 || segment === "." || segment === ".." + ); } export function getOutputPath(inputPath: string, outputDir: string): string { - const dir = dirname(inputPath); - const filename = inputPath.split(SEPARATOR).pop(); - - if (!filename) { - throw toError( - createError({ - type: "config", - message: `Invalid input path for CSS: ${inputPath}`, - }), - ); + const portableInput = portablePath(inputPath).normalize("NFC"); + if (!isSafeCSSRelativePath(portableInput)) { + throw new TypeError(`Invalid relative CSS input path: ${inputPath}`); + } + if (extname(portableInput).toLowerCase() !== ".css") { + throw new TypeError(`CSS input path must end in .css: ${inputPath}`); } - const outputFilename = `${filename.replace(".css", "")}.min.css`; - const isAbsolute = dir.startsWith("/") || /^[a-zA-Z]:/.test(dir); - const relativePath = isAbsolute ? relative(cwd(), dir) : dir; - - return join(outputDir, relativePath, outputFilename); + const filename = basename(portableInput); + const stem = filename.slice(0, -extname(filename).length); + if (stem.length === 0) { + throw new TypeError(`CSS input path requires a filename: ${inputPath}`); + } + const inputDirectory = dirname(portableInput); + const outputFilename = `${stem}.min.css`; + return inputDirectory === "." + ? join(outputDir, outputFilename) + : join(outputDir, inputDirectory, outputFilename); } export function extractSelectors(content: string): { @@ -84,29 +307,26 @@ export function extractSelectors(content: string): { for (const match of content.matchAll(/class(?:Name)?=["']([^"']+)["']/g)) { const value = match[1]; if (!value) continue; - - for (const cn of value.split(/\s+/)) { - classes.push(cn); - selectors.add(`.${cn}`); + for (const className of value.split(/\s+/)) { + if (!className) continue; + classes.push(className); + selectors.add(`.${className}`); } } for (const match of content.matchAll(/id=["']([^"']+)["']/g)) { const value = match[1]; if (!value) continue; - ids.push(value); selectors.add(`#${value}`); } const tagSet = new Set(); - for (const match of content.matchAll(/<(\w+)[\s>]/g)) { + for (const match of content.matchAll(/<([A-Za-z][\w-]*)[\s>]/g)) { const value = match[1]; if (!value) continue; - const tag = value.toLowerCase(); if (tagSet.has(tag)) continue; - tagSet.add(tag); tags.push(tag); selectors.add(tag); @@ -121,42 +341,173 @@ export function extractSelectorsFromHTML(html: string): string[] { const UNIVERSAL_SELECTORS = new Set(["*", ":root", "html", "body", "@"]); -export function shouldKeepSelector(selector: string, usedSelectors: Set): boolean { - for (const u of UNIVERSAL_SELECTORS) { - if (selector.includes(u)) return true; +/** + * @deprecated Retained for callers that inspect selector heuristics. Production + * purging is performed by the parser-backed PurgeCSS strategy. + */ +export function shouldKeepSelector( + selector: string, + usedSelectors: Set, +): boolean { + for (const universal of UNIVERSAL_SELECTORS) { + if (selector.includes(universal)) return true; } - const parts = selector.split(/[\s>+~]/).map((p) => p.trim()); + const parts = selector.split(/[\s>+~]/).map((part) => part.trim()); return parts.some((part) => usedSelectors.has(part)); } +/** + * Parser-backed compatibility helper. Invalid CSS rejects instead of being + * rewritten by regular expressions. + */ export function basicMinify(css: string): string { - return css - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\s+/g, " ") - .replace(/\s*([{};:,])\s*/g, "$1") - .replace(/;}/g, "}") - .trim(); + if (typeof css !== "string") { + throw new TypeError("CSS input must be a string"); + } + const code = encoder.encode(css); + if (code.length > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS input exceeds ${MAX_CSS_FILE_BYTES} bytes`, + ); + } + const result = transformCSS({ + filename: "inline.css", + code, + minify: true, + analyzeDependencies: false, + }); + return decoder.decode(result.code); } -export function calculateSavings(originalSize: number, minifiedSize: number): number { +export function calculateSavings( + originalSize: number, + minifiedSize: number, +): number { + if ( + !Number.isSafeInteger(originalSize) || + !Number.isSafeInteger(minifiedSize) || + originalSize < 0 || + minifiedSize < 0 + ) { + throw new TypeError("CSS sizes must be non-negative safe integers"); + } if (originalSize === 0) return 0; return Math.round(((originalSize - minifiedSize) / originalSize) * 100); } +function validateBrowserTargets(targets: BrowserTargets): BrowserTargets { + const validated: BrowserTargets = {}; + for (const [browser, version] of Object.entries(targets)) { + if ( + !/^[a-z][a-z_]*$/.test(browser) || + typeof version !== "number" || + !Number.isSafeInteger(version) || + version < 0 + ) { + throw new TypeError(`Invalid CSS browser target: ${browser}`); + } + validated[browser] = version; + } + return validated; +} + export function parseBrowserTargets( targets: string | string[] | BrowserTargets | undefined, ): BrowserTargets | undefined { - if (!targets) return undefined; + if (targets === undefined) return undefined; + if ( + typeof targets === "object" && + targets !== null && + !Array.isArray(targets) + ) { + return validateBrowserTargets(targets); + } - if (typeof targets === "string" || Array.isArray(targets)) { - return { - chrome: 90, - firefox: 88, - safari: 14, - edge: 90, - }; + if (typeof targets !== "string" && !Array.isArray(targets)) { + throw new TypeError("CSS browser targets must be queries or a target object"); + } + const queries = typeof targets === "string" ? [targets] : Array.from(targets); + if ( + queries.length === 0 || + queries.length > MAX_CSS_BROWSER_QUERIES || + queries.some((query) => + typeof query !== "string" || + query.length === 0 || + query.length > MAX_CSS_BROWSER_QUERY_CHARACTERS || + hasControlCharacters(query) + ) + ) { + throw new TypeError("CSS browser queries must be a bounded non-empty list"); } - return targets; + return browserslistToTargets(browserslist(queries)) as BrowserTargets; +} + +export function validateCSSSourceMap( + sourceMap: string, + logicalPath: string, +): void { + if ( + typeof sourceMap !== "string" || + encoder.encode(sourceMap).length > MAX_CSS_OUTPUT_FILE_BYTES + ) { + throw new TypeError( + `CSS source map exceeds ${MAX_CSS_OUTPUT_FILE_BYTES} bytes for ${logicalPath}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(sourceMap); + } catch (error) { + throw new TypeError(`CSS source map is malformed for ${logicalPath}`, { + cause: error, + }); + } + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + (parsed as { version?: unknown }).version !== 3 + ) { + throw new TypeError(`CSS source map is invalid for ${logicalPath}`); + } + + const map = parsed as Record; + const sources = map.sources; + const names = map.names; + const mappings = map.mappings; + if ( + !Array.isArray(sources) || + sources.length === 0 || + sources.length > MAX_CSS_FILES || + sources.some((source) => !isSafeCSSRelativePath(source)) || + !Array.isArray(names) || + names.length > MAX_CSS_SELECTOR_TOKENS || + names.some((name) => + typeof name !== "string" || + name.length > MAX_CSS_SELECTOR_TOKEN_CHARACTERS || + hasControlCharacters(name) + ) || + typeof mappings !== "string" || + !/^[A-Za-z0-9+/,;]*$/.test(mappings) || + (map.file !== undefined && !isSafeCSSRelativePath(map.file)) + ) { + throw new TypeError(`CSS source map is invalid for ${logicalPath}`); + } + + if (map.sourcesContent !== undefined) { + if ( + !Array.isArray(map.sourcesContent) || + map.sourcesContent.length !== sources.length || + map.sourcesContent.some((content) => + content !== null && + (typeof content !== "string" || + encoder.encode(content).length > MAX_CSS_FILE_BYTES) + ) + ) { + throw new TypeError(`CSS source map is invalid for ${logicalPath}`); + } + } } diff --git a/src/build/asset-pipeline/image-optimizer/constants.test.ts b/src/build/asset-pipeline/image-optimizer/constants.test.ts index d4d446d430..0ea4f0260e 100644 --- a/src/build/asset-pipeline/image-optimizer/constants.test.ts +++ b/src/build/asset-pipeline/image-optimizer/constants.test.ts @@ -4,7 +4,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { DEFAULT_OPTIONS, MANIFEST_FILENAME, - SHARP_CDN_URL, + SHARP_MODULE_SPECIFIER, SUPPORTED_EXTENSIONS, } from "./constants.ts"; @@ -52,9 +52,9 @@ describe("build/asset-pipeline/image-optimizer/constants", () => { }); }); - describe("SHARP_CDN_URL", () => { - it("should be a valid esm.sh URL", () => { - assertEquals(SHARP_CDN_URL.startsWith("https://esm.sh/sharp@"), true); + describe("SHARP_MODULE_SPECIFIER", () => { + it("pins the required npm dependency", () => { + assertEquals(SHARP_MODULE_SPECIFIER, "npm:sharp@0.34.5"); }); }); diff --git a/src/build/asset-pipeline/image-optimizer/constants.ts b/src/build/asset-pipeline/image-optimizer/constants.ts index 7400c1e6bd..500dc06b3b 100644 --- a/src/build/asset-pipeline/image-optimizer/constants.ts +++ b/src/build/asset-pipeline/image-optimizer/constants.ts @@ -1,8 +1,10 @@ import { IMAGE_OPTIMIZATION } from "#veryfront/utils"; +import { cwd } from "#veryfront/platform/compat/process.ts"; import type { ImageOptimizationOptions } from "./types.ts"; export const DEFAULT_OPTIONS: Required = { enabled: true, + projectDir: cwd(), formats: ["webp", "avif", "jpeg"], sizes: [...IMAGE_OPTIMIZATION.DEFAULT_SIZES], quality: IMAGE_OPTIMIZATION.DEFAULT_QUALITY, @@ -12,7 +14,12 @@ export const DEFAULT_OPTIONS: Required = { }; export const SUPPORTED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif"]; +export const SUPPORTED_FORMATS = ["webp", "avif", "jpeg", "png"] as const; -export const SHARP_CDN_URL = "https://esm.sh/sharp@0.33.0"; +export const SHARP_MODULE_SPECIFIER = "npm:sharp@0.34.5"; export const MANIFEST_FILENAME = "image-manifest.json"; +export const MAX_IMAGE_DIMENSION = IMAGE_OPTIMIZATION.MAX_DIMENSION; +export const MAX_IMAGE_OUTPUT_SIZES = IMAGE_OPTIMIZATION.MAX_OUTPUT_SIZES; +export const MAX_IMAGE_FILES = 100_000; +export const MAX_IMAGE_MANIFEST_BYTES = 16 * 1024 * 1024; diff --git a/src/build/asset-pipeline/image-optimizer/format-processor.test.ts b/src/build/asset-pipeline/image-optimizer/format-processor.test.ts index f4b6f60ecd..bb10a316e3 100644 --- a/src/build/asset-pipeline/image-optimizer/format-processor.test.ts +++ b/src/build/asset-pipeline/image-optimizer/format-processor.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { processFormat } from "./format-processor.ts"; import type { SharpInstance } from "./types.ts"; @@ -64,17 +64,23 @@ describe("build/asset-pipeline/image-optimizer/format-processor", () => { assertEquals(mock.lastCall?.args, [{ compressionLevel: 9, adaptiveFiltering: true }]); }); - it("should return image unchanged for unknown format", () => { + it("should reject an unknown format", () => { const mock = createMockSharp(); - const result = processFormat(mock, "bmp" as never, 80); - assertEquals(result, mock); + assertThrows( + () => processFormat(mock, "bmp" as never, 80), + TypeError, + "Unsupported image output format", + ); assertEquals(mock.lastCall, null); }); - it("should handle quality 0", () => { + it("should reject quality 0", () => { const mock = createMockSharp(); - processFormat(mock, "webp", 0); - assertEquals(mock.lastCall?.args, [{ quality: 0 }]); + assertThrows( + () => processFormat(mock, "webp", 0), + TypeError, + "1 through 100", + ); }); it("should handle quality 100", () => { diff --git a/src/build/asset-pipeline/image-optimizer/format-processor.ts b/src/build/asset-pipeline/image-optimizer/format-processor.ts index 1b35bd66a8..9c1525d1c9 100644 --- a/src/build/asset-pipeline/image-optimizer/format-processor.ts +++ b/src/build/asset-pipeline/image-optimizer/format-processor.ts @@ -5,6 +5,9 @@ export function processFormat( format: ImageFormat, quality: number, ): SharpInstance { + if (!Number.isInteger(quality) || quality < 1 || quality > 100) { + throw new TypeError("Image quality must be an integer from 1 through 100"); + } if (format === "webp") return image.webp({ quality }); if (format === "avif") return image.avif({ quality }); if (format === "jpeg") return image.jpeg({ quality, progressive: true }); @@ -12,5 +15,5 @@ export function processFormat( return image.png({ compressionLevel: 9, adaptiveFiltering: true }); } - return image; + throw new TypeError(`Unsupported image output format: ${String(format)}`); } diff --git a/src/build/asset-pipeline/image-optimizer/image-finder.test.ts b/src/build/asset-pipeline/image-optimizer/image-finder.test.ts index 13a4e33821..9c9cb3f70c 100644 --- a/src/build/asset-pipeline/image-optimizer/image-finder.test.ts +++ b/src/build/asset-pipeline/image-optimizer/image-finder.test.ts @@ -1,13 +1,14 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { findImages } from "./image-finder.ts"; describe("build/asset-pipeline/image-optimizer/image-finder", () => { describe("findImages", () => { - it("should return empty array for non-existent directory", async () => { - const result = await findImages("/tmp/nonexistent-dir-" + Date.now()); - assertEquals(result, []); + it("should reject a non-existent directory", async () => { + await assertRejects( + () => findImages("/tmp/nonexistent-dir-" + crypto.randomUUID()), + ); }); it("should find images in a directory with supported extensions", async () => { @@ -57,6 +58,7 @@ describe("build/asset-pipeline/image-optimizer/image-finder", () => { const result = await findImages(tmpDir); assertEquals(result.length, 2); + assertEquals(result, [...result].sort()); } finally { await Deno.remove(tmpDir, { recursive: true }); } @@ -75,5 +77,21 @@ describe("build/asset-pipeline/image-optimizer/image-finder", () => { await Deno.remove(tmpDir, { recursive: true }); } }); + + it("should reject image sets above the bounded discovery limit", async () => { + const tmpDir = await Deno.makeTempDir(); + try { + await Deno.writeTextFile(`${tmpDir}/a.jpg`, ""); + await Deno.writeTextFile(`${tmpDir}/b.jpg`, ""); + + await assertRejects( + () => findImages(tmpDir, { maxImages: 1 }), + TypeError, + "configured limit", + ); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } + }); }); }); diff --git a/src/build/asset-pipeline/image-optimizer/image-finder.ts b/src/build/asset-pipeline/image-optimizer/image-finder.ts index 6f3b9b0a71..c20f1f6aa0 100644 --- a/src/build/asset-pipeline/image-optimizer/image-finder.ts +++ b/src/build/asset-pipeline/image-optimizer/image-finder.ts @@ -1,34 +1,85 @@ -import { walk } from "#std/fs.ts"; -import { extname } from "#veryfront/compat/path/index.ts"; -import { logger } from "#veryfront/utils"; +import { extname, join } from "#veryfront/compat/path/index.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { SUPPORTED_EXTENSIONS } from "./constants.ts"; +import { createFileSystem, type FileSystem } from "#veryfront/platform/compat/fs.ts"; +import { MAX_IMAGE_FILES, SUPPORTED_EXTENSIONS } from "./constants.ts"; const supportedExtensionsSet = new Set(SUPPORTED_EXTENSIONS); +const MAX_DIRECTORY_DEPTH = 64; +const MAX_DISCOVERED_ENTRIES = 1_000_000; -export function findImages(dir: string): Promise { +interface ImageFinderDependencies { + fs?: FileSystem; + /** @internal Allows focused tests to exercise the production limit. */ + maxImages?: number; +} + +function comparePaths(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function findImages( + dir: string, + dependencies: ImageFinderDependencies = {}, +): Promise { return withSpan( "build.asset.findImages", async (): Promise => { + const fs = dependencies.fs ?? createFileSystem(); + const maxImages = dependencies.maxImages ?? MAX_IMAGE_FILES; + if ( + !Number.isInteger(maxImages) || + maxImages <= 0 || + maxImages > MAX_IMAGE_FILES + ) { + throw new TypeError( + `Image discovery limit must be an integer from 1 through ${MAX_IMAGE_FILES}`, + ); + } + const images: string[] = []; + let discoveredEntries = 0; - try { - for await ( - const entry of walk(dir, { - includeDirs: false, - followSymlinks: false, - }) - ) { - const ext = extname(entry.path).toLowerCase(); - if (supportedExtensionsSet.has(ext)) images.push(entry.path); + const visit = async (currentDir: string, depth: number): Promise => { + if (depth > MAX_DIRECTORY_DEPTH) { + throw new TypeError( + `Image directory nesting exceeds ${MAX_DIRECTORY_DEPTH} levels: ${currentDir}`, + ); } - } catch (error) { - logger.warn(`Failed to read directory ${dir}`, { - error: error instanceof Error ? error.message : String(error), - }); - } - return images; + const entries = []; + for await (const entry of fs.readDir(currentDir)) { + discoveredEntries++; + if (discoveredEntries > MAX_DISCOVERED_ENTRIES) { + throw new TypeError( + `Image discovery exceeds ${MAX_DISCOVERED_ENTRIES} filesystem entries`, + ); + } + entries.push(entry); + } + entries.sort((left, right) => comparePaths(left.name, right.name)); + + for (const entry of entries) { + if (entry.isSymlink) continue; + const path = join(currentDir, entry.name); + if (entry.isDirectory) { + await visit(path, depth + 1); + continue; + } + if (!entry.isFile) continue; + if (!supportedExtensionsSet.has(extname(entry.name).toLowerCase())) { + continue; + } + images.push(path); + if (images.length > maxImages) { + throw new TypeError( + `Image discovery exceeds the configured limit of ${maxImages} files`, + ); + } + } + }; + + await visit(dir, 0); + return images.sort(comparePaths); }, { "image.directory": dir }, ); diff --git a/src/build/asset-pipeline/image-optimizer/manifest-manager.test.ts b/src/build/asset-pipeline/image-optimizer/manifest-manager.test.ts index 2ad2b389e7..b2a7775f88 100644 --- a/src/build/asset-pipeline/image-optimizer/manifest-manager.test.ts +++ b/src/build/asset-pipeline/image-optimizer/manifest-manager.test.ts @@ -1,6 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import { join } from "#veryfront/compat/path"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { makeTempDir, readTextFile } from "#veryfront/testing/deno-compat.ts"; import { loadManifest, writeManifest } from "./manifest-manager.ts"; @@ -9,40 +9,183 @@ import type { OptimizedImageMetadata } from "./types.ts"; describe("manifest-manager", () => { it("writes and loads manifests via compat fs", async () => { const tmpDir = await makeTempDir(); - const manifest = new Map([ - [ - "logo.png", - { - original: "logo.png", - variants: [ - { + try { + const manifest = new Map([ + [ + "logo.png", + { + original: "logo.png", + originalSize: 2048, + variants: [ + { + format: "webp", + size: 400, + width: 400, + height: 200, + path: "logo-400.webp", + fileSize: 1234, + }, + ], + defaultFormat: "webp", + aspectRatio: 2, + }, + ], + ]); + + await writeManifest(manifest, tmpDir); + + const manifestPath = join(tmpDir, "image-manifest.json"); + const parsed = JSON.parse(await readTextFile(manifestPath)) as Record< + string, + OptimizedImageMetadata + >; + + assertEquals(parsed.logo, undefined); + assertEquals(parsed["logo.png"]?.defaultFormat, "webp"); + + const loaded = await loadManifest(tmpDir); + assertEquals(loaded.size, 1); + assertEquals(loaded.get("logo.png")?.defaultFormat, "webp"); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } + }); + + it("returns an empty manifest only when the file is absent", async () => { + const tmpDir = await makeTempDir(); + try { + assertEquals((await loadManifest(tmpDir)).size, 0); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } + }); + + it("rejects malformed JSON and malformed entries", async () => { + const tmpDir = await makeTempDir(); + const manifestPath = join(tmpDir, "image-manifest.json"); + try { + await Deno.writeTextFile(manifestPath, "{"); + await assertRejects(() => loadManifest(tmpDir), SyntaxError); + + await Deno.writeTextFile( + manifestPath, + JSON.stringify({ + "../logo.png": { + original: "../logo.png", + variants: [], + defaultFormat: "webp", + aspectRatio: 2, + }, + }), + ); + await assertRejects( + () => loadManifest(tmpDir), + TypeError, + "malformed", + ); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } + }); + + it("loads legacy entries without originalSize", async () => { + const tmpDir = await makeTempDir(); + try { + await Deno.writeTextFile( + join(tmpDir, "image-manifest.json"), + JSON.stringify({ + "logo.png": { + original: "logo.png", + variants: [{ format: "webp", size: 400, width: 400, height: 200, path: "logo-400.webp", fileSize: 1234, - }, - ], - defaultFormat: "webp", - aspectRatio: 2, - }, - ], - ]); + }], + defaultFormat: "webp", + aspectRatio: 2, + }, + }), + ); + + const loaded = await loadManifest(tmpDir); + assertEquals(loaded.get("logo.png")?.originalSize, undefined); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } + }); - await writeManifest(manifest, tmpDir); + it("normalizes exact duplicate variants emitted by the legacy generator", async () => { + const tmpDir = await makeTempDir(); + const variant = { + format: "webp", + size: 400, + width: 400, + height: 200, + path: "logo-400.webp", + fileSize: 1234, + }; + try { + await Deno.writeTextFile( + join(tmpDir, "image-manifest.json"), + JSON.stringify({ + "logo.png": { + original: "logo.png", + variants: [variant, { ...variant }], + defaultFormat: "webp", + aspectRatio: 2, + }, + }), + ); - const manifestPath = join(tmpDir, "image-manifest.json"); - const parsed = JSON.parse(await readTextFile(manifestPath)) as Record< - string, - OptimizedImageMetadata - >; + const loaded = await loadManifest(tmpDir); + assertEquals(loaded.get("logo.png")?.variants, [variant]); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } + }); - assertEquals(parsed.logo, undefined); - assertEquals(parsed["logo.png"]?.defaultFormat, "webp"); + it("rejects conflicting duplicate variants instead of normalizing them", async () => { + const tmpDir = await makeTempDir(); + try { + await Deno.writeTextFile( + join(tmpDir, "image-manifest.json"), + JSON.stringify({ + "logo.png": { + original: "logo.png", + variants: [ + { + format: "webp", + size: 400, + width: 400, + height: 200, + path: "logo-400.webp", + fileSize: 1234, + }, + { + format: "webp", + size: 400, + width: 400, + height: 200, + path: "logo-400.webp", + fileSize: 4321, + }, + ], + defaultFormat: "webp", + aspectRatio: 2, + }, + }), + ); - const loaded = await loadManifest(tmpDir); - assertEquals(loaded.size, 1); - assertEquals(loaded.get("logo.png")?.defaultFormat, "webp"); + await assertRejects( + () => loadManifest(tmpDir), + TypeError, + "malformed", + ); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } }); }); diff --git a/src/build/asset-pipeline/image-optimizer/manifest-manager.ts b/src/build/asset-pipeline/image-optimizer/manifest-manager.ts index 0663d8669f..8ab87f655a 100644 --- a/src/build/asset-pipeline/image-optimizer/manifest-manager.ts +++ b/src/build/asset-pipeline/image-optimizer/manifest-manager.ts @@ -1,24 +1,67 @@ import { join } from "#veryfront/compat/path/index.ts"; import { logger } from "#veryfront/utils"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; +import { + createFileSystem, + type FileSystem, + isNotFoundError, +} from "#veryfront/platform/compat/fs.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { MANIFEST_FILENAME } from "./constants.ts"; -import type { OptimizedImageMetadata } from "./types.ts"; +import { hasControlCharacters } from "../../utils/string-validation.ts"; +import { + MANIFEST_FILENAME, + MAX_IMAGE_DIMENSION, + MAX_IMAGE_FILES, + MAX_IMAGE_MANIFEST_BYTES, + SUPPORTED_FORMATS, +} from "./constants.ts"; +import type { ImageVariant, OptimizedImageMetadata } from "./types.ts"; + +const supportedFormats = new Set(SUPPORTED_FORMATS); + +function comparePaths(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function isSafeImageManifestPath(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_PATH_LENGTH_CHARS || + value !== value.normalize("NFC") || + hasControlCharacters(value) || + value.startsWith("/") || + value.startsWith("\\") || + /^[A-Za-z]:[\\/]/.test(value) || + value.includes("\\") + ) { + return false; + } + + return !value.split("/").some((segment) => + segment.length === 0 || segment === "." || segment === ".." + ); +} export function writeManifest( imageManifest: Map, outputDir: string, + fs: FileSystem = createFileSystem(), ): Promise { return withSpan( "build.asset.writeManifest", async (): Promise => { - const fs = createFileSystem(); const manifestPath = join(outputDir, MANIFEST_FILENAME); + const sortedEntries = [...imageManifest].sort(([left], [right]) => comparePaths(left, right)); + assertManifestEntries(sortedEntries); + const content = `${JSON.stringify(Object.fromEntries(sortedEntries), null, 2)}\n`; + if (new TextEncoder().encode(content).length > MAX_IMAGE_MANIFEST_BYTES) { + throw new TypeError( + `Image manifest exceeds ${MAX_IMAGE_MANIFEST_BYTES} bytes`, + ); + } - await fs.writeTextFile( - manifestPath, - JSON.stringify(Object.fromEntries(imageManifest), null, 2), - ); + await fs.writeTextFile(manifestPath, content); logger.debug(`Wrote image manifest to ${manifestPath}`); }, @@ -31,23 +74,196 @@ export function writeManifest( export function loadManifest( outputDir: string, + fs: FileSystem = createFileSystem(), ): Promise> { return withSpan( "build.asset.loadManifest", async (): Promise> => { - const fs = createFileSystem(); const manifestPath = join(outputDir, MANIFEST_FILENAME); + let content: string; try { - const content = await fs.readTextFile(manifestPath); - return new Map(Object.entries(JSON.parse(content))); + const fileInfo = fs.lstat ? await fs.lstat(manifestPath) : await fs.stat(manifestPath); + if (!fileInfo.isFile || fileInfo.isSymlink) { + throw new TypeError("Image manifest must be a regular file"); + } + if (fileInfo.size > MAX_IMAGE_MANIFEST_BYTES) { + throw new TypeError( + `Image manifest exceeds ${MAX_IMAGE_MANIFEST_BYTES} bytes`, + ); + } + content = await fs.readTextFile(manifestPath); } catch (error) { - logger.warn("Failed to load image manifest", { - error: error instanceof Error ? error.message : String(error), - }); - return new Map(); + if (isNotFoundError(error)) return new Map(); + throw error; } + + const parsed: unknown = JSON.parse(content); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + throw new TypeError("Image manifest must be a JSON object"); + } + + const entries = Object.entries(parsed).map( + ([key, value]): [string, unknown] => [ + key, + normalizeLegacyDuplicateVariants(value), + ], + ); + assertManifestEntries(entries); + return new Map(entries); }, { "manifest.outputDir": outputDir }, ); } + +function isPositiveFinite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function isImageVariant(value: unknown): value is ImageVariant { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const variant = value as Partial; + return typeof variant.format === "string" && + supportedFormats.has(variant.format) && + isSafeImageManifestPath(variant.path) && + Number.isSafeInteger(variant.size) && + variant.size! > 0 && + variant.size! <= MAX_IMAGE_DIMENSION && + Number.isSafeInteger(variant.width) && + variant.width! > 0 && + variant.width! <= MAX_IMAGE_DIMENSION && + Number.isSafeInteger(variant.height) && + variant.height! > 0 && + variant.height! <= MAX_IMAGE_DIMENSION && + Number.isSafeInteger(variant.fileSize) && + variant.fileSize! > 0; +} + +function variantFingerprint(variant: ImageVariant): string { + return JSON.stringify([ + variant.format, + variant.size, + variant.width, + variant.height, + variant.path, + variant.fileSize, + ]); +} + +/** Normalize only the exact duplicates emitted by the legacy size generator. */ +function normalizeLegacyDuplicateVariants(value: unknown): unknown { + if (typeof value !== "object" || value === null || Array.isArray(value)) return value; + const metadata = value as Partial; + if (!Array.isArray(metadata.variants)) return value; + + const fingerprints = new Set(); + const variants: ImageVariant[] = []; + let removedDuplicate = false; + for (const candidate of metadata.variants as unknown[]) { + if (!isImageVariant(candidate)) return value; + const fingerprint = variantFingerprint(candidate); + if (fingerprints.has(fingerprint)) { + removedDuplicate = true; + continue; + } + fingerprints.add(fingerprint); + variants.push(candidate); + } + + return removedDuplicate ? { ...metadata, variants } : value; +} + +function isOptimizedImageMetadata( + value: unknown, +): value is OptimizedImageMetadata { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const metadata = value as Partial; + if ( + !isSafeImageManifestPath(metadata.original) || + (metadata.originalSize !== undefined && + (!Number.isSafeInteger(metadata.originalSize) || + metadata.originalSize <= 0)) || + !Array.isArray(metadata.variants) || + metadata.variants.length === 0 || + typeof metadata.defaultFormat !== "string" || + !supportedFormats.has(metadata.defaultFormat) || + !isPositiveFinite(metadata.aspectRatio) + ) { + return false; + } + + const variantIdentities = new Set(); + const variantPaths = new Set(); + let hasDefaultFormat = false; + for (const candidate of metadata.variants as unknown[]) { + if (!isImageVariant(candidate)) return false; + const variant = candidate; + + const identity = `${variant.format}\0${variant.size}`; + const normalizedPath = variant.path.toLocaleLowerCase("en-US"); + if ( + variantIdentities.has(identity) || + variantPaths.has(normalizedPath) + ) { + return false; + } + variantIdentities.add(identity); + variantPaths.add(normalizedPath); + if (variant.format === metadata.defaultFormat) hasDefaultFormat = true; + } + return hasDefaultFormat; +} + +function assertManifestEntry( + key: string, + value: unknown, +): asserts value is OptimizedImageMetadata { + if ( + !isSafeImageManifestPath(key) || + !isOptimizedImageMetadata(value) || + value.original !== key + ) { + throw new TypeError( + `Image manifest entry ${JSON.stringify(key)} is malformed`, + ); + } +} + +function assertManifestEntries( + entries: Array<[string, unknown]>, +): asserts entries is Array<[string, OptimizedImageMetadata]> { + if (entries.length > MAX_IMAGE_FILES) { + throw new TypeError(`Image manifest exceeds ${MAX_IMAGE_FILES} entries`); + } + + const normalizedKeys = new Set(); + const normalizedVariantPaths = new Set(); + for (const [key, value] of entries) { + assertManifestEntry(key, value); + const normalizedKey = key.toLocaleLowerCase("en-US"); + if (normalizedKeys.has(normalizedKey)) { + throw new TypeError( + `Image manifest contains a path collision: ${JSON.stringify(key)}`, + ); + } + normalizedKeys.add(normalizedKey); + + for (const variant of value.variants) { + const normalizedPath = variant.path.toLocaleLowerCase("en-US"); + if (normalizedVariantPaths.has(normalizedPath)) { + throw new TypeError( + `Image manifest contains an output collision: ${JSON.stringify(variant.path)}`, + ); + } + normalizedVariantPaths.add(normalizedPath); + } + } +} diff --git a/src/build/asset-pipeline/image-optimizer/optimizer-core.test.ts b/src/build/asset-pipeline/image-optimizer/optimizer-core.test.ts index 6a510a8d27..a0f4be268e 100644 --- a/src/build/asset-pipeline/image-optimizer/optimizer-core.test.ts +++ b/src/build/asset-pipeline/image-optimizer/optimizer-core.test.ts @@ -1,8 +1,14 @@ import "#veryfront/schemas/_test-setup.ts"; +import { join } from "#veryfront/compat/path/index.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { chunkArray, ImageOptimizer } from "./optimizer-core.ts"; -import type { OptimizedImageMetadata } from "./types.ts"; +import type { + OptimizedImageMetadata, + SharpConstructor, + SharpInstance, + SharpMetadata, +} from "./types.ts"; function populateManifest( optimizer: ImageOptimizer, @@ -18,6 +24,7 @@ function populateManifest( const sampleMetadata: OptimizedImageMetadata = { original: "photo.jpg", + originalSize: 24000, variants: [ { format: "webp", size: 320, width: 320, height: 180, path: "photo-320w.webp", fileSize: 5000 }, { @@ -34,6 +41,54 @@ const sampleMetadata: OptimizedImageMetadata = { aspectRatio: 16 / 9, }; +function createSharpConstructor( + options: { failEncoding?: boolean } = {}, +): SharpConstructor { + const encodedMetadata = new WeakMap(); + const sourceMetadata: SharpMetadata = { width: 640, height: 480 }; + + const createInstance = (metadata: SharpMetadata): SharpInstance => { + let targetWidth = metadata.width ?? 640; + const instance: SharpInstance = { + metadata: () => Promise.resolve(metadata), + clone: () => createInstance(metadata), + resize: (width) => { + if (width !== null) targetWidth = width; + return instance; + }, + webp: () => instance, + avif: () => instance, + jpeg: () => instance, + png: () => instance, + toBuffer: async () => { + if (options.failEncoding) throw new Error("mock encoding failed"); + const buffer = new Uint8Array([1, 2, 3, 4]); + encodedMetadata.set(buffer, { + width: targetWidth, + height: Math.round(targetWidth / (640 / 480)), + }); + return buffer; + }, + }; + return instance; + }; + + return (input) => createInstance(encodedMetadata.get(input) ?? sourceMetadata); +} + +async function withTempProject( + callback: (projectDir: string, inputDir: string) => Promise, +): Promise { + const projectDir = await Deno.makeTempDir(); + const inputDir = join(projectDir, "public"); + try { + await Deno.mkdir(inputDir, { recursive: true }); + await callback(projectDir, inputDir); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } +} + describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { describe("chunkArray", () => { it("should split array into chunks of given size", () => { @@ -56,13 +111,16 @@ describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { assertEquals(result, [[1, 2, 3]], "should return single chunk"); }); - it("should return all items in one chunk when chunkSize is 0 or negative", () => { - assertEquals(chunkArray([1, 2, 3], 0), [[1, 2, 3]], "chunkSize 0 returns single chunk"); - assertEquals( - chunkArray([1, 2, 3], -1), - [[1, 2, 3]], - "negative chunkSize returns single chunk", - ); + it("should reject invalid chunk sizes", () => { + for (const chunkSize of [0, -1, 1.5]) { + let didThrow = false; + try { + chunkArray([1, 2, 3], chunkSize); + } catch (error) { + didThrow = error instanceof TypeError; + } + assertEquals(didThrow, true); + } }); it("should handle chunk size of 1", () => { @@ -143,7 +201,8 @@ describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { assertEquals(stats.totalImages, 0, "should have zero images"); assertEquals(stats.totalVariants, 0, "should have zero variants"); assertEquals(stats.totalSize, 0, "should have zero size"); - assertEquals(stats.averageSavings, 0, "should have zero average savings"); + assertEquals(stats.averageVariantSize, 0, "should have zero average size"); + assertEquals(stats.averageSavings, 0, "should retain the legacy stats alias"); }); it("should aggregate stats from populated manifest", () => { @@ -154,13 +213,19 @@ describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { assertEquals(stats.totalImages, 1, "should count one image"); assertEquals(stats.totalVariants, 3, "should count all 3 variants"); assertEquals(stats.totalSize, 5000 + 12000 + 4000, "should sum all variant file sizes"); - assertEquals(stats.averageSavings, 21000 / 3, "should calculate average correctly"); + assertEquals( + stats.averageVariantSize, + 21000 / 3, + "should calculate average correctly", + ); + assertEquals(stats.averageSavings, stats.averageVariantSize); }); it("should aggregate stats across multiple images", () => { const optimizer = new ImageOptimizer({ enabled: false }); const secondImage: OptimizedImageMetadata = { original: "banner.png", + originalSize: 42000, variants: [ { format: "webp", @@ -200,6 +265,44 @@ describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { }); assertEquals(optimizer.getStats().totalImages, 0, "should start with no images"); }); + + it("should reject unsafe or ambiguous path configurations", async () => { + await withTempProject(async (projectDir) => { + assertThrows( + () => new ImageOptimizer({ projectDir: "relative" }), + TypeError, + "must be absolute", + ); + assertThrows( + () => + new ImageOptimizer({ + projectDir, + inputDir: "public", + outputDir: "public/images", + }), + TypeError, + "must not overlap", + ); + assertThrows( + () => + new ImageOptimizer({ + projectDir, + formats: ["webp", "webp"], + }), + TypeError, + "unique", + ); + assertThrows( + () => + new ImageOptimizer({ + projectDir, + sizes: [320, 320], + }), + TypeError, + "unique", + ); + }); + }); }); describe("init", () => { @@ -209,5 +312,125 @@ describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { assertEquals(ready, false, "should not be ready when disabled"); }); }); + + describe("optimize", () => { + it("publishes a complete manifest, variants, and preserved original", async () => { + await withTempProject(async (projectDir, inputDir) => { + const source = new Uint8Array([9, 8, 7]); + await Deno.writeFile(join(inputDir, "photo.jpg"), source); + const optimizer = new ImageOptimizer( + { + projectDir, + inputDir: "public", + outputDir: ".veryfront/images", + formats: ["webp"], + sizes: [320], + preserveOriginal: true, + }, + { loadSharp: () => Promise.resolve(createSharpConstructor()) }, + ); + + const manifest = await optimizer.optimize(); + const metadata = manifest.get("photo.jpg"); + assertEquals(metadata?.originalSize, source.length); + assertEquals(metadata?.variants.length, 2); + assertEquals( + await Deno.readFile(join(projectDir, ".veryfront/images/photo.jpg")), + source, + ); + assertEquals( + JSON.parse( + await Deno.readTextFile( + join(projectDir, ".veryfront/images/image-manifest.json"), + ), + )["photo.jpg"].variants.length, + 2, + ); + assertEquals( + optimizer.generateSrcSet("photo.jpg"), + "/.veryfront/images/photo-320w.webp 320w, " + + "/.veryfront/images/photo-640w.webp 640w", + ); + + metadata!.variants.length = 0; + assertEquals(optimizer.getImageMetadata("photo.jpg")?.variants.length, 2); + const exposed = optimizer.getImageMetadata("photo.jpg")!; + exposed.variants.length = 0; + assertEquals(optimizer.getImageMetadata("photo.jpg")?.variants.length, 2); + }); + }); + + it("preserves the last known-good output when encoding fails", async () => { + await withTempProject(async (projectDir, inputDir) => { + await Deno.writeFile(join(inputDir, "photo.jpg"), new Uint8Array([9])); + const outputDir = join(projectDir, ".veryfront/images"); + await Deno.mkdir(outputDir, { recursive: true }); + await Deno.writeTextFile(join(outputDir, "sentinel.txt"), "known good"); + const optimizer = new ImageOptimizer( + { + projectDir, + inputDir: "public", + outputDir: ".veryfront/images", + formats: ["webp"], + sizes: [320], + }, + { + loadSharp: () => Promise.resolve(createSharpConstructor({ failEncoding: true })), + }, + ); + + await assertRejects( + () => optimizer.optimize(), + Error, + "mock encoding failed", + ); + assertEquals( + await Deno.readTextFile(join(outputDir, "sentinel.txt")), + "known good", + ); + assertEquals(optimizer.getStats().totalImages, 0); + + const parentEntries = []; + for await (const entry of Deno.readDir(join(projectDir, ".veryfront"))) { + parentEntries.push(entry.name); + } + assertEquals( + parentEntries.some((name) => + name.includes(".images.veryfront-stage-") || + name.includes(".images.veryfront-build.lock") + ), + false, + ); + }); + }); + + it("rejects an empty enabled input without replacing existing output", async () => { + await withTempProject(async (projectDir) => { + const outputDir = join(projectDir, ".veryfront/images"); + await Deno.mkdir(outputDir, { recursive: true }); + await Deno.writeTextFile(join(outputDir, "sentinel.txt"), "known good"); + const optimizer = new ImageOptimizer( + { + projectDir, + inputDir: "public", + outputDir: ".veryfront/images", + formats: ["webp"], + sizes: [320], + }, + { loadSharp: () => Promise.resolve(createSharpConstructor()) }, + ); + + await assertRejects( + () => optimizer.optimize(), + TypeError, + "found no supported images", + ); + assertEquals( + await Deno.readTextFile(join(outputDir, "sentinel.txt")), + "known good", + ); + }); + }); + }); }); }); diff --git a/src/build/asset-pipeline/image-optimizer/optimizer-core.ts b/src/build/asset-pipeline/image-optimizer/optimizer-core.ts index 14f7cbe9f0..0fa70ce16a 100644 --- a/src/build/asset-pipeline/image-optimizer/optimizer-core.ts +++ b/src/build/asset-pipeline/image-optimizer/optimizer-core.ts @@ -1,14 +1,43 @@ -import { relative } from "#veryfront/compat/path/index.ts"; -import { DEFAULT_BUILD_CONCURRENCY, logger } from "#veryfront/utils"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, +} from "#veryfront/compat/path/index.ts"; +import { INITIALIZATION_ERROR } from "#veryfront/errors"; +import { + createFileSystem, + type FileSystem, + isNotFoundError, + realPath, +} from "#veryfront/platform/compat/fs.ts"; +import { cwd } from "#veryfront/platform/compat/process.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { DEFAULT_OPTIONS } from "./constants.ts"; -import { loadSharp } from "./sharp-loader.ts"; +import { DEFAULT_BUILD_CONCURRENCY, logger } from "#veryfront/utils"; +import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; +import { + type BuildPublicationLock, + createBuildPublication, + nativeBuildPublicationLock, +} from "../../production-build/build/build-publication.ts"; +import { + calculateRequiredAspectRatio, + generateSrcSet, + isContainedAssetPath, +} from "../../utils/asset-utils.ts"; +import { hasControlCharacters } from "../../utils/string-validation.ts"; +import { + DEFAULT_OPTIONS, + MAX_IMAGE_DIMENSION, + MAX_IMAGE_OUTPUT_SIZES, + SUPPORTED_FORMATS, +} from "./constants.ts"; import { findImages } from "./image-finder.ts"; +import { loadSharp } from "./sharp-loader.ts"; +import { isSafeImageManifestPath, writeManifest } from "./manifest-manager.ts"; import { generateImageVariants } from "./variant-generator.ts"; -import { writeManifest } from "./manifest-manager.ts"; -import { calculateAspectRatio, generateSrcSet } from "../../utils/asset-utils.ts"; -import { createError, toError } from "#veryfront/errors"; import type { ImageFormat, ImageOptimizationOptions, @@ -17,9 +46,19 @@ import type { SharpConstructor, } from "./types.ts"; +const supportedFormats = new Set(SUPPORTED_FORMATS); + +export interface ImageOptimizerDependencies { + fs?: FileSystem; + loadSharp?: () => Promise; + publicationLock?: BuildPublicationLock; +} + /** @internal — exported for testing */ export function chunkArray(items: T[], chunkSize: number): T[][] { - if (chunkSize <= 0) return [items]; + if (!Number.isInteger(chunkSize) || chunkSize <= 0) { + throw new TypeError("Image optimization chunk size must be a positive integer"); + } const chunks: T[][] = []; for (let i = 0; i < items.length; i += chunkSize) { @@ -28,14 +67,214 @@ export function chunkArray(items: T[], chunkSize: number): T[][] { return chunks; } +function safeConfiguredPath(path: string, label: string): void { + if ( + typeof path !== "string" || + path.length === 0 || + path.length > MAX_PATH_LENGTH_CHARS || + hasControlCharacters(path) + ) { + throw new TypeError(`${label} must be a safe non-empty path`); + } +} + +async function canonicalTargetPath(path: string): Promise { + const absolutePath = resolve(path); + try { + return await realPath(absolutePath); + } catch (error) { + if (!isNotFoundError(error)) throw error; + } + + const suffix: string[] = []; + let current = absolutePath; + while (true) { + const parent = dirname(current); + if (parent === current) { + throw new TypeError(`Cannot resolve image output path: ${path}`); + } + suffix.unshift(basename(current)); + try { + return resolve(await realPath(parent), ...suffix); + } catch (error) { + if (!isNotFoundError(error)) throw error; + current = parent; + } + } +} + +function portablePath(path: string): string { + return path.replaceAll("\\", "/"); +} + +function cloneMetadata(metadata: OptimizedImageMetadata): OptimizedImageMetadata { + return { + ...metadata, + variants: metadata.variants.map((variant) => ({ ...variant })), + }; +} + +function cloneManifest( + manifest: Map, +): Map { + return new Map( + [...manifest].map(([path, metadata]) => [path, cloneMetadata(metadata)]), + ); +} + export class ImageOptimizer { private options: Required; private sharp: SharpConstructor | null = null; private imageManifest = new Map(); - private fs = createFileSystem(); + private fs: FileSystem; + private loadSharpDependency: () => Promise; + private publicationLock?: BuildPublicationLock; + private outputUrlPath: string; - constructor(options: ImageOptimizationOptions = {}) { - this.options = { ...DEFAULT_OPTIONS, ...options }; + constructor( + options: ImageOptimizationOptions = {}, + dependencies: ImageOptimizerDependencies = {}, + ) { + if (typeof options !== "object" || options === null || Array.isArray(options)) { + throw new TypeError("Image optimization options must be an object"); + } + if (options.formats !== undefined && !Array.isArray(options.formats)) { + throw new TypeError("Image formats must be an array"); + } + if (options.sizes !== undefined && !Array.isArray(options.sizes)) { + throw new TypeError("Image sizes must be an array"); + } + this.options = { + enabled: options.enabled === undefined ? DEFAULT_OPTIONS.enabled : options.enabled, + projectDir: options.projectDir === undefined ? cwd() : options.projectDir, + formats: options.formats === undefined ? [...DEFAULT_OPTIONS.formats] : [...options.formats], + sizes: options.sizes === undefined ? [...DEFAULT_OPTIONS.sizes] : [...options.sizes], + quality: options.quality === undefined ? DEFAULT_OPTIONS.quality : options.quality, + inputDir: options.inputDir === undefined ? DEFAULT_OPTIONS.inputDir : options.inputDir, + outputDir: options.outputDir === undefined ? DEFAULT_OPTIONS.outputDir : options.outputDir, + preserveOriginal: options.preserveOriginal === undefined + ? DEFAULT_OPTIONS.preserveOriginal + : options.preserveOriginal, + }; + this.fs = dependencies.fs ?? createFileSystem(); + this.loadSharpDependency = dependencies.loadSharp ?? loadSharp; + this.publicationLock = dependencies.publicationLock ?? + (dependencies.fs === undefined ? nativeBuildPublicationLock : undefined); + this.validateConfiguration(); + this.outputUrlPath = portablePath( + relative(this.options.projectDir, this.options.outputDir), + ); + } + + private validateConfiguration(): void { + safeConfiguredPath(this.options.projectDir, "Image project directory"); + safeConfiguredPath(this.options.inputDir, "Image input directory"); + safeConfiguredPath(this.options.outputDir, "Image output directory"); + if (!isAbsolute(this.options.projectDir)) { + throw new TypeError("Image project directory must be absolute"); + } + if (typeof this.options.enabled !== "boolean") { + throw new TypeError("Image optimization enabled must be a boolean"); + } + if (typeof this.options.preserveOriginal !== "boolean") { + throw new TypeError("Image preserveOriginal must be a boolean"); + } + if ( + !Number.isInteger(this.options.quality) || + this.options.quality < 1 || + this.options.quality > 100 + ) { + throw new TypeError("Image quality must be an integer from 1 through 100"); + } + if ( + this.options.formats.length === 0 || + new Set(this.options.formats).size !== this.options.formats.length || + this.options.formats.some((format) => !supportedFormats.has(format)) + ) { + throw new TypeError("Image formats must be a non-empty, unique supported list"); + } + if ( + this.options.sizes.length > MAX_IMAGE_OUTPUT_SIZES || + new Set(this.options.sizes).size !== this.options.sizes.length || + this.options.sizes.some((size) => + !Number.isInteger(size) || size <= 0 || size > MAX_IMAGE_DIMENSION + ) + ) { + throw new TypeError( + `Image sizes must contain at most ${MAX_IMAGE_OUTPUT_SIZES} unique positive integers no larger than ${MAX_IMAGE_DIMENSION}`, + ); + } + + const projectDir = resolve(this.options.projectDir); + const inputDir = isAbsolute(this.options.inputDir) + ? resolve(this.options.inputDir) + : resolve(projectDir, this.options.inputDir); + const outputDir = isAbsolute(this.options.outputDir) + ? resolve(this.options.outputDir) + : resolve(projectDir, this.options.outputDir); + if ( + inputDir === projectDir || + outputDir === projectDir || + !isContainedAssetPath(projectDir, inputDir) || + !isContainedAssetPath(projectDir, outputDir) + ) { + throw new TypeError("Image input and output directories must be inside the project"); + } + if ( + isContainedAssetPath(inputDir, outputDir) || + isContainedAssetPath(outputDir, inputDir) + ) { + throw new TypeError("Image input and output directories must not overlap"); + } + + this.options.projectDir = projectDir; + this.options.inputDir = inputDir; + this.options.outputDir = outputDir; + } + + private async validateFilesystemBoundaries(): Promise { + const lstat = this.fs.lstat?.bind(this.fs); + if (!lstat) { + throw INITIALIZATION_ERROR.create({ + detail: "Image optimization requires lstat support", + }); + } + + const [projectInfo, inputInfo] = await Promise.all([ + lstat(this.options.projectDir), + lstat(this.options.inputDir), + ]); + if (!projectInfo.isDirectory || projectInfo.isSymlink) { + throw new TypeError("Image project directory must be a real directory"); + } + if (!inputInfo.isDirectory || inputInfo.isSymlink) { + throw new TypeError("Image input directory must be a real directory"); + } + + if (await this.fs.exists(this.options.outputDir)) { + const outputInfo = await lstat(this.options.outputDir); + if (!outputInfo.isDirectory || outputInfo.isSymlink) { + throw new TypeError("Image output path must be a real directory"); + } + } + + const [canonicalProject, canonicalInput, canonicalOutput] = await Promise.all([ + realPath(this.options.projectDir), + realPath(this.options.inputDir), + canonicalTargetPath(this.options.outputDir), + ]); + if ( + !isContainedAssetPath(canonicalProject, canonicalInput) || + !isContainedAssetPath(canonicalProject, canonicalOutput) + ) { + throw new TypeError("Image input and output must remain inside the physical project"); + } + if ( + isContainedAssetPath(canonicalInput, canonicalOutput) || + isContainedAssetPath(canonicalOutput, canonicalInput) + ) { + throw new TypeError("Physical image input and output directories must not overlap"); + } } init(): Promise { @@ -46,9 +285,16 @@ export class ImageOptimizer { logger.info("Image optimization is disabled"); return false; } - - this.sharp = await loadSharp(); - return this.sharp !== null; + if (!this.sharp) { + const sharp = await this.loadSharpDependency(); + if (typeof sharp !== "function") { + throw INITIALIZATION_ERROR.create({ + detail: "Sharp loader did not return an image constructor", + }); + } + this.sharp = sharp; + } + return true; }, { "optimizer.enabled": this.options.enabled }, ); @@ -58,8 +304,9 @@ export class ImageOptimizer { return withSpan( "build.asset.ImageOptimizer.optimize", async () => { - const isReady = await this.init(); - if (!isReady) return this.imageManifest; + if (!this.options.enabled) return new Map(); + await this.validateFilesystemBoundaries(); + await this.init(); logger.info("Starting image optimization", { inputDir: this.options.inputDir, @@ -68,23 +315,81 @@ export class ImageOptimizer { sizes: this.options.sizes, }); - await this.fs.mkdir(this.options.outputDir, { recursive: true }); + const images = await findImages(this.options.inputDir, { fs: this.fs }); + if (images.length === 0) { + throw new TypeError( + `Image optimization found no supported images in ${this.options.inputDir}`, + ); + } + const stagedManifest = new Map(); + const outputOwners = new Map(); + const publication = await createBuildPublication( + this.options.outputDir, + false, + { fs: this.fs, lock: this.publicationLock }, + ); - const images = await findImages(this.options.inputDir); - logger.info(`Found ${images.length} images to optimize`); + let failed = false; + let failure: unknown; + try { + await this.fs.mkdir(publication.buildDir, { recursive: true }); + for (const chunk of chunkArray(images, DEFAULT_BUILD_CONCURRENCY)) { + const settledEntries = await Promise.allSettled( + chunk.map((imagePath) => + this.optimizeImage( + imagePath, + publication.buildDir, + outputOwners, + ) + ), + ); + const errors = settledEntries.flatMap((result) => + result.status === "rejected" ? [result.reason] : [] + ); + if (errors.length === 1) throw errors[0]; + if (errors.length > 1) { + throw new AggregateError( + errors, + "Multiple images failed optimization", + ); + } + const entries = settledEntries.map((result) => { + if (result.status !== "fulfilled") { + throw new TypeError("Unreachable rejected image result"); + } + return result.value; + }); + for (const [relativePath, metadata] of entries) { + stagedManifest.set(relativePath, metadata); + } + } - for (const chunk of chunkArray(images, DEFAULT_BUILD_CONCURRENCY)) { - await Promise.all(chunk.map((imagePath) => this.optimizeImage(imagePath))); + await writeManifest(stagedManifest, publication.buildDir, this.fs); + await publication.publish(); + this.imageManifest = cloneManifest(stagedManifest); + } catch (error) { + failed = true; + failure = error; } - await writeManifest(this.imageManifest, this.options.outputDir); + try { + await publication.cleanup(); + } catch (cleanupError) { + if (failed) { + throw new AggregateError( + [failure, cleanupError], + "Image optimization failed and staging cleanup also failed", + ); + } + throw cleanupError; + } + if (failed) throw failure; logger.info("Image optimization complete", { totalImages: this.imageManifest.size, totalVariants: this.getTotalVariants(), }); - - return this.imageManifest; + return cloneManifest(this.imageManifest); }, { "optimizer.inputDir": this.options.inputDir, @@ -94,76 +399,112 @@ export class ImageOptimizer { ); } - private optimizeImage(imagePath: string): Promise { - const relPath = relative(this.options.inputDir, imagePath); + private registerOutput( + outputOwners: Map, + path: string, + owner: string, + ): void { + const key = portablePath(path).normalize("NFC").toLocaleLowerCase("en-US"); + const previousOwner = outputOwners.get(key); + if (previousOwner !== undefined) { + throw new TypeError( + `Image output collision between ${previousOwner} and ${owner}: ${path}`, + ); + } + outputOwners.set(key, owner); + } + + private optimizeImage( + imagePath: string, + outputDir: string, + outputOwners: Map, + ): Promise<[string, OptimizedImageMetadata]> { + const relativePath = portablePath( + relative(this.options.inputDir, imagePath), + ).normalize("NFC"); + if (!isSafeImageManifestPath(relativePath)) { + throw new TypeError(`Unsafe image input path: ${relativePath}`); + } return withSpan( "build.asset.ImageOptimizer.optimizeImage", async () => { - logger.debug(`Optimizing: ${relPath}`); - - try { - const sharp = this.sharp; - if (!sharp) { - throw toError( - createError({ - type: "build", - message: "Sharp not initialized - call init() first", - }), - ); - } - - const defaultFormat = this.options.formats[0]; - if (!defaultFormat) { - throw toError( - createError({ - type: "build", - message: "No image formats configured for optimization", - }), - ); - } + const sharp = this.sharp; + if (!sharp) { + throw INITIALIZATION_ERROR.create({ + detail: "Sharp was not initialized", + }); + } + const defaultFormat = this.options.formats[0]!; + const lstat = this.fs.lstat?.bind(this.fs); + if (!lstat) { + throw INITIALIZATION_ERROR.create({ + detail: "Image optimization requires lstat support", + }); + } + const inputInfo = await lstat(imagePath); + if (!inputInfo.isFile || inputInfo.isSymlink) { + throw new TypeError(`Image input must be a regular file: ${relativePath}`); + } + const imageBuffer = await this.fs.readFile(imagePath); + if (imageBuffer.length === 0) { + throw new TypeError(`Image input is empty: ${relativePath}`); + } + const image = sharp(imageBuffer); + const metadata = await image.metadata(); + const variants = await generateImageVariants( + sharp, + image, + relativePath, + metadata, + this.options.formats, + this.options.sizes, + this.options.quality, + outputDir, + this.fs, + ); - const imageBuffer = await this.fs.readFile(imagePath); - const image = sharp(imageBuffer); - const metadata = await image.metadata(); - - const variants = await generateImageVariants( - sharp, - image, - relPath, - metadata, - this.options.formats, - this.options.sizes, - this.options.quality, - this.options.outputDir, + for (const variant of variants) { + this.registerOutput( + outputOwners, + variant.path, + `${relativePath} ${variant.width}px ${variant.format}`, ); + } + if (this.options.preserveOriginal) { + this.registerOutput(outputOwners, relativePath, `${relativePath} original`); + const originalOutput = join(outputDir, relativePath); + await this.fs.mkdir(dirname(originalOutput), { recursive: true }); + await this.fs.writeFile(originalOutput, imageBuffer); + } - this.imageManifest.set(relPath, { - original: relPath, + return [ + relativePath, + { + original: relativePath, + originalSize: imageBuffer.length, variants, defaultFormat, - aspectRatio: calculateAspectRatio(metadata.width, metadata.height), - }); - - logger.debug(`Generated ${variants.length} variants for ${relPath}`); - } catch (error) { - logger.error(`Failed to optimize ${relPath}`, { - error: error instanceof Error ? error.message : String(error), - }); - } + aspectRatio: calculateRequiredAspectRatio( + metadata.width!, + metadata.height!, + ), + }, + ]; }, - { "image.path": relPath }, + { "image.path": relativePath }, ); } getImageMetadata(imagePath: string): OptimizedImageMetadata | null { - return this.imageManifest.get(imagePath) ?? null; + const metadata = this.imageManifest.get(imagePath); + return metadata ? cloneMetadata(metadata) : null; } generateSrcSet(imagePath: string, format?: ImageFormat): string { const metadata = this.imageManifest.get(imagePath); if (!metadata) return ""; - return generateSrcSet(imagePath, metadata, this.options.outputDir, format); + return generateSrcSet(imagePath, metadata, this.outputUrlPath, format); } private getTotalVariants(): number { @@ -177,19 +518,18 @@ export class ImageOptimizer { getStats(): ImageOptimizationStats { const totalImages = this.imageManifest.size; const totalVariants = this.getTotalVariants(); - let totalSize = 0; for (const { variants } of this.imageManifest.values()) { - for (const { fileSize } of variants) { - totalSize += fileSize; - } + for (const { fileSize } of variants) totalSize += fileSize; } + const averageVariantSize = totalVariants > 0 ? totalSize / totalVariants : 0; return { totalImages, totalVariants, totalSize, - averageSavings: totalVariants > 0 ? totalSize / totalVariants : 0, + averageVariantSize, + averageSavings: averageVariantSize, }; } } diff --git a/src/build/asset-pipeline/image-optimizer/sharp-loader.ts b/src/build/asset-pipeline/image-optimizer/sharp-loader.ts index 29c34fd492..9d1324c3df 100644 --- a/src/build/asset-pipeline/image-optimizer/sharp-loader.ts +++ b/src/build/asset-pipeline/image-optimizer/sharp-loader.ts @@ -1,17 +1,20 @@ import { logger } from "#veryfront/utils"; -import { SHARP_CDN_URL } from "./constants.ts"; +import { DEPENDENCY_MISSING } from "#veryfront/errors"; +import { SHARP_MODULE_SPECIFIER } from "./constants.ts"; import type { SharpConstructor } from "./types.ts"; -export async function loadSharp(): Promise { +export async function loadSharp(): Promise { try { - const { default: sharp } = await import(SHARP_CDN_URL); + const { default: sharp } = await import(SHARP_MODULE_SPECIFIER); + if (typeof sharp !== "function") { + throw new TypeError("Sharp module did not export an image constructor"); + } logger.info("Sharp image optimizer loaded successfully"); - return sharp; + return sharp as unknown as SharpConstructor; } catch (error) { - logger.warn("Sharp not available. Install with: npm install sharp", { - error: error instanceof Error ? error.message : String(error), + throw DEPENDENCY_MISSING.create({ + detail: `Image optimization requires ${SHARP_MODULE_SPECIFIER}`, + cause: error, }); - logger.info("Skipping image optimization. Images will be copied as-is."); - return null; } } diff --git a/src/build/asset-pipeline/image-optimizer/types.ts b/src/build/asset-pipeline/image-optimizer/types.ts index 7d6c1fa1c4..5f1ac9991b 100644 --- a/src/build/asset-pipeline/image-optimizer/types.ts +++ b/src/build/asset-pipeline/image-optimizer/types.ts @@ -59,6 +59,8 @@ export type ImageFormat = "webp" | "avif" | "jpeg" | "png"; export interface ImageOptimizationOptions { enabled?: boolean; + /** Absolute project boundary for input and output paths. */ + projectDir?: string; formats?: ImageFormat[]; sizes?: number[]; quality?: number; @@ -69,6 +71,8 @@ export interface ImageOptimizationOptions { export interface OptimizedImageMetadata { original: string; + /** Source file size in bytes. Optional for manifests produced before this field existed. */ + originalSize?: number; variants: ImageVariant[]; defaultFormat: string; aspectRatio: number; @@ -87,5 +91,10 @@ export interface ImageOptimizationStats { totalImages: number; totalVariants: number; totalSize: number; + averageVariantSize: number; + /** + * @deprecated This legacy field was historically an alias for average + * variant size, despite its name. Use `averageVariantSize`. + */ averageSavings: number; } diff --git a/src/build/asset-pipeline/image-optimizer/variant-generator.test.ts b/src/build/asset-pipeline/image-optimizer/variant-generator.test.ts index c880588827..b183f1435f 100644 --- a/src/build/asset-pipeline/image-optimizer/variant-generator.test.ts +++ b/src/build/asset-pipeline/image-optimizer/variant-generator.test.ts @@ -1,6 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { generateImageVariants } from "./variant-generator.ts"; import type { ImageFormat, SharpConstructor, SharpInstance, SharpMetadata } from "./types.ts"; @@ -109,27 +109,25 @@ describe("build/asset-pipeline/image-optimizer/variant-generator", () => { }); }); - it("should use default width when metadata has no width", async () => { + it("should reject missing image dimensions", async () => { await withTempOutputDir(async (outputDir) => { const sharp = createMockSharp({ height: 600 }); // no width const image = createMockSharpInstance({ height: 600 }); - const formats: ImageFormat[] = ["webp"]; - const sizes = [320, 640]; - - const variants = await generateImageVariants( - sharp, - image, - "nowidth.jpg", - { height: 600 }, // no width - formats, - sizes, - 80, - outputDir, + await assertRejects( + () => + generateImageVariants( + sharp, + image, + "nowidth.jpg", + { height: 600 }, + ["webp"], + [320, 640], + 80, + outputDir, + ), + TypeError, + "positive integer dimensions", ); - - // When no metaWidth, validSizes = sizes (all pass), plus DEFAULT_IMAGE_WIDTH (1920) - // [320, 640, 1920] x 1 format = 3 - assertEquals(variants.length, 3, "should use all sizes when width is unknown"); }); }); @@ -155,23 +153,75 @@ describe("build/asset-pipeline/image-optimizer/variant-generator", () => { }); }); - it("should handle empty formats array", async () => { + it("should reject an empty formats array", async () => { await withTempOutputDir(async (outputDir) => { const sharp = createMockSharp(); const image = createMockSharpInstance(); - const variants = await generateImageVariants( - sharp, - image, - "test.jpg", - { width: 1920, height: 1080 }, - [], // no formats - [320], - 80, - outputDir, + await assertRejects( + () => + generateImageVariants( + sharp, + image, + "test.jpg", + { width: 1920, height: 1080 }, + [], + [320], + 80, + outputDir, + ), + TypeError, + "non-empty", ); + }); + }); - assertEquals(variants.length, 0, "should produce no variants with no formats"); + it("should reject encoded variants without real dimensions", async () => { + await withTempOutputDir(async (outputDir) => { + const sharp = createMockSharp({}); + const image = createMockSharpInstance({ width: 640, height: 480 }); + + await assertRejects( + () => + generateImageVariants( + sharp, + image, + "test.jpg", + { width: 640, height: 480 }, + ["webp"], + [320], + 80, + outputDir, + ), + TypeError, + "invalid dimensions", + ); + }); + }); + + it("should reject an empty encoder result", async () => { + await withTempOutputDir(async (outputDir) => { + const sharp = createMockSharp(); + const image = createMockSharpInstance( + { width: 640, height: 480 }, + new Uint8Array(), + ); + + await assertRejects( + () => + generateImageVariants( + sharp, + image, + "test.jpg", + { width: 640, height: 480 }, + ["webp"], + [320], + 80, + outputDir, + ), + TypeError, + "empty", + ); }); }); diff --git a/src/build/asset-pipeline/image-optimizer/variant-generator.ts b/src/build/asset-pipeline/image-optimizer/variant-generator.ts index 236c5508af..0c4ba92c28 100644 --- a/src/build/asset-pipeline/image-optimizer/variant-generator.ts +++ b/src/build/asset-pipeline/image-optimizer/variant-generator.ts @@ -1,9 +1,9 @@ import { dirname, relative } from "#veryfront/compat/path/index.ts"; -import { logger } from "#veryfront/utils"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { createFileSystem, type FileSystem } from "#veryfront/platform/compat/fs.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { processFormat } from "./format-processor.ts"; -import { calculateAspectRatio, getVariantPath } from "../../utils/asset-utils.ts"; +import { getVariantPath } from "../../utils/asset-utils.ts"; +import { MAX_IMAGE_DIMENSION, MAX_IMAGE_OUTPUT_SIZES, SUPPORTED_FORMATS } from "./constants.ts"; import type { ImageFormat, ImageVariant, @@ -12,53 +12,55 @@ import type { SharpMetadata, } from "./types.ts"; -/** Default assumed image width when metadata has no width */ -const DEFAULT_IMAGE_WIDTH = 1_920; - function generateVariant( sharp: SharpConstructor, image: SharpInstance, relPath: string, format: ImageFormat, width: number, - metadata: SharpMetadata, quality: number, outputDir: string, -): Promise { + fs: FileSystem, +): Promise { return withSpan( "build.asset.generateVariant", - async (): Promise => { - const fs = createFileSystem(); - - try { - const outputPath = getVariantPath(outputDir, relPath, format, width); - await fs.mkdir(dirname(outputPath), { recursive: true }); - - const processor = image.clone().resize(width, null, { - fit: "inside", - withoutEnlargement: true, - }); + async (): Promise => { + const outputPath = getVariantPath(outputDir, relPath, format, width); + await fs.mkdir(dirname(outputPath), { recursive: true }); - const buffer = await processFormat(processor, format, quality).toBuffer(); - await fs.writeFile(outputPath, buffer); + const processor = image.clone().resize(width, null, { + fit: "inside", + withoutEnlargement: true, + }); - const processedMetadata = await sharp(buffer).metadata(); - const aspectRatio = calculateAspectRatio(metadata.width, metadata.height); + const buffer = await processFormat(processor, format, quality).toBuffer(); + if (buffer.length === 0) { + throw new TypeError( + `Image encoder returned an empty ${format} variant at ${width}px`, + ); + } - return { - format, - size: width, - width: processedMetadata.width ?? width, - height: processedMetadata.height ?? Math.round(width / aspectRatio), - path: relative(outputDir, outputPath), - fileSize: buffer.length, - }; - } catch (error) { - logger.error(`Failed to generate ${format} variant at ${width}px`, { - error: error instanceof Error ? error.message : String(error), - }); - return null; + const processedMetadata = await sharp(buffer).metadata(); + const processedWidth = processedMetadata.width; + const processedHeight = processedMetadata.height; + if ( + !isValidDimension(processedWidth) || + !isValidDimension(processedHeight) + ) { + throw new TypeError( + `Encoded ${format} variant has invalid dimensions at ${width}px`, + ); } + await fs.writeFile(outputPath, buffer); + + return { + format, + size: width, + width: processedWidth, + height: processedHeight, + path: relative(outputDir, outputPath).replaceAll("\\", "/"), + fileSize: buffer.length, + }; }, { "image.path": relPath, @@ -78,31 +80,62 @@ export function generateImageVariants( sizes: number[], quality: number, outputDir: string, + fs: FileSystem = createFileSystem(), ): Promise { return withSpan( "build.asset.generateImageVariants", async (): Promise => { - const variants: ImageVariant[] = []; - const originalWidth = metadata.width ?? DEFAULT_IMAGE_WIDTH; - const metaWidth = metadata.width; + const originalWidth = metadata.width; + const originalHeight = metadata.height; + if ( + !isValidDimension(originalWidth) || + !isValidDimension(originalHeight) + ) { + throw new TypeError( + `Image metadata requires positive integer dimensions no larger than ${MAX_IMAGE_DIMENSION}: ${relPath}`, + ); + } + if ( + formats.length === 0 || + new Set(formats).size !== formats.length || + formats.some((format) => !SUPPORTED_FORMATS.includes(format)) + ) { + throw new TypeError( + "Image output formats must be a non-empty, unique supported list", + ); + } + if ( + sizes.length > MAX_IMAGE_OUTPUT_SIZES || + new Set(sizes).size !== sizes.length || + sizes.some((size) => !Number.isInteger(size) || size <= 0 || size > MAX_IMAGE_DIMENSION) + ) { + throw new TypeError( + `Image output sizes must contain at most ${MAX_IMAGE_OUTPUT_SIZES} unique positive integers no larger than ${MAX_IMAGE_DIMENSION}`, + ); + } - const validSizes = metaWidth ? sizes.filter((size) => metaWidth >= size) : sizes; - const allSizes = [...validSizes, originalWidth]; + const variants: ImageVariant[] = []; + const allSizes = [ + ...new Set([ + ...sizes.filter((size) => originalWidth >= size), + originalWidth, + ]), + ].sort((left, right) => left - right); for (const size of allSizes) { for (const format of formats) { - const variant = await generateVariant( - sharp, - image, - relPath, - format, - size, - metadata, - quality, - outputDir, + variants.push( + await generateVariant( + sharp, + image, + relPath, + format, + size, + quality, + outputDir, + fs, + ), ); - - if (variant) variants.push(variant); } } @@ -115,3 +148,9 @@ export function generateImageVariants( }, ); } + +function isValidDimension(value: unknown): value is number { + return Number.isInteger(value) && + (value as number) > 0 && + (value as number) <= MAX_IMAGE_DIMENSION; +} diff --git a/src/build/asset-pipeline/index.ts b/src/build/asset-pipeline/index.ts index 5937558345..7eb631a21c 100644 --- a/src/build/asset-pipeline/index.ts +++ b/src/build/asset-pipeline/index.ts @@ -25,13 +25,22 @@ export { CSSOptimizer, optimizeCSS } from "./css-optimizer/index.ts"; import { type CSSOptimizationOptions, CSSOptimizer } from "./css-optimizer/index.ts"; import { type ImageOptimizationOptions, ImageOptimizer } from "./image-optimizer/index.ts"; +import { DEFAULT_CSS_OPTIONS } from "./css-optimizer/constants.ts"; +import { DEFAULT_OPTIONS as DEFAULT_IMAGE_OPTIONS } from "./image-optimizer/constants.ts"; +import { + assertIndependentAssetStageOutputs, + type AssetStageOutputPlan, +} from "./output-planning.ts"; import { processTailwindCSSInDirectory, type TailwindProcessResult, } from "./tailwind-processor/index.ts"; import { getErrorMessage } from "#veryfront/errors"; +import { cwd } from "#veryfront/platform/compat/process.ts"; import { logger } from "#veryfront/utils"; +const DEFAULT_TAILWIND_OUTPUT_DIR = ".veryfront/css"; + export interface TailwindBatchOptions { enabled?: boolean; projectDir: string; @@ -67,9 +76,42 @@ export interface AssetPipelineResult { duration: number; } +function configuredStageOutputs( + options: AssetPipelineOptions, +): AssetStageOutputPlan[] { + const outputs: AssetStageOutputPlan[] = []; + if (options.images?.enabled !== false) { + outputs.push({ + stage: "images", + projectDir: options.images?.projectDir ?? cwd(), + outputDir: options.images?.outputDir ?? DEFAULT_IMAGE_OPTIONS.outputDir, + }); + } + if ( + options.tailwind !== undefined && + options.tailwind.enabled !== false && + options.tailwind.projectDir + ) { + outputs.push({ + stage: "tailwind", + projectDir: options.tailwind.projectDir, + outputDir: options.tailwind.outputDir ?? DEFAULT_TAILWIND_OUTPUT_DIR, + }); + } + if (options.css?.enabled !== false) { + outputs.push({ + stage: "css", + projectDir: options.css?.projectDir ?? cwd(), + outputDir: options.css?.outputDir ?? DEFAULT_CSS_OPTIONS.outputDir, + }); + } + return outputs; +} + export async function runAssetPipeline( options: AssetPipelineOptions = {}, ): Promise { + await assertIndependentAssetStageOutputs(configuredStageOutputs(options)); const startTime = Date.now(); logger.info("Starting asset pipeline"); @@ -106,7 +148,11 @@ export async function runAssetPipeline( const tailwindOptions = options.tailwind; if (tailwindOptions && tailwindOptions.enabled !== false) { - const { projectDir, sourceDir = "styles", outputDir = ".veryfront/css" } = tailwindOptions; + const { + projectDir, + sourceDir = "styles", + outputDir = DEFAULT_TAILWIND_OUTPUT_DIR, + } = tailwindOptions; if (!projectDir) { logger.warn("Tailwind CSS processing skipped: projectDir not provided"); diff --git a/src/build/asset-pipeline/output-planning.test.ts b/src/build/asset-pipeline/output-planning.test.ts new file mode 100644 index 0000000000..7a9347232e --- /dev/null +++ b/src/build/asset-pipeline/output-planning.test.ts @@ -0,0 +1,111 @@ +import { join } from "#veryfront/compat/path/index.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + assertIndependentAssetStageOutputs, + canonicalizePlannedAssetPath, +} from "./output-planning.ts"; + +async function withDirectory( + run: (directory: string) => Promise, +): Promise { + const directory = await Deno.makeTempDir(); + try { + await run(directory); + } finally { + await Deno.remove(directory, { recursive: true }); + } +} + +describe("build/asset-pipeline output planning", () => { + it("canonicalizes outputs through their nearest existing ancestor", async () => { + await withDirectory(async (projectDir) => { + const target = join(projectDir, "missing", "nested", "assets"); + assertEquals( + await canonicalizePlannedAssetPath(target), + join(await Deno.realPath(projectDir), "missing", "nested", "assets"), + ); + }); + }); + + it("accepts independent physical output trees", async () => { + await withDirectory(async (projectDir) => { + await assertIndependentAssetStageOutputs([ + { stage: "images", projectDir, outputDir: ".veryfront/images" }, + { stage: "css", projectDir, outputDir: ".veryfront/css" }, + ]); + }); + }); + + it("rejects lexical ancestor and descendant outputs", async () => { + await withDirectory(async (projectDir) => { + await assertRejects( + () => + assertIndependentAssetStageOutputs([ + { stage: "images", projectDir, outputDir: ".veryfront/assets" }, + { stage: "css", projectDir, outputDir: ".veryfront/assets/css" }, + ]), + TypeError, + "must not overlap physically", + ); + }); + }); + + it("rejects distinct configured paths that alias the same physical tree", async () => { + await withDirectory(async (projectDir) => { + const physicalOutput = join(projectDir, "generated"); + await Deno.mkdir(physicalOutput); + await Deno.symlink(physicalOutput, join(projectDir, "output-alias")); + + await assertRejects( + () => + assertIndependentAssetStageOutputs([ + { stage: "images", projectDir, outputDir: "generated" }, + { stage: "css", projectDir, outputDir: "output-alias/css" }, + ]), + TypeError, + "must not overlap physically", + ); + }); + }); + + it("rejects an output that escapes through a symlinked ancestor", async () => { + await withDirectory(async (rootDir) => { + const projectDir = join(rootDir, "project"); + const outsideDir = join(rootDir, "outside"); + await Promise.all([Deno.mkdir(projectDir), Deno.mkdir(outsideDir)]); + await Deno.symlink(outsideDir, join(projectDir, "escape")); + + await assertRejects( + () => + assertIndependentAssetStageOutputs([ + { stage: "images", projectDir, outputDir: "escape/images" }, + ]), + TypeError, + "must remain inside its physical project", + ); + }); + }); + + it("rejects relative project boundaries and project-root outputs", async () => { + await assertRejects( + () => + assertIndependentAssetStageOutputs([ + { stage: "images", projectDir: ".", outputDir: "images" }, + ]), + TypeError, + "must be absolute", + ); + + await withDirectory(async (projectDir) => { + await assertRejects( + () => + assertIndependentAssetStageOutputs([ + { stage: "images", projectDir, outputDir: projectDir }, + ]), + TypeError, + "must be inside its project", + ); + }); + }); +}); diff --git a/src/build/asset-pipeline/output-planning.ts b/src/build/asset-pipeline/output-planning.ts new file mode 100644 index 0000000000..7c370fa9c0 --- /dev/null +++ b/src/build/asset-pipeline/output-planning.ts @@ -0,0 +1,158 @@ +import { basename, dirname, isAbsolute, resolve } from "#veryfront/compat/path/index.ts"; +import { isNotFoundError, realPath } from "#veryfront/platform/compat/fs.ts"; +import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; +import { isContainedAssetPath } from "../utils/asset-utils.ts"; +import { hasControlCharacters } from "../utils/string-validation.ts"; + +/** A build stage and the output tree it intends to publish. */ +export interface AssetStageOutputPlan { + readonly stage: string; + readonly projectDir: string; + readonly outputDir: string; +} + +interface CanonicalAssetStageOutput { + readonly stage: string; + readonly projectDir: string; + readonly outputDir: string; + readonly comparisonProjectDir: string; + readonly comparisonOutputDir: string; +} + +function assertSafePath(value: unknown, label: string): asserts value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_PATH_LENGTH_CHARS || + hasControlCharacters(value) + ) { + throw new TypeError(`${label} must be a safe non-empty path`); + } +} + +function comparisonPath(path: string): string { + return resolve(path) + .replaceAll("\\", "/") + .normalize("NFC") + .toLocaleLowerCase("en-US"); +} + +function isComparisonPathContained(basePath: string, candidatePath: string): boolean { + return candidatePath === basePath || candidatePath.startsWith( + basePath.endsWith("/") ? basePath : `${basePath}/`, + ); +} + +/** + * Resolve a path through its nearest existing ancestor. + * + * Build outputs commonly do not exist yet. Resolving the nearest existing + * ancestor still exposes symlink aliases before a stage creates or replaces + * any output. + */ +export async function canonicalizePlannedAssetPath(path: string): Promise { + assertSafePath(path, "Asset output path"); + const absolutePath = resolve(path); + + try { + return resolve(await realPath(absolutePath)); + } catch (error) { + if (!isNotFoundError(error)) throw error; + } + + const suffix: string[] = []; + let candidate = absolutePath; + while (true) { + const parent = dirname(candidate); + if (parent === candidate) { + throw new TypeError(`Cannot resolve asset output path: ${path}`); + } + suffix.unshift(basename(candidate)); + try { + return resolve(await realPath(parent), ...suffix); + } catch (error) { + if (!isNotFoundError(error)) throw error; + candidate = parent; + } + } +} + +async function canonicalizePlan( + plan: AssetStageOutputPlan, +): Promise { + if (typeof plan !== "object" || plan === null || Array.isArray(plan)) { + throw new TypeError("Asset stage output plan must be an object"); + } + assertSafePath(plan.stage, "Asset stage name"); + assertSafePath(plan.projectDir, `Asset pipeline ${plan.stage} project directory`); + assertSafePath(plan.outputDir, `Asset pipeline ${plan.stage} output directory`); + + if (!isAbsolute(plan.projectDir)) { + throw new TypeError(`Asset pipeline ${plan.stage} project directory must be absolute`); + } + + const configuredProject = resolve(plan.projectDir); + const configuredOutput = isAbsolute(plan.outputDir) + ? resolve(plan.outputDir) + : resolve(configuredProject, plan.outputDir); + if ( + configuredOutput === configuredProject || + !isContainedAssetPath(configuredProject, configuredOutput) + ) { + throw new TypeError( + `Asset pipeline ${plan.stage} output directory must be inside its project`, + ); + } + + const [projectDir, outputDir] = await Promise.all([ + realPath(configuredProject).then(resolve), + canonicalizePlannedAssetPath(configuredOutput), + ]); + const comparisonProjectDir = comparisonPath(projectDir); + const comparisonOutputDir = comparisonPath(outputDir); + if ( + comparisonOutputDir === comparisonProjectDir || + !isComparisonPathContained(comparisonProjectDir, comparisonOutputDir) + ) { + throw new TypeError( + `Asset pipeline ${plan.stage} output directory must remain inside its physical project`, + ); + } + + return { + stage: plan.stage, + projectDir, + outputDir, + comparisonProjectDir, + comparisonOutputDir, + }; +} + +/** + * Reject output trees that overlap after resolving filesystem aliases. + * + * This must run before provider initialization: two otherwise independent + * stages cannot safely provide transactional publication when their physical + * destinations are the same tree or ancestor/descendant trees. + */ +export async function assertIndependentAssetStageOutputs( + plans: readonly AssetStageOutputPlan[], +): Promise { + if (!Array.isArray(plans)) { + throw new TypeError("Asset stage output plans must be an array"); + } + const canonical = await Promise.all(plans.map(canonicalizePlan)); + + for (const [index, first] of canonical.entries()) { + for (const second of canonical.slice(index + 1)) { + if ( + isComparisonPathContained(first.comparisonOutputDir, second.comparisonOutputDir) || + isComparisonPathContained(second.comparisonOutputDir, first.comparisonOutputDir) + ) { + throw new TypeError( + `Asset pipeline output directories for ${first.stage} and ${second.stage} must not overlap physically`, + ); + } + } + } +} diff --git a/src/build/production-build/build/build-publication.test.ts b/src/build/production-build/build/build-publication.test.ts new file mode 100644 index 0000000000..d011297ef0 --- /dev/null +++ b/src/build/production-build/build/build-publication.test.ts @@ -0,0 +1,177 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createFileSystem, type FileSystem } from "#veryfront/platform/compat/fs.ts"; +import { createBuildPublication, nativeBuildPublicationLock } from "./build-publication.ts"; + +describe("build/production-build/build/build-publication", () => { + it("replaces a previous output only when the staged build is published", async () => { + const root = await Deno.makeTempDir({ prefix: "vf-build-publication-" }); + const outputDir = `${root}/dist`; + await Deno.mkdir(outputDir); + await Deno.writeTextFile(`${outputDir}/version.txt`, "old"); + + const publication = await createBuildPublication(outputDir, false); + try { + await Deno.mkdir(publication.buildDir); + await Deno.writeTextFile(`${publication.buildDir}/version.txt`, "new"); + assertEquals(await Deno.readTextFile(`${outputDir}/version.txt`), "old"); + + await publication.publish(); + + assertEquals(await Deno.readTextFile(`${outputDir}/version.txt`), "new"); + } finally { + await publication.cleanup(); + await Deno.remove(root, { recursive: true }); + } + }); + + it("removes an abandoned stage and preserves the previous output", async () => { + const root = await Deno.makeTempDir({ prefix: "vf-build-publication-" }); + const outputDir = `${root}/dist`; + await Deno.mkdir(outputDir); + await Deno.writeTextFile(`${outputDir}/version.txt`, "old"); + + const publication = await createBuildPublication(outputDir, false); + try { + await Deno.mkdir(publication.buildDir); + await Deno.writeTextFile(`${publication.buildDir}/partial.txt`, "partial"); + } finally { + await publication.cleanup(); + } + + try { + assertEquals(await Deno.readTextFile(`${outputDir}/version.txt`), "old"); + await assertRejects( + () => Deno.stat(publication.buildDir), + Deno.errors.NotFound, + ); + } finally { + await Deno.remove(root, { recursive: true }); + } + }); + + it("allows cleanup to be retried after a transient filesystem failure", async () => { + const root = await Deno.makeTempDir({ prefix: "vf-build-publication-" }); + const outputDir = `${root}/dist`; + const delegate = createFileSystem(); + let failStageRemoval = true; + const flakyFs = new Proxy(delegate, { + get(target, property) { + if (property === "remove") { + return async (path: string, options?: { recursive?: boolean }): Promise => { + if (path.includes(".veryfront-stage-") && failStageRemoval) { + failStageRemoval = false; + throw new Error("transient removal failure"); + } + await target.remove(path, options); + }; + } + const value = Reflect.get(target, property); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as FileSystem; + + const publication = await createBuildPublication(outputDir, false, { + fs: flakyFs, + lock: nativeBuildPublicationLock, + }); + await Deno.mkdir(publication.buildDir); + try { + await assertRejects( + () => publication.cleanup(), + Error, + "transient removal failure", + ); + await publication.cleanup(); + await assertRejects( + () => Deno.stat(publication.buildDir), + Deno.errors.NotFound, + ); + } finally { + await Deno.remove(root, { recursive: true }); + } + }); + + it("rolls back the previous output when stage promotion fails", async () => { + const root = await Deno.makeTempDir({ prefix: "vf-build-publication-" }); + const outputDir = `${root}/dist`; + await Deno.mkdir(outputDir); + await Deno.writeTextFile(`${outputDir}/version.txt`, "old"); + const delegate = createFileSystem(); + let renameCalls = 0; + const failingFs = new Proxy(delegate, { + get(target, property) { + if (property === "rename") { + return async (from: string, to: string): Promise => { + renameCalls++; + if (renameCalls === 2) throw new Error("promotion failed"); + await target.rename!(from, to); + }; + } + const value = Reflect.get(target, property); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as FileSystem; + + const publication = await createBuildPublication(outputDir, false, { + fs: failingFs, + lock: nativeBuildPublicationLock, + }); + try { + await Deno.mkdir(publication.buildDir); + await Deno.writeTextFile(`${publication.buildDir}/version.txt`, "new"); + await assertRejects( + () => publication.publish(), + Error, + "Failed to publish staged build output", + ); + assertEquals(await Deno.readTextFile(`${outputDir}/version.txt`), "old"); + } finally { + await publication.cleanup(); + await Deno.remove(root, { recursive: true }); + } + }); + + it("serializes concurrent builds targeting the same output", async () => { + const root = await Deno.makeTempDir({ prefix: "vf-build-publication-" }); + const outputDir = `${root}/dist`; + const first = await createBuildPublication(outputDir, false); + try { + await assertRejects( + () => + createBuildPublication(outputDir, false, { + lockTimeoutMs: 25, + }), + Error, + "Timed out waiting for build output lock", + ); + } finally { + await first.cleanup(); + await Deno.remove(root, { recursive: true }); + } + }); + + it("requires custom filesystems to provide matching lock authority", async () => { + const delegate = createFileSystem(); + await assertRejects( + () => createBuildPublication("dist", false, { fs: delegate }), + Error, + "require a matching lock provider", + ); + }); + + it("does not create staging or lock artifacts for dry runs", async () => { + const root = await Deno.makeTempDir({ prefix: "vf-build-publication-" }); + const outputDir = `${root}/dist`; + try { + const publication = await createBuildPublication(outputDir, true); + assertEquals(publication.buildDir, outputDir); + await publication.publish(); + await publication.cleanup(); + assertEquals([...Deno.readDirSync(root)].length, 0); + } finally { + await Deno.remove(root, { recursive: true }); + } + }); +}); diff --git a/src/build/production-build/build/build-publication.ts b/src/build/production-build/build/build-publication.ts new file mode 100644 index 0000000000..7b80ec39fc --- /dev/null +++ b/src/build/production-build/build/build-publication.ts @@ -0,0 +1,209 @@ +import { basename, dirname, join, resolve } from "#veryfront/compat/path/index.ts"; +import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { BUILD_FAILED } from "#veryfront/errors"; +import { serverLogger } from "#veryfront/utils"; + +const logger = serverLogger.component("build-publication"); +const DEFAULT_LOCK_TIMEOUT_MS = 10_000; +const LOCK_RETRY_INITIAL_MS = 10; +const LOCK_RETRY_MAX_MS = 100; + +export interface BuildPublication { + readonly finalDir: string; + readonly buildDir: string; + publish(): Promise; + cleanup(): Promise; +} + +export interface BuildPublicationDependencies { + fs?: BuildPublicationFileSystem; + lock?: BuildPublicationLock; + lockTimeoutMs?: number; +} + +export interface BuildPublicationLock { + acquire(lockPath: string, timeoutMs: number): Promise<() => Promise>; +} + +export interface BuildPublicationFileSystem { + rename?(from: string, to: string): Promise; + exists(path: string): Promise; + mkdir(path: string, options?: { recursive?: boolean }): Promise; + remove(path: string, options?: { recursive?: boolean }): Promise; +} + +function publicationError(detail: string, cause?: unknown): Error { + return BUILD_FAILED.create({ detail, cause }); +} + +async function acquireBuildLock( + lockPath: string, + timeoutMs: number, +): Promise<() => Promise> { + const nodeFs = await import("node:fs/promises"); + const token = crypto.randomUUID(); + const content = `${JSON.stringify({ token, createdAt: new Date().toISOString() })}\n`; + const startedAt = Date.now(); + let retryDelayMs = LOCK_RETRY_INITIAL_MS; + + while (true) { + let handle: Awaited> | undefined; + let ownsLock = false; + try { + handle = await nodeFs.open(lockPath, "wx"); + ownsLock = true; + await handle.writeFile(content, { encoding: "utf8" }); + await handle.sync(); + await handle.close(); + handle = undefined; + break; + } catch (error) { + await handle?.close().catch(() => undefined); + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + if (ownsLock) { + await nodeFs.rm(lockPath, { force: true }).catch(() => undefined); + } + throw publicationError(`Failed to acquire build output lock: ${lockPath}`, error); + } + if (Date.now() - startedAt >= timeoutMs) { + throw publicationError( + `Timed out waiting for build output lock ${lockPath}; ` + + "confirm no build is active before removing the lock", + ); + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, retryDelayMs)); + retryDelayMs = Math.min(retryDelayMs * 2, LOCK_RETRY_MAX_MS); + } + } + + let released = false; + return async (): Promise => { + if (released) return; + const current = await nodeFs.readFile(lockPath, "utf8").catch((error) => { + throw publicationError(`Failed to verify build output lock: ${lockPath}`, error); + }); + if (current !== content) { + throw publicationError(`Build output lock ownership changed unexpectedly: ${lockPath}`); + } + await nodeFs.rm(lockPath); + released = true; + }; +} + +export const nativeBuildPublicationLock: BuildPublicationLock = { + acquire: acquireBuildLock, +}; + +export async function createBuildPublication( + outputDir: string, + dryRun: boolean, + dependencies: BuildPublicationDependencies = {}, +): Promise { + const finalDir = resolve(outputDir); + if (dryRun) { + return { + finalDir, + buildDir: finalDir, + publish: () => Promise.resolve(), + cleanup: () => Promise.resolve(), + }; + } + + const fs = dependencies.fs ?? createFileSystem(); + const lock = dependencies.lock ?? + (dependencies.fs === undefined ? nativeBuildPublicationLock : undefined); + if (!lock) { + throw publicationError( + "Custom build publication filesystems require a matching lock provider", + ); + } + const rename = fs.rename?.bind(fs); + if (!rename) { + throw publicationError( + "Atomic build publication requires filesystem rename support", + ); + } + + const parentDir = dirname(finalDir); + const outputName = basename(finalDir); + const id = crypto.randomUUID(); + const buildDir = join(parentDir, `.${outputName}.veryfront-stage-${id}`); + const backupDir = join(parentDir, `.${outputName}.veryfront-backup-${id}`); + const lockPath = join(parentDir, `.${outputName}.veryfront-build.lock`); + + await fs.mkdir(parentDir, { recursive: true }); + const releaseLock = await lock.acquire( + lockPath, + dependencies.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, + ); + + let published = false; + let cleaned = false; + + return { + finalDir, + buildDir, + async publish(): Promise { + if (published) return; + if (!(await fs.exists(buildDir))) { + throw publicationError(`Build staging directory is missing: ${buildDir}`); + } + + const hadPreviousOutput = await fs.exists(finalDir); + if (hadPreviousOutput) await rename(finalDir, backupDir); + + try { + await rename(buildDir, finalDir); + published = true; + } catch (error) { + if (hadPreviousOutput) { + try { + await rename(backupDir, finalDir); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + "Build publication failed and the previous output could not be restored", + ); + } + } + throw publicationError("Failed to publish staged build output", error); + } + + if (hadPreviousOutput) { + try { + await fs.remove(backupDir, { recursive: true }); + } catch (error) { + logger.warn("Published build but could not remove its backup directory", { + backupDir, + error, + }); + } + } + }, + async cleanup(): Promise { + if (cleaned) return; + const errors: unknown[] = []; + + if (!published && await fs.exists(buildDir)) { + try { + await fs.remove(buildDir, { recursive: true }); + } catch (error) { + errors.push(error); + } + } + + try { + await releaseLock(); + } catch (error) { + errors.push(error); + } + + if (errors.length === 0) { + cleaned = true; + return; + } + if (errors.length === 1) throw errors[0]; + throw new AggregateError(errors, "Failed to clean up build publication resources"); + }, + }; +} diff --git a/src/build/utils/asset-utils.test.ts b/src/build/utils/asset-utils.test.ts index 8930d576b7..993b3a5ec2 100644 --- a/src/build/utils/asset-utils.test.ts +++ b/src/build/utils/asset-utils.test.ts @@ -4,10 +4,13 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import type { OptimizedImageMetadata } from "../asset-pipeline/image-optimizer/types.ts"; import { calculateAspectRatio, + calculateRequiredAspectRatio, generateSrcSet, getImageDimensions, + getRequiredImageDimensions, getStandardPseudoSelectors, getVariantPath, + isContainedAssetPath, isPseudoSelector, } from "./asset-utils.ts"; @@ -16,6 +19,7 @@ function createMetadata( ): OptimizedImageMetadata { return { original: "img.jpg", + originalSize: 1_024, defaultFormat: "webp", aspectRatio: 4 / 3, variants: [], @@ -24,6 +28,45 @@ function createMetadata( } describe("build/utils/asset-utils", () => { + describe("isContainedAssetPath", () => { + it("accepts equality and descendants for absolute and relative paths", () => { + assertEquals(isContainedAssetPath("/project", "/project"), true); + assertEquals(isContainedAssetPath("/project", "/project/assets/app.css"), true); + assertEquals(isContainedAssetPath(".", "assets/app.css"), true); + assertEquals(isContainedAssetPath("project", "project/assets/app.css"), true); + }); + + it("rejects traversal, siblings, unrelated absolutes, and different drives", () => { + assertEquals( + isContainedAssetPath("/project", "/project/assets/../../secret.css"), + false, + ); + assertEquals(isContainedAssetPath("/project", "/project-other/app.css"), false); + assertEquals(isContainedAssetPath("/project", "/other/app.css"), false); + assertEquals( + isContainedAssetPath("C:/project", "D:/project/assets/app.css"), + false, + ); + }); + + it("normalizes Windows separators without prefix confusion", () => { + assertEquals( + isContainedAssetPath( + String.raw`C:\project`, + String.raw`C:\project\assets\app.css`, + ), + true, + ); + assertEquals( + isContainedAssetPath( + String.raw`C:\project`, + String.raw`C:\project-other\app.css`, + ), + false, + ); + }); + }); + describe("isPseudoSelector", () => { it("should detect pseudo selectors", () => { assertEquals(isPseudoSelector(":hover"), true); @@ -68,10 +111,14 @@ describe("build/utils/asset-utils", () => { assertEquals(calculateAspectRatio(100, 100), 1); }); - it("should return 1 for undefined dimensions", () => { + it("should preserve the legacy square fallback for missing dimensions", () => { assertEquals(calculateAspectRatio(undefined, 100), 1); assertEquals(calculateAspectRatio(100, undefined), 1); - assertEquals(calculateAspectRatio(undefined, undefined), 1); + }); + + it("should provide a strict calculation for production build paths", () => { + assertEquals(calculateRequiredAspectRatio(1920, 1080), 1920 / 1080); + assertThrows(() => calculateRequiredAspectRatio(0, 100), TypeError); }); }); @@ -144,6 +191,14 @@ describe("build/utils/asset-utils", () => { it("should return dimensions of default format variant", () => { const metadata = createMetadata({ variants: [ + { + path: "img-400w.webp", + format: "webp", + width: 400, + height: 300, + size: 1000, + fileSize: 1000, + }, { path: "img-800w.webp", format: "webp", @@ -168,7 +223,7 @@ describe("build/utils/asset-utils", () => { assertEquals(dims.height, 600); }); - it("should fallback to first variant", () => { + it("should preserve the legacy fallback to an available format", () => { const metadata = createMetadata({ defaultFormat: "png", variants: [ @@ -183,9 +238,12 @@ describe("build/utils/asset-utils", () => { ], }); - const dims = getImageDimensions(metadata); - assertEquals(dims.width, 400); - assertEquals(dims.height, 300); + assertEquals(getImageDimensions(metadata), { width: 400, height: 300 }); + assertThrows( + () => getRequiredImageDimensions(metadata), + Error, + "No png image variants", + ); }); it("should throw if no variants", () => { diff --git a/src/build/utils/asset-utils.ts b/src/build/utils/asset-utils.ts index ff2f81c077..c37741ebf2 100644 --- a/src/build/utils/asset-utils.ts +++ b/src/build/utils/asset-utils.ts @@ -3,10 +3,21 @@ * Merges CSS, Image, and Tailwind processor utilities */ -import { basename, dirname, extname, join } from "#veryfront/compat/path/index.ts"; +import { + basename, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, +} from "#veryfront/compat/path/index.ts"; import { walk } from "#std/fs.ts"; import { logger } from "#veryfront/utils"; -import type { OptimizedImageMetadata } from "../asset-pipeline/image-optimizer/types.ts"; +import type { + ImageVariant, + OptimizedImageMetadata, +} from "../asset-pipeline/image-optimizer/types.ts"; import { createError, toError } from "#veryfront/errors"; import { getOptimizedImageFormat, @@ -16,6 +27,20 @@ import { export const CSS_EXTENSIONS = [".css", ".scss", ".sass", ".less"]; +/** + * Return whether an asset path resolves to the base directory or one of its + * descendants. Resolving both operands keeps relative paths anchored to the + * same working directory, while `relative` handles platform path flavors. + */ +export function isContainedAssetPath(basePath: string, candidatePath: string): boolean { + const relativePath = relative(resolve(basePath), resolve(candidatePath)); + return relativePath === "." || + (relativePath !== ".." && + !relativePath.startsWith("../") && + !relativePath.startsWith("..\\") && + !isAbsolute(relativePath)); +} + export async function findCSSFiles(dir: string): Promise { const cssFiles: string[] = []; @@ -109,11 +134,26 @@ export function generateSrcSet( format?: ImageFormat, ): string { const targetFormat = format ?? metadata.defaultFormat; - const variants = metadata.variants.filter((v) => v.format === targetFormat); - - return variants.map((v) => `/${join(outputDir, v.path)} ${v.width}w`).join(", "); + const variants = metadata.variants + .filter((variant) => variant.format === targetFormat) + .sort((left, right) => left.width - right.width); + const outputSegments = outputDir.replaceAll("\\", "/").split("/") + .filter((segment) => segment.length > 0 && segment !== "."); + + return variants.map((variant) => { + const variantSegments = variant.path.replaceAll("\\", "/").split("/") + .filter((segment) => segment.length > 0 && segment !== "."); + const url = [...outputSegments, ...variantSegments] + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return `/${url} ${variant.width}w`; + }).join(", "); } +/** + * @deprecated Missing dimensions return the historical square ratio. Build + * code should use `calculateRequiredAspectRatio`. + */ export function calculateAspectRatio( width: number | undefined, height: number | undefined, @@ -121,24 +161,78 @@ export function calculateAspectRatio( return width && height ? width / height : 1; } +/** + * Strict aspect-ratio calculation for build paths where dimensions are + * required. Unlike the legacy `calculateAspectRatio` helper, this never + * substitutes a square ratio for missing metadata. + */ +export function calculateRequiredAspectRatio( + width: number, + height: number, +): number { + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + throw new TypeError("Image dimensions must be positive finite numbers"); + } + return width / height; +} + export const getOptimizedFormat = getOptimizedImageFormat; export const isImageFile = checkIsImage; +/** + * @deprecated Falls back to another available format for compatibility. Build + * code should use `getRequiredImageDimensions`. + */ export function getImageDimensions(metadata: OptimizedImageMetadata): { width: number; height: number; } { - const original = metadata.variants.find((v) => v.format === metadata.defaultFormat) ?? - metadata.variants[0]; - - if (!original) { - throw toError( - createError({ - type: "build", - message: "No image variants found in metadata", - }), - ); + const defaultVariant = findLargestVariant( + metadata.variants.filter((variant) => variant.format === metadata.defaultFormat), + ); + const selected = defaultVariant ?? findLargestVariant(metadata.variants); + if (!selected) { + throwMissingVariantError(metadata.defaultFormat); + } + return { width: selected.width, height: selected.height }; +} + +/** + * Resolve dimensions only from the declared default output format. + */ +export function getRequiredImageDimensions( + metadata: OptimizedImageMetadata, +): { width: number; height: number } { + const selected = findLargestVariant( + metadata.variants + .filter((variant) => variant.format === metadata.defaultFormat), + ); + + if (!selected) { + throwMissingVariantError(metadata.defaultFormat); } + return { width: selected.width, height: selected.height }; +} + +function findLargestVariant( + variants: ImageVariant[], +): ImageVariant | undefined { + return variants.reduce( + (largest, variant) => !largest || variant.width > largest.width ? variant : largest, + undefined, + ); +} - return { width: original.width, height: original.height }; +function throwMissingVariantError(defaultFormat: string): never { + throw toError( + createError({ + type: "build", + message: `No ${defaultFormat} image variants found in metadata`, + }), + ); } diff --git a/src/build/utils/index.ts b/src/build/utils/index.ts index e321a88b91..c8a5c54948 100644 --- a/src/build/utils/index.ts +++ b/src/build/utils/index.ts @@ -6,14 +6,17 @@ export { calculateAspectRatio, + calculateRequiredAspectRatio, CSS_EXTENSIONS, findCSSFiles, generateSrcSet, getImageDimensions, getOptimizedFormat, + getRequiredImageDimensions, getStandardPseudoSelectors, getVariantPath, globFiles, + isContainedAssetPath, isImageFile, isPseudoSelector, } from "./asset-utils.ts"; diff --git a/src/build/utils/string-validation.test.ts b/src/build/utils/string-validation.test.ts new file mode 100644 index 0000000000..3c3467df60 --- /dev/null +++ b/src/build/utils/string-validation.test.ts @@ -0,0 +1,17 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { hasControlCharacters } from "./string-validation.ts"; + +describe("build/utils/string-validation", () => { + it("recognizes C0, DEL, and C1 control characters", () => { + assertEquals(hasControlCharacters("plain text"), false); + assertEquals(hasControlCharacters("line\nbreak"), true); + assertEquals(hasControlCharacters(`del${String.fromCharCode(0x7f)}`), true); + assertEquals(hasControlCharacters(`c1${String.fromCharCode(0x85)}`), true); + }); + + it("does not reject printable Unicode", () => { + assertEquals(hasControlCharacters("Grüße 世界 🚀"), false); + }); +}); diff --git a/src/build/utils/string-validation.ts b/src/build/utils/string-validation.ts new file mode 100644 index 0000000000..f5ce8b4a15 --- /dev/null +++ b/src/build/utils/string-validation.ts @@ -0,0 +1,8 @@ +/** Whether text contains a C0, DEL, or C1 control character. */ +export function hasControlCharacters(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true; + } + return false; +} diff --git a/src/config/schemas/config.schema.test.ts b/src/config/schemas/config.schema.test.ts index 3e16573b9b..3dfa14b206 100644 --- a/src/config/schemas/config.schema.test.ts +++ b/src/config/schemas/config.schema.test.ts @@ -1,6 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { CSS_OPTIMIZATION } from "#veryfront/utils/constants/build.ts"; import { findUnknownTopLevelKeys, validateVeryfrontConfig } from "./config.schema.ts"; describe("configSchema", () => { @@ -14,6 +15,42 @@ describe("configSchema", () => { assertEquals(findUnknownTopLevelKeys({ foo: 1, router: "pages" }), ["foo"]); }); + it("keeps CSS asset-pipeline schema constraints aligned with runtime", () => { + const projectDir = Deno.cwd(); + const css = { + enabled: true, + projectDir, + inputFiles: ["styles/main.css"], + browsers: ["defaults", "not IE 11"], + purge: true, + purgeContent: ["app/**/*.tsx"], + purgeSafelist: ["dynamic"], + }; + assertEquals( + validateVeryfrontConfig({ assetPipeline: { css } }).assetPipeline?.css, + css, + ); + + for ( + const invalid of [ + { projectDir: "relative/project" }, + { browsers: [] }, + { + purgeSafelist: Array.from( + { length: CSS_OPTIMIZATION.MAX_PURGE_SAFELIST_ENTRIES + 1 }, + (_, index) => `selector-${index}`, + ), + }, + ] + ) { + assertThrows( + () => validateVeryfrontConfig({ assetPipeline: { css: invalid } }), + Error, + "assetPipeline.css", + ); + } + }); + it("accepts build.ssg as a boolean", () => { const enabled = validateVeryfrontConfig({ build: { ssg: true } }); assertEquals(enabled.build?.ssg, true); diff --git a/src/config/schemas/config.schema.ts b/src/config/schemas/config.schema.ts index 50c38076af..03c9e057d4 100644 --- a/src/config/schemas/config.schema.ts +++ b/src/config/schemas/config.schema.ts @@ -1,8 +1,11 @@ import { defineSchema, lazySchema } from "#veryfront/schemas/index.ts"; +import { isAbsolute } from "#veryfront/compat/path/index.ts"; import type { InferInput, InferSchema } from "#veryfront/extensions/schema/index.ts"; import { type ConfigContext, createError, toError } from "#veryfront/errors/veryfront-error.ts"; import { ALL_INTEGRATION_NAMES } from "#veryfront/integrations/schema.ts"; import type { SourceIntegrationPolicyConfig } from "#veryfront/integrations/source-policy.ts"; +import { CSS_OPTIMIZATION, IMAGE_OPTIMIZATION } from "#veryfront/utils/constants/build.ts"; +import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; const integrationNames = new Set(ALL_INTEGRATION_NAMES); @@ -232,8 +235,16 @@ export const getVeryfrontConfigSchema = defineSchema((v) => images: v .object({ enabled: v.boolean().optional(), + projectDir: v.string().optional(), formats: v.array(v.enum(["webp", "avif", "jpeg", "png"])).optional(), - sizes: v.array(v.number().int().positive()).optional(), + sizes: v + .array( + v.number().int().positive().max( + IMAGE_OPTIMIZATION.MAX_DIMENSION, + ), + ) + .max(IMAGE_OPTIMIZATION.MAX_OUTPUT_SIZES) + .optional(), quality: v.number().int().min(1).max(100).optional(), inputDir: v.string().optional(), outputDir: v.string().optional(), @@ -244,14 +255,42 @@ export const getVeryfrontConfigSchema = defineSchema((v) => css: v .object({ enabled: v.boolean().optional(), + projectDir: v + .string() + .min(1) + .max(MAX_PATH_LENGTH_CHARS) + .refine( + isAbsolute, + "CSS projectDir must be an absolute path", + ) + .optional(), minify: v.boolean().optional(), autoprefixer: v.boolean().optional(), purge: v.boolean().optional(), criticalCSS: v.boolean().optional(), - inputDir: v.string().optional(), - outputDir: v.string().optional(), - browsers: v.array(v.string()).optional(), - purgeContent: v.array(v.string()).optional(), + inputFiles: v + .array(v.string().min(1).max(MAX_PATH_LENGTH_CHARS)) + .max(CSS_OPTIMIZATION.MAX_FILES) + .optional(), + inputDir: v.string().min(1).max(MAX_PATH_LENGTH_CHARS).optional(), + outputDir: v.string().min(1).max(MAX_PATH_LENGTH_CHARS).optional(), + browsers: v + .array( + v.string().min(1).max( + CSS_OPTIMIZATION.MAX_BROWSER_QUERY_CHARACTERS, + ), + ) + .min(1) + .max(CSS_OPTIMIZATION.MAX_BROWSER_QUERIES) + .optional(), + purgeContent: v + .array(v.string().min(1).max(MAX_PATH_LENGTH_CHARS)) + .max(CSS_OPTIMIZATION.MAX_PURGE_PATTERNS) + .optional(), + purgeSafelist: v + .array(v.string().min(1).max(MAX_PATH_LENGTH_CHARS)) + .max(CSS_OPTIMIZATION.MAX_PURGE_SAFELIST_ENTRIES) + .optional(), sourceMap: v.boolean().optional(), }) .partial() diff --git a/src/extensions/capabilities.test.ts b/src/extensions/capabilities.test.ts index 677376a9a4..3ca7d04753 100644 --- a/src/extensions/capabilities.test.ts +++ b/src/extensions/capabilities.test.ts @@ -5,7 +5,7 @@ import "#veryfront/schemas/_test-setup.ts"; * @module extensions/capabilities.test */ -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { formatCapabilities, mapToDenoPermissions } from "./capabilities.ts"; import type { Capability } from "./types.ts"; @@ -43,6 +43,41 @@ describe("capabilities", () => { assertEquals(perms, ["--allow-read=./src,./public"]); }); + it("rejects malformed scalar filesystem scopes", () => { + assertThrows( + () => + mapToDenoPermissions([{ + type: "fs:read", + paths: "./src", + } as unknown as Capability]), + TypeError, + "string array", + ); + }); + + it("snapshots scoped permissions without invoking array methods", () => { + const paths = ["./src"]; + Object.defineProperty(paths, "some", { + value: () => { + throw new Error("must not execute extension-owned array methods"); + }, + }); + assertEquals( + mapToDenoPermissions([{ type: "fs:read", paths }]), + ["--allow-read=./src"], + ); + }); + + it("rejects empty and comma-delimited permission scopes", () => { + for (const path of ["", "./src,./secrets"]) { + assertThrows( + () => mapToDenoPermissions([{ type: "fs:read", paths: [path] }]), + TypeError, + "non-empty strings without commas", + ); + } + }); + it("should map net:outbound to --allow-net with hosts", () => { const caps: Capability[] = [{ type: "net:outbound", hosts: ["api.example.com"] }]; const perms = mapToDenoPermissions(caps); @@ -61,6 +96,132 @@ describe("capabilities", () => { assertEquals(perms, ["--allow-run=esbuild"]); }); + it("maps declared read-only system APIs without broadening access", () => { + const caps: Capability[] = [{ + type: "system:read", + apis: ["cpus", "getPriority", "cpus"], + }]; + assertEquals( + mapToDenoPermissions(caps), + ["--allow-sys=cpus,getPriority"], + ); + }); + + it("requires an explicit, dense system API scope", () => { + assertThrows( + () => mapToDenoPermissions([{ type: "system:read" }]), + TypeError, + "system:read.apis must be an enumerable own data property", + ); + assertThrows( + () => mapToDenoPermissions([{ type: "system:read", apis: [] }]), + TypeError, + "must contain between 1 and", + ); + + const sparse = new Array(1); + assertThrows( + () => mapToDenoPermissions([{ type: "system:read", apis: sparse }]), + TypeError, + "system:read.apis[0] must be an enumerable own data property", + ); + }); + + it("rejects unsupported and mutating system APIs", () => { + for (const api of ["setPriority", "foobar", "cpus=all", "node:os.cpus"]) { + assertThrows( + () => mapToDenoPermissions([{ type: "system:read", apis: [api] }]), + TypeError, + "supported read-only Deno system API name", + ); + } + }); + + it("does not invoke system scope accessors or custom iterators", () => { + let accessorReads = 0; + const accessorCapability = { type: "system:read" } as Capability; + Object.defineProperty(accessorCapability, "apis", { + enumerable: true, + get() { + accessorReads++; + return ["cpus"]; + }, + }); + assertThrows( + () => mapToDenoPermissions([accessorCapability]), + TypeError, + "own data property", + ); + assertEquals(accessorReads, 0); + + let iteratorReads = 0; + const apis = ["cpus"]; + Object.defineProperty(apis, Symbol.iterator, { + get() { + iteratorReads++; + throw new Error("iterator must not be read"); + }, + }); + assertEquals( + mapToDenoPermissions([{ type: "system:read", apis }]), + ["--allow-sys=cpus"], + ); + assertEquals(iteratorReads, 0); + }); + + it("rejects inherited scopes and hostile proxy inspection", () => { + const inherited = Object.assign( + Object.create({ apis: ["cpus"] }), + { type: "system:read" }, + ) as Capability; + assertThrows( + () => mapToDenoPermissions([inherited]), + TypeError, + "own data property", + ); + + const proxy = new Proxy( + { type: "system:read", apis: ["cpus"] }, + { + getOwnPropertyDescriptor(target, property) { + if (property === "apis") throw new Error("blocked"); + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }, + ); + assertThrows( + () => mapToDenoPermissions([proxy]), + TypeError, + "could not be inspected", + ); + }); + + it("cannot be broadened by poisoned Set operations", () => { + const originalHas = Set.prototype.has; + let rejected: unknown; + let allowed: string[] | undefined; + try { + Set.prototype.has = () => true; + try { + mapToDenoPermissions([{ + type: "system:read", + apis: ["setPriority"], + }]); + } catch (error) { + rejected = error; + } + allowed = mapToDenoPermissions([{ + type: "system:read", + apis: ["cpus"], + }]); + } finally { + Set.prototype.has = originalHas; + } + + assertEquals(rejected instanceof TypeError, true); + assertEquals(allowed, ["--allow-sys=cpus"]); + }); + it("should map net:listen ports to localhost:port by default", () => { const caps: Capability[] = [{ type: "net:listen", ports: [3000, 8080] }]; const perms = mapToDenoPermissions(caps); diff --git a/src/extensions/capabilities.ts b/src/extensions/capabilities.ts index 9e182aa041..2d99954043 100644 --- a/src/extensions/capabilities.ts +++ b/src/extensions/capabilities.ts @@ -5,6 +5,18 @@ */ import type { Capability, ExtensionLogger } from "./types.ts"; +import { + applyExtensionMethod, + freezeExtensionContract, + getExtensionOwnPropertyDescriptor, + isDataPropertyDescriptor, + isExtensionArray, +} from "./property-inspection.ts"; + +const isInteger = Number.isInteger; +const joinArray = Array.prototype.join; +const mapGet = Map.prototype.get; +const stringIndexOf = String.prototype.indexOf; /** * Format capabilities as human-readable strings for logging. @@ -27,51 +39,235 @@ interface PermissionMapping { scopeKey?: string; /** Resolve scopes from the full capability (overrides scopeKey when present). */ resolveScopes?: (cap: Capability) => string[]; + /** Fail closed instead of emitting an unscoped permission. */ + requireScopes?: boolean; +} + +/** + * Read-only system information kinds from Deno's broader system permission + * contract. Mutating operations such as `setPriority` are deliberately absent. + */ +const DENO_READ_ONLY_SYSTEM_APIS = freezeExtensionContract( + [ + "loadavg", + "hostname", + "systemMemoryInfo", + "networkInterfaces", + "osRelease", + "osUptime", + "uid", + "gid", + "username", + "cpus", + "homedir", + "statfs", + "getPriority", + ] as const, +); + +/** Return whether a Deno system permission name is explicitly read-only. */ +export function isSupportedDenoSystemReadApi(value: string): boolean { + for (let index = 0; index < DENO_READ_ONLY_SYSTEM_APIS.length; index++) { + if (DENO_READ_ONLY_SYSTEM_APIS[index] === value) return true; + } + return false; } -const DENO_PERMISSION_MAP: Record = { - "fs:read": { flag: "--allow-read", scopeKey: "paths" }, - "fs:write": { flag: "--allow-write", scopeKey: "paths" }, - "net:outbound": { flag: "--allow-net", scopeKey: "hosts" }, - "net:listen": { +function readOwnDataProperty( + value: object, + property: PropertyKey, + label: string, + enumerable: boolean, +): unknown { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = getExtensionOwnPropertyDescriptor(value, property); + } catch (cause) { + throw new TypeError(`${label} could not be inspected`, { cause }); + } + if ( + !isDataPropertyDescriptor(descriptor) || + descriptor.enumerable !== enumerable + ) { + throw new TypeError( + `${label} must be ${enumerable ? "an enumerable" : "a non-enumerable"} own data property`, + ); + } + return descriptor.value; +} + +/** Snapshot a dense system-read scope without invoking getters or iterators. */ +function resolveSystemReadScopes(capability: Capability): string[] { + const value = readOwnDataProperty(capability, "apis", "system:read.apis", true); + if (!isExtensionArray(value)) { + throw new TypeError("system:read.apis must be a non-empty array"); + } + + const length = readOwnDataProperty(value, "length", "system:read.apis.length", false); + if ( + !isInteger(length) || + (length as number) < 1 || + (length as number) > DENO_READ_ONLY_SYSTEM_APIS.length + ) { + throw new TypeError( + `system:read.apis must contain between 1 and ${DENO_READ_ONLY_SYSTEM_APIS.length} entries`, + ); + } + + const scopes: string[] = []; + for (let index = 0; index < (length as number); index++) { + const api = readOwnDataProperty( + value, + String(index), + `system:read.apis[${index}]`, + true, + ); + if (typeof api !== "string" || !isSupportedDenoSystemReadApi(api)) { + throw new TypeError( + `system:read.apis[${index}] must be a supported read-only Deno system API name`, + ); + } + let duplicate = false; + for (let scopeIndex = 0; scopeIndex < scopes.length; scopeIndex++) { + if (scopes[scopeIndex] === api) { + duplicate = true; + break; + } + } + if (!duplicate) { + scopes[scopes.length] = api; + } + } + return scopes; +} + +/** Validate the bounded scope required by a `system:read` capability. */ +export function assertSystemReadCapability(capability: Capability): void { + resolveSystemReadScopes(capability); +} + +/** Snapshot a dense scoped-permission array without invoking extension code. */ +function resolveStringScopes( + capability: Capability, + scopeKey: string, +): string[] { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = getExtensionOwnPropertyDescriptor(capability, scopeKey); + } catch (cause) { + throw new TypeError(`${capability.type}.${scopeKey} could not be inspected`, { cause }); + } + if (descriptor === undefined) return []; + if (!isDataPropertyDescriptor(descriptor) || descriptor.enumerable !== true) { + throw new TypeError( + `${capability.type} scoped permissions must be an enumerable own data property`, + ); + } + + const value = descriptor.value; + if (!isExtensionArray(value)) { + throw new TypeError(`${capability.type} scoped permissions must be a string array`); + } + const length = readOwnDataProperty( + value, + "length", + `${capability.type}.${scopeKey}.length`, + false, + ); + if (!isInteger(length) || (length as number) < 0) { + throw new TypeError(`${capability.type} scoped permissions must be a dense string array`); + } + + const scopes: string[] = []; + for (let index = 0; index < (length as number); index++) { + const scope = readOwnDataProperty( + value, + String(index), + `${capability.type}.${scopeKey}[${index}]`, + true, + ); + if ( + typeof scope !== "string" || + scope.length === 0 || + applyExtensionMethod(stringIndexOf, scope, [","]) !== -1 + ) { + throw new TypeError( + `${capability.type} scoped permissions must contain non-empty strings without commas`, + ); + } + scopes[scopes.length] = scope; + } + return scopes; +} + +const DENO_PERMISSION_MAP: ReadonlyMap = new Map< + string, + PermissionMapping +>([ + ["fs:read", { flag: "--allow-read", scopeKey: "paths" }], + ["fs:write", { flag: "--allow-write", scopeKey: "paths" }], + ["net:outbound", { flag: "--allow-net", scopeKey: "hosts" }], + ["net:listen", { flag: "--allow-net", - resolveScopes: (cap) => { + resolveScopes: (cap: Capability) => { const ports = cap.ports as (string | number)[] | undefined; if (!ports || ports.length === 0) return []; const host = (cap.host as string) || "localhost"; return ports.map((p) => `${host}:${p}`); }, - }, - "env:read": { flag: "--allow-env", scopeKey: "keys" }, - "process:spawn": { flag: "--allow-run", scopeKey: "commands" }, - "native:ffi": { flag: "--allow-ffi" }, -}; + }], + ["env:read", { flag: "--allow-env", scopeKey: "keys" }], + ["process:spawn", { flag: "--allow-run", scopeKey: "commands" }], + [ + "system:read", + { + flag: "--allow-sys", + resolveScopes: resolveSystemReadScopes, + requireScopes: true, + }, + ], + ["native:ffi", { flag: "--allow-ffi" }], +]); /** * Map capabilities to Deno CLI permission flags. * Skips capabilities without a Deno permission mapping. */ export function mapToDenoPermissions(capabilities: Capability[]): string[] { - const seen = new Set(); const flags: string[] = []; - for (const cap of capabilities) { - const mapping = DENO_PERMISSION_MAP[cap.type]; + for (let capabilityIndex = 0; capabilityIndex < capabilities.length; capabilityIndex++) { + const cap = capabilities[capabilityIndex]!; + const mapping = applyExtensionMethod(mapGet, DENO_PERMISSION_MAP, [ + cap.type, + ]) as PermissionMapping | undefined; if (!mapping) continue; let flag = mapping.flag; - const scopes = mapping.resolveScopes - ? mapping.resolveScopes(cap) - : mapping.scopeKey - ? (cap[mapping.scopeKey] as string[] | undefined) ?? [] - : []; + let scopes: string[]; + if (mapping.resolveScopes) { + scopes = mapping.resolveScopes(cap); + } else if (mapping.scopeKey) { + scopes = resolveStringScopes(cap, mapping.scopeKey); + } else { + scopes = []; + } + if (mapping.requireScopes && scopes.length === 0) { + throw new TypeError(`${cap.type} must resolve at least one scoped permission`); + } if (scopes.length > 0) { - flag = `${flag}=${scopes.join(",")}`; + flag = `${flag}=${applyExtensionMethod(joinArray, scopes, [","])}`; } - if (!seen.has(flag)) { - seen.add(flag); - flags.push(flag); + let duplicate = false; + for (let flagIndex = 0; flagIndex < flags.length; flagIndex++) { + if (flags[flagIndex] === flag) { + duplicate = true; + break; + } + } + if (!duplicate) { + flags[flags.length] = flag; } } diff --git a/src/extensions/contracts.test.ts b/src/extensions/contracts.test.ts index d9baaf192c..beaafa72b0 100644 --- a/src/extensions/contracts.test.ts +++ b/src/extensions/contracts.test.ts @@ -36,6 +36,22 @@ describe("extensions/contracts", () => { "deno add @veryfront/ext-bundler-esbuild", ); }); + + it("recommends first-party asset engine extensions", () => { + for ( + const [contract, packageName] of [ + ["CSSOptimizationEngine", "@veryfront/ext-css-lightning"], + ["CSSPurgingEngine", "@veryfront/ext-css-purgecss"], + ["ImageOptimizationEngine", "@veryfront/ext-image-sharp"], + ] as const + ) { + assertThrows( + () => resolve(contract), + Error, + `deno add ${packageName}`, + ); + } + }); }); describe("tryResolve()", () => { diff --git a/src/extensions/css/css-optimization-engine.test.ts b/src/extensions/css/css-optimization-engine.test.ts new file mode 100644 index 0000000000..8434561910 --- /dev/null +++ b/src/extensions/css/css-optimization-engine.test.ts @@ -0,0 +1,183 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + assertCSSOptimizationEngine, + captureCSSOptimizationEngine, + type CSSOptimizationEngine, + MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, +} from "./css-optimization-engine.ts"; + +interface StatefulCSSOptimizationEngine extends CSSOptimizationEngine { + marker: string; +} + +function engine(): StatefulCSSOptimizationEngine { + return { + cacheIdentity: "test-css-optimization-engine@1", + marker: "bound", + optimize(request) { + return { css: `${this.marker}:${request.css}` }; + }, + }; +} + +describe("CSSOptimizationEngine contract", () => { + it("captures identity, method, implementation, and this binding once", () => { + const value = engine(); + const captured = captureCSSOptimizationEngine(value); + (value as { cacheIdentity: string }).cacheIdentity = "mutated"; + value.optimize = () => ({ css: "replacement" }); + + assertEquals(captured.cacheIdentity, "test-css-optimization-engine@1"); + assertEquals( + captured.optimize({ + css: "input", + sourcePath: "input.css", + minify: true, + sourceMap: false, + }).css, + "bound:input", + ); + }); + + it("accepts inherited data methods without invoking adjacent accessors", () => { + let reads = 0; + const prototype = Object.create(null); + Object.defineProperty(prototype, "optimize", { + value: (request: { css: string }) => ({ css: request.css }), + }); + Object.defineProperty(prototype, "unrelated", { + get() { + reads++; + return "hostile"; + }, + }); + const value = Object.assign(Object.create(prototype), { + cacheIdentity: "prototype-engine@1", + }); + + assertCSSOptimizationEngine(value); + assertEquals(reads, 0); + }); + + it("rejects accessors and proxy inspection failures without invoking getters", () => { + let identityReads = 0; + const accessor = Object.defineProperty({}, "cacheIdentity", { + get() { + identityReads++; + return "hostile"; + }, + }); + Object.defineProperty(accessor, "optimize", { value: engine().optimize }); + + assertThrows(() => assertCSSOptimizationEngine(accessor), TypeError, "own data property"); + assertEquals(identityReads, 0); + + const proxy = new Proxy(engine(), { + getOwnPropertyDescriptor() { + throw new Error("blocked"); + }, + }); + assertThrows( + () => assertCSSOptimizationEngine(proxy), + TypeError, + "properties could not be read", + ); + }); + + it("rejects accessor descriptors even when Object.prototype is polluted", () => { + let inheritedValueReads = 0; + let identityReads = 0; + const previous = Object.getOwnPropertyDescriptor(Object.prototype, "value"); + const restoreDescriptor = previous === undefined + ? undefined + : Object.assign(Object.create(null), previous) as PropertyDescriptor; + const accessor = Object.defineProperty({}, "cacheIdentity", { + get() { + identityReads++; + return "hostile"; + }, + }); + Object.defineProperty(accessor, "optimize", { value: engine().optimize }); + try { + Object.defineProperty(Object.prototype, "value", { + configurable: true, + get() { + inheritedValueReads++; + return "forged-data-descriptor"; + }, + }); + + assertThrows( + () => assertCSSOptimizationEngine(accessor), + TypeError, + "own data property", + ); + assertEquals(inheritedValueReads, 0); + assertEquals(identityReads, 0); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(Object.prototype, "value"); + } else { + Object.defineProperty(Object.prototype, "value", restoreDescriptor!); + } + } + }); + + it("rejects unstable or oversized identities", () => { + for ( + const cacheIdentity of [ + "", + " padded ", + "line\nbreak", + "line\u2028break", + "unpaired\uD800surrogate", + "e\u0301", + "x".repeat(MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS + 1), + ] + ) { + assertThrows( + () => assertCSSOptimizationEngine({ ...engine(), cacheIdentity }), + TypeError, + "bounded stable cacheIdentity", + ); + } + }); + + it("uses captured validation and invocation intrinsics", () => { + const originalTrim = String.prototype.trim; + const originalApply = Reflect.apply; + let paddedIdentityError: unknown; + let optimizedCSS: string | undefined; + try { + String.prototype.trim = function () { + return String(this); + }; + Reflect.apply = () => { + throw new Error("poisoned Reflect.apply"); + }; + + try { + assertCSSOptimizationEngine({ + ...engine(), + cacheIdentity: " padded ", + }); + } catch (error) { + paddedIdentityError = error; + } + optimizedCSS = captureCSSOptimizationEngine(engine()).optimize({ + css: "input", + sourcePath: "input.css", + minify: false, + sourceMap: false, + }).css; + } finally { + String.prototype.trim = originalTrim; + Reflect.apply = originalApply; + } + + assertEquals(paddedIdentityError instanceof TypeError, true); + assertEquals(optimizedCSS, "bound:input"); + }); +}); diff --git a/src/extensions/css/css-optimization-engine.ts b/src/extensions/css/css-optimization-engine.ts new file mode 100644 index 0000000000..03d502de2f --- /dev/null +++ b/src/extensions/css/css-optimization-engine.ts @@ -0,0 +1,133 @@ +/** + * Dependency-free contract for CSS optimization engines. + * + * Core owns request and result validation. Implementations own parser, + * minifier, browser-query, and source-map details. + * + * @module extensions/css/css-optimization-engine + */ + +import { + applyExtensionMethod, + findExtensionPropertyDescriptor, + freezeExtensionContract, + getExtensionOwnPropertyDescriptor, + isDataPropertyDescriptor, + isExtensionArray, + isStableExtensionCacheIdentity, +} from "../property-inspection.ts"; + +/** Registry name used for the CSS optimization extension contract. */ +export const CSSOptimizationEngineName = "CSSOptimizationEngine" as const; + +/** Maximum stable implementation identity accepted across the runtime boundary. */ +export const MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS = 256; + +/** Immutable optimization request supplied by core. */ +export interface CSSOptimizationRequest { + readonly css: string; + readonly sourcePath: string; + readonly minify: boolean; + readonly sourceMap: boolean; +} + +/** Portable output returned by a CSS optimization engine. */ +export interface CSSOptimizationResult { + readonly css: string; + readonly sourceMap?: string; +} + +/** + * Parser-backed CSS optimization contract. + * + * The operation is intentionally synchronous so existing synchronous core + * helpers cannot conceal an asynchronous fallback or dynamic import. + */ +export interface CSSOptimizationEngine { + /** + * Includes every implementation/version input capable of changing output. + * Represented state must remain immutable for the captured engine lifetime. + */ + readonly cacheIdentity: string; + optimize(request: CSSOptimizationRequest): CSSOptimizationResult; +} + +function readCSSOptimizationEngine(value: unknown): { + implementation: object; + cacheIdentity: string; + optimize: CSSOptimizationEngine["optimize"]; +} { + if (typeof value !== "object" || value === null || isExtensionArray(value)) { + throw new TypeError("CSSOptimizationEngine must be an object"); + } + + let cacheIdentityDescriptor: PropertyDescriptor | undefined; + let optimizeDescriptor: PropertyDescriptor | undefined; + try { + cacheIdentityDescriptor = getExtensionOwnPropertyDescriptor( + value, + "cacheIdentity", + ); + optimizeDescriptor = findExtensionPropertyDescriptor(value, "optimize"); + } catch (cause) { + throw new TypeError("CSSOptimizationEngine properties could not be read", { + cause, + }); + } + + if ( + !isDataPropertyDescriptor(cacheIdentityDescriptor) + ) { + throw new TypeError( + "CSSOptimizationEngine cacheIdentity must be an own data property", + ); + } + if (!isDataPropertyDescriptor(optimizeDescriptor)) { + throw new TypeError("CSSOptimizationEngine optimize must be a data property"); + } + + const cacheIdentity = cacheIdentityDescriptor.value; + const optimize = optimizeDescriptor.value; + + if ( + !isStableExtensionCacheIdentity( + cacheIdentity, + MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, + ) + ) { + throw new TypeError( + "CSSOptimizationEngine must declare a bounded stable cacheIdentity", + ); + } + if (typeof optimize !== "function") { + throw new TypeError("CSSOptimizationEngine must implement optimize()"); + } + return { + implementation: value, + cacheIdentity, + optimize: optimize as CSSOptimizationEngine["optimize"], + }; +} + +/** Validate an implementation received through the dynamic contract registry. */ +export function assertCSSOptimizationEngine( + value: unknown, +): asserts value is CSSOptimizationEngine { + readCSSOptimizationEngine(value); +} + +/** + * Capture dynamic properties once so later mutation or accessors cannot change + * the implementation that core invokes. + */ +export function captureCSSOptimizationEngine( + value: unknown, +): CSSOptimizationEngine { + const captured = readCSSOptimizationEngine(value); + return freezeExtensionContract({ + cacheIdentity: captured.cacheIdentity, + optimize(request: CSSOptimizationRequest): CSSOptimizationResult { + return applyExtensionMethod(captured.optimize, captured.implementation, [request]); + }, + }); +} diff --git a/src/extensions/css/css-purging-engine.test.ts b/src/extensions/css/css-purging-engine.test.ts new file mode 100644 index 0000000000..4d79aaa3c5 --- /dev/null +++ b/src/extensions/css/css-purging-engine.test.ts @@ -0,0 +1,93 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + assertCSSPurgingEngine, + captureCSSPurgingEngine, + type CSSPurgingEngine, + MAX_CSS_PURGING_ENGINE_IDENTITY_CHARACTERS, +} from "./css-purging-engine.ts"; + +interface StatefulCSSPurgingEngine extends CSSPurgingEngine { + marker: string; +} + +function engine(): StatefulCSSPurgingEngine { + return { + cacheIdentity: "test-css-purging-engine@1", + marker: "bound", + purge(request) { + return Promise.resolve({ + css: `${this.marker}:${request.css}`, + ...(request.includeRejectedCSS ? { rejectedCSS: "rejected" } : {}), + }); + }, + }; +} + +describe("CSSPurgingEngine contract", () => { + it("captures identity, method, implementation, and this binding once", async () => { + const value = engine(); + const captured = captureCSSPurgingEngine(value); + (value as { cacheIdentity: string }).cacheIdentity = "mutated"; + value.purge = () => Promise.resolve({ css: "replacement" }); + + assertEquals(captured.cacheIdentity, "test-css-purging-engine@1"); + assertEquals( + (await captured.purge({ + css: "input", + content: [], + safelist: [], + includeRejectedCSS: false, + })).css, + "bound:input", + ); + }); + + it("rejects method accessors and cyclic prototypes without invoking getters", () => { + let methodReads = 0; + const accessor = { cacheIdentity: "accessor-engine@1" }; + Object.defineProperty(accessor, "purge", { + get() { + methodReads++; + return engine().purge; + }, + }); + + assertThrows(() => assertCSSPurgingEngine(accessor), TypeError, "data property"); + assertEquals(methodReads, 0); + + const cyclic: object = new Proxy( + { cacheIdentity: "cyclic-engine@1" }, + { getPrototypeOf: () => cyclic }, + ); + assertThrows( + () => assertCSSPurgingEngine(cyclic), + TypeError, + "properties could not be read", + ); + }); + + it("rejects arrays, missing methods, and unstable identities", () => { + assertThrows(() => assertCSSPurgingEngine([]), TypeError, "must be an object"); + assertThrows( + () => assertCSSPurgingEngine({ cacheIdentity: "missing-method@1" }), + TypeError, + "purge must be a data property", + ); + + for ( + const cacheIdentity of [ + "", + " padded ", + "x".repeat(MAX_CSS_PURGING_ENGINE_IDENTITY_CHARACTERS + 1), + ] + ) { + assertThrows( + () => assertCSSPurgingEngine({ ...engine(), cacheIdentity }), + TypeError, + "bounded stable cacheIdentity", + ); + } + }); +}); diff --git a/src/extensions/css/css-purging-engine.ts b/src/extensions/css/css-purging-engine.ts new file mode 100644 index 0000000000..d6f6d99e12 --- /dev/null +++ b/src/extensions/css/css-purging-engine.ts @@ -0,0 +1,107 @@ +/** + * Dependency-free contract for unused-rule and critical-CSS engines. + * + * Core owns validation and resource limits. Extensions own parser/vendor + * imports and their implementation-specific configuration. + * + * @module extensions/css/css-purging-engine + */ + +import { + applyExtensionMethod, + findExtensionPropertyDescriptor, + freezeExtensionContract, + getExtensionOwnPropertyDescriptor, + isDataPropertyDescriptor, + isExtensionArray, + isStableExtensionCacheIdentity, +} from "../property-inspection.ts"; + +export const CSSPurgingEngineName = "CSSPurgingEngine" as const; +export const MAX_CSS_PURGING_ENGINE_IDENTITY_CHARACTERS = 256; + +export interface CSSPurgeContentSource { + readonly raw: string; + readonly extension: string; +} + +export interface CSSPurgingRequest { + readonly css: string; + readonly content: readonly CSSPurgeContentSource[]; + readonly safelist: readonly string[]; + readonly includeRejectedCSS: boolean; +} + +export interface CSSPurgingResult { + readonly css: string; + readonly rejectedCSS?: string; +} + +export interface CSSPurgingEngine { + /** + * Includes every implementation/version input capable of changing output. + * Represented state must remain immutable for the captured engine lifetime. + */ + readonly cacheIdentity: string; + purge(request: CSSPurgingRequest): Promise; +} + +function readCSSPurgingEngine(value: unknown): { + implementation: object; + cacheIdentity: string; + purge: CSSPurgingEngine["purge"]; +} { + if (typeof value !== "object" || value === null || isExtensionArray(value)) { + throw new TypeError("CSSPurgingEngine must be an object"); + } + + let cacheIdentityDescriptor: PropertyDescriptor | undefined; + let purgeDescriptor: PropertyDescriptor | undefined; + try { + cacheIdentityDescriptor = getExtensionOwnPropertyDescriptor(value, "cacheIdentity"); + purgeDescriptor = findExtensionPropertyDescriptor(value, "purge"); + } catch (cause) { + throw new TypeError("CSSPurgingEngine properties could not be read", { cause }); + } + + if (!isDataPropertyDescriptor(cacheIdentityDescriptor)) { + throw new TypeError("CSSPurgingEngine cacheIdentity must be an own data property"); + } + if (!isDataPropertyDescriptor(purgeDescriptor)) { + throw new TypeError("CSSPurgingEngine purge must be a data property"); + } + + const cacheIdentity = cacheIdentityDescriptor.value; + const purge = purgeDescriptor.value; + if ( + !isStableExtensionCacheIdentity( + cacheIdentity, + MAX_CSS_PURGING_ENGINE_IDENTITY_CHARACTERS, + ) + ) { + throw new TypeError("CSSPurgingEngine must declare a bounded stable cacheIdentity"); + } + if (typeof purge !== "function") { + throw new TypeError("CSSPurgingEngine must implement purge()"); + } + return { + implementation: value, + cacheIdentity, + purge: purge as CSSPurgingEngine["purge"], + }; +} + +export function assertCSSPurgingEngine(value: unknown): asserts value is CSSPurgingEngine { + readCSSPurgingEngine(value); +} + +/** Capture identity and method once so registry mutation cannot split a run. */ +export function captureCSSPurgingEngine(value: unknown): CSSPurgingEngine { + const captured = readCSSPurgingEngine(value); + return freezeExtensionContract({ + cacheIdentity: captured.cacheIdentity, + purge(request: CSSPurgingRequest): Promise { + return applyExtensionMethod(captured.purge, captured.implementation, [request]); + }, + }); +} diff --git a/src/extensions/css/index.ts b/src/extensions/css/index.ts index 9f5b0e8f0c..42a8ccd6f5 100644 --- a/src/extensions/css/index.ts +++ b/src/extensions/css/index.ts @@ -11,3 +11,28 @@ export type { CSSProcessor, CSSStylesheetSource, } from "./css-processor.ts"; + +export type { + CSSOptimizationEngine, + CSSOptimizationRequest, + CSSOptimizationResult, +} from "./css-optimization-engine.ts"; +export { + assertCSSOptimizationEngine, + captureCSSOptimizationEngine, + CSSOptimizationEngineName, + MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, +} from "./css-optimization-engine.ts"; + +export type { + CSSPurgeContentSource, + CSSPurgingEngine, + CSSPurgingRequest, + CSSPurgingResult, +} from "./css-purging-engine.ts"; +export { + assertCSSPurgingEngine, + captureCSSPurgingEngine, + CSSPurgingEngineName, + MAX_CSS_PURGING_ENGINE_IDENTITY_CHARACTERS, +} from "./css-purging-engine.ts"; diff --git a/src/extensions/image/image-optimization-engine.test.ts b/src/extensions/image/image-optimization-engine.test.ts new file mode 100644 index 0000000000..e1ac5507fc --- /dev/null +++ b/src/extensions/image/image-optimization-engine.test.ts @@ -0,0 +1,107 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + assertImageOptimizationEngine, + captureImageOptimizationEngine, + type ImageOptimizationEngine, + MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, +} from "./image-optimization-engine.ts"; + +function engine(): ImageOptimizationEngine { + return { + cacheIdentity: "test-image-engine@1", + optimize: () => + Promise.resolve({ + sourceWidth: 1, + sourceHeight: 1, + variants: [{ + format: "png", + width: 1, + height: 1, + data: new Uint8Array([1]), + }], + }), + }; +} + +describe("ImageOptimizationEngine contract", () => { + it("accepts and captures a provider-neutral engine", async () => { + const value = engine(); + assertImageOptimizationEngine(value); + const captured = captureImageOptimizationEngine(value); + (value as { cacheIdentity: string }).cacheIdentity = "mutated"; + value.optimize = () => Promise.reject(new Error("mutated")); + + assertEquals(captured.cacheIdentity, "test-image-engine@1"); + assertEquals( + (await captured.optimize({ + input: new Uint8Array([1]), + targetWidths: Object.freeze([1]), + formats: Object.freeze(["png"]), + quality: 80, + signal: new AbortController().signal, + })).sourceWidth, + 1, + ); + }); + + it("rejects accessors without invoking them", () => { + let calls = 0; + const value = Object.defineProperty({}, "cacheIdentity", { + enumerable: true, + get() { + calls++; + return "hostile"; + }, + }); + Object.defineProperty(value, "optimize", { + enumerable: true, + value: engine().optimize, + }); + + assertThrows(() => assertImageOptimizationEngine(value), TypeError); + assertEquals(calls, 0); + }); + + it("does not consult a replacement method after capture", async () => { + const value = engine(); + const captured = captureImageOptimizationEngine(value); + value.optimize = () => Promise.reject(new Error("replacement invoked")); + await captured.optimize({ + input: new Uint8Array([1]), + targetWidths: Object.freeze([1]), + formats: Object.freeze(["png"]), + quality: 80, + signal: new AbortController().signal, + }); + await assertRejects( + () => + value.optimize({ + input: new Uint8Array([1]), + targetWidths: [], + formats: [], + quality: 80, + signal: new AbortController().signal, + }), + Error, + "replacement invoked", + ); + }); + + it("rejects unstable and oversized identities", () => { + for ( + const cacheIdentity of [ + "", + " padded ", + "x".repeat(MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS + 1), + ] + ) { + assertThrows( + () => assertImageOptimizationEngine({ ...engine(), cacheIdentity }), + TypeError, + "bounded stable cacheIdentity", + ); + } + }); +}); diff --git a/src/extensions/image/image-optimization-engine.ts b/src/extensions/image/image-optimization-engine.ts new file mode 100644 index 0000000000..c0bed81df7 --- /dev/null +++ b/src/extensions/image/image-optimization-engine.ts @@ -0,0 +1,148 @@ +/** + * Dependency-free contract for image optimization engines. + * + * Core owns filesystem access, publication, request/result validation, and + * resource limits. Extensions own decoding, resizing, and encoding. + * + * @module extensions/image/image-optimization-engine + */ + +import { IMAGE_OPTIMIZATION } from "#veryfront/utils/constants/build.ts"; +import { + applyExtensionMethod, + findExtensionPropertyDescriptor, + freezeExtensionContract, + getExtensionOwnPropertyDescriptor, + isDataPropertyDescriptor, + isExtensionArray, + isStableExtensionCacheIdentity, +} from "../property-inspection.ts"; + +/** Registry name used for the image optimization extension contract. */ +export const ImageOptimizationEngineName = "ImageOptimizationEngine" as const; + +/** Maximum stable implementation identity accepted across the boundary. */ +export const MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS = + IMAGE_OPTIMIZATION.MAX_ENGINE_IDENTITY_CHARACTERS; + +/** Formats core can request from an image optimization engine. */ +export type ImageOptimizationFormat = "webp" | "avif" | "jpeg" | "png"; + +/** Immutable byte-oriented request supplied by core. */ +export interface ImageOptimizationRequest { + readonly input: Uint8Array; + /** + * Configured target widths. The engine returns each distinct width that does + * not exceed the decoded source width, plus the source width itself. + */ + readonly targetWidths: readonly number[]; + /** Every format to produce for each resulting width. */ + readonly formats: readonly ImageOptimizationFormat[]; + /** Encoding quality applied consistently across requested formats. */ + readonly quality: number; + /** Aborted when the caller cancels or the core operation deadline expires. */ + readonly signal: AbortSignal; +} + +/** One encoded output returned by an image optimization engine. */ +export interface ImageOptimizationVariantResult { + readonly format: ImageOptimizationFormat; + readonly width: number; + readonly height: number; + readonly data: Uint8Array; +} + +/** Portable result returned by an image optimization engine. */ +export interface ImageOptimizationResult { + readonly sourceWidth: number; + readonly sourceHeight: number; + readonly variants: readonly ImageOptimizationVariantResult[]; +} + +/** Image decoder, resizer, and encoder implemented by an explicit extension. */ +export interface ImageOptimizationEngine { + /** + * Includes every implementation/version input capable of changing output. + * Represented state must remain immutable for the captured engine lifetime. + */ + readonly cacheIdentity: string; + optimize(request: ImageOptimizationRequest): Promise; +} + +function readImageOptimizationEngine(value: unknown): { + implementation: object; + cacheIdentity: string; + optimize: ImageOptimizationEngine["optimize"]; +} { + if (typeof value !== "object" || value === null || isExtensionArray(value)) { + throw new TypeError("ImageOptimizationEngine must be an object"); + } + + let cacheIdentityDescriptor: PropertyDescriptor | undefined; + let optimizeDescriptor: PropertyDescriptor | undefined; + try { + cacheIdentityDescriptor = getExtensionOwnPropertyDescriptor( + value, + "cacheIdentity", + ); + optimizeDescriptor = findExtensionPropertyDescriptor(value, "optimize"); + } catch (cause) { + throw new TypeError("ImageOptimizationEngine properties could not be read", { + cause, + }); + } + + if ( + !isDataPropertyDescriptor(cacheIdentityDescriptor) + ) { + throw new TypeError( + "ImageOptimizationEngine cacheIdentity must be an own data property", + ); + } + if (!isDataPropertyDescriptor(optimizeDescriptor)) { + throw new TypeError("ImageOptimizationEngine optimize must be a data property"); + } + + const cacheIdentity = cacheIdentityDescriptor.value; + const optimize = optimizeDescriptor.value; + if ( + !isStableExtensionCacheIdentity( + cacheIdentity, + MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, + ) + ) { + throw new TypeError( + "ImageOptimizationEngine must declare a bounded stable cacheIdentity", + ); + } + if (typeof optimize !== "function") { + throw new TypeError("ImageOptimizationEngine must implement optimize()"); + } + return { + implementation: value, + cacheIdentity, + optimize: optimize as ImageOptimizationEngine["optimize"], + }; +} + +/** Validate an implementation received through the dynamic contract registry. */ +export function assertImageOptimizationEngine( + value: unknown, +): asserts value is ImageOptimizationEngine { + readImageOptimizationEngine(value); +} + +/** Capture dynamic properties once so one run cannot split across mutations. */ +export function captureImageOptimizationEngine( + value: unknown, +): ImageOptimizationEngine { + const captured = readImageOptimizationEngine(value); + return freezeExtensionContract({ + cacheIdentity: captured.cacheIdentity, + optimize( + request: ImageOptimizationRequest, + ): Promise { + return applyExtensionMethod(captured.optimize, captured.implementation, [request]); + }, + }); +} diff --git a/src/extensions/image/index.ts b/src/extensions/image/index.ts new file mode 100644 index 0000000000..d416015163 --- /dev/null +++ b/src/extensions/image/index.ts @@ -0,0 +1,19 @@ +/** + * Image extension contracts. + * + * @module extensions/image + */ + +export type { + ImageOptimizationEngine, + ImageOptimizationFormat, + ImageOptimizationRequest, + ImageOptimizationResult, + ImageOptimizationVariantResult, +} from "./image-optimization-engine.ts"; +export { + assertImageOptimizationEngine, + captureImageOptimizationEngine, + ImageOptimizationEngineName, + MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, +} from "./image-optimization-engine.ts"; diff --git a/src/extensions/index.ts b/src/extensions/index.ts index 9aadccc08d..c09e695091 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -67,7 +67,13 @@ export type { ConflictInfo } from "./validation.ts"; export { detectConflicts, validateExtension } from "./validation.ts"; // Capabilities -export { auditCapabilities, formatCapabilities, mapToDenoPermissions } from "./capabilities.ts"; +export { + assertSystemReadCapability, + auditCapabilities, + formatCapabilities, + isSupportedDenoSystemReadApi, + mapToDenoPermissions, +} from "./capabilities.ts"; // Recommendations export { getRecommendation } from "./recommendations.ts"; @@ -80,6 +86,21 @@ export { MISSING_EXTENSION_ERROR, } from "./errors.ts"; +// Image optimization +export type { + ImageOptimizationEngine, + ImageOptimizationFormat, + ImageOptimizationRequest, + ImageOptimizationResult, + ImageOptimizationVariantResult, +} from "./image/index.ts"; +export { + assertImageOptimizationEngine, + captureImageOptimizationEngine, + ImageOptimizationEngineName, + MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, +} from "./image/index.ts"; + // Sandbox export type { CreateSandboxShellToolsInput, diff --git a/src/extensions/property-inspection.test.ts b/src/extensions/property-inspection.test.ts new file mode 100644 index 0000000000..b48625074d --- /dev/null +++ b/src/extensions/property-inspection.test.ts @@ -0,0 +1,90 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + findExtensionPropertyDescriptor, + isDataPropertyDescriptor, + isStableExtensionCacheIdentity, +} from "./property-inspection.ts"; + +describe("extension property inspection", () => { + it("finds inherited data properties without invoking accessors", () => { + let reads = 0; + const prototype = Object.create(null); + Object.defineProperty(prototype, "method", { + value: () => "ok", + enumerable: false, + }); + Object.defineProperty(prototype, "accessor", { + get() { + reads++; + return "wrong"; + }, + }); + const value = Object.create(prototype); + + assertEquals( + typeof findExtensionPropertyDescriptor(value, "method")?.value, + "function", + ); + assertEquals("get" in findExtensionPropertyDescriptor(value, "accessor")!, true); + assertEquals(reads, 0); + }); + + it("rejects cyclic and excessively deep prototype chains", () => { + const cyclic: object = new Proxy({}, { getPrototypeOf: () => cyclic }); + assertThrows( + () => findExtensionPropertyDescriptor(cyclic, "missing"), + TypeError, + "invalid prototype chain", + ); + + let deep = Object.create(null); + for (let index = 0; index < 32; index++) deep = Object.create(deep); + assertThrows( + () => findExtensionPropertyDescriptor(deep, "missing"), + TypeError, + "invalid prototype chain", + ); + }); + + it("propagates proxy inspection failures without retrying traps", () => { + let descriptorReads = 0; + const hostile = new Proxy({}, { + getOwnPropertyDescriptor() { + descriptorReads++; + throw new Error("blocked"); + }, + }); + + assertThrows( + () => findExtensionPropertyDescriptor(hostile, "method"), + Error, + "blocked", + ); + assertEquals(descriptorReads, 1); + }); + + it("does not trust or invoke inherited descriptor values", () => { + let reads = 0; + const inheritedValue = Object.create({ + get value() { + reads++; + return "hostile"; + }, + }) as PropertyDescriptor; + + assertEquals(isDataPropertyDescriptor(inheritedValue), false); + assertEquals(isDataPropertyDescriptor({ get: () => "accessor" }), false); + assertEquals(isDataPropertyDescriptor({ value: undefined }), true); + assertEquals(reads, 0); + }); + + it("validates well-formed identities without newer String intrinsics", () => { + assertEquals(isStableExtensionCacheIdentity("engine-v1", 64), true); + assertEquals(isStableExtensionCacheIdentity("engine-\u{1F680}", 64), true); + assertEquals(isStableExtensionCacheIdentity("engine-\uD800", 64), false); + assertEquals(isStableExtensionCacheIdentity("engine-\uDC00", 64), false); + assertEquals(isStableExtensionCacheIdentity("engine-\uD800x", 64), false); + }); +}); diff --git a/src/extensions/property-inspection.ts b/src/extensions/property-inspection.ts new file mode 100644 index 0000000000..f36654ed51 --- /dev/null +++ b/src/extensions/property-inspection.ts @@ -0,0 +1,99 @@ +/** Bounded, descriptor-only inspection for extension contract implementations. */ + +const MAX_EXTENSION_PROTOTYPE_DEPTH = 32; +const apply = Reflect.apply; +const freeze = Object.freeze; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getPrototypeOf = Object.getPrototypeOf; +const hasOwn = Object.hasOwn; +const isArray = Array.isArray; +const charCodeAtString = String.prototype.charCodeAt; +const normalizeString = String.prototype.normalize; +const trimString = String.prototype.trim; +const executeRegularExpression = RegExp.prototype.exec; +const addToSet = Set.prototype.add; +const setHas = Set.prototype.has; +const SetConstructor = Set; +const CONTROL_OR_LINE_SEPARATOR_PATTERN = /[\p{Cc}\u2028\u2029]/u; + +/** Captured invocation intrinsic for extension methods. */ +export const applyExtensionMethod: typeof Reflect.apply = apply; + +/** Captured freeze intrinsic for immutable contract snapshots. */ +export const freezeExtensionContract: typeof Object.freeze = freeze; + +/** Captured array brand check for extension-boundary values. */ +export const isExtensionArray: typeof Array.isArray = isArray; + +/** Read one own descriptor through the captured inspection intrinsic. */ +export function getExtensionOwnPropertyDescriptor( + value: object, + property: PropertyKey, +): PropertyDescriptor | undefined { + return getOwnPropertyDescriptor(value, property); +} + +/** Return whether a descriptor owns a data value without walking its prototype. */ +export function isDataPropertyDescriptor( + descriptor: PropertyDescriptor | undefined, +): descriptor is PropertyDescriptor & { value: unknown } { + return descriptor !== undefined && hasOwn(descriptor, "value"); +} + +function isWellFormedExtensionString(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = apply(charCodeAtString, value, [index]) as number; + if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) { + index++; + if (index >= value.length) return false; + const trailingCodeUnit = apply(charCodeAtString, value, [index]) as number; + if (trailingCodeUnit < 0xDC00 || trailingCodeUnit > 0xDFFF) return false; + } else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) { + return false; + } + } + return true; +} + +/** Validate the canonical, bounded identity shared by asset engine contracts. */ +export function isStableExtensionCacheIdentity( + value: unknown, + maxCharacters: number, +): value is string { + return typeof value === "string" && + value.length > 0 && + value.length <= maxCharacters && + isWellFormedExtensionString(value) && + apply(trimString, value, []) === value && + apply(normalizeString, value, ["NFC"]) === value && + apply(executeRegularExpression, CONTROL_OR_LINE_SEPARATOR_PATTERN, [value]) === null; +} + +/** + * Find a property without invoking accessors or trusting a hostile prototype + * chain. Cycles and implausibly deep chains fail closed. + */ +export function findExtensionPropertyDescriptor( + value: object, + property: PropertyKey, +): PropertyDescriptor | undefined { + const visited = new SetConstructor(); + let owner: object | null = value; + let depth = 0; + + while (owner !== null) { + if ( + depth >= MAX_EXTENSION_PROTOTYPE_DEPTH || + apply(setHas, visited, [owner]) + ) { + throw new TypeError("Extension implementation has an invalid prototype chain"); + } + apply(addToSet, visited, [owner]); + const descriptor = getOwnPropertyDescriptor(owner, property); + if (descriptor !== undefined) return descriptor; + owner = getPrototypeOf(owner); + depth++; + } + + return undefined; +} diff --git a/src/extensions/recommendations.ts b/src/extensions/recommendations.ts index fce01a3889..e5d4fe0310 100644 --- a/src/extensions/recommendations.ts +++ b/src/extensions/recommendations.ts @@ -11,6 +11,9 @@ const recommendations = new Map([ ["ModuleLexer", "@veryfront/ext-bundler-esbuild"], ["TokenCacheStore", "@veryfront/ext-cache-redis"], ["CSSProcessor", "@veryfront/ext-css-tailwind"], + ["CSSOptimizationEngine", "@veryfront/ext-css-lightning"], + ["CSSPurgingEngine", "@veryfront/ext-css-purgecss"], + ["ImageOptimizationEngine", "@veryfront/ext-image-sharp"], ["ContentProcessor", "@veryfront/ext-content-mdx"], ["DocumentExtractor", "@veryfront/ext-document-kreuzberg"], ["AuthProvider", "@veryfront/ext-auth-jwt"], diff --git a/src/extensions/validation.test.ts b/src/extensions/validation.test.ts index 59166d0378..43aeb6277c 100644 --- a/src/extensions/validation.test.ts +++ b/src/extensions/validation.test.ts @@ -87,6 +87,50 @@ describe("validateExtension", () => { assertEquals(issues, []); }); + it("accepts bounded read-only system capabilities", () => { + assertEquals( + validateExtension({ + name: "system-reader", + version: "1.0.0", + capabilities: [{ type: "system:read", apis: ["cpus"] }], + }), + [], + ); + }); + + it("rejects broad, mutating, and accessor-backed system capabilities", () => { + for ( + const capability of [ + { type: "system:read" }, + { type: "system:read", apis: ["setPriority"] }, + ] + ) { + const issues = validateExtension({ + name: "unsafe-system-reader", + version: "1.0.0", + capabilities: [capability], + }); + assertEquals(issues.length > 0, true); + } + + let reads = 0; + const accessorCapability = { type: "system:read" }; + Object.defineProperty(accessorCapability, "apis", { + enumerable: true, + get() { + reads++; + return ["cpus"]; + }, + }); + const issues = validateExtension({ + name: "accessor-system-reader", + version: "1.0.0", + capabilities: [accessorCapability], + }); + assertEquals(issues.length > 0, true); + assertEquals(reads, 0); + }); + it("rejects malformed contract metadata", () => { const issues = validateExtension({ name: "test-ext", diff --git a/src/extensions/validation.ts b/src/extensions/validation.ts index 23dadc3c63..297c0575ff 100644 --- a/src/extensions/validation.ts +++ b/src/extensions/validation.ts @@ -5,6 +5,7 @@ */ import type { Extension, ExtensionSource, ResolvedExtension } from "./types.ts"; +import { assertSystemReadCapability } from "./capabilities.ts"; /** * Information about a contract conflict between extensions. @@ -112,6 +113,12 @@ export function validateExtension(ext: unknown): string[] { } if (typeof cap.type !== "string" || cap.type.length === 0) { issues.push(`capabilities[${i}].type must be a non-empty string`); + } else if (cap.type === "system:read") { + try { + assertSystemReadCapability(cap); + } catch (error) { + issues.push(error instanceof Error ? error.message : String(error)); + } } } diff --git a/src/platform/adapters/base.ts b/src/platform/adapters/base.ts index 1755ef885a..e2a833d122 100644 --- a/src/platform/adapters/base.ts +++ b/src/platform/adapters/base.ts @@ -226,6 +226,8 @@ export interface FileSystemAdapter { /** Read raw bytes when binary-safe access is required */ readFileBytes?(path: string): Promise; writeFile(path: string, content: string): Promise; + /** Atomically replace a path when the runtime supports same-filesystem rename. */ + rename?(from: string, to: string): Promise; exists(path: string): Promise; readDir(path: string): AsyncIterable; stat(path: string): Promise; diff --git a/src/platform/adapters/runtime/bun/filesystem-adapter.ts b/src/platform/adapters/runtime/bun/filesystem-adapter.ts index 1a61cc817a..ef345a45d2 100644 --- a/src/platform/adapters/runtime/bun/filesystem-adapter.ts +++ b/src/platform/adapters/runtime/bun/filesystem-adapter.ts @@ -33,6 +33,11 @@ export class BunFileSystemAdapter implements FileSystemAdapter { await Bun.write(path, content); } + async rename(from: string, to: string): Promise { + const { rename } = await import("node:fs/promises"); + await rename(from, to); + } + async exists(path: string): Promise { const { stat } = await import("node:fs/promises"); diff --git a/src/platform/adapters/runtime/deno/adapter.test.ts b/src/platform/adapters/runtime/deno/adapter.test.ts index 8a56dbc717..a658ba2e42 100644 --- a/src/platform/adapters/runtime/deno/adapter.test.ts +++ b/src/platform/adapters/runtime/deno/adapter.test.ts @@ -92,6 +92,10 @@ if (!isDeno) { assertFunction(denoAdapter.fs.writeFile); }); + it("should have rename method", () => { + assertFunction(denoAdapter.fs.rename); + }); + it("should have exists method", () => { assertFunction(denoAdapter.fs.exists); }); @@ -197,6 +201,21 @@ if (!isDeno) { } }); + it("should rename a file", async () => { + const tmpDir = await denoAdapter.fs.makeTempDir("test-rename-"); + const from = `${tmpDir}/source.txt`; + const to = `${tmpDir}/destination.txt`; + + try { + await denoAdapter.fs.writeFile(from, "renamed"); + await denoAdapter.fs.rename(from, to); + assertEquals(await denoAdapter.fs.exists(from), false); + assertEquals(await denoAdapter.fs.readFile(to), "renamed"); + } finally { + await denoAdapter.fs.remove(tmpDir, { recursive: true }); + } + }); + it("should create nested directories", async () => { const tmpDir = await denoAdapter.fs.makeTempDir("test-mkdir-"); const nestedDir = `${tmpDir}/a/b/c`; diff --git a/src/platform/adapters/runtime/deno/adapter.ts b/src/platform/adapters/runtime/deno/adapter.ts index f48a829ec6..d527e65303 100644 --- a/src/platform/adapters/runtime/deno/adapter.ts +++ b/src/platform/adapters/runtime/deno/adapter.ts @@ -158,6 +158,11 @@ class DenoFileSystemAdapter implements FileSystemAdapter { await Deno.writeTextFile(path, content); } + async rename(from: string, to: string): Promise { + assertDenoRuntime("DenoFileSystemAdapter", "rename"); + await Deno.rename(from, to); + } + async exists(path: string): Promise { if (typeof Deno === "undefined") return false; try { diff --git a/src/platform/adapters/runtime/node/filesystem-adapter.test.ts b/src/platform/adapters/runtime/node/filesystem-adapter.test.ts index 7e6c8a78ba..72be193c6e 100644 --- a/src/platform/adapters/runtime/node/filesystem-adapter.test.ts +++ b/src/platform/adapters/runtime/node/filesystem-adapter.test.ts @@ -44,6 +44,22 @@ function captureDebugLogs(): { entries: LogEntry[]; restore: () => void } { } describe("NodeFileSystemAdapter", () => { + it("renames files on the native filesystem", async () => { + const adapter = new NodeFileSystemAdapter(); + const tempDir = await adapter.makeTempDir("vf-node-fs-rename-"); + const from = `${tempDir}/source.txt`; + const to = `${tempDir}/destination.txt`; + + try { + await adapter.writeFile(from, "renamed"); + await adapter.rename(from, to); + assertEquals(await adapter.exists(from), false); + assertEquals(await adapter.readFile(to), "renamed"); + } finally { + await adapter.remove(tempDir, { recursive: true }); + } + }); + it("does not log an expected missing path as an access failure", async () => { const adapter = new NodeFileSystemAdapter(); const tempDir = await adapter.makeTempDir("vf-node-fs-exists-"); diff --git a/src/platform/adapters/runtime/node/filesystem-adapter.ts b/src/platform/adapters/runtime/node/filesystem-adapter.ts index e437940b90..70482d9084 100644 --- a/src/platform/adapters/runtime/node/filesystem-adapter.ts +++ b/src/platform/adapters/runtime/node/filesystem-adapter.ts @@ -32,6 +32,11 @@ export class NodeFileSystemAdapter implements FileSystemAdapter { await fs.writeFile(path, content, "utf-8"); } + async rename(from: string, to: string): Promise { + const fs = await import("node:fs/promises"); + await fs.rename(from, to); + } + async exists(path: string): Promise { const fs = await import("node:fs/promises"); diff --git a/src/platform/compat/fs.test.ts b/src/platform/compat/fs.test.ts index 3620d5c744..3bc45ac22f 100644 --- a/src/platform/compat/fs.test.ts +++ b/src/platform/compat/fs.test.ts @@ -50,6 +50,7 @@ describe("Filesystem Compat", () => { const methods = [ "readTextFile", "writeTextFile", + "rename", "exists", "mkdir", "remove", @@ -81,6 +82,20 @@ describe("Filesystem Compat", () => { }); }); + describe("rename", () => { + it("atomically moves a file within one filesystem", async () => { + const fs = createFileSystem(); + const from = join(testDir, "rename-source.txt"); + const to = join(testDir, "rename-destination.txt"); + await fs.writeTextFile(from, "renamed"); + + await fs.rename!(from, to); + + assertEquals(await fs.exists(from), false); + assertEquals(await fs.readTextFile(to), "renamed"); + }); + }); + describe("writeFile / readFile", () => { it("should write and read binary files", async () => { const filePath = join(testDir, "test-binary.bin"); diff --git a/src/platform/compat/fs.ts b/src/platform/compat/fs.ts index f9fcbb0315..19765c0c26 100644 --- a/src/platform/compat/fs.ts +++ b/src/platform/compat/fs.ts @@ -20,6 +20,8 @@ export interface FileSystem { readFile(path: string): Promise; writeTextFile(path: string, data: string): Promise; writeFile(path: string, data: Uint8Array): Promise; + /** Atomically replace a path when same-filesystem rename is supported. */ + rename?(from: string, to: string): Promise; exists(path: string): Promise; stat(path: string): Promise; lstat?(path: string): Promise; @@ -42,6 +44,7 @@ interface NodeFsPromises { data: string | Uint8Array, options?: { encoding?: string; flag?: string } | string, ): Promise; + rename(from: string, to: string): Promise; access(path: string, mode?: number): Promise; stat(path: string): Promise<{ isFile(): boolean; @@ -136,6 +139,11 @@ class NodeFileSystem implements FileSystem { await this.getFs().writeFile(path, data); } + async rename(from: string, to: string): Promise { + await this.ensureInitialized(); + await this.getFs().rename(from, to); + } + async exists(path: string): Promise { await this.ensureInitialized(); try { @@ -241,6 +249,10 @@ class DenoFileSystem implements FileSystem { await denoGlobal().writeFile(path, data); } + async rename(from: string, to: string): Promise { + await denoGlobal().rename(from, to); + } + async exists(path: string): Promise { try { await denoGlobal().stat(path); diff --git a/src/utils/constants/build.ts b/src/utils/constants/build.ts index adff55bf23..4499976b14 100644 --- a/src/utils/constants/build.ts +++ b/src/utils/constants/build.ts @@ -5,4 +5,16 @@ export const DEFAULT_BUILD_CONCURRENCY = 4; export const IMAGE_OPTIMIZATION = { DEFAULT_SIZES: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], DEFAULT_QUALITY: 80, + MAX_DIMENSION: 32_768, + MAX_OUTPUT_SIZES: 64, + MAX_ENGINE_IDENTITY_CHARACTERS: 256, +} as const; + +/** Shared CSS optimization resource bounds. */ +export const CSS_OPTIMIZATION = { + MAX_FILES: 10_000, + MAX_BROWSER_QUERIES: 64, + MAX_BROWSER_QUERY_CHARACTERS: 512, + MAX_PURGE_PATTERNS: 128, + MAX_PURGE_SAFELIST_ENTRIES: 1_024, } as const; diff --git a/src/utils/constants/index.ts b/src/utils/constants/index.ts index a086839f64..fdcc63d23e 100644 --- a/src/utils/constants/index.ts +++ b/src/utils/constants/index.ts @@ -4,7 +4,7 @@ * @module utils/constants */ -export { DEFAULT_BUILD_CONCURRENCY, IMAGE_OPTIMIZATION } from "./build.ts"; +export { CSS_OPTIMIZATION, DEFAULT_BUILD_CONCURRENCY, IMAGE_OPTIMIZATION } from "./build.ts"; export { BUFFER_SIZE_16_KB, BUFFER_SIZE_1_KB, diff --git a/tests/_helpers/css-optimization-engine.ts b/tests/_helpers/css-optimization-engine.ts new file mode 100644 index 0000000000..e73db6ca40 --- /dev/null +++ b/tests/_helpers/css-optimization-engine.ts @@ -0,0 +1,60 @@ +/** Test-only explicit composition helpers for CSSOptimizationEngine. */ + +import type { + CSSOptimizationEngine, + CSSOptimizationRequest, + CSSOptimizationResult, +} from "../../src/extensions/css/index.ts"; +import { CSSOptimizationEngineName } from "../../src/extensions/css/index.ts"; +import { register, tryResolve, unregister } from "../../src/extensions/contracts.ts"; + +export function createTestCSSSourceMap(sourcePath: string): string { + return JSON.stringify({ + version: 3, + sources: [sourcePath], + names: [], + mappings: "AAAA", + }); +} + +export function createTestCSSOptimizationEngine( + optimize: ( + request: CSSOptimizationRequest, + ) => CSSOptimizationResult = (request) => ({ + css: request.css, + ...(request.sourceMap ? { sourceMap: createTestCSSSourceMap(request.sourcePath) } : {}), + }), + cacheIdentity = "test-css-optimization-engine@1", +): CSSOptimizationEngine { + return { cacheIdentity, optimize }; +} + +/** Explicitly compose a test engine and return an idempotent restoration callback. */ +export function installTestCSSOptimizationEngine( + engine: CSSOptimizationEngine = createTestCSSOptimizationEngine(), +): () => void { + const previous = tryResolve(CSSOptimizationEngineName); + unregister(CSSOptimizationEngineName); + register(CSSOptimizationEngineName, engine); + let active = true; + return () => { + if (!active) return; + active = false; + unregister(CSSOptimizationEngineName); + if (previous !== undefined) { + register(CSSOptimizationEngineName, previous); + } + }; +} + +export async function withTestCSSOptimizationEngine( + engine: CSSOptimizationEngine, + run: () => T | Promise, +): Promise { + const restore = installTestCSSOptimizationEngine(engine); + try { + return await run(); + } finally { + restore(); + } +} diff --git a/tests/_helpers/css-purging-engine.ts b/tests/_helpers/css-purging-engine.ts new file mode 100644 index 0000000000..d49b49f384 --- /dev/null +++ b/tests/_helpers/css-purging-engine.ts @@ -0,0 +1,50 @@ +/** Test-only explicit composition helpers for CSSPurgingEngine. */ + +import type { + CSSPurgingEngine, + CSSPurgingRequest, + CSSPurgingResult, +} from "../../src/extensions/css/index.ts"; +import { CSSPurgingEngineName } from "../../src/extensions/css/index.ts"; +import { register, tryResolve, unregister } from "../../src/extensions/contracts.ts"; + +export function createTestCSSPurgingEngine( + purge: ( + request: CSSPurgingRequest, + ) => Promise = (request) => + Promise.resolve({ + css: request.css, + ...(request.includeRejectedCSS ? { rejectedCSS: "" } : {}), + }), + cacheIdentity = "test-css-purging-engine@1", +): CSSPurgingEngine { + return { cacheIdentity, purge }; +} + +/** Explicitly compose a test engine and return an idempotent restoration callback. */ +export function installTestCSSPurgingEngine( + engine: CSSPurgingEngine = createTestCSSPurgingEngine(), +): () => void { + const previous = tryResolve(CSSPurgingEngineName); + unregister(CSSPurgingEngineName); + register(CSSPurgingEngineName, engine); + let active = true; + return () => { + if (!active) return; + active = false; + unregister(CSSPurgingEngineName); + if (previous !== undefined) register(CSSPurgingEngineName, previous); + }; +} + +export async function withTestCSSPurgingEngine( + engine: CSSPurgingEngine, + run: () => Promise | T, +): Promise { + const restore = installTestCSSPurgingEngine(engine); + try { + return await run(); + } finally { + restore(); + } +} diff --git a/tests/_helpers/image-optimization-engine.ts b/tests/_helpers/image-optimization-engine.ts new file mode 100644 index 0000000000..140825f418 --- /dev/null +++ b/tests/_helpers/image-optimization-engine.ts @@ -0,0 +1,70 @@ +/** Test-only explicit composition helpers for ImageOptimizationEngine. */ + +import { register, tryResolve, unregister } from "../../src/extensions/contracts.ts"; +import type { + ImageOptimizationEngine, + ImageOptimizationRequest, + ImageOptimizationResult, +} from "../../src/extensions/image/index.ts"; +import { ImageOptimizationEngineName } from "../../src/extensions/image/index.ts"; + +function defaultTestImageOptimize( + request: ImageOptimizationRequest, +): Promise { + const sourceWidth = 1; + const widths = [ + ...new Set([ + ...request.targetWidths.filter((width) => width <= sourceWidth), + sourceWidth, + ]), + ]; + return Promise.resolve({ + sourceWidth, + sourceHeight: 1, + variants: widths.flatMap((width) => + request.formats.map((format) => ({ + format, + width, + height: width, + data: new Uint8Array([request.quality]), + })) + ), + }); +} + +export function createTestImageOptimizationEngine( + optimize: ( + request: ImageOptimizationRequest, + ) => Promise = defaultTestImageOptimize, + cacheIdentity = "test-image-optimization-engine@1", +): ImageOptimizationEngine { + return { cacheIdentity, optimize }; +} + +/** Explicitly compose a test engine and return an idempotent restoration callback. */ +export function installTestImageOptimizationEngine( + engine: ImageOptimizationEngine = createTestImageOptimizationEngine(), +): () => void { + const previous = tryResolve(ImageOptimizationEngineName); + unregister(ImageOptimizationEngineName); + register(ImageOptimizationEngineName, engine); + let active = true; + return () => { + if (!active) return; + active = false; + unregister(ImageOptimizationEngineName); + if (previous !== undefined) register(ImageOptimizationEngineName, previous); + }; +} + +export async function withTestImageOptimizationEngine( + engine: ImageOptimizationEngine, + run: () => Promise | T, +): Promise { + const restore = installTestImageOptimizationEngine(engine); + try { + return await run(); + } finally { + restore(); + } +} diff --git a/tests/integration/build/asset-pipeline/integration.test.ts b/tests/integration/build/asset-pipeline/integration.test.ts index 7c1a562bfa..b965eb6ba8 100644 --- a/tests/integration/build/asset-pipeline/integration.test.ts +++ b/tests/integration/build/asset-pipeline/integration.test.ts @@ -3,7 +3,7 @@ */ import "../../../_helpers/contract-init.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert"; +import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert"; import { describe, it } from "#veryfront/testing/bdd"; import { type AssetPipelineOptions, @@ -62,6 +62,72 @@ describe("Asset Pipeline", () => { assertEquals(result.images.enabled, false); assertEquals(typeof result.css.optimized, "number"); }); + + it("rejects overlapping Tailwind and CSS outputs before either stage writes", async () => { + const projectDir = await Deno.makeTempDir(); + const outputDir = `${projectDir}/.veryfront/css`; + try { + await assertRejects( + () => + runAssetPipeline({ + images: { enabled: false }, + tailwind: { + enabled: true, + projectDir, + }, + css: { + enabled: true, + projectDir, + outputDir: ".veryfront/css", + }, + }), + TypeError, + "output directories for tailwind and css must not overlap physically", + ); + + await assertRejects( + () => Deno.stat(outputDir), + Deno.errors.NotFound, + ); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); + + it("rejects Tailwind output aliases of the CSS publication tree", async () => { + const projectDir = await Deno.makeTempDir(); + const physicalOutputDir = `${projectDir}/.veryfront/physical-css`; + const aliasOutputDir = `${projectDir}/.veryfront/alias-css`; + const sentinelPath = `${physicalOutputDir}/sentinel.txt`; + try { + await Deno.mkdir(physicalOutputDir, { recursive: true }); + await Deno.symlink(physicalOutputDir, aliasOutputDir, { type: "dir" }); + await Deno.writeTextFile(sentinelPath, "preserve me"); + + await assertRejects( + () => + runAssetPipeline({ + images: { enabled: false }, + tailwind: { + enabled: true, + projectDir, + outputDir: ".veryfront/alias-css", + }, + css: { + enabled: true, + projectDir, + outputDir: ".veryfront/physical-css", + }, + }), + TypeError, + "output directories for tailwind and css must not overlap physically", + ); + + assertEquals(await Deno.readTextFile(sentinelPath), "preserve me"); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } + }); }); describe("checkAssetPipelineDependencies", () => { @@ -161,22 +227,24 @@ describe("Asset Pipeline", () => { }); describe("error handling", () => { - it("handles invalid paths without crashing", async () => { - const result = await runAssetPipeline({ - images: { - enabled: true, - inputDir: "/invalid/path/that/does/not/exist", - outputDir: "/invalid/output/path", - }, - css: { - enabled: true, - inputDir: "/invalid/css/path", - outputDir: "/invalid/css/output", - }, - }); - - assertExists(result); - assertEquals(typeof result.duration, "number"); + it("rejects output paths outside the project boundary", async () => { + await assertRejects( + () => + runAssetPipeline({ + images: { + enabled: true, + inputDir: "/invalid/path/that/does/not/exist", + outputDir: "/invalid/output/path", + }, + css: { + enabled: true, + inputDir: "/invalid/css/path", + outputDir: "/invalid/css/output", + }, + }), + TypeError, + "must be inside its project", + ); }); });