diff --git a/deno.json b/deno.json index 26ebabd0d3..6e640e1066 100644 --- a/deno.json +++ b/deno.json @@ -48,7 +48,10 @@ "./extensions/ext-blob-s3", "./extensions/ext-bundler-esbuild", "./extensions/ext-cache-redis", + "./extensions/ext-css-lightning", + "./extensions/ext-css-purgecss", "./extensions/ext-css-tailwind", + "./extensions/ext-image-sharp", "./extensions/ext-node-websocket-ws", "./extensions/ext-document-kreuzberg", "./extensions/ext-db-sqlite", diff --git a/deno.lock b/deno.lock index 5dffd2f2f5..ed3e5b8424 100644 --- a/deno.lock +++ b/deno.lock @@ -48,6 +48,8 @@ "npm:@redis/client@1.5.8": "1.5.8", "npm:@sentry/deno@10.68.0": "10.68.0", "npm:@sentry/node@10.68.0": "10.68.0_@opentelemetry+core@2.9.0__@opentelemetry+api@1.9.1_@opentelemetry+exporter-trace-otlp-http@0.220.0__@opentelemetry+api@1.9.1", + "npm:@tailwindcss/forms@0.5.11": "0.5.11_tailwindcss@4.2.2", + "npm:@tailwindcss/typography@0.5.19": "0.5.19_tailwindcss@4.2.2", "npm:@types/better-sqlite3@7.6.13": "7.6.13", "npm:@types/hast@3.0.3": "3.0.3", "npm:@types/mdast@4.0.3": "4.0.3", @@ -59,6 +61,7 @@ "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:daisyui@5.5.14": "5.5.14", "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", @@ -87,6 +90,9 @@ "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:sharp@0.35.3": "0.35.3", + "npm:tailwind-scrollbar-hide@2.0.0": "2.0.0_tailwindcss@4.2.2", + "npm:tailwindcss-animate@1.0.7": "1.0.7_tailwindcss@4.2.2", "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", @@ -781,6 +787,12 @@ "@csstools/css-tokenizer@4.0.0": { "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==" }, + "@emnapi/runtime@1.11.3": { + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dependencies": [ + "tslib@2.8.1" + ] + }, "@emnapi/runtime@1.8.1": { "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dependencies": [ @@ -950,7 +962,7 @@ "@huggingface/tokenizers", "onnxruntime-node", "onnxruntime-web", - "sharp" + "sharp@0.34.5" ] }, "@img/colour@1.1.0": { @@ -959,7 +971,15 @@ "@img/sharp-darwin-arm64@0.34.5": { "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "optionalDependencies": [ - "@img/sharp-libvips-darwin-arm64" + "@img/sharp-libvips-darwin-arm64@1.2.4" + ], + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-darwin-arm64@0.35.3": { + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-arm64@1.3.2" ], "os": ["darwin"], "cpu": ["arm64"] @@ -967,65 +987,138 @@ "@img/sharp-darwin-x64@0.34.5": { "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "optionalDependencies": [ - "@img/sharp-libvips-darwin-x64" + "@img/sharp-libvips-darwin-x64@1.2.4" ], "os": ["darwin"], "cpu": ["x64"] }, + "@img/sharp-darwin-x64@0.35.3": { + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-x64@1.3.2" + ], + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-freebsd-wasm32@0.35.3": { + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "dependencies": [ + "@img/sharp-wasm32@0.35.3" + ], + "os": ["freebsd"] + }, "@img/sharp-libvips-darwin-arm64@1.2.4": { "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "os": ["darwin"], "cpu": ["arm64"] }, + "@img/sharp-libvips-darwin-arm64@1.3.2": { + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, "@img/sharp-libvips-darwin-x64@1.2.4": { "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "os": ["darwin"], "cpu": ["x64"] }, + "@img/sharp-libvips-darwin-x64@1.3.2": { + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "os": ["darwin"], + "cpu": ["x64"] + }, "@img/sharp-libvips-linux-arm64@1.2.4": { "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "os": ["linux"], "cpu": ["arm64"] }, + "@img/sharp-libvips-linux-arm64@1.3.2": { + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "os": ["linux"], + "cpu": ["arm64"] + }, "@img/sharp-libvips-linux-arm@1.2.4": { "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "os": ["linux"], "cpu": ["arm"] }, + "@img/sharp-libvips-linux-arm@1.3.2": { + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "os": ["linux"], + "cpu": ["arm"] + }, "@img/sharp-libvips-linux-ppc64@1.2.4": { "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "os": ["linux"], "cpu": ["ppc64"] }, + "@img/sharp-libvips-linux-ppc64@1.3.2": { + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "os": ["linux"], + "cpu": ["ppc64"] + }, "@img/sharp-libvips-linux-riscv64@1.2.4": { "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "os": ["linux"], "cpu": ["riscv64"] }, + "@img/sharp-libvips-linux-riscv64@1.3.2": { + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "os": ["linux"], + "cpu": ["riscv64"] + }, "@img/sharp-libvips-linux-s390x@1.2.4": { "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "os": ["linux"], "cpu": ["s390x"] }, + "@img/sharp-libvips-linux-s390x@1.3.2": { + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "os": ["linux"], + "cpu": ["s390x"] + }, "@img/sharp-libvips-linux-x64@1.2.4": { "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "os": ["linux"], "cpu": ["x64"] }, + "@img/sharp-libvips-linux-x64@1.3.2": { + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "os": ["linux"], + "cpu": ["x64"] + }, "@img/sharp-libvips-linuxmusl-arm64@1.2.4": { "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "os": ["linux"], "cpu": ["arm64"] }, + "@img/sharp-libvips-linuxmusl-arm64@1.3.2": { + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "os": ["linux"], + "cpu": ["arm64"] + }, "@img/sharp-libvips-linuxmusl-x64@1.2.4": { "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "os": ["linux"], "cpu": ["x64"] }, + "@img/sharp-libvips-linuxmusl-x64@1.3.2": { + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "os": ["linux"], + "cpu": ["x64"] + }, "@img/sharp-linux-arm64@0.34.5": { "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "optionalDependencies": [ - "@img/sharp-libvips-linux-arm64" + "@img/sharp-libvips-linux-arm64@1.2.4" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linux-arm64@0.35.3": { + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm64@1.3.2" ], "os": ["linux"], "cpu": ["arm64"] @@ -1033,7 +1126,15 @@ "@img/sharp-linux-arm@0.34.5": { "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "optionalDependencies": [ - "@img/sharp-libvips-linux-arm" + "@img/sharp-libvips-linux-arm@1.2.4" + ], + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-linux-arm@0.35.3": { + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm@1.3.2" ], "os": ["linux"], "cpu": ["arm"] @@ -1041,7 +1142,15 @@ "@img/sharp-linux-ppc64@0.34.5": { "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "optionalDependencies": [ - "@img/sharp-libvips-linux-ppc64" + "@img/sharp-libvips-linux-ppc64@1.2.4" + ], + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@img/sharp-linux-ppc64@0.35.3": { + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-ppc64@1.3.2" ], "os": ["linux"], "cpu": ["ppc64"] @@ -1049,7 +1158,15 @@ "@img/sharp-linux-riscv64@0.34.5": { "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "optionalDependencies": [ - "@img/sharp-libvips-linux-riscv64" + "@img/sharp-libvips-linux-riscv64@1.2.4" + ], + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@img/sharp-linux-riscv64@0.35.3": { + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-riscv64@1.3.2" ], "os": ["linux"], "cpu": ["riscv64"] @@ -1057,7 +1174,15 @@ "@img/sharp-linux-s390x@0.34.5": { "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "optionalDependencies": [ - "@img/sharp-libvips-linux-s390x" + "@img/sharp-libvips-linux-s390x@1.2.4" + ], + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-linux-s390x@0.35.3": { + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-s390x@1.3.2" ], "os": ["linux"], "cpu": ["s390x"] @@ -1065,7 +1190,15 @@ "@img/sharp-linux-x64@0.34.5": { "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "optionalDependencies": [ - "@img/sharp-libvips-linux-x64" + "@img/sharp-libvips-linux-x64@1.2.4" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linux-x64@0.35.3": { + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-x64@1.3.2" ], "os": ["linux"], "cpu": ["x64"] @@ -1073,7 +1206,15 @@ "@img/sharp-linuxmusl-arm64@0.34.5": { "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "optionalDependencies": [ - "@img/sharp-libvips-linuxmusl-arm64" + "@img/sharp-libvips-linuxmusl-arm64@1.2.4" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linuxmusl-arm64@0.35.3": { + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-arm64@1.3.2" ], "os": ["linux"], "cpu": ["arm64"] @@ -1081,7 +1222,15 @@ "@img/sharp-linuxmusl-x64@0.34.5": { "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "optionalDependencies": [ - "@img/sharp-libvips-linuxmusl-x64" + "@img/sharp-libvips-linuxmusl-x64@1.2.4" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linuxmusl-x64@0.35.3": { + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-x64@1.3.2" ], "os": ["linux"], "cpu": ["x64"] @@ -1089,7 +1238,20 @@ "@img/sharp-wasm32@0.34.5": { "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "dependencies": [ - "@emnapi/runtime" + "@emnapi/runtime@1.11.3" + ], + "cpu": ["wasm32"] + }, + "@img/sharp-wasm32@0.35.3": { + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "dependencies": [ + "@emnapi/runtime@1.11.3" + ] + }, + "@img/sharp-webcontainers-wasm32@0.35.3": { + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "dependencies": [ + "@img/sharp-wasm32@0.35.3" ], "cpu": ["wasm32"] }, @@ -1098,16 +1260,31 @@ "os": ["win32"], "cpu": ["arm64"] }, + "@img/sharp-win32-arm64@0.35.3": { + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "os": ["win32"], + "cpu": ["arm64"] + }, "@img/sharp-win32-ia32@0.34.5": { "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "os": ["win32"], "cpu": ["ia32"] }, + "@img/sharp-win32-ia32@0.35.3": { + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "os": ["win32"], + "cpu": ["ia32"] + }, "@img/sharp-win32-x64@0.34.5": { "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "os": ["win32"], "cpu": ["x64"] }, + "@img/sharp-win32-x64@0.35.3": { + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "os": ["win32"], + "cpu": ["x64"] + }, "@jitl/quickjs-ffi-types@0.32.0": { "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==" }, @@ -1181,7 +1358,7 @@ "@kreuzberg/node@4.4.2": { "integrity": "sha512-8JT3Q9s1hxIHcCaASzZY53BhhNmM6peGtvm7XGu97dSN2eMavxh0O6l9cAWdzLdwQHXY6UZKtywJO7wZbI71+w==", "dependencies": [ - "@emnapi/runtime", + "@emnapi/runtime@1.8.1", "which" ], "optionalDependencies": [ @@ -2491,6 +2668,20 @@ "@standard-schema/spec@1.1.0": { "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, + "@tailwindcss/forms@0.5.11_tailwindcss@4.2.2": { + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "dependencies": [ + "mini-svg-data-uri", + "tailwindcss" + ] + }, + "@tailwindcss/typography@0.5.19_tailwindcss@4.2.2": { + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dependencies": [ + "postcss-selector-parser@6.0.10", + "tailwindcss" + ] + }, "@tokenizer/inflate@0.4.1": { "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "dependencies": [ @@ -2771,13 +2962,6 @@ ], "bin": true }, - "buffer@5.6.0": { - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", - "dependencies": [ - "base64-js", - "ieee754" - ] - }, "buffer@5.7.1": { "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dependencies": [ @@ -2870,6 +3054,9 @@ "csstype@3.2.3": { "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "daisyui@5.5.14": { + "integrity": "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==" + }, "data-uri-to-buffer@4.0.1": { "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" }, @@ -4248,6 +4435,10 @@ "mimic-response@3.1.0": { "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" }, + "mini-svg-data-uri@1.4.4": { + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "bin": true + }, "minimatch@10.2.5": { "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dependencies": [ @@ -4430,6 +4621,13 @@ ], "bin": true }, + "postcss-selector-parser@6.0.10": { + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dependencies": [ + "cssesc", + "util-deprecate" + ] + }, "postcss-selector-parser@7.1.4": { "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", "dependencies": [ @@ -4518,7 +4716,7 @@ "commander@12.1.0", "fast-glob", "postcss", - "postcss-selector-parser" + "postcss-selector-parser@7.1.4" ], "bin": true }, @@ -4796,8 +4994,8 @@ "semver-compare@1.0.0": { "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==" }, - "semver@7.8.0": { - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "semver@7.8.5": { + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "bin": true }, "serialize-error@7.0.1": { @@ -4817,33 +5015,68 @@ "semver" ], "optionalDependencies": [ - "@img/sharp-darwin-arm64", - "@img/sharp-darwin-x64", - "@img/sharp-libvips-darwin-arm64", - "@img/sharp-libvips-darwin-x64", - "@img/sharp-libvips-linux-arm", - "@img/sharp-libvips-linux-arm64", - "@img/sharp-libvips-linux-ppc64", - "@img/sharp-libvips-linux-riscv64", - "@img/sharp-libvips-linux-s390x", - "@img/sharp-libvips-linux-x64", - "@img/sharp-libvips-linuxmusl-arm64", - "@img/sharp-libvips-linuxmusl-x64", - "@img/sharp-linux-arm", - "@img/sharp-linux-arm64", - "@img/sharp-linux-ppc64", - "@img/sharp-linux-riscv64", - "@img/sharp-linux-s390x", - "@img/sharp-linux-x64", - "@img/sharp-linuxmusl-arm64", - "@img/sharp-linuxmusl-x64", - "@img/sharp-wasm32", - "@img/sharp-win32-arm64", - "@img/sharp-win32-ia32", - "@img/sharp-win32-x64" + "@img/sharp-darwin-arm64@0.34.5", + "@img/sharp-darwin-x64@0.34.5", + "@img/sharp-libvips-darwin-arm64@1.2.4", + "@img/sharp-libvips-darwin-x64@1.2.4", + "@img/sharp-libvips-linux-arm@1.2.4", + "@img/sharp-libvips-linux-arm64@1.2.4", + "@img/sharp-libvips-linux-ppc64@1.2.4", + "@img/sharp-libvips-linux-riscv64@1.2.4", + "@img/sharp-libvips-linux-s390x@1.2.4", + "@img/sharp-libvips-linux-x64@1.2.4", + "@img/sharp-libvips-linuxmusl-arm64@1.2.4", + "@img/sharp-libvips-linuxmusl-x64@1.2.4", + "@img/sharp-linux-arm@0.34.5", + "@img/sharp-linux-arm64@0.34.5", + "@img/sharp-linux-ppc64@0.34.5", + "@img/sharp-linux-riscv64@0.34.5", + "@img/sharp-linux-s390x@0.34.5", + "@img/sharp-linux-x64@0.34.5", + "@img/sharp-linuxmusl-arm64@0.34.5", + "@img/sharp-linuxmusl-x64@0.34.5", + "@img/sharp-wasm32@0.34.5", + "@img/sharp-win32-arm64@0.34.5", + "@img/sharp-win32-ia32@0.34.5", + "@img/sharp-win32-x64@0.34.5" ], "scripts": true }, + "sharp@0.35.3": { + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "dependencies": [ + "@img/colour", + "detect-libc", + "semver" + ], + "optionalDependencies": [ + "@img/sharp-darwin-arm64@0.35.3", + "@img/sharp-darwin-x64@0.35.3", + "@img/sharp-freebsd-wasm32", + "@img/sharp-libvips-darwin-arm64@1.3.2", + "@img/sharp-libvips-darwin-x64@1.3.2", + "@img/sharp-libvips-linux-arm@1.3.2", + "@img/sharp-libvips-linux-arm64@1.3.2", + "@img/sharp-libvips-linux-ppc64@1.3.2", + "@img/sharp-libvips-linux-riscv64@1.3.2", + "@img/sharp-libvips-linux-s390x@1.3.2", + "@img/sharp-libvips-linux-x64@1.3.2", + "@img/sharp-libvips-linuxmusl-arm64@1.3.2", + "@img/sharp-libvips-linuxmusl-x64@1.3.2", + "@img/sharp-linux-arm@0.35.3", + "@img/sharp-linux-arm64@0.35.3", + "@img/sharp-linux-ppc64@0.35.3", + "@img/sharp-linux-riscv64@0.35.3", + "@img/sharp-linux-s390x@0.35.3", + "@img/sharp-linux-x64@0.35.3", + "@img/sharp-linuxmusl-arm64@0.35.3", + "@img/sharp-linuxmusl-x64@0.35.3", + "@img/sharp-webcontainers-wasm32", + "@img/sharp-win32-arm64@0.35.3", + "@img/sharp-win32-ia32@0.35.3", + "@img/sharp-win32-x64@0.35.3" + ] + }, "simple-concat@1.0.1": { "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" }, @@ -4945,6 +5178,18 @@ "os": ["darwin", "linux", "win32", "freebsd", "openbsd", "netbsd", "sunos", "android"], "bin": true }, + "tailwind-scrollbar-hide@2.0.0_tailwindcss@4.2.2": { + "integrity": "sha512-lqiIutHliEiODwBRHy4G2+Tcayo2U7+3+4frBmoMETD72qtah+XhOk5XcPzC1nJvXhXUdfl2ajlMhUc2qC6CIg==", + "dependencies": [ + "tailwindcss" + ] + }, + "tailwindcss-animate@1.0.7_tailwindcss@4.2.2": { + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dependencies": [ + "tailwindcss" + ] + }, "tailwindcss@4.2.2": { "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==" }, @@ -5314,6 +5559,10 @@ "https://esm.sh/@smithy/util-utf8@^2.0.2?target=denonext": "https://esm.sh/@smithy/util-utf8@2.3.0?target=denonext", "https://esm.sh/@smithy/util-utf8@^2.3.0?target=denonext": "https://esm.sh/@smithy/util-utf8@2.3.0?target=denonext", "https://esm.sh/@smithy/util-waiter@^2.0.16?target=denonext": "https://esm.sh/@smithy/util-waiter@2.2.0?target=denonext", + "https://esm.sh/@types/color-convert@~2.0.4/index.d.ts": "https://esm.sh/@types/color-convert@2.0.4/index.d.ts", + "https://esm.sh/@types/color-name@~1.1.5/index.d.ts": "https://esm.sh/@types/color-name@1.1.5/index.d.ts", + "https://esm.sh/@types/color-string@~1.5.5/index.d.ts": "https://esm.sh/@types/color-string@1.5.5/index.d.ts", + "https://esm.sh/@types/color@~4.2.0/index.d.ts": "https://esm.sh/@types/color@4.2.1/index.d.ts", "https://esm.sh/@types/react-dom@19.2.3?deps=csstype@3.2.3": "https://esm.sh/@types/react-dom@19.2.3/index.d.ts", "https://esm.sh/@types/react-dom@~19.2.3/X-ZGNzc3R5cGVAMy4yLjMKZXJlYWN0/client.d.ts": "https://esm.sh/@types/react-dom@19.2.3/client.d.ts", "https://esm.sh/@types/react-dom@~19.2.3/X-ZGNzc3R5cGVAMy4yLjMKZXJlYWN0/index.d.ts": "https://esm.sh/@types/react-dom@19.2.3/index.d.ts", @@ -5323,6 +5572,9 @@ "https://esm.sh/@types/react@~19.2.14/X-ZGNzc3R5cGVAMy4yLjMKZXJlYWN0/jsx-dev-runtime.d.ts": "https://esm.sh/@types/react@19.2.14/jsx-dev-runtime.d.ts", "https://esm.sh/@types/react@~19.2.14/X-ZGNzc3R5cGVAMy4yLjMKZXJlYWN0/jsx-runtime.d.ts": "https://esm.sh/@types/react@19.2.14/jsx-runtime.d.ts", "https://esm.sh/@types/scheduler@~0.26.0/index.d.ts": "https://esm.sh/@types/scheduler@0.26.0/index.d.ts", + "https://esm.sh/@types/semver@~7.7.1/functions/coerce.d.ts": "https://esm.sh/@types/semver@7.7.1/functions/coerce.d.ts", + "https://esm.sh/@types/semver@~7.7.1/functions/gte.d.ts": "https://esm.sh/@types/semver@7.7.1/functions/gte.d.ts", + "https://esm.sh/@types/simple-swizzle@~0.2.2/index.d.ts": "https://esm.sh/@types/simple-swizzle@0.2.2/index.d.ts", "https://esm.sh/@types/uuid@~9.0.8/index.d.mts": "https://esm.sh/@types/uuid@9.0.8/index.d.mts", "https://esm.sh/agent-base@6?target=denonext": "https://esm.sh/agent-base@6.0.2?target=denonext", "https://esm.sh/ansi-styles@^4.1.0?target=denonext": "https://esm.sh/ansi-styles@4.3.0?target=denonext", @@ -6082,10 +6334,30 @@ "npm:vfile@6.0.3" ] }, + "extensions/ext-css-lightning": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:browserslist@4.28.7", + "npm:lightningcss@1.29.2" + ] + }, + "extensions/ext-css-purgecss": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:purgecss@8.0.0" + ] + }, "extensions/ext-css-tailwind": { "dependencies": [ "jsr:@std/assert@1.0.19", "jsr:@std/testing@1.0.17", + "npm:@tailwindcss/forms@0.5.11", + "npm:@tailwindcss/typography@0.5.19", + "npm:daisyui@5.5.14", + "npm:tailwind-scrollbar-hide@2.0.0", + "npm:tailwindcss-animate@1.0.7", "npm:tailwindcss@4.2.2" ] }, @@ -6115,6 +6387,13 @@ "jsr:@std/testing@1.0.17" ] }, + "extensions/ext-image-sharp": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/testing@1.0.17", + "npm:sharp@0.35.3" + ] + }, "extensions/ext-llm-anthropic": { "dependencies": [ "jsr:@std/assert@1.0.19", diff --git a/docs/api-reference/veryfront/extensions.md b/docs/api-reference/veryfront/extensions.md index 45a6243543..7cb48a672a 100644 --- a/docs/api-reference/veryfront/extensions.md +++ b/docs/api-reference/veryfront/extensions.md @@ -50,10 +50,10 @@ await loader.teardownAll(); | 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) | +| `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#L137) | | `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) | +| `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#L144) | | `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#L155) | | `discoverLocalExtensions` | Find `*.extension.ts` files in the project root. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/discovery.ts#L241) | @@ -91,7 +91,7 @@ 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) | +| `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#L71) | | `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) | @@ -256,10 +256,10 @@ import { register, reset, resolve } from "veryfront/extensions/contracts"; ### `veryfront/extensions/css` -CSS category barrel - CSS processor and compiler contracts. +CSS category barrel - CSS compilation and optimization contracts. ```ts -import { assertCSSOptimizationEngine, assertCSSPurgingEngine, captureCSSOptimizationEngine } from "veryfront/extensions/css"; +import { assertCSSOptimizationEngine, assertCSSProcessor, assertCSSPurgingEngine } from "veryfront/extensions/css"; ``` #### Components @@ -267,8 +267,11 @@ import { assertCSSOptimizationEngine, assertCSSPurgingEngine, captureCSSOptimiza | 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) | +| `CSSProcessorName` | Registry name used for the CSS compiler extension contract. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L30) | | `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_PROCESSOR_DEFAULT_STYLESHEET_CHARACTERS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L32) | +| `MAX_CSS_PROCESSOR_IDENTITY_CHARACTERS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L31) | | `MAX_CSS_PURGING_ENGINE_IDENTITY_CHARACTERS` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L21) | #### Functions @@ -276,26 +279,26 @@ import { assertCSSOptimizationEngine, assertCSSPurgingEngine, captureCSSOptimiza | 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) | +| `assertCSSProcessor` | Validate an implementation received through the dynamic extension registry. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L166) | | `assertCSSPurgingEngine` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-purging-engine.ts#L94) | +| `captureCSSCompiler` | Capture a compiler method once so accessors and later mutation cannot redirect a build. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L98) | | `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) | +| `captureCSSProcessor` | Capture the complete processor surface once. A registry or implementation mutation can therefore affect only a subsequently acquired operation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L174) | | `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 | |------|-------------|--------| -| `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) | +| `CSSCompiler` | Stateful compiler returned by `CSSProcessor.compile`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L37) | | `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) | +| `CSSProcessor` | CSSProcessor contract interface. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/extensions/css/css-processor.ts#L53) | | `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` @@ -383,14 +386,14 @@ import { assertImageOptimizationEngine, captureImageOptimizationEngine, ImageOpt | 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) | +| `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#L137) | +| `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#L144) | #### 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) | +| `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#L71) | | `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) | diff --git a/docs/api-reference/veryfront/release-assets.md b/docs/api-reference/veryfront/release-assets.md index a417799b4b..db80a94677 100644 --- a/docs/api-reference/veryfront/release-assets.md +++ b/docs/api-reference/veryfront/release-assets.md @@ -49,7 +49,7 @@ const url = releaseAssetUrl("a".repeat(64), "js"); | `clearReleaseAssetManifestCache` | Clear the cache and fetcher registry (tests / adapter teardown). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L369) | | `configureReleaseAssetManifestFetcher` | Register a single global fetcher (for tests / simple single-project setups). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L121) | | `contentTypeForExtension` | Resolve the content type for an extension, or null if not allowed. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L59) | -| `createCompileProjectCss` | Build a `compileProjectCss` function bound to a specific release build. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/css-compile.ts#L59) | +| `createCompileProjectCss` | Build a `compileProjectCss` function bound to a specific release build. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/css-compile.ts#L64) | | `getReadyManifestForRender` | Return a ready manifest for `releaseId` if one is cached, else null. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L184) | | `isAllowedReleaseAssetContentType` | True when the value is a valid allowlisted release asset content type. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/constants.ts#L68) | | `isReleaseAssetManifestEnabled` | True when production manifest consumption is enabled via env flag. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/manifest-cache.ts#L139) | @@ -68,8 +68,8 @@ const url = releaseAssetUrl("a".repeat(64), "js"); | Name | Description | Source | |------|-------------|--------| -| `CompileProjectCssOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/css-compile.ts#L39) | -| `CompileProjectCssResult` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/css-compile.ts#L34) | +| `CompileProjectCssOptions` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/css-compile.ts#L44) | +| `CompileProjectCssResult` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/css-compile.ts#L39) | | `ReleaseAssetBuildClient` | Subset of the API client used by the builder (eases testing). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/build-executor.ts#L183) | | `ReleaseAssetBuildInput` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/build-executor.ts#L103) | | `ReleaseAssetBuildResult` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/release-assets/build-executor.ts#L220) | diff --git a/docs/api-reference/veryfront/utils.md b/docs/api-reference/veryfront/utils.md index a0d09d77dc..259d6533d9 100644 --- a/docs/api-reference/veryfront/utils.md +++ b/docs/api-reference/veryfront/utils.md @@ -10,10 +10,10 @@ order: 39 import { __registerLogRecordEmitter, __registerTraceContextGetter, + assertCSSPipelineIdentity, + assertStyleProfileHash, base64urlEncode, base64urlEncodeBytes, - computeCodeHash, - computeHash, } from "veryfront/utils"; ``` @@ -75,6 +75,8 @@ serverLogger.info("Booting server", { project_id: "proj_123" }); | `HTTP_UNAVAILABLE` | Shared HTTP unavailable value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/constants/http.ts#L54) | | `IMAGE_OPTIMIZATION` | Shared image optimization value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/constants/build.ts#L5) | | `MAX_BATCH_SIZE` | ****** Batch limits ******* | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/constants/limits.ts#L31) | +| `MAX_CSS_PIPELINE_IDENTITY_CODE_UNITS` | Maximum UTF-16 code units accepted for one complete CSS pipeline identity. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/css-artifact-identity.ts#L13) | +| `MAX_CSS_PIPELINE_IDENTITY_UTF8_BYTES` | Maximum encoded UTF-8 bytes accepted for one complete CSS pipeline identity. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/css-artifact-identity.ts#L16) | | `MAX_PATH_LENGTH` | Maximum value for path length. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/constants/security.ts#L16) | | `MAX_PATH_TRAVERSAL_DEPTH` | Maximum value for path traversal depth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/constants/security.ts#L2) | | `MS_PER_SECOND` | Shared ms per second value. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/constants/cache.ts#L5) | @@ -97,6 +99,8 @@ serverLogger.info("Booting server", { project_id: "proj_123" }); |------|-------------|--------| | `__registerLogRecordEmitter` | Register a process-level structured log emitter, for example an OTel bridge. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/logger/logger.ts#L217) | | `__registerTraceContextGetter` | Register the trace context getter. Called by trace-bridge.ts after OTLP initialization. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/logger/logger.ts#L649) | +| `assertCSSPipelineIdentity` | Validate and return an immutable string snapshot for cache or wire use. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/css-artifact-identity.ts#L71) | +| `assertStyleProfileHash` | Validate and return the canonical style-scope profile SHA-256 identity. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/css-artifact-identity.ts#L90) | | `base64urlEncode` | Encode a string as unpadded base64url. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/base64url.ts#L52) | | `base64urlEncodeBytes` | Encode raw bytes as unpadded base64url. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/base64url.ts#L57) | | `computeCodeHash` | Compute code hash. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/hash-utils.ts#L29) | @@ -118,8 +122,10 @@ serverLogger.info("Booting server", { project_id: "proj_123" }); | `hasDenoRuntime` | Check whether Deno runtime is present. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/runtime-guards.ts#L31) | | `hasNodeProcess` | Check whether node process is present. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/runtime-guards.ts#L38) | | `isCompiledBinary` | Detect if the code is running in a compiled Deno binary | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/platform.ts#L11) | +| `isCSSPipelineIdentity` | Whether a value is safe to compare, encode, and persist as a CSS pipeline identity. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/css-artifact-identity.ts#L50) | | `isEnabled` | Check whether request performance timing is enabled. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/perf-timer.ts#L143) | | `isRSCEnabled` | Check whether RSC is enabled. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/feature-flags.ts#L4) | +| `isStyleProfileHash` | Whether a value is the canonical style-scope profile SHA-256 identity. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/css-artifact-identity.ts#L84) | | `isTruthyEnvValue` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/constants/env.ts#L11) | | `isWithinDirectory` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/path-utils.ts#L22) | | `memoize` | Memoize. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/utils/memoize.ts#L79) | diff --git a/extensions/README.md b/extensions/README.md index 75fbdc5044..def7619bc5 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -41,8 +41,11 @@ Extension availability is separate from contract requirement: | Package | Contract | Description | | --------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------- | | [`@veryfront/ext-bundler-esbuild`](./ext-bundler-esbuild) | `Bundler`, `ModuleLexer` | ESM bundling and module analysis via `esbuild` and `es-module-lexer` | +| [`@veryfront/ext-css-lightning`](./ext-css-lightning) | `CSSOptimizationEngine` | Explicit CSS compilation, minification, browser targets, and source maps | +| [`@veryfront/ext-css-purgecss`](./ext-css-purgecss) | `CSSPurgingEngine` | Explicit parser-backed unused and critical CSS extraction via PurgeCSS | +| [`@veryfront/ext-css-tailwind`](./ext-css-tailwind) | `CSSProcessor` | Tailwind CSS v4 compilation with pinned local plugins | +| [`@veryfront/ext-image-sharp`](./ext-image-sharp) | `ImageOptimizationEngine` | Explicit bounded native image transformation via Sharp | | [`@veryfront/ext-parser-babel`](./ext-parser-babel) | `CodeParser` | JS/TS AST parsing, traversal, and JSX source-position injection via Babel | -| [`@veryfront/ext-css-tailwind`](./ext-css-tailwind) | `CSSProcessor` | Tailwind CSS v4 compilation with dynamic plugin loading | ### Content @@ -112,7 +115,6 @@ root package. | `@veryfront/ext-bundler-esbuild` | `Bundler`, `ModuleLexer` | | `@veryfront/ext-parser-babel` | `CodeParser` | | `@veryfront/ext-content-mdx` | `ContentProcessor` | -| `@veryfront/ext-css-tailwind` | `CSSProcessor` | | `@veryfront/ext-document-kreuzberg` | `DocumentExtractor` | | `@veryfront/ext-db-sqlite` | `SqliteStore` | | `@veryfront/ext-sandbox-shell-tools` | `SandboxShellToolsProvider` | @@ -130,6 +132,9 @@ raw transitive dependencies such as `bash-tool`, `just-bash`, `jose`, | Runtime or service role | Install these extension packages | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | CLI, build image, or project server runtime | `@veryfront/ext-bundler-esbuild`, `@veryfront/ext-content-mdx`, `@veryfront/ext-css-tailwind`, `@veryfront/ext-parser-babel` | +| Build with CSS optimization | `@veryfront/ext-css-lightning` (register explicitly) | +| Build with CSS purging or critical CSS | `@veryfront/ext-css-purgecss` (register explicitly) | +| Build with image optimization | `@veryfront/ext-image-sharp` (register explicitly) | | Proxy or JWT-authenticated service | `@veryfront/ext-auth-jwt` | | Document upload or knowledge ingestion | `@veryfront/ext-document-kreuzberg` | | Redis-backed cache or token store | `@veryfront/ext-cache-redis` | @@ -156,7 +161,10 @@ level. | `Bundler`, `ModuleLexer` | Build, import analysis, or module bundling runs | Auto-enabled core extension | | `CodeParser` | AST parsing or build-time code analysis runs | Auto-enabled core extension | | `ContentProcessor` | MDX or Markdown content compilation runs | Auto-enabled core extension | -| `CSSProcessor` | Tailwind CSS processing runs | Auto-enabled core extension | +| `CSSProcessor` | Class-candidate CSS processing runs | Explicit user-installed extension | +| `CSSOptimizationEngine` | CSS compilation or minification runs | Explicit user-installed extension | +| `CSSPurgingEngine` | CSS purging or critical-CSS extraction runs | Explicit user-installed extension | +| `ImageOptimizationEngine` | Image optimization runs | Explicit user-installed extension | | `DocumentExtractor` | Document text extraction runs | Auto-enabled native service extension | | `SqliteStore` | SQLite-backed persistence runs | Auto-enabled native service extension | | `SandboxShellToolsProvider` | Sandbox shell tools are created | Auto-enabled core extension | diff --git a/extensions/ext-css-lightning/README.md b/extensions/ext-css-lightning/README.md new file mode 100644 index 0000000000..25ad73c11e --- /dev/null +++ b/extensions/ext-css-lightning/README.md @@ -0,0 +1,59 @@ +# @veryfront/ext-css-lightning + +> **Category:** Build | **Contract:** `CSSOptimizationEngine` | **Activation:** Explicit + +Provides parser-backed CSS optimization through pinned Lightning CSS and +Browserslist implementations. Veryfront core depends only on the +`CSSOptimizationEngine` contract; this package owns the vendor imports, browser +query grammar, target conversion, and native binary. + +## Registration + +Install and compose the extension explicitly: + +```ts +import extCSSLightning from "@veryfront/ext-css-lightning"; + +export default defineConfig({ + extensions: [extCSSLightning()], +}); +``` + +Core never probes the network, dynamically imports Lightning CSS, or substitutes +a regex/no-op implementation. Requested optimization fails with an actionable +missing-extension error until a provider is registered. + +## Configuration + +Browser queries are extension-owned input and are resolved exactly once for an +engine instance: + +```ts +extCSSLightning({ + browserQueries: [">= 0.5%", "not dead"], +}); +``` + +When omitted, the pinned Browserslist defaults are used. Configuration rejects +inherited fields, accessors, sparse arrays, custom iterators, unknown keys, +non-canonical strings, and queries that could load workspace configuration or +external statistics. + +## Cache and failure semantics + +`cacheIdentity` includes the extension semantics version, exact Lightning CSS +and Browserslist versions, selected native/WASM runtime, resolved targets, and +the pinned browser dataset. Core captures one immutable provider session for +each optimization operation, validates resource bounds and complete source-map +v3 output, and never switches providers midway through a publication. + +Lightning parser errors, invalid UTF-8 output, missing requested source maps, +and unexpected source maps are fatal. The extension requests no filesystem, +network, or subprocess capability. + +## Capabilities + +- `env:read` for Lightning's native/WASM selector and Browserslist's documented + cache and warning controls. The extension does not request Browserslist's + dangerous external-configuration override. +- `native:ffi` for Lightning CSS's platform-specific Node-API binary. diff --git a/extensions/ext-css-lightning/deno.json b/extensions/ext-css-lightning/deno.json new file mode 100644 index 0000000000..6ae80e6614 --- /dev/null +++ b/extensions/ext-css-lightning/deno.json @@ -0,0 +1,35 @@ +{ + "name": "@veryfront/ext-css-lightning", + "version": "0.1.0", + "exports": "./src/index.ts", + "veryfront": { + "extension": true, + "activation": "explicit", + "contracts": { + "provides": ["CSSOptimizationEngine"] + }, + "capabilities": [ + { + "type": "env:read", + "keys": [ + "CSS_TRANSFORMER_WASM", + "BROWSERSLIST_DISABLE_CACHE", + "BROWSERSLIST_IGNORE_OLD_DATA", + "BROWSERSLIST_TRACE_WARNING" + ] + }, + { "type": "native:ffi" } + ] + }, + "imports": { + "browserslist": "npm:browserslist@4.28.7", + "lightningcss": "npm:lightningcss@1.29.2", + "@std/assert": "jsr:@std/assert@1.0.19", + "@std/testing/bdd": "jsr:@std/testing@1.0.17/bdd", + "veryfront/extensions": "../../src/extensions/types.ts", + "veryfront/extensions/css": "../../src/extensions/css/index.ts" + }, + "tasks": { + "test": "deno test --no-lock --allow-env=CSS_TRANSFORMER_WASM,BROWSERSLIST_DISABLE_CACHE,BROWSERSLIST_IGNORE_OLD_DATA,BROWSERSLIST_TRACE_WARNING --allow-ffi src/" + } +} diff --git a/extensions/ext-css-lightning/src/index.test.ts b/extensions/ext-css-lightning/src/index.test.ts new file mode 100644 index 0000000000..fcb4d4ec7c --- /dev/null +++ b/extensions/ext-css-lightning/src/index.test.ts @@ -0,0 +1,321 @@ +import { + assert, + assertEquals, + assertNotEquals, + assertStringIncludes, + assertThrows, +} from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { + captureCSSOptimizationEngine, + CSSOptimizationEngineName, + MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, +} from "veryfront/extensions/css"; +import factory, { LightningCSSOptimizationEngine } from "./index.ts"; +import extensionPackage from "../deno.json" with { type: "json" }; + +const noopLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +describe("ext-css-lightning", () => { + it("does not require String.prototype.isWellFormed on Node 18", async () => { + const descriptor = Object.getOwnPropertyDescriptor( + String.prototype, + "isWellFormed", + ); + Object.defineProperty(String.prototype, "isWellFormed", { + value: undefined, + configurable: true, + writable: true, + }); + + try { + const node18Module = await import("./index.ts?node18-compat"); + const result = new node18Module.LightningCSSOptimizationEngine({ + browserQueries: ["ie 11"], + }).optimize({ + css: ".field { user-select: none; }", + sourcePath: "styles/field.css", + minify: true, + sourceMap: false, + }); + assertStringIncludes(result.css, "-ms-user-select:none"); + } finally { + if (descriptor === undefined) { + delete (String.prototype as { isWellFormed?: unknown }).isWellFormed; + } else { + Object.defineProperty(String.prototype, "isWellFormed", descriptor); + } + } + }); + + it("declares and registers only the explicit optimization contract", async () => { + const provided = new Map(); + const extension = factory({ browserQueries: ["ie 11"] }); + assertEquals(extensionPackage.veryfront.activation, "explicit"); + assertEquals(extension.name, "ext-css-lightning"); + assertEquals(extension.version, extensionPackage.version); + assertEquals(extension.contracts?.provides, [CSSOptimizationEngineName]); + assertEquals(extension.capabilities, [ + { + type: "env:read", + keys: [ + "CSS_TRANSFORMER_WASM", + "BROWSERSLIST_DISABLE_CACHE", + "BROWSERSLIST_IGNORE_OLD_DATA", + "BROWSERSLIST_TRACE_WARNING", + ], + }, + { type: "native:ffi" }, + ]); + + await extension.setup?.({ + config: {}, + logger: noopLogger, + provide: (name: string, implementation: unknown) => { + provided.set(name, implementation); + }, + get: () => undefined, + require: () => { + throw new Error("require is not used during setup"); + }, + }); + assert( + provided.get(CSSOptimizationEngineName) instanceof + LightningCSSOptimizationEngine, + ); + }); + + it("binds immutable identity to vendor, dataset, runtime, and targets", () => { + const defaultsA = new LightningCSSOptimizationEngine(); + const defaultsB = new LightningCSSOptimizationEngine(); + const legacy = new LightningCSSOptimizationEngine({ + browserQueries: ["ie 11"], + }); + + assertEquals(defaultsA.cacheIdentity, defaultsB.cacheIdentity); + assertNotEquals(defaultsA.cacheIdentity, legacy.cacheIdentity); + assertStringIncludes( + defaultsA.cacheIdentity, + `ext-css-lightning@${extensionPackage.version}`, + ); + assertStringIncludes(defaultsA.cacheIdentity, "lightningcss@1.29.2"); + assertStringIncludes(defaultsA.cacheIdentity, "browserslist@4.28.7"); + assertStringIncludes(defaultsA.cacheIdentity, "targets+data="); + assert( + defaultsA.cacheIdentity.length <= + MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, + ); + assert(Object.isFrozen(defaultsA)); + assert(Object.isFrozen(captureCSSOptimizationEngine(defaultsA))); + }); + + it("minifies valid CSS and applies extension-owned compatibility targets", () => { + const engine = new LightningCSSOptimizationEngine({ + browserQueries: ["ie 11"], + }); + const result = engine.optimize({ + css: "/* comment */ .field { user-select: none; color: red; }", + sourcePath: "field.css", + minify: true, + sourceMap: false, + }); + assertStringIncludes(result.css, "-ms-user-select:none"); + assertEquals(result.css.includes("/* comment */"), false); + assertEquals(Object.isFrozen(result), true); + }); + + it("returns a bounded flat source-map v3 document when requested", () => { + const result = new LightningCSSOptimizationEngine().optimize({ + css: ".field { color: red; }", + sourcePath: "styles/field.css", + minify: true, + sourceMap: true, + }); + const map = JSON.parse(result.sourceMap!); + assertEquals(map.version, 3); + assertEquals(map.sources, ["styles/field.css"]); + assertEquals(map.names, []); + assertEquals(typeof map.mappings, "string"); + }); + + it("snapshots configuration and rejects external Browserslist sources", () => { + const browserQueries = ["ie 11"]; + const engine = new LightningCSSOptimizationEngine({ browserQueries }); + browserQueries[0] = "defaults"; + const result = engine.optimize({ + css: ".field { user-select: none; }", + sourcePath: "field.css", + minify: true, + sourceMap: false, + }); + assertStringIncludes(result.css, "-ms-user-select:none"); + + for ( + const query of [ + "extends browserslist-config-example", + "> 1% in my stats", + ] + ) { + assertThrows( + () => new LightningCSSOptimizationEngine({ browserQueries: [query] }), + TypeError, + ); + } + }); + + it("rejects sparse, accessor-backed, and custom-property query arrays", () => { + const sparse = new Array(2); + sparse[1] = "ie 11"; + assertThrows( + () => new LightningCSSOptimizationEngine({ browserQueries: sparse }), + TypeError, + "bounded dense array", + ); + + let accessorCalls = 0; + const accessorBacked = ["ie 11"]; + Object.defineProperty(accessorBacked, "0", { + enumerable: true, + get() { + accessorCalls++; + return "defaults"; + }, + }); + assertThrows( + () => + new LightningCSSOptimizationEngine({ + browserQueries: accessorBacked, + }), + TypeError, + "data-property strings", + ); + assertEquals(accessorCalls, 0); + + let iteratorCalls = 0; + const customIterator = ["ie 11"]; + Object.defineProperty(customIterator, Symbol.iterator, { + value() { + iteratorCalls++; + throw new Error("must not iterate hostile input"); + }, + }); + assertThrows( + () => + new LightningCSSOptimizationEngine({ + browserQueries: customIterator, + }), + TypeError, + "bounded dense array", + ); + assertEquals(iteratorCalls, 0); + }); + + it("rejects inherited, accessor, proxy-trapped, and unknown config", () => { + let inheritedReads = 0; + const inherited = Object.create({ + get browserQueries() { + inheritedReads++; + return ["ie 11"]; + }, + }); + assertThrows( + () => new LightningCSSOptimizationEngine(inherited), + TypeError, + "must not inherit configuration", + ); + assertEquals(inheritedReads, 0); + + let ownReads = 0; + const accessor = Object.defineProperty({}, "browserQueries", { + get() { + ownReads++; + return ["ie 11"]; + }, + }); + assertThrows( + () => factory(accessor), + TypeError, + "data property", + ); + assertEquals(ownReads, 0); + + const trapped = new Proxy({}, { + ownKeys() { + throw new Error("blocked"); + }, + }); + assertThrows( + () => factory(trapped), + TypeError, + "config could not be inspected", + ); + assertThrows( + () => factory({ browserQueries: ["defaults"], unknown: true } as never), + TypeError, + "unsupported properties", + ); + }); + + it("does not invoke request accessors and rejects malformed CSS", () => { + const engine = new LightningCSSOptimizationEngine(); + let reads = 0; + const request = { + sourcePath: "field.css", + minify: true, + sourceMap: false, + } as Record; + Object.defineProperty(request, "css", { + enumerable: true, + get() { + reads++; + return ".field{}"; + }, + }); + assertThrows( + () => engine.optimize(request as never), + TypeError, + "data property", + ); + assertEquals(reads, 0); + + assertThrows(() => + engine.optimize({ + css: "@media ( { .broken { color: red; }", + sourcePath: "broken.css", + minify: true, + sourceMap: false, + }) + ); + }); + + it("uses captured inspection intrinsics after ambient mutation", () => { + const originalDescriptors = Object.getOwnPropertyDescriptors; + const originalApply = Reflect.apply; + const originalNormalize = String.prototype.normalize; + let engine: LightningCSSOptimizationEngine | undefined; + try { + Object.getOwnPropertyDescriptors = () => { + throw new Error("poisoned descriptors"); + }; + Reflect.apply = () => { + throw new Error("poisoned apply"); + }; + String.prototype.normalize = () => { + throw new Error("poisoned normalize"); + }; + engine = new LightningCSSOptimizationEngine({ + browserQueries: ["ie 11"], + }); + } finally { + Object.getOwnPropertyDescriptors = originalDescriptors; + Reflect.apply = originalApply; + String.prototype.normalize = originalNormalize; + } + assert(engine instanceof LightningCSSOptimizationEngine); + }); +}); diff --git a/extensions/ext-css-lightning/src/index.ts b/extensions/ext-css-lightning/src/index.ts new file mode 100644 index 0000000000..544e8f974e --- /dev/null +++ b/extensions/ext-css-lightning/src/index.ts @@ -0,0 +1,595 @@ +/** + * Lightning CSS and Browserslist implementation of CSSOptimizationEngine. + * + * @module extensions/ext-css-lightning + */ + +import { createHash } from "node:crypto"; +import process from "node:process"; +import type { ExtensionFactory } from "veryfront/extensions"; +import { + type CSSOptimizationEngine, + CSSOptimizationEngineName, + type CSSOptimizationRequest, + type CSSOptimizationResult, + MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, +} from "veryfront/extensions/css"; +import browserslist from "browserslist"; +import { browserslistToTargets, transform } from "lightningcss"; +import extensionPackage from "../deno.json" with { type: "json" }; +import { isWellFormedString } from "./is-well-formed-string.ts"; + +const MAX_BROWSER_QUERIES = 32; +const MAX_BROWSER_QUERY_CHARACTERS = 256; +const MAX_RESOLVED_BROWSERS = 4_096; +const MAX_SOURCE_PATH_CHARACTERS = 4_096; +const ENGINE_SEMANTICS_VERSION = "veryfront.css-lightning.v3"; + +const apply = Reflect.apply; +const arrayIsArray = Array.isArray; +const freeze = Object.freeze; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const getPrototypeOf = Object.getPrototypeOf; +const hasOwn = Object.hasOwn; +const ownKeys = Reflect.ownKeys; +const createObject = Object.create; +const defineProperty = Object.defineProperty; +const arrayPush = Array.prototype.push; +const arraySort = Array.prototype.sort; +const arrayJoin = Array.prototype.join; +const encodeText = TextEncoder.prototype.encode; +const decodeText = TextDecoder.prototype.decode; +const normalizeString = String.prototype.normalize; +const trimString = String.prototype.trim; +const charCodeAtString = String.prototype.charCodeAt; +const executeRegularExpression = RegExp.prototype.exec; +const stringifyJSON = JSON.stringify; +const isSafeInteger = Number.isSafeInteger; +const isArrayBufferView = ArrayBuffer.isView; +const objectToString = Object.prototype.toString; +const ObjectPrototype = Object.prototype; +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true }); +const EXACT_NPM_SPECIFIER = /^npm:(?:@[^/]+\/)?[^@/]+@([^@/]+)$/; +const INEXACT_VERSION_CHARACTER = /[~^*<>=|\s]/u; +const TARGET_NAME = /^[a-z][a-z0-9_]*$/; + +export interface LightningCSSOptimizationConfig { + /** Browserslist expressions resolved once when the extension is created. */ + readonly browserQueries?: readonly string[]; +} + +interface ResolvedConfig { + readonly browserQueries: readonly string[] | undefined; +} + +interface ResolvedTargets { + readonly targets: Readonly>; + readonly identity: string; +} + +function descriptorValue( + object: object, + property: PropertyKey, + label: string, +): unknown { + const descriptor = getOwnPropertyDescriptor(object, property); + if (descriptor === undefined || !hasOwn(descriptor, "value")) { + throw new TypeError(`${label} must be an own data property`); + } + return descriptor.value; +} + +function dataPropertyValue( + descriptors: PropertyDescriptorMap, + property: string, + label: string, +): unknown { + const descriptor = descriptors[property]; + if (descriptor === undefined || !hasOwn(descriptor, "value")) { + throw new TypeError(`${label} must be a data property`); + } + return descriptor.value; +} + +function exactNpmVersion(value: unknown, dependency: string): string { + if (typeof value !== "string") { + throw new TypeError(`${dependency} dependency must use an exact npm version`); + } + const match = apply(executeRegularExpression, EXACT_NPM_SPECIFIER, [value]); + if ( + match?.[1] === undefined || + apply(executeRegularExpression, INEXACT_VERSION_CHARACTER, [match[1]]) !== + null + ) { + throw new TypeError(`${dependency} dependency must use an exact npm version`); + } + return match[1]; +} + +const extensionVersion = descriptorValue( + extensionPackage, + "version", + "ext-css-lightning version", +); +const extensionImports = descriptorValue( + extensionPackage, + "imports", + "ext-css-lightning imports", +); +if ( + typeof extensionVersion !== "string" || + typeof extensionImports !== "object" || + extensionImports === null || + arrayIsArray(extensionImports) +) { + throw new TypeError("ext-css-lightning manifest is invalid"); +} +const lightningCSSVersion = exactNpmVersion( + descriptorValue(extensionImports, "lightningcss", "lightningcss import"), + "lightningcss", +); +const browserslistVersion = exactNpmVersion( + descriptorValue(extensionImports, "browserslist", "browserslist import"), + "browserslist", +); + +const hashPrototype = getPrototypeOf(createHash("sha256")); +const updateHashValue = descriptorValue(hashPrototype, "update", "Hash.update"); +const digestHashValue = descriptorValue(hashPrototype, "digest", "Hash.digest"); +if ( + typeof updateHashValue !== "function" || + typeof digestHashValue !== "function" +) { + throw new TypeError("SHA-256 implementation is unavailable"); +} +type HashInstance = ReturnType; +const updateHash = updateHashValue as ( + this: HashInstance, + value: string, + encoding: "utf8", +) => HashInstance; +const digestHash = digestHashValue as ( + this: HashInstance, + encoding: "hex", +) => string; + +function sha256(value: string): string { + const hash = createHash("sha256"); + apply(updateHash, hash, [value, "utf8"]); + return apply(digestHash, hash, ["hex"]) as string; +} + +function hasControlOrLineSeparator(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = apply(charCodeAtString, value, [index]); + if ( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) || + code === 0x2028 || + code === 0x2029 + ) { + return true; + } + } + return false; +} + +function frozenResolvedConfig( + browserQueries: readonly string[] | undefined, +): ResolvedConfig { + const config = createObject(null) as ResolvedConfig; + defineProperty(config, "browserQueries", { + value: browserQueries, + enumerable: true, + }); + return freeze(config); +} + +function snapshotBrowserQueries(value: unknown): readonly string[] | undefined { + if (value === undefined) return undefined; + let isArray: boolean; + try { + isArray = arrayIsArray(value); + } catch (cause) { + throw new TypeError( + "ext-css-lightning browserQueries could not be inspected", + { cause }, + ); + } + if (!isArray) { + throw new TypeError("ext-css-lightning browserQueries must be an array"); + } + + let descriptors: PropertyDescriptorMap; + try { + descriptors = getOwnPropertyDescriptors( + value as unknown[], + ) as unknown as PropertyDescriptorMap; + } catch (cause) { + throw new TypeError( + "ext-css-lightning browserQueries could not be inspected", + { cause }, + ); + } + const length = dataPropertyValue( + descriptors, + "length", + "ext-css-lightning browserQueries length", + ); + if ( + !isSafeInteger(length) || + (length as number) < 1 || + (length as number) > MAX_BROWSER_QUERIES || + ownKeys(descriptors).length !== (length as number) + 1 + ) { + throw new TypeError( + "ext-css-lightning browserQueries must be a bounded dense array", + ); + } + + const queries: string[] = []; + for (let index = 0; index < (length as number); index++) { + const descriptor = descriptors[String(index)]; + if ( + descriptor === undefined || + !hasOwn(descriptor, "value") || + descriptor.enumerable !== true + ) { + throw new TypeError( + "ext-css-lightning browserQueries must contain data-property strings", + ); + } + const query = descriptor.value; + if ( + typeof query !== "string" || + query.length === 0 || + query.length > MAX_BROWSER_QUERY_CHARACTERS || + !isWellFormedString(query) || + apply(normalizeString, query, ["NFC"]) !== query || + apply(trimString, query, []) !== query || + hasControlOrLineSeparator(query) + ) { + throw new TypeError( + "ext-css-lightning browserQueries must contain canonical bounded strings", + ); + } + apply(arrayPush, queries, [query]); + } + return freeze(queries); +} + +function readConfig(value: unknown): ResolvedConfig { + if (value === undefined) return frozenResolvedConfig(undefined); + let valueIsArray: boolean; + try { + valueIsArray = arrayIsArray(value); + } catch (cause) { + throw new TypeError("ext-css-lightning config could not be inspected", { + cause, + }); + } + if (typeof value !== "object" || value === null || valueIsArray) { + throw new TypeError("ext-css-lightning config must be an object"); + } + + let descriptors: PropertyDescriptorMap; + let prototype: object | null; + try { + prototype = getPrototypeOf(value); + descriptors = getOwnPropertyDescriptors(value); + } catch (cause) { + throw new TypeError("ext-css-lightning config could not be inspected", { + cause, + }); + } + if (prototype !== ObjectPrototype && prototype !== null) { + throw new TypeError("ext-css-lightning config must not inherit configuration"); + } + const keys = ownKeys(descriptors); + if (keys.length > 1 || (keys.length === 1 && keys[0] !== "browserQueries")) { + throw new TypeError("ext-css-lightning config contains unsupported properties"); + } + const descriptor = descriptors.browserQueries; + if (descriptor !== undefined && !hasOwn(descriptor, "value")) { + throw new TypeError( + "ext-css-lightning browserQueries must be a data property", + ); + } + return frozenResolvedConfig( + snapshotBrowserQueries(descriptor?.value), + ); +} + +function copyStrings(values: readonly string[]): string[] { + const copy: string[] = []; + for (let index = 0; index < values.length; index++) { + apply(arrayPush, copy, [values[index]]); + } + return copy; +} + +function createIsolatedBrowserQueryOptions(): browserslist.Options { + return { + path: false, + stats: createObject(null) as browserslist.Stats, + dangerousExtend: false, + }; +} + +function rejectExternalBrowserQuerySources(queries: readonly string[]): void { + const parsed = browserslist.parse( + copyStrings(queries), + createIsolatedBrowserQueryOptions(), + ); + if (!arrayIsArray(parsed) || parsed.length > MAX_BROWSER_QUERIES * 4) { + throw new TypeError("CSS browser queries produced an invalid parse result"); + } + for (let index = 0; index < parsed.length; index++) { + const type = parsed[index]?.type; + if ( + type === "browserslist_config" || + type === "extends" || + type === "popularity_in_config_stats" || + type === "cover_config" + ) { + throw new TypeError( + "CSS browser queries must not load external configuration or statistics", + ); + } + } +} + +function snapshotResolvedTargets(value: unknown): Readonly> { + if (typeof value !== "object" || value === null || arrayIsArray(value)) { + throw new TypeError("Lightning CSS returned invalid browser targets"); + } + const descriptors = getOwnPropertyDescriptors(value); + const keys = ownKeys(descriptors); + if (keys.length === 0 || keys.length > 128) { + throw new TypeError("Lightning CSS returned invalid browser targets"); + } + for (let index = 0; index < keys.length; index++) { + if (typeof keys[index] !== "string") { + throw new TypeError("Lightning CSS returned invalid browser targets"); + } + } + apply(arraySort, keys, []); + + const targets = createObject(null) as Record; + for (let index = 0; index < keys.length; index++) { + const key = keys[index] as string; + const descriptor = descriptors[key]!; + if ( + apply(executeRegularExpression, TARGET_NAME, [key]) === null || + !hasOwn(descriptor, "value") + ) { + throw new TypeError("Lightning CSS returned invalid browser targets"); + } + const version = descriptor.value; + if ( + !isSafeInteger(version) || + version < 0 || + version > 0xff_ffff + ) { + throw new TypeError("Lightning CSS returned invalid browser targets"); + } + defineProperty(targets, key, { + value: version, + enumerable: true, + }); + } + return freeze(targets); +} + +function datasetIdentity(): string { + const serialized = apply(stringifyJSON, JSON, [{ + data: browserslist.data, + defaults: browserslist.defaults, + nodeVersions: browserslist.nodeVersions, + usage: browserslist.usage.global, + versionAliases: browserslist.versionAliases, + }]); + if (typeof serialized !== "string") { + throw new TypeError("Browserslist dataset could not be identified"); + } + return sha256(serialized); +} + +const defaultBrowserQueries = snapshotBrowserQueries(browserslist.defaults)!; +const loadedDatasetIdentity = datasetIdentity(); +const loadedRuntimeFlavor = process.env.CSS_TRANSFORMER_WASM + ? "wasm" + : `native:${process.platform}:${process.arch}`; + +function resolveBrowserTargets( + queries: readonly string[] | undefined, +): ResolvedTargets { + const selectedQueries = queries ?? defaultBrowserQueries; + rejectExternalBrowserQuerySources(selectedQueries); + const browsers = browserslist( + copyStrings(selectedQueries), + createIsolatedBrowserQueryOptions(), + ); + if ( + !arrayIsArray(browsers) || + browsers.length === 0 || + browsers.length > MAX_RESOLVED_BROWSERS + ) { + throw new TypeError("CSS browser queries resolved to no bounded browser targets"); + } + const targets = snapshotResolvedTargets(browserslistToTargets(browsers)); + const identityParts = [`dataset=${loadedDatasetIdentity}`]; + const targetKeys = ownKeys(targets) as string[]; + apply(arraySort, targetKeys, []); + for (let index = 0; index < targetKeys.length; index++) { + const name = targetKeys[index]!; + apply(arrayPush, identityParts, [`${name}=${targets[name]!}`]); + } + return freeze({ + targets, + identity: sha256(apply(arrayJoin, identityParts, ["\n"])), + }); +} + +function snapshotRequest(request: CSSOptimizationRequest): CSSOptimizationRequest { + if (typeof request !== "object" || request === null || arrayIsArray(request)) { + throw new TypeError("CSS optimization request must be an object"); + } + const descriptors = getOwnPropertyDescriptors(request); + const keys = ownKeys(descriptors); + if ( + keys.length !== 4 || + !hasOwn(descriptors, "css") || + !hasOwn(descriptors, "sourcePath") || + !hasOwn(descriptors, "minify") || + !hasOwn(descriptors, "sourceMap") + ) { + throw new TypeError("CSS optimization request has an invalid shape"); + } + for (let index = 0; index < keys.length; index++) { + if ( + keys[index] !== "css" && + keys[index] !== "sourcePath" && + keys[index] !== "minify" && + keys[index] !== "sourceMap" + ) { + throw new TypeError("CSS optimization request has an invalid shape"); + } + } + const css = dataPropertyValue(descriptors, "css", "CSS optimization css"); + const sourcePath = dataPropertyValue( + descriptors, + "sourcePath", + "CSS optimization sourcePath", + ); + const minify = dataPropertyValue( + descriptors, + "minify", + "CSS optimization minify", + ); + const sourceMap = dataPropertyValue( + descriptors, + "sourceMap", + "CSS optimization sourceMap", + ); + if (typeof css !== "string" || !isWellFormedString(css)) { + throw new TypeError("CSS optimization css must be a well-formed string"); + } + if ( + typeof sourcePath !== "string" || + sourcePath.length === 0 || + sourcePath.length > MAX_SOURCE_PATH_CHARACTERS || + !isWellFormedString(sourcePath) || + apply(normalizeString, sourcePath, ["NFC"]) !== sourcePath || + hasControlOrLineSeparator(sourcePath) + ) { + throw new TypeError("CSS optimization sourcePath must be a canonical path"); + } + if (typeof minify !== "boolean" || typeof sourceMap !== "boolean") { + throw new TypeError("CSS optimization flags must be booleans"); + } + return freeze({ css, sourcePath, minify, sourceMap }); +} + +function isByteView(value: unknown): value is Uint8Array { + return isArrayBufferView(value) && + apply(objectToString, value, []) === "[object Uint8Array]"; +} + +/** Parser-backed CSS optimizer provided by this extension. */ +export class LightningCSSOptimizationEngine implements CSSOptimizationEngine { + readonly cacheIdentity: string; + readonly #targets: Readonly>; + + constructor(config: LightningCSSOptimizationConfig = {}) { + const resolved = resolveBrowserTargets(readConfig(config).browserQueries); + this.#targets = resolved.targets; + const identityParts = [ + ENGINE_SEMANTICS_VERSION, + `ext-css-lightning@${extensionVersion}`, + `lightningcss@${lightningCSSVersion}`, + `browserslist@${browserslistVersion}`, + loadedRuntimeFlavor, + `targets+data=${resolved.identity}`, + ]; + this.cacheIdentity = apply(arrayJoin, identityParts, [";"]); + if ( + this.cacheIdentity.length > + MAX_CSS_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS + ) { + throw new TypeError("ext-css-lightning cache identity exceeds the core limit"); + } + freeze(this); + } + + optimize(request: CSSOptimizationRequest): CSSOptimizationResult { + const input = snapshotRequest(request); + const result = transform({ + filename: input.sourcePath, + code: apply(encodeText, encoder, [input.css]), + minify: input.minify, + sourceMap: input.sourceMap, + targets: this.#targets, + analyzeDependencies: false, + }); + const descriptors = getOwnPropertyDescriptors(result); + const code = dataPropertyValue(descriptors, "code", "Lightning CSS code"); + const mapDescriptor = descriptors.map; + if (mapDescriptor !== undefined && !hasOwn(mapDescriptor, "value")) { + throw new TypeError("Lightning CSS source map must be a data property"); + } + const map = mapDescriptor?.value; + + if (!isByteView(code)) { + throw new TypeError("Lightning CSS returned invalid output bytes"); + } + if (input.sourceMap && !isByteView(map)) { + throw new TypeError( + "Lightning CSS did not return the requested source map", + ); + } + if (!input.sourceMap && map !== undefined && map !== null) { + throw new TypeError("Lightning CSS returned an unrequested source map"); + } + + const css = apply(decodeText, decoder, [code]) as string; + if (map === undefined || map === null) return freeze({ css }); + return freeze({ + css, + sourceMap: apply(decodeText, decoder, [map]) as string, + }); + } +} + +const extCSSLightning: ExtensionFactory = (config) => { + const engine = new LightningCSSOptimizationEngine(readConfig(config)); + return { + name: "ext-css-lightning", + version: extensionVersion, + contracts: { + provides: ["CSSOptimizationEngine"], + }, + capabilities: [ + { + type: "env:read", + keys: [ + "CSS_TRANSFORMER_WASM", + "BROWSERSLIST_DISABLE_CACHE", + "BROWSERSLIST_IGNORE_OLD_DATA", + "BROWSERSLIST_TRACE_WARNING", + ], + }, + { type: "native:ffi" }, + ], + setup(ctx) { + ctx.provide(CSSOptimizationEngineName, engine); + ctx.logger.debug( + `[ext-css-lightning] ${CSSOptimizationEngineName} registered`, + ); + }, + }; +}; + +export default extCSSLightning; diff --git a/extensions/ext-css-lightning/src/is-well-formed-string.test.ts b/extensions/ext-css-lightning/src/is-well-formed-string.test.ts new file mode 100644 index 0000000000..68cf1beb43 --- /dev/null +++ b/extensions/ext-css-lightning/src/is-well-formed-string.test.ts @@ -0,0 +1,12 @@ +import { assertEquals } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { isWellFormedString } from "./is-well-formed-string.ts"; + +describe("ext-css-lightning isWellFormedString", () => { + it("accepts valid UTF-16 and rejects unpaired surrogates", () => { + assertEquals(isWellFormedString("styles/\uD83D\uDE00.css"), true); + assertEquals(isWellFormedString("\uD800"), false); + assertEquals(isWellFormedString("\uDC00"), false); + assertEquals(isWellFormedString("\uD800x"), false); + }); +}); diff --git a/extensions/ext-css-lightning/src/is-well-formed-string.ts b/extensions/ext-css-lightning/src/is-well-formed-string.ts new file mode 100644 index 0000000000..99d30f4572 --- /dev/null +++ b/extensions/ext-css-lightning/src/is-well-formed-string.ts @@ -0,0 +1,19 @@ +/** Local UTF-16 well-formedness check for the extension's Node 18 package. */ + +const apply = Reflect.apply; +const stringCharCodeAt = String.prototype.charCodeAt; + +export function isWellFormedString(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = apply(stringCharCodeAt, value, [index]) as number; + if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) { + index++; + if (index >= value.length) return false; + const trailingCodeUnit = apply(stringCharCodeAt, value, [index]) as number; + if (trailingCodeUnit < 0xDC00 || trailingCodeUnit > 0xDFFF) return false; + } else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) { + return false; + } + } + return true; +} diff --git a/extensions/ext-css-purgecss/README.md b/extensions/ext-css-purgecss/README.md new file mode 100644 index 0000000000..8b38edc213 --- /dev/null +++ b/extensions/ext-css-purgecss/README.md @@ -0,0 +1,35 @@ +# @veryfront/ext-css-purgecss + +> **Category:** Build | **Contract:** `CSSPurgingEngine` | **Explicit** + +Provides parser-backed unused-rule removal and critical/remaining CSS splitting +through PurgeCSS. Veryfront core owns the dependency-free contract, request and +result validation, resource limits, and filesystem collection. This extension +owns the third-party implementation. + +## Registration + +```ts +import extCSSPurgeCSS from "@veryfront/ext-css-purgecss"; + +export default defineConfig({ + extensions: [extCSSPurgeCSS()], +}); +``` + +The extension is never imported, probed, or auto-loaded by core. A purge or +critical-CSS operation fails with a missing-extension error when no +`CSSPurgingEngine` is registered. There is no regex, no-op, dynamic-import, +network, or workspace fallback. + +## Configuration and capabilities + +The factory accepts no options. It receives only bounded in-memory CSS and +content snapshots from core. PurgeCSS loads `fast-glob`, which reads the CPU +count to size its concurrency, so the extension requests only `system:read` +with `apis: ["cpus"]`. In Deno this maps to `--allow-sys=cpus`. The extension +requests no filesystem, network, environment, subprocess, or native capability. + +PurgeCSS does not expose an operation-level cancellation signal, so this +contract cannot interrupt an invocation after it enters the provider. Core +still validates all inputs before invocation and all outputs before use. diff --git a/extensions/ext-css-purgecss/deno.json b/extensions/ext-css-purgecss/deno.json new file mode 100644 index 0000000000..91dd53b975 --- /dev/null +++ b/extensions/ext-css-purgecss/deno.json @@ -0,0 +1,23 @@ +{ + "name": "@veryfront/ext-css-purgecss", + "version": "0.1.0", + "exports": "./src/index.ts", + "veryfront": { + "extension": true, + "activation": "explicit", + "contracts": { + "provides": ["CSSPurgingEngine"] + }, + "capabilities": [{ "type": "system:read", "apis": ["cpus"] }] + }, + "imports": { + "purgecss": "npm:purgecss@8.0.0", + "@std/assert": "jsr:@std/assert@1.0.19", + "@std/testing/bdd": "jsr:@std/testing@1.0.17/bdd", + "veryfront/extensions": "../../src/extensions/types.ts", + "veryfront/extensions/css": "../../src/extensions/css/index.ts" + }, + "tasks": { + "test": "deno test --frozen --no-check --allow-sys=cpus src/" + } +} diff --git a/extensions/ext-css-purgecss/src/index.test.ts b/extensions/ext-css-purgecss/src/index.test.ts new file mode 100644 index 0000000000..14aeeef4c4 --- /dev/null +++ b/extensions/ext-css-purgecss/src/index.test.ts @@ -0,0 +1,175 @@ +import { assertEquals, assertRejects, assertStringIncludes, assertThrows } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { type CSSPurgingEngine, CSSPurgingEngineName } from "veryfront/extensions/css"; +import { createCSSPurgingSession } from "../../../src/build/asset-pipeline/css-optimizer/purging-engine.ts"; +import extensionPackage from "../deno.json" with { type: "json" }; +import factory from "./index.ts"; + +const noopLogger = { debug() {}, info() {}, warn() {}, error() {} }; + +async function createEngine(): Promise { + let engine: CSSPurgingEngine | undefined; + await factory().setup?.({ + config: {}, + logger: noopLogger, + provide: (name, implementation) => { + if (name === CSSPurgingEngineName) { + engine = implementation as CSSPurgingEngine; + } + }, + get: () => undefined, + require: () => { + throw new Error("not used"); + }, + }); + if (engine === undefined) { + throw new Error("ext-css-purgecss did not provide CSSPurgingEngine"); + } + return engine; +} + +describe("ext-css-purgecss", () => { + it("aligns explicit factory metadata and immutable identity with the package", async () => { + const provided = new Map(); + const extension = factory(); + assertEquals(extensionPackage.veryfront.activation, "explicit"); + assertEquals(extension.version, extensionPackage.version); + assertEquals(extension.contracts?.provides, [CSSPurgingEngineName]); + assertEquals(extensionPackage.veryfront.capabilities, [ + { type: "system:read", apis: ["cpus"] }, + ]); + assertEquals( + extension.capabilities, + extensionPackage.veryfront.capabilities, + ); + assertEquals( + extensionPackage.tasks.test, + "deno test --frozen --no-check --allow-sys=cpus src/", + ); + + await extension.setup?.({ + config: {}, + logger: noopLogger, + provide: (name, implementation) => provided.set(name, implementation), + get: () => undefined, + require: () => { + throw new Error("not used"); + }, + }); + const engine = provided.get( + CSSPurgingEngineName, + ) as CSSPurgingEngine; + assertEquals(Object.isFrozen(engine), true); + assertStringIncludes( + engine.cacheIdentity, + `ext-css-purgecss@${extensionPackage.version}`, + ); + assertStringIncludes(engine.cacheIdentity, "purgecss@8.0.0"); + assertThrows( + () => { + (engine as { cacheIdentity: string }).cacheIdentity = "changed@2"; + }, + TypeError, + ); + }); + + it("purges nested rules and returns rejected CSS through the core boundary", async () => { + const session = createCSSPurgingSession(await createEngine()); + const result = await session.run({ + css: ".used { color: red } .unused { color: blue } " + + "@media (min-width: 40rem) { .used { display: flex } .aside { display: block } }", + content: [{ raw: '
', extension: "html" }], + safelist: [], + includeRejectedCSS: true, + }); + assertEquals(result.css.includes(".used"), true); + assertEquals(result.css.includes(".unused"), false); + assertEquals(result.css.includes("@media"), true); + assertEquals(result.css.includes(".aside"), false); + assertEquals(result.rejectedCSS?.includes(".unused"), true); + assertEquals(result.rejectedCSS?.includes(".aside"), true); + assertEquals(Object.isFrozen(result), true); + }); + + it("honors a bounded safelist without returning unrequested rejected CSS", async () => { + const session = createCSSPurgingSession(await createEngine()); + const result = await session.run({ + css: ".used { color: red } .dynamic { color: blue } .unused { color: black }", + content: [{ raw: '
', extension: "html" }], + safelist: ["dynamic"], + includeRejectedCSS: false, + }); + assertEquals(result.css.includes(".used"), true); + assertEquals(result.css.includes(".dynamic"), true); + assertEquals(result.css.includes(".unused"), false); + assertEquals("rejectedCSS" in result, false); + }); + + it("preserves normalized tag and class evidence through the provider", async () => { + const session = createCSSPurgingSession(await createEngine()); + const result = await session.run({ + css: "body { margin: 0 } .keep { display: block } .remove { display: none }", + content: [{ raw: "body keep", extension: "html" }], + safelist: ["body", "keep"], + includeRejectedCSS: false, + }); + assertEquals(result.css.includes("body"), true); + assertEquals(result.css.includes(".keep"), true); + assertEquals(result.css.includes(".remove"), false); + }); + + it("rejects inherited, populated, accessor, and revoked configuration", () => { + assertThrows( + () => factory(Object.create({ option: true })), + TypeError, + "must not inherit", + ); + assertThrows( + () => factory({ option: true }), + TypeError, + "does not accept", + ); + + let getterCalls = 0; + assertThrows( + () => + factory({ + get option() { + getterCalls++; + return true; + }, + }), + TypeError, + "does not accept", + ); + assertEquals(getterCalls, 0); + + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + assertThrows( + () => factory(revoked.proxy), + TypeError, + "could not be inspected", + ); + }); + + it("never lets hostile request accessors cross the validated boundary", async () => { + const session = createCSSPurgingSession(await createEngine()); + let getterCalls = 0; + await assertRejects( + () => + session.run({ + get css() { + getterCalls++; + return ".x {}"; + }, + content: [{ raw: '
', extension: "html" }], + safelist: [], + includeRejectedCSS: false, + }), + TypeError, + "data property", + ); + assertEquals(getterCalls, 0); + }); +}); diff --git a/extensions/ext-css-purgecss/src/index.ts b/extensions/ext-css-purgecss/src/index.ts new file mode 100644 index 0000000000..535163e5ef --- /dev/null +++ b/extensions/ext-css-purgecss/src/index.ts @@ -0,0 +1,273 @@ +/** PurgeCSS implementation of the provider-neutral CSS purging contract. */ + +import type { ExtensionFactory } from "veryfront/extensions"; +import { + type CSSPurgeContentSource, + type CSSPurgingEngine, + CSSPurgingEngineName, + type CSSPurgingRequest, + type CSSPurgingResult, +} from "veryfront/extensions/css"; +import { PurgeCSS } from "purgecss"; +import extensionPackage from "../deno.json" with { type: "json" }; + +const arrayIsArray = Array.isArray; +const freeze = Object.freeze; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const getPrototypeOf = Object.getPrototypeOf; +const hasOwn = Object.hasOwn; +const objectPrototype = Object.prototype; +const ownKeys = Reflect.ownKeys; +const executeRegularExpression = RegExp.prototype.exec; +const apply = Reflect.apply; +const purgeCSSPurge = PurgeCSS.prototype.purge; +const EXACT_NPM_SPECIFIER_PATTERN = + /^npm:purgecss@((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/; +const ENGINE_SEMANTICS_VERSION = "veryfront.css-purgecss.v1"; +const PROVIDER_RESULT_KEYS = freeze( + [ + "css", + "file", + "rejectedCss", + ] as const, +); + +function dependencyVersion(specifier: string): string { + const match = apply(executeRegularExpression, EXACT_NPM_SPECIFIER_PATTERN, [ + specifier, + ]) as RegExpExecArray | null; + if (match?.[1] === undefined) { + throw new TypeError( + "ext-css-purgecss dependency must use an exact npm version", + ); + } + return match[1]; +} + +function assertEmptyConfig(value: unknown): void { + if (value === undefined) return; + if (typeof value !== "object" || value === null) { + throw new TypeError("ext-css-purgecss config must be an object"); + } + let isArray: boolean; + let prototype: object | null; + let keys: PropertyKey[]; + try { + isArray = arrayIsArray(value); + prototype = getPrototypeOf(value); + keys = ownKeys(getOwnPropertyDescriptors(value)); + } catch (cause) { + throw new TypeError("ext-css-purgecss config could not be inspected", { + cause, + }); + } + if (isArray) { + throw new TypeError("ext-css-purgecss config must be an object"); + } + if (prototype !== objectPrototype && prototype !== null) { + throw new TypeError( + "ext-css-purgecss config must not inherit configuration", + ); + } + if (keys.length !== 0) { + throw new TypeError( + "ext-css-purgecss does not accept configuration properties", + ); + } +} + +function inspectArray(value: unknown, label: string): value is unknown[] { + try { + return arrayIsArray(value); + } catch (cause) { + throw new TypeError(`${label} could not be inspected`, { cause }); + } +} + +function descriptorValue( + value: object, + property: string, + label: string, + required: boolean, + enumerable = true, +): unknown { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = getOwnPropertyDescriptor(value, property); + } catch (cause) { + throw new TypeError(`${label} could not be inspected`, { cause }); + } + if (descriptor === undefined) { + if (!required) return undefined; + throw new TypeError(`${label} must define ${property}`); + } + if (!hasOwn(descriptor, "value") || descriptor.enumerable !== enumerable) { + throw new TypeError(`${label} ${property} must be an own data property`); + } + return descriptor.value; +} + +function copyContent( + content: readonly CSSPurgeContentSource[], +): Array<{ raw: string; extension: string }> { + const copied: Array<{ raw: string; extension: string }> = []; + for (let index = 0; index < content.length; index++) { + const source = content[index]!; + copied[index] = { raw: source.raw, extension: source.extension }; + } + return copied; +} + +function copyStrings(values: readonly string[]): string[] { + const copied: string[] = []; + for (let index = 0; index < values.length; index++) { + copied[index] = values[index]!; + } + return copied; +} + +function providerResult( + value: unknown, + includeRejectedCSS: boolean, +): CSSPurgingResult { + if (!inspectArray(value, "PurgeCSS result")) { + throw new TypeError("PurgeCSS returned a non-array result"); + } + const length = descriptorValue( + value, + "length", + "PurgeCSS result", + true, + false, + ); + if (length !== 1) { + throw new TypeError("PurgeCSS must return exactly one result"); + } + let arrayKeys: PropertyKey[]; + try { + arrayKeys = ownKeys(getOwnPropertyDescriptors(value)); + } catch (cause) { + throw new TypeError("PurgeCSS result could not be inspected", { cause }); + } + let hasEntry = false; + let hasLength = false; + for (let index = 0; index < arrayKeys.length; index++) { + if (arrayKeys[index] === "0") hasEntry = true; + if (arrayKeys[index] === "length") hasLength = true; + } + if (arrayKeys.length !== 2 || !hasEntry || !hasLength) { + throw new TypeError("PurgeCSS returned unsupported result entries"); + } + const entry = descriptorValue(value, "0", "PurgeCSS result", true); + if ( + typeof entry !== "object" || + entry === null || + inspectArray(entry, "PurgeCSS result entry") + ) { + throw new TypeError("PurgeCSS returned an invalid result entry"); + } + let keys: PropertyKey[]; + try { + keys = ownKeys(getOwnPropertyDescriptors(entry)); + } catch (cause) { + throw new TypeError("PurgeCSS result entry could not be inspected", { + cause, + }); + } + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + const key = keys[keyIndex]!; + let allowed = false; + for (let index = 0; index < PROVIDER_RESULT_KEYS.length; index++) { + if (PROVIDER_RESULT_KEYS[index] === key) { + allowed = true; + break; + } + } + if (!allowed) { + throw new TypeError("PurgeCSS returned unsupported result properties"); + } + } + + const css = descriptorValue(entry, "css", "PurgeCSS result entry", true); + if (typeof css !== "string") { + throw new TypeError("PurgeCSS result css must be a string"); + } + const file = descriptorValue( + entry, + "file", + "PurgeCSS result entry", + false, + ); + if (file !== undefined) { + throw new TypeError("PurgeCSS returned an unexpected file-backed result"); + } + let rejectedDescriptor: PropertyDescriptor | undefined; + try { + rejectedDescriptor = getOwnPropertyDescriptor(entry, "rejectedCss"); + } catch (cause) { + throw new TypeError("PurgeCSS result entry could not be inspected", { + cause, + }); + } + const rejectedCSS = includeRejectedCSS + ? descriptorValue( + entry, + "rejectedCss", + "PurgeCSS result entry", + true, + ) + : undefined; + if (includeRejectedCSS && typeof rejectedCSS !== "string") { + throw new TypeError("PurgeCSS omitted requested rejected CSS"); + } + if (!includeRejectedCSS && rejectedDescriptor !== undefined) { + throw new TypeError("PurgeCSS returned unrequested rejected CSS"); + } + return freeze({ + css, + ...(typeof rejectedCSS === "string" ? { rejectedCSS } : {}), + }); +} + +/** Explicit PurgeCSS provider. Invoke it through core's validated session. */ +class PurgeCSSPurgingEngine implements CSSPurgingEngine { + readonly cacheIdentity: string; + + constructor() { + this.cacheIdentity = + `${ENGINE_SEMANTICS_VERSION};ext-css-purgecss@${extensionPackage.version};purgecss@${ + dependencyVersion(extensionPackage.imports.purgecss) + }`; + freeze(this); + } + + async purge(request: CSSPurgingRequest): Promise { + const results = await apply(purgeCSSPurge, new PurgeCSS(), [{ + content: copyContent(request.content), + css: [{ raw: request.css }], + safelist: copyStrings(request.safelist), + rejectedCss: request.includeRejectedCSS, + }]); + return providerResult(results, request.includeRejectedCSS); + } +} + +const extCSSPurgeCSS: ExtensionFactory = (config) => { + assertEmptyConfig(config); + const engine = new PurgeCSSPurgingEngine(); + return { + name: "ext-css-purgecss", + version: extensionPackage.version, + contracts: { provides: ["CSSPurgingEngine"] }, + capabilities: [{ type: "system:read", apis: ["cpus"] }], + setup(ctx) { + ctx.provide(CSSPurgingEngineName, engine); + ctx.logger.debug( + `[ext-css-purgecss] ${CSSPurgingEngineName} registered`, + ); + }, + }; +}; + +export default extCSSPurgeCSS; diff --git a/extensions/ext-css-tailwind/README.md b/extensions/ext-css-tailwind/README.md index 4021e0cf0c..b31679064e 100644 --- a/extensions/ext-css-tailwind/README.md +++ b/extensions/ext-css-tailwind/README.md @@ -1,12 +1,14 @@ # @veryfront/ext-css-tailwind -> **Category:** Build | **Contract:** `CSSProcessor` | **Built-in** +> **Category:** Build | **Contract:** `CSSProcessor` | **Explicit** -Provides Tailwind CSS v4 compilation for Veryfront. Compiles stylesheets at render time and supports dynamic plugin loading from CDN. +Provides Tailwind CSS v4 compilation for Veryfront. The extension owns the +pinned compiler, local base stylesheet, plugin policy, plugin module loading, +and every third-party import; framework core sees only `CSSProcessor`. ## Registration -This extension is auto-enabled by core bootstrap. Add it to `veryfront.config.ts` only when you need to override the built-in registration: +Install and compose the extension explicitly: ```ts import extTailwind from "@veryfront/ext-css-tailwind"; @@ -16,26 +18,54 @@ export default defineConfig({ }); ``` +Core never discovers or auto-registers this provider. Production pipelines +that request CSS minification must also explicitly compose a +`CSSOptimizationEngine` provider such as `@veryfront/ext-css-lightning`. +If either requested provider is absent, compilation fails instead of returning +empty or regex-rewritten CSS. + ## Provided contract `CSSProcessor` — exposes: -- `compile(stylesheet, options)` — delegates to Tailwind's native `compile()` and returns a compiler whose `build(candidates)` emits CSS for the class-name candidates discovered at render time. +- `defaultStylesheet` — the extension-owned Tailwind import, typography plugin, + and dark-mode variant used only when the application supplies no stylesheet; +- `compile(stylesheet)` — delegates to Tailwind's native `compile()` and returns + a compiler whose `build(candidates)` emits CSS for the class-name candidates + discovered at render time. + +## Plugin isolation -## Plugin shims +The extension accepts only its audited plugin allowlist and resolves every +plugin to one exact, extension-owned npm dependency. All five plugin modules are +statically imported and registered locally; compilation performs no plugin +download, temporary-module write, source rewriting, or global shim injection. +Unpinned version overrides and unknown packages are rejected before Tailwind +receives a plugin value. -On setup the extension installs three `globalThis` shims so that Tailwind plugin bundles loaded at runtime from `esm.sh` bind their `tailwindcss/plugin`, `tailwindcss/defaultTheme`, and `tailwindcss/colors` imports to the same tailwindcss copy this extension ships. Core's `plugin-loader.ts` rewrites plugin bundle code to reference these shims by name; without them, dynamic plugin loading fails. +The audited local inventory is: -The shims live at: +- `@tailwindcss/forms@0.5.11` +- `@tailwindcss/typography@0.5.19` +- `daisyui@5.5.14` +- `tailwind-scrollbar-hide@2.0.0` +- `tailwindcss-animate@1.0.7` -- `globalThis.__tailwindPluginShim` -- `globalThis.__tailwindDefaultThemeShim` -- `globalThis.__tailwindColorsShim` +Stylesheets may use either the bare package name or that exact versioned +specifier. No other version is accepted. + +The complete local plugin inventory and exact versions are bound into the +processor cache identity. Changing any plugin implementation policy therefore +invalidates compiled CSS caches deterministically. ## Capabilities -- **net `esm.sh`:** loading user-declared Tailwind plugins from CDN at runtime. +- **filesystem read:** reading the pinned package's `index.css`. + +The extension requests no network or filesystem-write capability. ## Configuration -No factory options. The extension reads no environment variables and takes no config. +No factory options. Inherited configuration, accessors, and unknown keys are +rejected. The base stylesheet is local and mandatory; there is no CDN or empty +stylesheet fallback. diff --git a/extensions/ext-css-tailwind/deno.json b/extensions/ext-css-tailwind/deno.json index 3d63690e62..9c36287ef7 100644 --- a/extensions/ext-css-tailwind/deno.json +++ b/extensions/ext-css-tailwind/deno.json @@ -4,24 +4,31 @@ "exports": "./src/index.ts", "veryfront": { "extension": true, + "activation": "explicit", "contracts": { "provides": ["CSSProcessor"] }, - "capabilities": [ - { "type": "net:outbound", "hosts": ["esm.sh"] } - ] + "npm": { + "runtimeVersionFromManifest": true + }, + "capabilities": [{ "type": "fs:read" }] }, "imports": { + "@tailwindcss/forms": "npm:@tailwindcss/forms@0.5.11", + "@tailwindcss/typography": "npm:@tailwindcss/typography@0.5.19", + "daisyui": "npm:daisyui@5.5.14", + "tailwind-scrollbar-hide": "npm:tailwind-scrollbar-hide@2.0.0", "tailwindcss": "npm:tailwindcss@4.2.2", - "tailwindcss/plugin": "npm:tailwindcss@4.2.2/plugin", - "tailwindcss/defaultTheme": "npm:tailwindcss@4.2.2/defaultTheme", - "tailwindcss/colors": "npm:tailwindcss@4.2.2/colors", + "tailwindcss-animate": "npm:tailwindcss-animate@1.0.7", + "tailwindcss/index.css": "npm:tailwindcss@4.2.2/index.css", + "tailwindcss/package.json": "npm:tailwindcss@4.2.2/package.json", "@std/assert": "jsr:@std/assert@1.0.19", "@std/testing/bdd": "jsr:@std/testing@1.0.17/bdd", "veryfront/extensions": "../../src/extensions/index.ts", - "veryfront/extensions/css": "../../src/extensions/css/index.ts" + "veryfront/extensions/css": "../../src/extensions/css/index.ts", + "veryfront/errors": "../../src/errors/index.ts" }, "tasks": { - "test": "deno test --no-check --allow-all src/" + "test": "deno test --frozen --allow-read src/" } } diff --git a/extensions/ext-css-tailwind/src/dynamic-esm-import.integration.test.ts b/extensions/ext-css-tailwind/src/dynamic-esm-import.integration.test.ts deleted file mode 100644 index 4a0b39b7e7..0000000000 --- a/extensions/ext-css-tailwind/src/dynamic-esm-import.integration.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Integration test for dynamic ESM imports in compiled Deno binaries. - * - * This test proves that the fetch + rewrite + file:// approach works in compiled binaries, - * which is critical for loading Tailwind plugins dynamically at runtime. - * - * To run: - * deno test --allow-all tests/integration/dynamic-esm-import.test.ts - * - * To test in compiled binary: - * deno compile --allow-all --unstable-net -o /tmp/test-dynamic-esm tests/integration/dynamic-esm-import.test.ts - * /tmp/test-dynamic-esm - */ - -import "../_helpers/contract-init.ts"; -import plugin from "tailwindcss/plugin"; - -// Set up global shim for tailwindcss/plugin - same as in tailwind-compiler.ts -(globalThis as Record).__tailwindPluginShim = { - default: plugin, - __esModule: true, -}; - -/** - * Load a module dynamically from esm.sh. - * Works in both regular Deno and compiled binaries. - */ -async function loadModuleFromEsmSh(packageName: string): Promise { - const stubUrl = `https://esm.sh/${packageName}?bundle&external=tailwindcss`; - console.log(`Fetching stub: ${stubUrl}`); - - const stubResponse = await fetch(stubUrl); - if (!stubResponse.ok) throw new Error(`Failed to fetch stub: ${stubResponse.status}`); - - const stubCode = await stubResponse.text(); - const bundleMatch = stubCode.match(/from\s*["'](\/[^"']+\.bundle\.mjs)["']/); - if (!bundleMatch) { - throw new Error( - `Could not find bundle path in esm.sh response: ${stubCode.substring(0, 200)}`, - ); - } - - const bundleUrl = `https://esm.sh${bundleMatch[1]}`; - console.log(`Fetching bundle: ${bundleUrl}`); - - const bundleResponse = await fetch(bundleUrl); - if (!bundleResponse.ok) throw new Error(`Failed to fetch bundle: ${bundleResponse.status}`); - - let code = await bundleResponse.text(); - - const tailwindImport = 'import*as __0$ from"tailwindcss/plugin"'; - if (code.includes(tailwindImport)) { - code = code.replace(tailwindImport, "const __0$ = globalThis.__tailwindPluginShim"); - console.log("Rewrote tailwindcss/plugin import"); - } - - const tempPath = `/tmp/tw_plugin_${crypto.randomUUID()}.mjs`; - await Deno.writeTextFile(tempPath, code); - console.log(`Wrote to temp file: ${tempPath}`); - - try { - const mod = await import(`file://${tempPath}`); - console.log("Successfully imported module"); - return mod; - } finally { - await Deno.remove(tempPath).catch(() => {}); - } -} - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function assert(condition: unknown, message: string): asserts condition { - if (!condition) throw new Error(message); -} - -// Main test -async function runTests(): Promise { - console.log("\n=== Dynamic ESM Import Integration Test ===\n"); - - console.log("Test 1: Loading tailwindcss-animate@1.0.7..."); - try { - const mod = await loadModuleFromEsmSh("tailwindcss-animate@1.0.7"); - const pluginExport = (mod as { default: unknown }).default; - - assert( - typeof pluginExport === "object" && pluginExport !== null, - `Expected object, got ${typeof pluginExport}`, - ); - assert("handler" in pluginExport, "Plugin missing 'handler' property"); - - console.log("✅ Test 1 PASSED: tailwindcss-animate loaded successfully\n"); - } catch (error) { - console.error(`❌ Test 1 FAILED: ${getErrorMessage(error)}\n`); - Deno.exit(1); - } - - console.log("Test 2: Loading is-odd@3.0.1..."); - try { - const mod = await loadModuleFromEsmSh("is-odd@3.0.1"); - const isOdd = (mod as { default: (n: number) => boolean }).default; - - assert(typeof isOdd === "function", `Expected function, got ${typeof isOdd}`); - assert(isOdd(3) === true, "isOdd(3) should return true"); - assert(isOdd(4) === false, "isOdd(4) should return false"); - - console.log("✅ Test 2 PASSED: is-odd loaded and works correctly\n"); - } catch (error) { - console.error(`❌ Test 2 FAILED: ${getErrorMessage(error)}\n`); - Deno.exit(1); - } - - console.log("=== All tests passed! ===\n"); -} - -// Run tests if executed directly -if (import.meta.main) { - await runTests(); -} - -// Export for use as a Deno test -export { runTests }; diff --git a/extensions/ext-css-tailwind/src/index.test.ts b/extensions/ext-css-tailwind/src/index.test.ts index 13bef6a9c3..bc857ff705 100644 --- a/extensions/ext-css-tailwind/src/index.test.ts +++ b/extensions/ext-css-tailwind/src/index.test.ts @@ -1,69 +1,112 @@ -/** - * ext-css-tailwind extension tests. - * - * @module extensions/ext-css-tailwind/test - */ - -import { assertEquals } from "@std/assert"; +import { assertEquals, assertRejects, assertStringIncludes, assertThrows } from "@std/assert"; import { describe, it } from "@std/testing/bdd"; +import { createHash } from "node:crypto"; +import tailwindPackage from "tailwindcss/package.json" with { type: "json" }; +import extensionPackage from "../deno.json" with { type: "json" }; +import factory, { TAILWIND_DEFAULT_STYLESHEET, TailwindCSSProcessor } from "./index.ts"; +import { exactTailwindVersion } from "./manifest-dependency.ts"; +import { TAILWIND_PLUGIN_POLICY_IDENTITY } from "./plugin-policy.ts"; -import factory from "./index.ts"; +const noopLogger = { debug() {}, info() {}, warn() {}, error() {} }; -const noopLogger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, -}; +describe("ext-css-tailwind", () => { + it("aligns package, factory, contract, and capability metadata", () => { + const extension = factory(); + assertEquals(extensionPackage.veryfront.activation, "explicit"); + assertEquals(extension.name, "ext-css-tailwind"); + assertEquals(extension.version, extensionPackage.version); + assertEquals(extension.contracts?.provides, ["CSSProcessor"]); + assertEquals(extension.capabilities, [{ type: "fs:read" }]); + }); -describe("ext-css-tailwind factory", () => { - it("produces an Extension with name ext-css-tailwind", () => { - const ext = factory(); - assertEquals(ext.name, "ext-css-tailwind"); - assertEquals(ext.version, "0.1.0"); - assertEquals(ext.contracts?.provides, ["CSSProcessor"]); - assertEquals(ext.capabilities, [{ type: "net:outbound", hosts: ["esm.sh"] }]); + it("binds cache identity to extension, compiler, base CSS, and plugin policy", () => { + const processor = new TailwindCSSProcessor(); + const identity = processor.cacheIdentity; + assertEquals(processor.defaultStylesheet, TAILWIND_DEFAULT_STYLESHEET); + assertEquals(Object.isFrozen(processor), true); + assertEquals(Object.isFrozen(TailwindCSSProcessor.prototype), true); + assertEquals(Object.isFrozen(TailwindCSSProcessor), true); + assertStringIncludes(identity, `ext-css-tailwind@${extensionPackage.version}`); + assertEquals( + exactTailwindVersion(extensionPackage.imports.tailwindcss), + tailwindPackage.version, + ); + assertStringIncludes(identity, `tailwindcss@${tailwindPackage.version}`); + assertStringIncludes(identity, "base="); + assertStringIncludes( + identity, + `plugins=${ + createHash("sha256").update(TAILWIND_PLUGIN_POLICY_IDENTITY, "utf8").digest("hex") + }`, + ); + assertEquals(identity.includes("https://"), false); }); -}); -describe("ext-css-tailwind CSSProcessor", () => { - it("registers CSSProcessor on setup", async () => { + it("rejects an unpinned or structurally different Tailwind manifest import", () => { + for ( + const specifier of [ + undefined, + "tailwindcss@4.2.2", + "npm:other-package@4.2.2", + "npm:tailwindcss@^4.2.2", + "npm:tailwindcss@4.2", + "npm:tailwindcss@04.2.2", + "npm:tailwindcss@4.2.2-01", + "npm:tailwindcss@4.2.2/index.css", + ] + ) { + assertThrows(() => exactTailwindVersion(specifier), TypeError); + } + }); + + it("registers a processor without persistent plugin globals", async () => { const provided = new Map(); - const ctx = { + const global = globalThis as Record; + delete global.__tailwindPluginShim; + delete global.__tailwindDefaultThemeShim; + delete global.__tailwindColorsShim; + await factory().setup?.({ config: {}, logger: noopLogger, - provide: (name: string, impl: unknown) => provided.set(name, impl), + provide: (name, implementation) => provided.set(name, implementation), get: () => undefined, - resolve: () => { - throw new Error("resolve not used in setup"); + require: () => { + throw new Error("not used"); }, - }; - const ext = factory(); - await ext.setup?.(ctx as never); - assertEquals(provided.has("CSSProcessor"), true); - await ext.teardown?.(); + }); + assertEquals(provided.get("CSSProcessor") instanceof TailwindCSSProcessor, true); + assertEquals(global.__tailwindPluginShim, undefined); + assertEquals(global.__tailwindDefaultThemeShim, undefined); + assertEquals(global.__tailwindColorsShim, undefined); }); - it("installs tailwindcss plugin shims on globalThis after setup", async () => { - const g = globalThis as Record; - delete g.__tailwindPluginShim; - delete g.__tailwindDefaultThemeShim; - delete g.__tailwindColorsShim; - - const ctx = { - config: {}, - logger: noopLogger, - provide: () => {}, - get: () => undefined, - resolve: () => { - throw new Error("resolve not used in setup"); - }, - }; - const ext = factory(); - await ext.setup?.(ctx as never); + it("compiles using its local base stylesheet without network fallback", async () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.reject(new Error("base compilation must not fetch")); + }) as typeof fetch; + try { + const compiler = await new TailwindCSSProcessor().compile('@import "tailwindcss";'); + const css = compiler.build(["text-red-500"]); + assertEquals(css.includes(".text-red-500"), true); + assertEquals(fetchCalls, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); - assertEquals(typeof g.__tailwindPluginShim, "object"); - assertEquals(typeof g.__tailwindDefaultThemeShim, "object"); - assertEquals(typeof g.__tailwindColorsShim, "object"); + it("rejects unknown stylesheet imports and inherited factory config", async () => { + await assertRejects( + () => new TailwindCSSProcessor().compile('@import "unknown-css-package";'), + Error, + "cannot resolve stylesheet import", + ); + assertThrows( + () => factory(Object.create({ option: true })), + TypeError, + "must not inherit", + ); }); }); diff --git a/extensions/ext-css-tailwind/src/index.ts b/extensions/ext-css-tailwind/src/index.ts index 7a3cd57633..5db6b3d944 100644 --- a/extensions/ext-css-tailwind/src/index.ts +++ b/extensions/ext-css-tailwind/src/index.ts @@ -1,80 +1,162 @@ -/** - * ext-css-tailwind — CSSProcessor implementation backed by Tailwind CSS v4. - * - * Provides the `CSSProcessor` contract: - * - `compile(stylesheet, options)` — delegates to tailwindcss `compile()` - * and returns a compiler whose `build(candidates)` emits CSS for the - * class-name candidates discovered at render time. - * - * The extension also installs three `globalThis` shims on setup so that - * Tailwind plugin bundles loaded at runtime from esm.sh can bind their - * `tailwindcss/plugin`, `tailwindcss/defaultTheme`, and `tailwindcss/colors` - * imports to the same tailwindcss copy this extension ships. Core's - * `plugin-loader.ts` rewrites plugin bundle code to reference these shims - * by name; without the shims installed, dynamic plugin loading fails. - * - * @module extensions/ext-css-tailwind - */ +/** Tailwind CSS implementation of the provider-neutral CSSProcessor contract. */ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { isProxy as isProxyWithoutHooks } from "node:util/types"; import type { ExtensionFactory } from "veryfront/extensions"; -import type { CSSCompileOptions, CSSCompiler, CSSProcessor } from "veryfront/extensions/css"; - +import { type CSSCompiler, type CSSProcessor, CSSProcessorName } from "veryfront/extensions/css"; +import { IMPORT_RESOLUTION_ERROR } from "veryfront/errors"; import { compile } from "tailwindcss"; -import plugin from "tailwindcss/plugin"; -import defaultTheme from "tailwindcss/defaultTheme"; -import colors from "tailwindcss/colors"; +import extensionPackage from "../deno.json" with { type: "json" }; +import { exactTailwindVersion } from "./manifest-dependency.ts"; +import { loadPlugin } from "./plugin-loader.ts"; +import { TAILWIND_PLUGIN_POLICY_IDENTITY } from "./plugin-policy.ts"; + +const ENGINE_SEMANTICS_VERSION = "veryfront.css-tailwind.v4"; +const apply = Reflect.apply; +const arrayJoin = Array.prototype.join; +const freeze = Object.freeze; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const getPrototypeOf = Object.getPrototypeOf; +const isArray = Array.isArray; +const objectPrototype = Object.prototype; +const ownKeys = Reflect.ownKeys; +const requireFromExtension = createRequire(import.meta.url); +const tailwindVersion = exactTailwindVersion(extensionPackage.imports.tailwindcss); +export const TAILWIND_DEFAULT_STYLESHEET = `@import "tailwindcss"; +@plugin "@tailwindcss/typography"; +@custom-variant dark (&:is(.dark, [data-theme="dark"]) *, &:is(.dark, [data-theme="dark"]));`; +function sha256(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function loadTailwindBaseStylesheet(): string { + let resolved: string; + try { + resolved = requireFromExtension.resolve("tailwindcss/index.css"); + } catch (cause) { + throw IMPORT_RESOLUTION_ERROR.create({ + detail: "ext-css-tailwind could not resolve its pinned base stylesheet", + cause, + }); + } + try { + return readFileSync(resolved, "utf8"); + } catch (cause) { + throw IMPORT_RESOLUTION_ERROR.create({ + detail: `ext-css-tailwind could not read its pinned base stylesheet: ${resolved}`, + cause, + }); + } +} -type ShimGlobal = Record; +const tailwindBaseStylesheet = loadTailwindBaseStylesheet(); -function installTailwindPluginShims(): void { - const g = globalThis as ShimGlobal; - g.__tailwindPluginShim = { default: plugin, __esModule: true }; - g.__tailwindDefaultThemeShim = { default: defaultTheme, __esModule: true }; - g.__tailwindColorsShim = { default: colors, __esModule: true }; +function assertEmptyConfig(value: unknown): void { + if (value === undefined) return; + if ( + typeof value !== "object" || + value === null || + isArray(value) || + isProxyWithoutHooks(value) + ) { + throw new TypeError("ext-css-tailwind config must be an object"); + } + let prototype: object | null; + let keys: PropertyKey[]; + try { + prototype = getPrototypeOf(value); + keys = ownKeys(getOwnPropertyDescriptors(value)); + } catch (cause) { + throw new TypeError("ext-css-tailwind config could not be inspected", { cause }); + } + if (prototype !== objectPrototype && prototype !== null) { + throw new TypeError("ext-css-tailwind config must not inherit configuration"); + } + if (keys.length !== 0) { + throw new TypeError("ext-css-tailwind does not accept configuration properties"); + } } -class TailwindCSSProcessor implements CSSProcessor { - async compile(stylesheet: string, options: CSSCompileOptions): Promise { +export class TailwindCSSProcessor implements CSSProcessor { + readonly cacheIdentity: string; + readonly defaultStylesheet = TAILWIND_DEFAULT_STYLESHEET; + + constructor() { + const identityParts = [ + ENGINE_SEMANTICS_VERSION, + `ext-css-tailwind@${extensionPackage.version}`, + `tailwindcss@${tailwindVersion}`, + `base=${sha256(tailwindBaseStylesheet)}`, + `default=${sha256(this.defaultStylesheet)}`, + `plugins=${sha256(TAILWIND_PLUGIN_POLICY_IDENTITY)}`, + ]; + this.cacheIdentity = apply(arrayJoin, identityParts, [";"]) as string; + freeze(this); + } + + async compile(stylesheet: string): Promise { + if (typeof stylesheet !== "string") { + throw new TypeError("ext-css-tailwind stylesheet must be a string"); + } const native = await compile(stylesheet, { - base: options.base, - loadStylesheet: options.loadStylesheet, - loadModule: async (id: string) => { - const loaded = await options.loadModule(id); - // deno-lint-ignore no-explicit-any -- loaded plugin modules are opaque to the contract - return { module: loaded.module as any, base: loaded.base, path: loaded.path }; + base: "/", + loadStylesheet: (id: string) => { + if (id !== "tailwindcss") { + throw IMPORT_RESOLUTION_ERROR.create({ + detail: `ext-css-tailwind cannot resolve stylesheet import "${id}"`, + }); + } + return Promise.resolve({ + content: tailwindBaseStylesheet, + base: "/", + path: "/tailwindcss/index.css", + }); }, + loadModule: (id: string) => + Promise.resolve({ + // deno-lint-ignore no-explicit-any -- Tailwind's vendor API accepts opaque plugin modules. + module: loadPlugin(id) as any, + base: "/", + path: "/", + }), }); - return { + const buildDescriptor = getOwnPropertyDescriptor(native, "build"); + if ( + buildDescriptor === undefined || + !("value" in buildDescriptor) || + typeof buildDescriptor.value !== "function" + ) { + throw IMPORT_RESOLUTION_ERROR.create({ + detail: "ext-css-tailwind compiler did not expose a stable build method", + }); + } + const nativeBuild = buildDescriptor.value as (candidates: string[]) => string; + return freeze({ build(candidates: string[]): string { - return native.build(candidates); + return apply(nativeBuild, native, [candidates]) as string; }, - }; + }); } } -const extTailwind: ExtensionFactory = () => { - const impl = new TailwindCSSProcessor(); +freeze(TailwindCSSProcessor.prototype); +freeze(TailwindCSSProcessor); + +const extTailwind: ExtensionFactory = (config) => { + assertEmptyConfig(config); return { name: "ext-css-tailwind", - version: "0.1.0", - contracts: { - provides: ["CSSProcessor"], - }, - capabilities: [ - { type: "net:outbound", hosts: ["esm.sh"] }, - ], + version: extensionPackage.version, + contracts: { provides: [CSSProcessorName] }, + capabilities: [{ type: "fs:read" }], setup(ctx) { - installTailwindPluginShims(); - ctx.provide("CSSProcessor", impl); - ctx.logger.debug("[ext-css-tailwind] CSSProcessor registered"); - }, - teardown() { - // Shims stay installed — removing them could break in-flight plugin - // loads. The globalThis pollution is intentional and scoped to keys - // with `__tailwind` prefix. + ctx.provide(CSSProcessorName, new TailwindCSSProcessor()); + ctx.logger.debug(`[ext-css-tailwind] ${CSSProcessorName} registered`); }, }; }; export default extTailwind; -export { TailwindCSSProcessor }; diff --git a/extensions/ext-css-tailwind/src/local-plugin.integration.test.ts b/extensions/ext-css-tailwind/src/local-plugin.integration.test.ts new file mode 100644 index 0000000000..1a6003e948 --- /dev/null +++ b/extensions/ext-css-tailwind/src/local-plugin.integration.test.ts @@ -0,0 +1,29 @@ +/** Integration check for every extension-owned local Tailwind plugin. */ + +import { TailwindCSSProcessor } from "./index.ts"; +import { TAILWIND_PLUGIN_POLICY } from "./plugin-policy.ts"; + +export async function runTests(): Promise { + const stylesheet = [ + '@import "tailwindcss";', + ...TAILWIND_PLUGIN_POLICY.map(({ name }) => `@plugin "${name}";`), + ].join("\n"); + const compiler = await new TailwindCSSProcessor().compile(stylesheet); + const css = compiler.build([ + "animate-in", + "btn", + "prose", + "scrollbar-hide", + ]); + for (const marker of ["animate-in", "btn", "prose", "scrollbar-hide"]) { + if (!css.includes(marker)) { + throw new TypeError(`Local Tailwind plugins did not emit ${marker}`); + } + } +} + +if (import.meta.main) { + await runTests(); +} else { + Deno.test("all audited local Tailwind plugins compile without network or write access", runTests); +} diff --git a/extensions/ext-css-tailwind/src/manifest-dependency.ts b/extensions/ext-css-tailwind/src/manifest-dependency.ts new file mode 100644 index 0000000000..5f76d0e550 --- /dev/null +++ b/extensions/ext-css-tailwind/src/manifest-dependency.ts @@ -0,0 +1,21 @@ +/** Resolve Tailwind's exact version from this extension's checked-in manifest. */ + +const TAILWIND_NPM_PREFIX = "npm:tailwindcss@"; +const EXACT_SEMVER_PATTERN = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:(?:0|[1-9]\d*)|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +export function exactTailwindVersion(specifier: unknown): string { + if (typeof specifier !== "string" || !specifier.startsWith(TAILWIND_NPM_PREFIX)) { + throw new TypeError( + "ext-css-tailwind imports.tailwindcss must target npm:tailwindcss at an exact semantic version", + ); + } + + const version = specifier.slice(TAILWIND_NPM_PREFIX.length); + if (!EXACT_SEMVER_PATTERN.test(version)) { + throw new TypeError( + "ext-css-tailwind imports.tailwindcss must use an exact semantic version without a range or subpath", + ); + } + return version; +} diff --git a/extensions/ext-css-tailwind/src/plugin-loader.test.ts b/extensions/ext-css-tailwind/src/plugin-loader.test.ts new file mode 100644 index 0000000000..3384a465a0 --- /dev/null +++ b/extensions/ext-css-tailwind/src/plugin-loader.test.ts @@ -0,0 +1,88 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import extensionPackage from "../deno.json" with { type: "json" }; +import { loadPlugin } from "./plugin-loader.ts"; +import { + bareName, + TAILWIND_PLUGIN_ALLOWLIST, + TAILWIND_PLUGIN_POLICY, + TAILWIND_PLUGIN_POLICY_IDENTITY, +} from "./plugin-policy.ts"; + +const extensionImports: Readonly> = extensionPackage.imports; + +describe("ext-css-tailwind local plugin policy", () => { + it("pins every static plugin to its exact extension-owned npm dependency", () => { + assertEquals( + TAILWIND_PLUGIN_ALLOWLIST.length, + TAILWIND_PLUGIN_POLICY.length, + ); + + for (const policy of TAILWIND_PLUGIN_POLICY) { + assertEquals( + extensionImports[policy.importSpecifier], + policy.npmSpecifier, + ); + assertEquals( + TAILWIND_PLUGIN_ALLOWLIST.includes(bareName(policy.name)), + true, + ); + } + }); + + it("binds the complete pinned local registry into the policy identity", () => { + assertEquals(JSON.parse(TAILWIND_PLUGIN_POLICY_IDENTITY), { + schema: "veryfront.tailwind-plugin-policy.v3", + resolution: "extension-owned-static-npm-imports", + plugins: TAILWIND_PLUGIN_POLICY.map((entry) => ({ ...entry })).sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0 + ), + }); + }); + + it("returns each statically imported plugin for bare and exact specifiers", () => { + for (const policy of TAILWIND_PLUGIN_POLICY) { + const barePlugin = loadPlugin(policy.name); + const pinnedPlugin = loadPlugin(`${policy.name}@${policy.version}`); + assertEquals(barePlugin, pinnedPlugin); + assertEquals( + typeof barePlugin === "function" || + (typeof barePlugin === "object" && barePlugin !== null), + true, + ); + } + }); + + it("rejects unknown, malformed, and unpinned plugin specifiers", () => { + for ( + const specifier of [ + "is-odd@3.0.1", + "@tailwindcss/typography@latest", + "https://example.test/plugin.js", + ] + ) { + assertThrows( + () => loadPlugin(specifier), + Error, + "not an audited ext-css-tailwind plugin", + ); + } + }); + + it("performs no runtime network access or global shim injection", () => { + const originalFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + throw new Error("local plugin loading must not fetch"); + }) as typeof fetch; + try { + const globalKeys = Reflect.ownKeys(globalThis); + for (const name of TAILWIND_PLUGIN_ALLOWLIST) loadPlugin(name); + assertEquals(fetchCalls, 0); + assertEquals(Reflect.ownKeys(globalThis), globalKeys); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/extensions/ext-css-tailwind/src/plugin-loader.ts b/extensions/ext-css-tailwind/src/plugin-loader.ts new file mode 100644 index 0000000000..fd4e0a5d38 --- /dev/null +++ b/extensions/ext-css-tailwind/src/plugin-loader.ts @@ -0,0 +1,80 @@ +/** Static plugin loading owned entirely by ext-css-tailwind. */ + +import forms from "@tailwindcss/forms"; +import typography from "@tailwindcss/typography"; +import daisyui from "daisyui"; +import scrollbarHide from "tailwind-scrollbar-hide"; +import tailwindcssAnimate from "tailwindcss-animate"; +import { IMPORT_RESOLUTION_ERROR, SECURITY_VIOLATION } from "veryfront/errors"; +import { + bareName, + resolveTailwindPluginPolicy, + TAILWIND_PLUGIN_ALLOWLIST, +} from "./plugin-policy.ts"; + +function getStaticPlugin(name: string): unknown { + switch (name) { + case "@tailwindcss/forms": + return forms; + case "@tailwindcss/typography": + return typography; + case "daisyui": + return daisyui; + case "tailwind-scrollbar-hide": + return scrollbarHide; + case "tailwindcss-animate": + return tailwindcssAnimate; + default: + throw IMPORT_RESOLUTION_ERROR.create({ + detail: `Tailwind plugin "${name}" is absent from the static extension registry`, + }); + } +} + +function requirePinnedPlugin(specifier: string): string { + try { + return resolveTailwindPluginPolicy(specifier).name; + } catch (cause) { + const displayName = typeof specifier === "string" ? bareName(specifier) : ""; + throw SECURITY_VIOLATION.create({ + detail: `Package "${displayName}" is not an audited ext-css-tailwind plugin`, + cause, + }); + } +} + +function assertStaticPlugin(name: string, value: unknown): void { + if ( + typeof value !== "function" && + (typeof value !== "object" || value === null) + ) { + throw IMPORT_RESOLUTION_ERROR.create({ + detail: `Tailwind plugin "${name}" did not export a plugin value`, + }); + } +} + +function assertCompleteStaticRegistry(): void { + for (let index = 0; index < TAILWIND_PLUGIN_ALLOWLIST.length; index++) { + const name = TAILWIND_PLUGIN_ALLOWLIST[index]; + if (name === undefined) { + throw IMPORT_RESOLUTION_ERROR.create({ + detail: "ext-css-tailwind plugin policy contains an invalid entry", + }); + } + assertStaticPlugin(name, getStaticPlugin(name)); + } +} + +assertCompleteStaticRegistry(); + +/** + * Resolve an audited plugin from the extension's immutable local registry. + * Unknown packages and version overrides fail before Tailwind receives a value. + */ +export function loadPlugin(id: string): unknown { + const name = requirePinnedPlugin(id); + const plugin = getStaticPlugin(name); + assertStaticPlugin(name, plugin); + return plugin; +} diff --git a/extensions/ext-css-tailwind/src/plugin-policy.ts b/extensions/ext-css-tailwind/src/plugin-policy.ts new file mode 100644 index 0000000000..fa9063690d --- /dev/null +++ b/extensions/ext-css-tailwind/src/plugin-policy.ts @@ -0,0 +1,126 @@ +/** Audited local plugin policy owned by ext-css-tailwind. */ + +export type TailwindPluginPolicyEntry = Readonly<{ + name: string; + version: string; + importSpecifier: string; + npmSpecifier: string; +}>; + +const apply = Reflect.apply; +const MapConstructor = Map; +const NativeTypeError = TypeError; +const arrayMap = Array.prototype.map; +const arraySort = Array.prototype.sort; +const freeze = Object.freeze; +const jsonStringify = JSON.stringify; +const mapGet = Map.prototype.get; +const regexpExec = RegExp.prototype.exec; +const stringIndexOf = String.prototype.indexOf; +const stringSlice = String.prototype.slice; +const stringStartsWith = String.prototype.startsWith; + +function definePlugin( + name: string, + version: string, +): TailwindPluginPolicyEntry { + return freeze({ + name, + version, + importSpecifier: name, + npmSpecifier: `npm:${name}@${version}`, + }); +} + +/** + * Complete immutable inventory of third-party plugin implementations shipped + * by this extension. Each entry must have a matching exact import-map target + * and a static import in `plugin-loader.ts`. + */ +export const TAILWIND_PLUGIN_POLICY = freeze([ + definePlugin("tailwindcss-animate", "1.0.7"), + definePlugin("@tailwindcss/typography", "0.5.19"), + definePlugin("@tailwindcss/forms", "0.5.11"), + definePlugin("tailwind-scrollbar-hide", "2.0.0"), + definePlugin("daisyui", "5.5.14"), +]); + +export const PACKAGE_SPEC_RE = freeze( + /^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(?:@[\w.+-]+)?$/, +); + +export function bareName(specifier: string): string { + if (typeof specifier !== "string") { + throw new NativeTypeError("Tailwind plugin specifier must be a string"); + } + if (apply(stringStartsWith, specifier, ["@"])) { + const index = apply(stringIndexOf, specifier, ["@", 1]) as number; + return index === -1 ? specifier : apply(stringSlice, specifier, [0, index]) as string; + } + const index = apply(stringIndexOf, specifier, ["@"]) as number; + return index === -1 ? specifier : apply(stringSlice, specifier, [0, index]) as string; +} + +const POLICY_BY_NAME = new MapConstructor( + apply(arrayMap, TAILWIND_PLUGIN_POLICY, [ + (entry: TailwindPluginPolicyEntry) => [entry.name, entry] as const, + ]) as ReadonlyArray, +); + +/** Immutable public view of the packages this extension can load. */ +const allowlist = apply(arrayMap, TAILWIND_PLUGIN_POLICY, [ + (entry: TailwindPluginPolicyEntry) => entry.name, +]) as string[]; +apply(arraySort, allowlist, []); +export const TAILWIND_PLUGIN_ALLOWLIST = freeze(allowlist); + +export function resolveTailwindPluginPolicy( + specifier: string, +): TailwindPluginPolicyEntry { + if ( + typeof specifier !== "string" || + apply(regexpExec, PACKAGE_SPEC_RE, [specifier]) === null + ) { + throw new NativeTypeError(`Invalid Tailwind plugin specifier: ${specifier}`); + } + const name = bareName(specifier); + const policy = apply(mapGet, POLICY_BY_NAME, [name]) as + | TailwindPluginPolicyEntry + | undefined; + if (policy === undefined) { + throw new NativeTypeError(`Tailwind plugin is not allowlisted: ${name}`); + } + const pinnedSpecifier = `${policy.name}@${policy.version}`; + if (specifier !== name && specifier !== pinnedSpecifier) { + throw new NativeTypeError( + `Tailwind plugin ${name} must use the audited version ${pinnedSpecifier}`, + ); + } + return policy; +} + +/** Stable, complete local-plugin input included in the processor cache identity. */ +type PluginIdentityEntry = { + name: string; + version: string; + importSpecifier: string; + npmSpecifier: string; +}; + +const identityPlugins = apply(arrayMap, TAILWIND_PLUGIN_POLICY, [ + (entry: TailwindPluginPolicyEntry) => ({ + name: entry.name, + version: entry.version, + importSpecifier: entry.importSpecifier, + npmSpecifier: entry.npmSpecifier, + }), +]) as PluginIdentityEntry[]; +apply(arraySort, identityPlugins, [ + (left: PluginIdentityEntry, right: PluginIdentityEntry) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, +]); +export const TAILWIND_PLUGIN_POLICY_IDENTITY = apply(jsonStringify, JSON, [{ + schema: "veryfront.tailwind-plugin-policy.v3", + resolution: "extension-owned-static-npm-imports", + plugins: identityPlugins, +}]) as string; diff --git a/extensions/ext-image-sharp/README.md b/extensions/ext-image-sharp/README.md new file mode 100644 index 0000000000..53d9f776a0 --- /dev/null +++ b/extensions/ext-image-sharp/README.md @@ -0,0 +1,58 @@ +# @veryfront/ext-image-sharp + +> **Category:** Build | **Contract:** `ImageOptimizationEngine` | **Explicit** + +Provides native raster image decoding, resizing, and encoding through the exact +Sharp dependency owned by this extension. Veryfront core depends only on the +first-party `ImageOptimizationEngine` contract and never discovers or imports +Sharp itself. + +The npm package requires Node.js 20.9 or newer, matching Sharp 0.35's native +runtime floor. This does not change Veryfront core's runtime support because +the extension is installed and activated only by applications that opt in. + +## Registration + +Install this extension and compose it explicitly in `veryfront.config.ts`: + +```ts +import extImageSharp from "@veryfront/ext-image-sharp"; + +export default defineConfig({ + extensions: [extImageSharp()], + assetPipeline: { + images: { enabled: true }, + }, +}); +``` + +Enabling image optimization without a registered `ImageOptimizationEngine` +fails before build output is published. The extension statically binds its +exact Sharp package; it never searches global installations or project +workspaces and never uses the network to discover an implementation. + +## Output semantics + +For each source image, the engine emits every requested format for each unique +target width that does not exceed the auto-oriented source width. The source +width is always included exactly once. Variants are returned deterministically +by ascending width and then canonical format order (`webp`, `avif`, `jpeg`, +`png`). Sharp is always called with `withoutEnlargement: true`, and returned +dimensions, aspect ratio, and format metadata must match the planned variant. +The complete supported matrix is 64 configured widths plus the source width, +across all four formats (260 variants). + +The extension snapshots descriptor-only request data before native work and +uses an independent Sharp pipeline for every operation. Inputs, decoded pixels, +variant count, per-variant bytes, and aggregate output bytes are bounded. Each +request carries an abort signal used by core for cancellation and deadlines. +Sharp cannot interrupt every native call already in progress, so the extension +races native steps with that signal and discards any late result. Engines are +long-lived concurrency-safe services with no caller-managed cleanup lifecycle; +operation-scoped resources are released when each provider Promise settles. + +The extension requests only Sharp's import-time environment reads +(`MALLOC_ARENA_MAX` and `npm_package_config_libvips`), read access to +`/proc/self/exe` and `/usr/bin/ldd` for Linux libc detection, and native FFI for +its packaged libvips module. It requests no network, subprocess, or project +workspace capability. diff --git a/extensions/ext-image-sharp/deno.json b/extensions/ext-image-sharp/deno.json new file mode 100644 index 0000000000..588d08d013 --- /dev/null +++ b/extensions/ext-image-sharp/deno.json @@ -0,0 +1,36 @@ +{ + "name": "@veryfront/ext-image-sharp", + "version": "0.1.0", + "exports": "./src/index.ts", + "veryfront": { + "extension": true, + "activation": "explicit", + "npm": { + "nodeEngine": ">=20.9.0" + }, + "contracts": { + "provides": ["ImageOptimizationEngine"] + }, + "capabilities": [ + { + "type": "fs:read", + "paths": ["/proc/self/exe", "/usr/bin/ldd"] + }, + { + "type": "env:read", + "keys": ["MALLOC_ARENA_MAX", "npm_package_config_libvips"] + }, + { "type": "native:ffi" } + ] + }, + "imports": { + "sharp": "npm:sharp@0.35.3", + "@std/assert": "jsr:@std/assert@1.0.19", + "@std/testing/bdd": "jsr:@std/testing@1.0.17/bdd", + "veryfront/extensions": "../../src/extensions/types.ts", + "veryfront/extensions/image": "../../src/extensions/image/index.ts" + }, + "tasks": { + "test": "deno test --allow-read=/proc/self/exe,/usr/bin/ldd --allow-env=MALLOC_ARENA_MAX,npm_package_config_libvips --allow-ffi src/" + } +} diff --git a/extensions/ext-image-sharp/src/index.test.ts b/extensions/ext-image-sharp/src/index.test.ts new file mode 100644 index 0000000000..5ea744979c --- /dev/null +++ b/extensions/ext-image-sharp/src/index.test.ts @@ -0,0 +1,170 @@ +import { assertEquals, assertRejects, assertStringIncludes, assertThrows } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { ImageOptimizationEngineName } from "veryfront/extensions/image"; +import sharp from "sharp"; +import factory, { SharpImageOptimizationEngine } from "./index.ts"; + +const pixel = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + ), + (character) => character.charCodeAt(0), +); + +describe("ext-image-sharp", () => { + it("is explicit and provides only ImageOptimizationEngine", () => { + const extension = factory(); + assertEquals(extension.name, "ext-image-sharp"); + assertEquals(extension.contracts?.provides, [ImageOptimizationEngineName]); + assertEquals(extension.capabilities, [ + { + type: "fs:read", + paths: ["/proc/self/exe", "/usr/bin/ldd"], + }, + { + type: "env:read", + keys: ["MALLOC_ARENA_MAX", "npm_package_config_libvips"], + }, + { type: "native:ffi" }, + ]); + }); + + it("registers one explicit Sharp engine", async () => { + const provided = new Map(); + await factory().setup?.({ + get: () => undefined, + require: () => { + throw new Error("unexpected require"); + }, + provide: (name, value) => provided.set(name, value), + config: {}, + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }); + assertEquals( + provided.get(ImageOptimizationEngineName) instanceof + SharpImageOptimizationEngine, + true, + ); + }); + + it("keeps cache identity immutable and backend-specific", () => { + const engine = new SharpImageOptimizationEngine(); + assertStringIncludes(engine.cacheIdentity, "sharp@0.35.3"); + assertStringIncludes(engine.cacheIdentity, `vips@${sharp.versions.vips}`); + assertEquals(Object.isFrozen(engine), true); + assertThrows( + () => { + (engine as { cacheIdentity: string }).cacheIdentity = "mutated"; + }, + TypeError, + ); + assertStringIncludes(engine.cacheIdentity, "veryfront.image-sharp.v2"); + }); + + it("emits exact no-upscale widths and deterministic formats", async () => { + const input = await sharp({ + create: { + width: 3, + height: 2, + channels: 3, + background: { r: 25, g: 50, b: 75 }, + }, + }).png().toBuffer(); + const result = await new SharpImageOptimizationEngine().optimize({ + input, + targetWidths: [5, 2, 1, 2], + formats: ["png", "webp"], + quality: 67, + signal: new AbortController().signal, + }); + + assertEquals([result.sourceWidth, result.sourceHeight], [3, 2]); + assertEquals( + result.variants.map((variant) => [ + variant.width, + variant.height, + variant.format, + ]), + [ + [1, 1, "webp"], + [1, 1, "png"], + [2, 1, "webp"], + [2, 1, "png"], + [3, 2, "webp"], + [3, 2, "png"], + ], + ); + assertEquals( + result.variants.every((variant) => variant.data.length > 0), + true, + ); + }); + + it("uses auto-oriented source dimensions without enlargement", async () => { + const input = await sharp({ + create: { + width: 2, + height: 3, + channels: 3, + background: { r: 25, g: 50, b: 75 }, + }, + }).jpeg().withMetadata({ orientation: 6 }).toBuffer(); + const result = await new SharpImageOptimizationEngine().optimize({ + input, + targetWidths: [6, 1], + formats: ["webp"], + quality: 80, + signal: new AbortController().signal, + }); + + assertEquals([result.sourceWidth, result.sourceHeight], [3, 2]); + assertEquals( + result.variants.map((variant) => [variant.width, variant.height]), + [[1, 1], [3, 2]], + ); + }); + + it("fails before native work when cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + await assertRejects( + () => + new SharpImageOptimizationEngine().optimize({ + input: pixel, + targetWidths: [1], + formats: ["png"], + quality: 80, + signal: controller.signal, + }), + Error, + "cancelled or exceeded its deadline", + ); + }); + + it("rejects inherited, accessor, and unknown factory configuration", () => { + assertThrows( + () => factory(Object.create({ value: true })), + TypeError, + "must not inherit", + ); + assertThrows( + () => factory({ value: true }), + TypeError, + "does not accept", + ); + let reads = 0; + const hostile = Object.defineProperty({}, "value", { + get() { + reads++; + return true; + }, + }); + assertThrows(() => factory(hostile), TypeError, "does not accept"); + assertEquals(reads, 0); + }); +}); diff --git a/extensions/ext-image-sharp/src/index.ts b/extensions/ext-image-sharp/src/index.ts new file mode 100644 index 0000000000..8437e37579 --- /dev/null +++ b/extensions/ext-image-sharp/src/index.ts @@ -0,0 +1,166 @@ +/** + * Sharp implementation of the dependency-free ImageOptimizationEngine. + * + * @module extensions/ext-image-sharp + */ + +import type { ExtensionFactory } from "veryfront/extensions"; +import { + type ImageOptimizationEngine, + ImageOptimizationEngineName, + type ImageOptimizationRequest, + type ImageOptimizationResult, + MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS, +} from "veryfront/extensions/image"; +import { arch, platform } from "node:process"; +import sharp from "sharp"; +import extensionPackage from "../deno.json" with { type: "json" }; +import { + BoundSharpImageOptimizationEngine, + captureSharpRuntime, + SHARP_IMAGE_LIMITS, +} from "./sharp-runtime.ts"; + +const ENGINE_SEMANTICS_VERSION = "veryfront.image-sharp.v2"; +const SHARP_VERSION_PATTERN = /^npm:sharp@((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/; +const VERSION_COMPONENTS = [ + "vips", + "aom", + "ffi", + "heif", + "mozjpeg", + "png", + "webp", + "zlib-ng", +] as const; +const executeRegularExpression = RegExp.prototype.exec; +const apply = Reflect.apply; +const arrayIsArray = Array.isArray; +const arrayJoin = Array.prototype.join; +const arrayPush = Array.prototype.push; +const defineProperty = Object.defineProperty; +const freeze = Object.freeze; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const getPrototypeOf = Object.getPrototypeOf; +const ownKeys = Reflect.ownKeys; +const standardObjectPrototype = Object.prototype; + +const sharpRuntime = captureSharpRuntime(sharp); + +function exactSharpVersion(specifier: string): string { + const match = apply(executeRegularExpression, SHARP_VERSION_PATTERN, [specifier]); + if (!match?.[1]) { + throw new TypeError("ext-image-sharp requires an exact Sharp dependency version"); + } + return match[1]; +} + +function createCacheIdentity(): string { + const components = [ + `ext-image-sharp@${extensionPackage.version}`, + `sharp@${exactSharpVersion(extensionPackage.imports.sharp)}`, + ENGINE_SEMANTICS_VERSION, + `runtime@${platform}-${arch}`, + ]; + for (let index = 0; index < VERSION_COMPONENTS.length; index++) { + const name = VERSION_COMPONENTS[index]!; + const version = sharpRuntime.versions[name]; + if (typeof version !== "string" || version.length === 0) { + throw new TypeError(`Sharp did not report its ${name} backend version`); + } + apply(arrayPush, components, [`${name}@${version}`]); + } + apply(arrayPush, components, [ + `limits@${SHARP_IMAGE_LIMITS.maxDecodedPixels}:` + + `${SHARP_IMAGE_LIMITS.maxOutputBytesPerVariant}:` + + `${SHARP_IMAGE_LIMITS.maxTotalOutputBytes}`, + ]); + const identity = apply(arrayJoin, components, ["|"]) as string; + if (identity.length > MAX_IMAGE_OPTIMIZATION_ENGINE_IDENTITY_CHARACTERS) { + throw new TypeError("ext-image-sharp cache identity exceeds the core limit"); + } + return identity; +} + +const CACHE_IDENTITY = createCacheIdentity(); + +/** Native image decoder, resizer, and encoder supplied by this extension. */ +export class SharpImageOptimizationEngine implements ImageOptimizationEngine { + readonly cacheIdentity!: string; + readonly #engine: BoundSharpImageOptimizationEngine; + + constructor() { + this.#engine = new BoundSharpImageOptimizationEngine( + sharpRuntime, + CACHE_IDENTITY, + ); + defineProperty(this, "cacheIdentity", { + value: CACHE_IDENTITY, + enumerable: true, + configurable: false, + writable: false, + }); + freeze(this); + } + + optimize(request: ImageOptimizationRequest): Promise { + return this.#engine.optimize(request); + } +} + +freeze(SharpImageOptimizationEngine.prototype); +freeze(SharpImageOptimizationEngine); + +function readConfig(value: unknown): void { + if (value === undefined) return; + if (typeof value !== "object" || value === null || arrayIsArray(value)) { + throw new TypeError("ext-image-sharp config must be an object"); + } + let prototype: object | null; + let keys: PropertyKey[]; + try { + prototype = getPrototypeOf(value); + keys = ownKeys(getOwnPropertyDescriptors(value)); + } catch (cause) { + throw new TypeError("ext-image-sharp config could not be inspected", { + cause, + }); + } + if (prototype !== standardObjectPrototype && prototype !== null) { + throw new TypeError("ext-image-sharp config must not inherit configuration"); + } + if (keys.length !== 0) { + throw new TypeError("ext-image-sharp does not accept configuration properties"); + } +} + +const factory: ExtensionFactory = (config?: unknown) => { + readConfig(config); + const engine = new SharpImageOptimizationEngine(); + return { + name: "ext-image-sharp", + version: extensionPackage.version, + capabilities: [ + { + type: "fs:read", + paths: ["/proc/self/exe", "/usr/bin/ldd"], + }, + { + type: "env:read", + keys: ["MALLOC_ARENA_MAX", "npm_package_config_libvips"], + }, + { type: "native:ffi" }, + ], + contracts: { + provides: ["ImageOptimizationEngine"], + }, + setup(ctx) { + ctx.provide(ImageOptimizationEngineName, engine); + ctx.logger.debug( + `[ext-image-sharp] ${ImageOptimizationEngineName} registered`, + ); + }, + }; +}; + +export default factory; diff --git a/extensions/ext-image-sharp/src/sharp-runtime.test.ts b/extensions/ext-image-sharp/src/sharp-runtime.test.ts new file mode 100644 index 0000000000..5e6848b728 --- /dev/null +++ b/extensions/ext-image-sharp/src/sharp-runtime.test.ts @@ -0,0 +1,520 @@ +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import type { ImageOptimizationFormat, ImageOptimizationRequest } from "veryfront/extensions/image"; +import { + BoundSharpImageOptimizationEngine, + captureSharpRuntime, + type SharpImageLimits, +} from "./sharp-runtime.ts"; + +interface FakePipelineState { + readonly sourceWidth: number; + readonly sourceHeight: number; + width: number; + format: ImageOptimizationFormat; +} + +interface FakeSharpOptions { + readonly sourceWidth?: number; + readonly sourceHeight?: number; + readonly outputBytes?: number; + readonly outputSizeOffset?: number; + readonly metadata?: unknown; + readonly metadataPromise?: Promise; + readonly outputPromise?: Promise; + readonly onToBuffer?: () => void; +} + +interface FakeSharpFixture { + readonly sharp: unknown; + readonly createdInputs: number[]; + readonly encoded: Array; +} + +const defineProperty = Object.defineProperty; + +function fakeSharpFixture(options: FakeSharpOptions = {}): FakeSharpFixture { + const sourceWidth = options.sourceWidth ?? 4; + const sourceHeight = options.sourceHeight ?? 2; + const states = new WeakMap(); + const createdInputs: number[] = []; + const encoded: Array = []; + + function state(pipeline: object): FakePipelineState { + const value = states.get(pipeline); + if (!value) throw new Error("missing fake Sharp pipeline state"); + return value; + } + + function createPipeline(value?: FakePipelineState): object { + const pipeline = Object.create(fakeSharp.prototype); + states.set( + pipeline, + value ?? { + sourceWidth, + sourceHeight, + width: sourceWidth, + format: "png", + }, + ); + return pipeline; + } + + function fakeSharp(input: Uint8Array): object { + createdInputs.push(input[0] ?? -1); + return createPipeline(); + } + + defineProperty(fakeSharp, "versions", { + value: { + vips: "test", + aom: "test", + heif: "test", + mozjpeg: "test", + png: "test", + webp: "test", + "zlib-ng": "test", + }, + }); + defineProperty(fakeSharp.prototype, "autoOrient", { + configurable: true, + writable: true, + value: function (this: object): object { + state(this); + return this; + }, + }); + defineProperty(fakeSharp.prototype, "metadata", { + configurable: true, + writable: true, + value: function (this: object): unknown { + state(this); + if (options.metadataPromise) return options.metadataPromise; + return Promise.resolve( + options.metadata ?? { + autoOrient: { width: sourceWidth, height: sourceHeight }, + }, + ); + }, + }); + defineProperty(fakeSharp.prototype, "clone", { + configurable: true, + writable: true, + value: function (this: object): object { + const current = state(this); + return createPipeline({ ...current }); + }, + }); + defineProperty(fakeSharp.prototype, "resize", { + configurable: true, + writable: true, + value: function ( + this: object, + resizeOptions: { width: number }, + ): object { + state(this).width = resizeOptions.width; + return this; + }, + }); + for (const format of ["webp", "avif", "jpeg", "png"] as const) { + defineProperty(fakeSharp.prototype, format, { + configurable: true, + writable: true, + value: function (this: object): object { + state(this).format = format; + return this; + }, + }); + } + defineProperty(fakeSharp.prototype, "toBuffer", { + configurable: true, + writable: true, + value: function (this: object): unknown { + const current = state(this); + encoded.push([current.width, current.format]); + options.onToBuffer?.(); + if (options.outputPromise) return options.outputPromise; + const outputBytes = options.outputBytes ?? 2; + const data = new Uint8Array(outputBytes); + data.fill(current.width); + const height = Math.max( + 1, + Math.round( + current.sourceHeight * current.width / current.sourceWidth, + ), + ); + return Promise.resolve({ + data, + info: { + width: current.width, + height, + size: outputBytes + (options.outputSizeOffset ?? 0), + format: current.format === "avif" ? "heif" : current.format, + }, + }); + }, + }); + + return { sharp: fakeSharp, createdInputs, encoded }; +} + +function request( + overrides: Partial = {}, +): ImageOptimizationRequest { + return { + input: new Uint8Array([7]), + targetWidths: [3, 1, 3, 9], + formats: ["png", "webp"], + quality: 80, + signal: new AbortController().signal, + ...overrides, + }; +} + +function engine( + fixture = fakeSharpFixture(), + limits?: SharpImageLimits, +): { + engine: BoundSharpImageOptimizationEngine; + fixture: FakeSharpFixture; +} { + return { + engine: new BoundSharpImageOptimizationEngine( + captureSharpRuntime(fixture.sharp), + "test-sharp@1", + limits, + ), + fixture, + }; +} + +const smallLimits: SharpImageLimits = Object.freeze({ + maxInputBytes: 4, + maxDecodedPixels: 100, + maxDimension: 10, + maxTargetWidths: 4, + maxFormats: 4, + maxVariants: 8, + maxOutputBytesPerVariant: 3, + maxTotalOutputBytes: 8, +}); + +describe("captured Sharp runtime", () => { + it("captures prototype methods once without consulting replacements", async () => { + const fixture = fakeSharpFixture(); + const captured = captureSharpRuntime(fixture.sharp); + defineProperty( + (fixture.sharp as { prototype: object }).prototype, + "metadata", + { + value: () => Promise.reject(new Error("replacement invoked")), + }, + ); + const result = await new BoundSharpImageOptimizationEngine( + captured, + "test-sharp@1", + ).optimize(request()); + assertEquals(result.sourceWidth, 4); + }); + + it("rejects method and version accessors without invoking them", () => { + let reads = 0; + const fixture = fakeSharpFixture(); + defineProperty( + (fixture.sharp as { prototype: object }).prototype, + "metadata", + { + configurable: true, + get() { + reads++; + return () => Promise.resolve({ width: 1, height: 1 }); + }, + }, + ); + assertThrows( + () => captureSharpRuntime(fixture.sharp), + TypeError, + "data-property method", + ); + assertEquals(reads, 0); + + const hostile = function (): void {}; + defineProperty(hostile, "versions", { + get() { + reads++; + return {}; + }, + }); + assertThrows( + () => captureSharpRuntime(hostile), + TypeError, + "versions must be an own data property", + ); + assertEquals(reads, 0); + }); + + it("fails closed on cyclic Sharp prototype chains", () => { + const fixture = fakeSharpFixture(); + const cyclic: object = new Proxy({}, { getPrototypeOf: () => cyclic }); + defineProperty(fixture.sharp as object, "prototype", { + value: cyclic, + }); + assertThrows( + () => captureSharpRuntime(fixture.sharp), + TypeError, + "invalid prototype chain", + ); + }); +}); + +describe("BoundSharpImageOptimizationEngine", () => { + it("emits the exact no-upscale width by format product canonically", async () => { + const value = engine(); + const result = await value.engine.optimize(request()); + + assertEquals([result.sourceWidth, result.sourceHeight], [4, 2]); + assertEquals( + result.variants.map((variant) => [variant.width, variant.format]), + [ + [1, "webp"], + [1, "png"], + [3, "webp"], + [3, "png"], + [4, "webp"], + [4, "png"], + ], + ); + assertEquals(value.fixture.encoded, [ + [1, "webp"], + [1, "png"], + [3, "webp"], + [3, "png"], + [4, "webp"], + [4, "png"], + ]); + }); + + it("snapshots each operation before asynchronous native work", async () => { + const value = engine(); + const input = new Uint8Array([9]); + const targetWidths = [2]; + const formats: ImageOptimizationFormat[] = ["png"]; + const operation = value.engine.optimize( + request({ input, targetWidths, formats }), + ); + input[0] = 1; + targetWidths[0] = 4; + formats[0] = "webp"; + + const result = await operation; + assertEquals(value.fixture.createdInputs, [9]); + assertEquals( + result.variants.map((variant) => [variant.width, variant.format]), + [[2, "png"], [4, "png"]], + ); + }); + + it("uses an independent source pipeline for concurrent operations", async () => { + const value = engine(); + const [first, second] = await Promise.all([ + value.engine.optimize(request({ targetWidths: [1], formats: ["webp"] })), + value.engine.optimize(request({ targetWidths: [2], formats: ["png"] })), + ]); + assertEquals(value.fixture.createdInputs.length, 2); + assertEquals( + first.variants.map((variant) => [variant.width, variant.format]), + [[1, "webp"], [4, "webp"]], + ); + assertEquals( + second.variants.map((variant) => [variant.width, variant.format]), + [[2, "png"], [4, "png"]], + ); + }); + + it("supports the complete core width-by-format matrix", async () => { + const targetWidths = Array.from({ length: 64 }, (_, index) => index + 1); + const result = await engine( + fakeSharpFixture({ sourceWidth: 65, sourceHeight: 65 }), + ).engine.optimize( + request({ + targetWidths, + formats: ["webp", "avif", "jpeg", "png"], + }), + ); + + assertEquals(result.variants.length, 260); + assertEquals(result.variants[0]?.width, 1); + assertEquals(result.variants.at(-1)?.width, 65); + }); + + it("rejects request accessors, sparse arrays, and custom array behavior", async () => { + let reads = 0; + const value = engine(); + const hostile = Object.create(null); + for (const [property, entry] of Object.entries(request())) { + if (property === "targetWidths") continue; + defineProperty(hostile, property, { + enumerable: true, + value: entry, + }); + } + defineProperty(hostile, "targetWidths", { + enumerable: true, + get() { + reads++; + return [1]; + }, + }); + await assertRejects( + () => value.engine.optimize(hostile), + TypeError, + "data property", + ); + assertEquals(reads, 0); + + const sparse = new Array(2); + sparse[1] = 1; + await assertRejects( + () => value.engine.optimize(request({ targetWidths: sparse })), + TypeError, + "dense data-property array", + ); + + const custom = [1]; + defineProperty(custom, Symbol.iterator, { + get() { + reads++; + return Array.prototype[Symbol.iterator]; + }, + }); + await assertRejects( + () => value.engine.optimize(request({ targetWidths: custom })), + TypeError, + "custom properties", + ); + assertEquals(reads, 0); + }); + + it("requires exact Uint8Array inputs instead of generic typed arrays", async () => { + const wrongView = new Uint16Array([1]); + await assertRejects( + () => + engine().engine.optimize( + request({ input: wrongView as unknown as Uint8Array }), + ), + TypeError, + "must be a Uint8Array", + ); + }); + + it("rejects invalid dimensions and bounded variant plans", async () => { + await assertRejects( + () => + engine( + fakeSharpFixture({ + metadata: { autoOrient: { width: 11, height: 1 } }, + }), + smallLimits, + ).engine.optimize(request({ targetWidths: [1], formats: ["png"] })), + TypeError, + "from 1 through 10", + ); + + await assertRejects( + () => + engine(fakeSharpFixture(), { + ...smallLimits, + maxTargetWidths: 2, + }).engine.optimize( + request({ targetWidths: [1, 2, 3], formats: ["png"] }), + ), + TypeError, + "exceeds its supported length", + ); + + await assertRejects( + () => + engine(fakeSharpFixture(), { + ...smallLimits, + maxVariants: 2, + }).engine.optimize( + request({ targetWidths: [1], formats: ["png", "webp"] }), + ), + TypeError, + "more than 2 variants", + ); + }); + + it("validates output byte and metadata bounds", async () => { + await assertRejects( + () => + engine( + fakeSharpFixture({ outputBytes: 4 }), + smallLimits, + ).engine.optimize(request({ targetWidths: [], formats: ["png"] })), + TypeError, + "exceeds 3 bytes", + ); + await assertRejects( + () => + engine( + fakeSharpFixture({ outputSizeOffset: 1 }), + smallLimits, + ).engine.optimize(request({ targetWidths: [], formats: ["png"] })), + TypeError, + "size metadata does not match", + ); + await assertRejects( + () => + engine(fakeSharpFixture(), { + ...smallLimits, + maxTotalOutputBytes: 3, + }).engine.optimize( + request({ targetWidths: [], formats: ["webp", "png"] }), + ), + TypeError, + "outputs exceed 3 bytes", + ); + }); + + it("does not settle after abort until started native work finishes", async () => { + let markNativeStarted!: () => void; + const nativeStarted = new Promise((resolve) => { + markNativeStarted = resolve; + }); + let finishNative!: (value: unknown) => void; + const nativeOperation = new Promise((resolve) => { + finishNative = resolve; + }); + const value = engine(fakeSharpFixture({ + outputPromise: nativeOperation, + onToBuffer: markNativeStarted, + })); + const controller = new AbortController(); + const operation = value.engine.optimize(request({ signal: controller.signal })); + const providerSettlement = operation.then( + () => true, + () => true, + ); + + await nativeStarted; + controller.abort(); + const settledBeforeNative = await Promise.race([ + providerSettlement, + new Promise((resolve) => setTimeout(() => resolve(false), 0)), + ]); + if (settledBeforeNative) { + finishNative(undefined); + await operation.catch(() => undefined); + } + assertEquals(settledBeforeNative, false); + + finishNative(undefined); + await assertRejects( + () => operation, + Error, + "cancelled or exceeded its deadline", + ); + assertEquals(await providerSettlement, true); + }); +}); diff --git a/extensions/ext-image-sharp/src/sharp-runtime.ts b/extensions/ext-image-sharp/src/sharp-runtime.ts new file mode 100644 index 0000000000..2199708679 --- /dev/null +++ b/extensions/ext-image-sharp/src/sharp-runtime.ts @@ -0,0 +1,868 @@ +/** + * Descriptor-safe Sharp runtime capture and bounded image optimization. + * + * This module is intentionally not a package entry point. The public extension + * binds it to the exact Sharp dependency declared by the extension manifest. + */ + +import type { + ImageOptimizationEngine, + ImageOptimizationFormat, + ImageOptimizationRequest, + ImageOptimizationResult, + ImageOptimizationVariantResult, +} from "veryfront/extensions/image"; + +const apply = Reflect.apply; +const arrayIncludes = Array.prototype.includes; +const arrayIsArray = Array.isArray; +const arrayPush = Array.prototype.push; +const arraySort = Array.prototype.sort; +const createObject = Object.create; +const defineProperty = Object.defineProperty; +const freeze = Object.freeze; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const getPrototypeOf = Object.getPrototypeOf; +const hasOwn = Object.hasOwn; +const numberIsInteger = Number.isInteger; +const numberIsSafeInteger = Number.isSafeInteger; +const ownKeys = Reflect.ownKeys; +const promiseResolve = Promise.resolve; +const PromiseConstructor = Promise; +const setAdd = Set.prototype.add; +const setHas = Set.prototype.has; +const SetConstructor = Set; +const setBytes = Uint8Array.prototype.set; +const sortNumbers = (left: number, right: number): number => left - right; +const standardArrayPrototype = Array.prototype; +const standardObjectPrototype = Object.prototype; +const Uint8ArrayConstructor = Uint8Array; + +const typedArrayPrototype = getPrototypeOf(Uint8Array.prototype); +const byteLengthGetter = getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)?.get; +const typedArrayNameGetter = getOwnPropertyDescriptor( + typedArrayPrototype, + Symbol.toStringTag, +)?.get; +const abortedGetter = getOwnPropertyDescriptor( + AbortSignal.prototype, + "aborted", +)?.get; +if (!byteLengthGetter || !typedArrayNameGetter || !abortedGetter) { + throw new TypeError("Required image optimization intrinsics are unavailable"); +} + +const REQUEST_PROPERTIES = freeze( + [ + "input", + "targetWidths", + "formats", + "quality", + "signal", + ] as const, +); +const SUPPORTED_FORMATS = freeze( + [ + "webp", + "avif", + "jpeg", + "png", + ] as const satisfies readonly ImageOptimizationFormat[], +); +const MAX_SHARP_PROTOTYPE_DEPTH = 16; + +/** Provider-level resource limits, independent of core's stricter boundary. */ +export const SHARP_IMAGE_LIMITS = freeze({ + maxInputBytes: 64 * 1024 * 1024, + maxDecodedPixels: 100_000_000, + maxDimension: 32_768, + maxTargetWidths: 64, + maxFormats: SUPPORTED_FORMATS.length, + maxVariants: 260, + maxOutputBytesPerVariant: 64 * 1024 * 1024, + maxTotalOutputBytes: 256 * 1024 * 1024, +}); + +export interface SharpImageLimits { + readonly maxInputBytes: number; + readonly maxDecodedPixels: number; + readonly maxDimension: number; + readonly maxTargetWidths: number; + readonly maxFormats: number; + readonly maxVariants: number; + readonly maxOutputBytesPerVariant: number; + readonly maxTotalOutputBytes: number; +} + +type UnknownFunction = (...args: never[]) => unknown; + +/** Captured calls into one exact Sharp module implementation. */ +export interface CapturedSharpRuntime { + readonly versions: Readonly>; + create(input: Uint8Array, options: Readonly>): object; + autoOrient(pipeline: object): object; + metadata(pipeline: object): unknown; + clone(pipeline: object): object; + resize(pipeline: object, options: Readonly>): object; + webp(pipeline: object, options: Readonly>): object; + avif(pipeline: object, options: Readonly>): object; + jpeg(pipeline: object, options: Readonly>): object; + png(pipeline: object, options: Readonly>): object; + toBuffer(pipeline: object, options: Readonly>): unknown; +} + +interface RequestSnapshot { + readonly input: Uint8Array; + readonly targetWidths: readonly number[]; + readonly formats: readonly ImageOptimizationFormat[]; + readonly quality: number; + readonly signal: AbortSignal; +} + +interface Dimensions { + readonly width: number; + readonly height: number; +} + +function invoke( + method: UnknownFunction, + receiver: unknown, + args: readonly unknown[], +): T { + return apply(method, receiver, args) as T; +} + +function ownDescriptors(value: object, label: string): PropertyDescriptorMap { + try { + return getOwnPropertyDescriptors(value); + } catch (cause) { + throw new TypeError(`${label} properties could not be inspected`, { cause }); + } +} + +function readDataProperty( + descriptors: PropertyDescriptorMap, + property: string, + label: string, +): unknown { + const descriptor = descriptors[property]; + if (descriptor === undefined || !hasOwn(descriptor, "value")) { + throw new TypeError(`${label} ${property} must be a data property`); + } + return descriptor.value; +} + +function readOptionalDataProperty( + descriptors: PropertyDescriptorMap, + property: string, + label: string, +): unknown { + const descriptor = descriptors[property]; + if (descriptor === undefined) return undefined; + if (!hasOwn(descriptor, "value")) { + throw new TypeError(`${label} ${property} must be a data property`); + } + return descriptor.value; +} + +function assertPlainRecord(value: object, label: string): void { + let prototype: object | null; + try { + prototype = getPrototypeOf(value); + } catch (cause) { + throw new TypeError(`${label} prototype could not be inspected`, { cause }); + } + if (prototype !== standardObjectPrototype && prototype !== null) { + throw new TypeError(`${label} must not inherit custom behavior`); + } +} + +function assertExactProperties( + descriptors: PropertyDescriptorMap, + expected: readonly string[], + label: string, +): void { + const keys = ownKeys(descriptors); + if (keys.length !== expected.length) { + throw new TypeError(`${label} contains unsupported properties`); + } + for (let index = 0; index < expected.length; index++) { + if (!hasOwn(descriptors, expected[index]!)) { + throw new TypeError(`${label} contains unsupported properties`); + } + } +} + +function denseDataArray( + value: unknown, + maximumLength: number, + label: string, +): unknown[] { + if (!arrayIsArray(value)) { + throw new TypeError(`${label} must be an array`); + } + let prototype: object | null; + try { + prototype = getPrototypeOf(value); + } catch (cause) { + throw new TypeError(`${label} prototype could not be inspected`, { cause }); + } + if (prototype !== standardArrayPrototype) { + throw new TypeError(`${label} must use the standard array prototype`); + } + + const descriptors = ownDescriptors(value, label); + const length = readDataProperty(descriptors, "length", label); + if ( + !numberIsSafeInteger(length) || + (length as number) < 0 || + (length as number) > maximumLength + ) { + throw new TypeError(`${label} exceeds its supported length`); + } + + const entries: unknown[] = []; + for (let index = 0; index < (length as number); index++) { + const descriptor = descriptors[String(index)]; + if ( + descriptor === undefined || + !hasOwn(descriptor, "value") || + descriptor.enumerable !== true + ) { + throw new TypeError(`${label} must be a dense data-property array`); + } + invoke(arrayPush, entries, [descriptor.value]); + } + if (ownKeys(descriptors).length !== entries.length + 1) { + throw new TypeError(`${label} must not define custom properties`); + } + return entries; +} + +function byteLength(value: unknown, label: string): number { + try { + return invoke(byteLengthGetter!, value, []); + } catch (cause) { + throw new TypeError(`${label} must be a Uint8Array`, { cause }); + } +} + +function copyBytes( + value: unknown, + maximumLength: number, + label: string, +): Uint8Array { + let typedArrayName: unknown; + try { + typedArrayName = invoke(typedArrayNameGetter!, value, []); + } catch (cause) { + throw new TypeError(`${label} must be a Uint8Array`, { cause }); + } + if (typedArrayName !== "Uint8Array") { + throw new TypeError(`${label} must be a Uint8Array`); + } + const length = byteLength(value, label); + if (length === 0) { + throw new TypeError(`${label} must not be empty`); + } + if (length > maximumLength) { + throw new TypeError(`${label} exceeds ${maximumLength} bytes`); + } + try { + const copy = new Uint8ArrayConstructor(length); + invoke(setBytes, copy, [value]); + return copy; + } catch (cause) { + throw new TypeError(`${label} could not be copied`, { cause }); + } +} + +function isAborted(signal: AbortSignal): boolean { + return invoke(abortedGetter!, signal, []); +} + +function snapshotAbortSignal(value: unknown): AbortSignal { + try { + invoke(abortedGetter!, value, []); + } catch (cause) { + throw new TypeError("Image optimization signal must be an AbortSignal", { + cause, + }); + } + return value as AbortSignal; +} + +function cancellationError(cause?: unknown): Error { + const message = "Image optimization was cancelled or exceeded its deadline"; + return cause === undefined ? new Error(message) : new Error(message, { cause }); +} + +function throwIfAborted(signal: AbortSignal): void { + if (isAborted(signal)) throw cancellationError(); +} + +async function awaitNativeOperation( + value: unknown, + signal: AbortSignal, +): Promise { + const operation = invoke>(promiseResolve, PromiseConstructor, [value]); + let result: T; + try { + result = await operation; + } catch (cause) { + if (isAborted(signal)) throw cancellationError(cause); + throw cause; + } + throwIfAborted(signal); + return result; +} + +function snapshotRequest( + value: ImageOptimizationRequest, + limits: SharpImageLimits, +): RequestSnapshot { + if (typeof value !== "object" || value === null || arrayIsArray(value)) { + throw new TypeError("Image optimization request must be an object"); + } + assertPlainRecord(value, "Image optimization request"); + const descriptors = ownDescriptors(value, "Image optimization request"); + assertExactProperties( + descriptors, + REQUEST_PROPERTIES, + "Image optimization request", + ); + + const signal = snapshotAbortSignal( + readDataProperty(descriptors, "signal", "Image optimization request"), + ); + throwIfAborted(signal); + const input = copyBytes( + readDataProperty(descriptors, "input", "Image optimization request"), + limits.maxInputBytes, + "Image optimization input", + ); + const targetWidthEntries = denseDataArray( + readDataProperty( + descriptors, + "targetWidths", + "Image optimization request", + ), + limits.maxTargetWidths, + "Image optimization target widths", + ); + const targetWidths: number[] = []; + for (let index = 0; index < targetWidthEntries.length; index++) { + const width = targetWidthEntries[index]; + if ( + !numberIsInteger(width) || + (width as number) <= 0 || + (width as number) > limits.maxDimension + ) { + throw new TypeError( + `Image optimization target widths must be integers from 1 through ${limits.maxDimension}`, + ); + } + if (!invoke(arrayIncludes, targetWidths, [width])) { + invoke(arrayPush, targetWidths, [width]); + } + } + + const formatEntries = denseDataArray( + readDataProperty(descriptors, "formats", "Image optimization request"), + limits.maxFormats, + "Image optimization formats", + ); + if (formatEntries.length === 0) { + throw new TypeError("Image optimization formats must not be empty"); + } + const requestedFormats = new SetConstructor(); + for (let index = 0; index < formatEntries.length; index++) { + const format = formatEntries[index]; + if ( + format !== "webp" && format !== "avif" && format !== "jpeg" && + format !== "png" + ) { + throw new TypeError("Image optimization format is unsupported"); + } + if (invoke(setHas, requestedFormats, [format])) { + throw new TypeError("Image optimization formats must be unique"); + } + invoke(setAdd, requestedFormats, [format]); + } + const formats: ImageOptimizationFormat[] = []; + for (let index = 0; index < SUPPORTED_FORMATS.length; index++) { + const format = SUPPORTED_FORMATS[index]!; + if (invoke(setHas, requestedFormats, [format])) { + invoke(arrayPush, formats, [format]); + } + } + + const quality = readDataProperty( + descriptors, + "quality", + "Image optimization request", + ); + if (!numberIsInteger(quality) || (quality as number) < 1 || (quality as number) > 100) { + throw new TypeError("Image optimization quality must be an integer from 1 through 100"); + } + + return freeze({ + input, + targetWidths: freeze(targetWidths), + formats: freeze(formats), + quality: quality as number, + signal, + }); +} + +function findMethod( + prototype: object, + property: string, +): UnknownFunction { + const visited = new SetConstructor(); + let owner: object | null = prototype; + let depth = 0; + while (owner !== null) { + if ( + depth >= MAX_SHARP_PROTOTYPE_DEPTH || + invoke(setHas, visited, [owner]) + ) { + throw new TypeError("Sharp has an invalid prototype chain"); + } + invoke(setAdd, visited, [owner]); + const descriptor = getOwnPropertyDescriptor(owner, property); + if (descriptor !== undefined) { + if (!hasOwn(descriptor, "value") || typeof descriptor.value !== "function") { + throw new TypeError(`Sharp ${property} must be a data-property method`); + } + return descriptor.value as UnknownFunction; + } + owner = getPrototypeOf(owner); + depth++; + } + throw new TypeError(`Sharp does not implement ${property}()`); +} + +function snapshotVersions(value: unknown): Readonly> { + if (typeof value !== "object" || value === null || arrayIsArray(value)) { + throw new TypeError("Sharp versions must be an object"); + } + const descriptors = ownDescriptors(value, "Sharp versions"); + const versions: Record = createObject(null); + for (const property of ownKeys(descriptors)) { + if (typeof property !== "string") { + throw new TypeError("Sharp versions must not contain symbol properties"); + } + const version = readDataProperty(descriptors, property, "Sharp versions"); + if ( + typeof version !== "string" || version.length === 0 || + version.length > 64 + ) { + throw new TypeError(`Sharp reported an invalid ${property} version`); + } + defineProperty(versions, property, { + value: version, + enumerable: true, + configurable: false, + writable: false, + }); + } + return freeze(versions); +} + +function requirePipeline(value: unknown, operation: string): object { + if ((typeof value !== "object" && typeof value !== "function") || value === null) { + throw new TypeError(`Sharp ${operation} did not return an image pipeline`); + } + return value; +} + +/** Capture Sharp's callable, versions, and prototype methods exactly once. */ +export function captureSharpRuntime(value: unknown): CapturedSharpRuntime { + if (typeof value !== "function") { + throw new TypeError("Sharp must export an image constructor function"); + } + let prototypeDescriptor: PropertyDescriptor | undefined; + let versionsDescriptor: PropertyDescriptor | undefined; + try { + prototypeDescriptor = getOwnPropertyDescriptor(value, "prototype"); + versionsDescriptor = getOwnPropertyDescriptor(value, "versions"); + } catch (cause) { + throw new TypeError("Sharp export could not be inspected", { cause }); + } + if ( + prototypeDescriptor === undefined || + !hasOwn(prototypeDescriptor, "value") || + typeof prototypeDescriptor.value !== "object" || + prototypeDescriptor.value === null + ) { + throw new TypeError("Sharp prototype must be an own data property"); + } + if (versionsDescriptor === undefined || !hasOwn(versionsDescriptor, "value")) { + throw new TypeError("Sharp versions must be an own data property"); + } + + const callable = value as UnknownFunction; + const prototype = prototypeDescriptor.value as object; + const methods = freeze({ + autoOrient: findMethod(prototype, "autoOrient"), + metadata: findMethod(prototype, "metadata"), + clone: findMethod(prototype, "clone"), + resize: findMethod(prototype, "resize"), + webp: findMethod(prototype, "webp"), + avif: findMethod(prototype, "avif"), + jpeg: findMethod(prototype, "jpeg"), + png: findMethod(prototype, "png"), + toBuffer: findMethod(prototype, "toBuffer"), + }); + const versions = snapshotVersions(versionsDescriptor.value); + + return freeze({ + versions, + create( + input: Uint8Array, + options: Readonly>, + ): object { + return requirePipeline(invoke(callable, undefined, [input, options]), "constructor"); + }, + autoOrient(pipeline: object): object { + return requirePipeline(invoke(methods.autoOrient, pipeline, []), "autoOrient"); + }, + metadata(pipeline: object): unknown { + return invoke(methods.metadata, pipeline, []); + }, + clone(pipeline: object): object { + return requirePipeline(invoke(methods.clone, pipeline, []), "clone"); + }, + resize( + pipeline: object, + options: Readonly>, + ): object { + return requirePipeline(invoke(methods.resize, pipeline, [options]), "resize"); + }, + webp( + pipeline: object, + options: Readonly>, + ): object { + return requirePipeline(invoke(methods.webp, pipeline, [options]), "webp"); + }, + avif( + pipeline: object, + options: Readonly>, + ): object { + return requirePipeline(invoke(methods.avif, pipeline, [options]), "avif"); + }, + jpeg( + pipeline: object, + options: Readonly>, + ): object { + return requirePipeline(invoke(methods.jpeg, pipeline, [options]), "jpeg"); + }, + png( + pipeline: object, + options: Readonly>, + ): object { + return requirePipeline(invoke(methods.png, pipeline, [options]), "png"); + }, + toBuffer( + pipeline: object, + options: Readonly>, + ): unknown { + return invoke(methods.toBuffer, pipeline, [options]); + }, + }); +} + +function assertDimension( + value: unknown, + label: string, + limits: SharpImageLimits, +): asserts value is number { + if ( + !numberIsInteger(value) || + (value as number) <= 0 || + (value as number) > limits.maxDimension + ) { + throw new TypeError( + `${label} must be an integer from 1 through ${limits.maxDimension}`, + ); + } +} + +function readDimensions( + value: unknown, + label: string, + limits: SharpImageLimits, +): Dimensions { + if (typeof value !== "object" || value === null || arrayIsArray(value)) { + throw new TypeError(`${label} must be an object`); + } + const descriptors = ownDescriptors(value, label); + const width = readDataProperty(descriptors, "width", label); + const height = readDataProperty(descriptors, "height", label); + assertDimension(width, `${label} width`, limits); + assertDimension(height, `${label} height`, limits); + if (width * height > limits.maxDecodedPixels) { + throw new TypeError(`${label} exceeds ${limits.maxDecodedPixels} pixels`); + } + return freeze({ width, height }); +} + +function sourceDimensions( + value: unknown, + limits: SharpImageLimits, +): Dimensions { + if (typeof value !== "object" || value === null || arrayIsArray(value)) { + throw new TypeError("Sharp metadata must be an object"); + } + const descriptors = ownDescriptors(value, "Sharp metadata"); + const oriented = readOptionalDataProperty( + descriptors, + "autoOrient", + "Sharp metadata", + ); + if (oriented !== undefined) { + return readDimensions(oriented, "Auto-oriented image", limits); + } + return readDimensions(value, "Source image", limits); +} + +function outputWidths( + configured: readonly number[], + sourceWidth: number, + limits: SharpImageLimits, +): readonly number[] { + const widths: number[] = []; + for (let index = 0; index < configured.length; index++) { + const width = configured[index]!; + if (width <= sourceWidth) invoke(arrayPush, widths, [width]); + } + if (!invoke(arrayIncludes, widths, [sourceWidth])) { + invoke(arrayPush, widths, [sourceWidth]); + } + invoke(arraySort, widths, [sortNumbers]); + if (widths.length > limits.maxTargetWidths + 1) { + throw new TypeError( + `Image optimization produces more than ${limits.maxTargetWidths + 1} output widths`, + ); + } + return freeze(widths); +} + +function encode( + runtime: CapturedSharpRuntime, + pipeline: object, + format: ImageOptimizationFormat, + quality: number, +): object { + if (format === "webp") return runtime.webp(pipeline, freeze({ quality })); + if (format === "avif") return runtime.avif(pipeline, freeze({ quality })); + if (format === "jpeg") { + return runtime.jpeg(pipeline, freeze({ quality, progressive: true })); + } + return runtime.png( + pipeline, + freeze({ quality, compressionLevel: 9, adaptiveFiltering: true }), + ); +} + +function readOutput( + value: unknown, + format: ImageOptimizationFormat, + width: number, + sourceWidth: number, + sourceHeight: number, + aggregate: { value: number }, + limits: SharpImageLimits, +): ImageOptimizationVariantResult { + if (typeof value !== "object" || value === null || arrayIsArray(value)) { + throw new TypeError("Sharp output must be an object"); + } + const descriptors = ownDescriptors(value, "Sharp output"); + const dataValue = readDataProperty(descriptors, "data", "Sharp output"); + const infoValue = readDataProperty(descriptors, "info", "Sharp output"); + const dataLength = byteLength(dataValue, "Sharp output data"); + if (dataLength === 0) throw new TypeError("Sharp returned an empty image"); + if (dataLength > limits.maxOutputBytesPerVariant) { + throw new TypeError( + `Sharp output exceeds ${limits.maxOutputBytesPerVariant} bytes`, + ); + } + if (typeof infoValue !== "object" || infoValue === null || arrayIsArray(infoValue)) { + throw new TypeError("Sharp output info must be an object"); + } + const infoDescriptors = ownDescriptors(infoValue, "Sharp output info"); + const outputWidth = readDataProperty( + infoDescriptors, + "width", + "Sharp output info", + ); + const outputHeight = readDataProperty( + infoDescriptors, + "height", + "Sharp output info", + ); + const outputSize = readDataProperty( + infoDescriptors, + "size", + "Sharp output info", + ); + const outputFormat = readDataProperty( + infoDescriptors, + "format", + "Sharp output info", + ); + assertDimension(outputWidth, "Sharp output width", limits); + assertDimension(outputHeight, "Sharp output height", limits); + if (outputWidth !== width) { + throw new TypeError( + `Sharp encoded ${outputWidth}px for requested ${width}px output`, + ); + } + const expectedHeight = Math.max( + 1, + Math.round(sourceHeight * width / sourceWidth), + ); + if (outputHeight !== expectedHeight) { + throw new TypeError("Sharp did not preserve the source aspect ratio"); + } + if (!numberIsSafeInteger(outputSize) || outputSize !== dataLength) { + throw new TypeError("Sharp output size metadata does not match its bytes"); + } + if ( + outputFormat !== format && + !(format === "avif" && outputFormat === "heif") + ) { + throw new TypeError(`Sharp returned ${String(outputFormat)} for ${format} output`); + } + aggregate.value += dataLength; + if ( + !numberIsSafeInteger(aggregate.value) || + aggregate.value > limits.maxTotalOutputBytes + ) { + throw new TypeError( + `Sharp outputs exceed ${limits.maxTotalOutputBytes} bytes in total`, + ); + } + + return freeze({ + format, + width, + height: outputHeight, + data: copyBytes( + dataValue, + limits.maxOutputBytesPerVariant, + "Sharp output data", + ), + }); +} + +const SHARP_INPUT_OPTIONS = freeze({ + failOn: "warning", + limitInputPixels: SHARP_IMAGE_LIMITS.maxDecodedPixels, + pages: 1, + sequentialRead: true, +}); +const SHARP_OUTPUT_OPTIONS = freeze({ resolveWithObject: true }); + +/** + * Internal engine used by the public extension and focused fake-runtime tests. + * Every call snapshots one request and creates an independent Sharp pipeline. + */ +export class BoundSharpImageOptimizationEngine implements ImageOptimizationEngine { + readonly cacheIdentity!: string; + readonly #runtime: CapturedSharpRuntime; + readonly #limits: SharpImageLimits; + + constructor( + runtime: CapturedSharpRuntime, + cacheIdentity: string, + limits: SharpImageLimits = SHARP_IMAGE_LIMITS, + ) { + this.#runtime = runtime; + this.#limits = limits; + defineProperty(this, "cacheIdentity", { + value: cacheIdentity, + enumerable: true, + configurable: false, + writable: false, + }); + freeze(this); + } + + async optimize( + request: ImageOptimizationRequest, + ): Promise { + const snapshot = snapshotRequest(request, this.#limits); + throwIfAborted(snapshot.signal); + + let source = this.#runtime.create(snapshot.input, SHARP_INPUT_OPTIONS); + throwIfAborted(snapshot.signal); + source = this.#runtime.autoOrient(source); + throwIfAborted(snapshot.signal); + const metadata = await awaitNativeOperation( + this.#runtime.metadata(source), + snapshot.signal, + ); + const sourceSize = sourceDimensions(metadata, this.#limits); + const widths = outputWidths( + snapshot.targetWidths, + sourceSize.width, + this.#limits, + ); + if (widths.length * snapshot.formats.length > this.#limits.maxVariants) { + throw new TypeError( + `Image optimization produces more than ${this.#limits.maxVariants} variants`, + ); + } + + const variants: ImageOptimizationVariantResult[] = []; + const aggregate = { value: 0 }; + for (let widthIndex = 0; widthIndex < widths.length; widthIndex++) { + const width = widths[widthIndex]!; + for ( + let formatIndex = 0; + formatIndex < snapshot.formats.length; + formatIndex++ + ) { + const format = snapshot.formats[formatIndex]!; + throwIfAborted(snapshot.signal); + let pipeline = this.#runtime.clone(source); + pipeline = this.#runtime.resize( + pipeline, + freeze({ width, fit: "inside", withoutEnlargement: true }), + ); + pipeline = encode(this.#runtime, pipeline, format, snapshot.quality); + const output = await awaitNativeOperation( + this.#runtime.toBuffer(pipeline, SHARP_OUTPUT_OPTIONS), + snapshot.signal, + ); + throwIfAborted(snapshot.signal); + invoke(arrayPush, variants, [ + readOutput( + output, + format, + width, + sourceSize.width, + sourceSize.height, + aggregate, + this.#limits, + ), + ]); + } + } + + return freeze({ + sourceWidth: sourceSize.width, + sourceHeight: sourceSize.height, + variants: freeze(variants), + }); + } +} + +freeze(BoundSharpImageOptimizationEngine.prototype); +freeze(BoundSharpImageOptimizationEngine); diff --git a/scripts/build/compile-binary.ts b/scripts/build/compile-binary.ts index 9d73e6ba39..2c22ac8509 100644 --- a/scripts/build/compile-binary.ts +++ b/scripts/build/compile-binary.ts @@ -5,7 +5,6 @@ import { parseArgs } from "#std/flags"; import { fromFileUrl, isAbsolute, join } from "#std/path.ts"; -import { getBinaryPluginBundleIncludes } from "../../src/build/binary-plugin-includes.ts"; const PROJECT_ROOT = fromFileUrl(new URL("../..", import.meta.url)); export const DEFAULT_INCLUDES = [ @@ -21,11 +20,14 @@ export const DEFAULT_INCLUDES = [ "extensions/ext-bundler-esbuild/src/index.ts", "extensions/ext-cache-redis/src/index.ts", "extensions/ext-content-mdx/src/index.ts", + "extensions/ext-css-lightning/src/index.ts", + "extensions/ext-css-purgecss/src/index.ts", "extensions/ext-css-tailwind/src/index.ts", "extensions/ext-db-sqlite/src/index.ts", "extensions/ext-document-kreuzberg/src/index.ts", "extensions/ext-eval-report-http/src/index.ts", "extensions/ext-eval-report-mlflow/src/index.ts", + "extensions/ext-image-sharp/src/index.ts", "extensions/ext-observability-opentelemetry/src/index.ts", "extensions/ext-observability-sentry/src/index.ts", "extensions/ext-parser-babel/src/index.ts", @@ -54,7 +56,6 @@ export function createCompileArgs(options: CompileBinaryOptions): string[] { for (const include of [ ...DEFAULT_INCLUDES, - ...getBinaryPluginBundleIncludes(), ...options.extraIncludes, ]) { args.push("--include", include); diff --git a/scripts/build/npm-extension-package-metadata.test.ts b/scripts/build/npm-extension-package-metadata.test.ts index 86ef3beea1..1b09fdc8fb 100644 --- a/scripts/build/npm-extension-package-metadata.test.ts +++ b/scripts/build/npm-extension-package-metadata.test.ts @@ -51,6 +51,17 @@ describe("manifestDependencies", () => { assertEquals(manifest.veryfront?.npm?.nodeEngine, ">=20.0.0"); }); + it("pins the audit-clean Sharp release and its Node runtime floor", async () => { + const manifest = JSON.parse( + await Deno.readTextFile("extensions/ext-image-sharp/deno.json"), + ) as ExtensionManifest & { + veryfront?: { npm?: { nodeEngine?: string } }; + }; + + assertEquals(manifestDependencies(manifest), { sharp: "0.35.3" }); + assertEquals(manifest.veryfront?.npm?.nodeEngine, ">=20.9.0"); + }); + it("pins bash-tool's required AI SDK peer in the sandbox extension", async () => { const manifest = JSON.parse( await Deno.readTextFile( diff --git a/scripts/build/npm-package-metadata.ts b/scripts/build/npm-package-metadata.ts index 910eac1dd5..72c9507c94 100644 --- a/scripts/build/npm-package-metadata.ts +++ b/scripts/build/npm-package-metadata.ts @@ -60,9 +60,12 @@ export const EXTENSION_OWNED_DEPENDENCIES = [ "@opentelemetry/semantic-conventions", "@sentry/deno", "@sentry/node", + "@tailwindcss/forms", + "@tailwindcss/typography", "ai", "bash-tool", - "brace-expansion", + "browserslist", + "daisyui", "es-module-lexer", "jszip", "pdf-lib", @@ -70,12 +73,12 @@ export const EXTENSION_OWNED_DEPENDENCIES = [ "gaxios", "gcp-metadata", "github-slugger", - "gaxios", - "gcp-metadata", "jose", "just-bash", + "lightningcss", "mdast-util-to-string", "protobufjs", + "purgecss", "rehype-highlight", "rehype-raw", "rehype-sanitize", @@ -86,8 +89,10 @@ export const EXTENSION_OWNED_DEPENDENCIES = [ "remark-gfm", "remark-parse", "remark-rehype", - "protobufjs", + "sharp", + "tailwind-scrollbar-hide", "tailwindcss", + "tailwindcss-animate", "unified", "unist-util-visit", "vfile", diff --git a/scripts/lint/audit-extension-capabilities.test.ts b/scripts/lint/audit-extension-capabilities.test.ts index a4d86dc5b9..b1523721b8 100644 --- a/scripts/lint/audit-extension-capabilities.test.ts +++ b/scripts/lint/audit-extension-capabilities.test.ts @@ -67,6 +67,30 @@ describe("auditExtensionCapabilities", () => { ]); }); + it("requires the bounded Sharp native runtime capabilities", () => { + const capabilities = [ + { + type: "fs:read", + paths: ["/proc/self/exe", "/usr/bin/ldd"], + }, + { + type: "env:read", + keys: ["MALLOC_ARENA_MAX", "npm_package_config_libvips"], + }, + ]; + const issues = auditExtensionCapabilities([ + input({ + manifestPath: "extensions/ext-image-sharp/deno.json", + manifestCapabilities: capabilities, + factoryCapabilities: capabilities, + }), + ]); + + assertEquals(issues.map((issue) => issue.message), [ + 'extensions/ext-image-sharp/deno.json sensitive extension "native image optimization" is missing capability {"type":"native:ffi"}', + ]); + }); + it("requires MLflow export capabilities and forbids the exporter-id env key", () => { const manifestPath = "extensions/ext-eval-report-mlflow/deno.json"; const allowedCapabilities = [ diff --git a/scripts/lint/audit-extension-capabilities.ts b/scripts/lint/audit-extension-capabilities.ts index 9f03672b58..fcbd7e28b8 100644 --- a/scripts/lint/audit-extension-capabilities.ts +++ b/scripts/lint/audit-extension-capabilities.ts @@ -51,6 +51,21 @@ export const SENSITIVE_EXTENSION_CAPABILITY_POLICIES: manifestPath: "extensions/ext-document-kreuzberg/deno.json", requiredCapabilities: [{ type: "fs:read" }], }, + { + label: "native image optimization", + manifestPath: "extensions/ext-image-sharp/deno.json", + requiredCapabilities: [ + { + type: "fs:read", + paths: ["/proc/self/exe", "/usr/bin/ldd"], + }, + { + type: "env:read", + keys: ["MALLOC_ARENA_MAX", "npm_package_config_libvips"], + }, + { type: "native:ffi" }, + ], + }, { label: "OpenTelemetry observability", manifestPath: "extensions/ext-observability-opentelemetry/deno.json", diff --git a/scripts/lint/audit-extension-contracts.test.ts b/scripts/lint/audit-extension-contracts.test.ts index 6398fb9ce3..f4c1341aec 100644 --- a/scripts/lint/audit-extension-contracts.test.ts +++ b/scripts/lint/audit-extension-contracts.test.ts @@ -112,6 +112,7 @@ describe("extractExtensionSourceMetadata contracts", () => { it("resolves known exported contract name constants in contract arrays", () => { const metadata = extractExtensionSourceMetadata(` import { + CSSProcessorName, LLMProviderRegistryName, SandboxShellToolsProviderName, } from "veryfront/extensions/sandbox"; @@ -121,6 +122,7 @@ describe("extractExtensionSourceMetadata contracts", () => { const ext = () => ({ contracts: { provides: [ + CSSProcessorName, NodeWebSocketServerProviderName, SandboxShellToolsProviderName, ], @@ -131,6 +133,7 @@ describe("extractExtensionSourceMetadata contracts", () => { `); assertEquals(metadata.contracts?.provides, [ + "CSSProcessor", "NodeWebSocketServerProvider", "SandboxShellToolsProvider", ]); diff --git a/scripts/lint/extension-source-metadata.ts b/scripts/lint/extension-source-metadata.ts index 8866f4b234..df1280307a 100644 --- a/scripts/lint/extension-source-metadata.ts +++ b/scripts/lint/extension-source-metadata.ts @@ -12,6 +12,7 @@ export interface ExtensionSourceMetadata { } const KNOWN_CONTRACT_CONSTANTS: Record = { + CSSProcessorName: "CSSProcessor", LLMProviderRegistryName: "LLMProviderRegistry", NodeWebSocketServerProviderName: "NodeWebSocketServerProvider", SandboxShellToolsProviderName: "SandboxShellToolsProvider", diff --git a/scripts/lint/lint-platform-agnostic.ts b/scripts/lint/lint-platform-agnostic.ts index ab3c3cbb3f..788d60b3b2 100644 --- a/scripts/lint/lint-platform-agnostic.ts +++ b/scripts/lint/lint-platform-agnostic.ts @@ -169,8 +169,6 @@ const EXCEPTIONS: Record = { "src/extensions/discovery.ts": ["Deno.readTextFile()"], // Studio bridge handler reads TypeScript source for on-the-fly bundling "src/server/handlers/studio/bridge-modules.handler.ts": ["Deno.readTextFile()"], - // Plugin loader writes temp files for dynamic import in compiled binaries - "src/html/styles-builder/plugin-loader.ts": ["Deno.writeTextFile()"], // JSDoc example references Deno.cwd() — not runtime code "src/extensions/index.ts": ["Deno.cwd()"], // JSDoc example references process.env — not runtime code diff --git a/src/build/README.md b/src/build/README.md index 3a37849f27..3686fa731d 100644 --- a/src/build/README.md +++ b/src/build/README.md @@ -10,9 +10,9 @@ The build module is Veryfront's comprehensive build system, responsible for tran - MDX compilation to React components - JavaScript/TypeScript bundling and code splitting -- CSS optimization with Lightning CSS -- Image optimization with Sharp -- Tailwind CSS processing +- CSS optimization through an explicitly composed provider +- Image optimization through an explicitly composed provider +- Tailwind CSS processing through an explicitly composed provider - Static site generation (SSG) - Asset pipeline orchestration - Production build optimization @@ -28,9 +28,9 @@ The build module is Veryfront's comprehensive build system, responsible for tran ``` build/ ├── asset-pipeline/ # Image/CSS optimization -│ ├── image-optimizer/ # Sharp integration -│ ├── css-optimizer/ # Lightning CSS -│ └── tailwind-processor/ # Tailwind processing +│ ├── image-optimizer/ # Provider-neutral image optimization +│ ├── css-optimizer/ # Provider-neutral CSS optimization +│ └── tailwind-processor/ # Provider-neutral Tailwind orchestration ├── compiler/ # MDX → React compilation │ ├── mdx-compiler/ # MDX processor │ └── mdx-to-js.ts # JavaScript output @@ -77,8 +77,10 @@ DX(source, options)` - MDX compilation ### External - `esbuild` - JavaScript bundling -- `sharp` (optional) - Image optimization -- `lightningcss` (optional) - CSS optimization +- `@veryfront/ext-image-sharp` (optional) - Explicit image optimization provider +- `@veryfront/ext-css-lightning` (optional) - Explicit Lightning CSS provider +- `@veryfront/ext-css-purgecss` (optional) - Explicit CSS purging provider +- `@veryfront/ext-css-tailwind` (optional) - Explicit Tailwind CSS provider - `@mdx-js/mdx` - MDX compilation ## Usage Examples @@ -117,6 +119,14 @@ console.log(`Generated ${result.staticPages.length} static pages`) ### Asset Optimization +Compose the provider extensions for every enabled stage during application +setup. See +[`@veryfront/ext-image-sharp`](../../extensions/ext-image-sharp/README.md), +[`@veryfront/ext-css-lightning`](../../extensions/ext-css-lightning/README.md), +and [`@veryfront/ext-css-tailwind`](../../extensions/ext-css-tailwind/README.md). +Absent stages are skipped; a requested stage fails the build if its provider or +processing fails. + ```typescript import { runAssetPipeline } from "./build/asset-pipeline"; @@ -129,7 +139,6 @@ const result = await runAssetPipeline({ css: { enabled: true, minify: true, - autoprefixer: true, }, tailwind: { enabled: true, @@ -177,7 +186,6 @@ export default { }, css: { minify: true, - autoprefixer: true, }, }, splitting: { @@ -190,6 +198,26 @@ export default { }; ``` +Browser targets belong to the explicit Lightning CSS extension, not the core +asset-pipeline configuration. Configure `browserQueries` when composing +`@veryfront/ext-css-lightning`; requested optimization fails closed when no +`CSSOptimizationEngine` provider is registered. + +### Provider status fields + +`checkAssetPipelineDependencies()` returns provider-neutral capability status: + +```typescript +{ + imageOptimization: boolean; + cssOptimization: boolean; +} +``` + +The former `sharp` and `lightningCSS` properties are removed. Consumers of the +status helper must read `imageOptimization` and `cssOptimization`; the helper +does not report package discovery because core validates registered contracts. + ## Performance ### Build Times (Typical Project) @@ -247,8 +275,9 @@ NODE_OPTIONS="--max-old-space-size=4096" deno task build ### Asset Optimization Failures -- Check Sharp installation: `npm ls sharp` -- Check Lightning CSS: `npm ls lightningcss` +- Ensure `@veryfront/ext-image-sharp` is installed and explicitly composed +- Ensure `@veryfront/ext-css-lightning` is installed and explicitly composed +- Ensure `@veryfront/ext-css-tailwind` is installed and explicitly composed - Disable optional optimizers if needed ## References diff --git a/src/build/asset-pipeline/css-optimizer.test.ts b/src/build/asset-pipeline/css-optimizer.test.ts index 716eb4cb1e..2bfaeeaf5a 100644 --- a/src/build/asset-pipeline/css-optimizer.test.ts +++ b/src/build/asset-pipeline/css-optimizer.test.ts @@ -2,7 +2,17 @@ import "#veryfront/schemas/_test-setup.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 { + createTestCSSPurgingEngine, + withTestCSSPurgingEngine, +} from "../../../tests/_helpers/css-purging-engine.ts"; import { CSSOptimizer, loadCSSManifest, optimizeCSS } from "./css-optimizer/index.ts"; +import { LightningCSSOptimizationEngine } from "../../../extensions/ext-css-lightning/src/index.ts"; + +const optimizationEngine = new LightningCSSOptimizationEngine(); +const legacyOptimizationEngine = new LightningCSSOptimizationEngine({ + browserQueries: ["ie 11"], +}); async function withProject( callback: (projectDir: string) => Promise, @@ -36,11 +46,19 @@ describe("build/asset-pipeline/CSSOptimizer", () => { it("initializes the required compiler and honors disabled mode", async () => { await withProject(async (projectDir) => { assertEquals( - await new CSSOptimizer({ projectDir, enabled: true }).init(), + await new CSSOptimizer( + { projectDir, enabled: true }, + undefined, + { optimizationEngine }, + ).init(), true, ); assertEquals( - (await new CSSOptimizer({ projectDir, enabled: false }).optimize()).size, + (await new CSSOptimizer( + { projectDir, enabled: false }, + undefined, + { optimizationEngine }, + ).optimize()).size, 0, ); }); @@ -50,11 +68,15 @@ describe("build/asset-pipeline/CSSOptimizer", () => { 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", - }); + const optimizer = new CSSOptimizer( + { + projectDir, + inputDir: "styles", + outputDir: ".veryfront/css", + }, + undefined, + { optimizationEngine }, + ); const bundle = (await optimizer.optimize()).get("main.css")!; assertEquals(bundle.content, ".button{color:red;padding:1rem}"); @@ -67,7 +89,7 @@ describe("build/asset-pipeline/CSSOptimizer", () => { }); }); - it("uses Browserslist targets only when prefixing is enabled", async () => { + it("takes browser compatibility from the explicit provider", async () => { await withProject(async (projectDir) => { await Deno.writeTextFile( join(projectDir, "styles/main.css"), @@ -77,24 +99,20 @@ describe("build/asset-pipeline/CSSOptimizer", () => { 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, - }); + }, { optimizationEngine: legacyOptimizationEngine }); assertEquals( prefixed.get("main.css")!.content.includes("-ms-user-select"), true, ); - assertEquals( - unprefixed.get("main.css")!.content.includes("-ms-user-select"), - false, + assertThrows( + () => + new CSSOptimizer({ + projectDir, + browsers: ["ie 11"], + }), + TypeError, + "moved to the optimization extension", ); }); }); @@ -105,12 +123,16 @@ describe("build/asset-pipeline/CSSOptimizer", () => { join(projectDir, "styles/main.css"), ".main { color: red; }", ); - const optimizer = new CSSOptimizer({ - projectDir, - inputDir: "styles", - outputDir: ".veryfront/css", - sourceMap: true, - }); + const optimizer = new CSSOptimizer( + { + projectDir, + inputDir: "styles", + outputDir: ".veryfront/css", + sourceMap: true, + }, + undefined, + { optimizationEngine }, + ); const bundle = (await optimizer.optimize()).get("main.css")!; assertEquals( bundle.content.includes("sourceMappingURL=main.min.css.map"), @@ -130,7 +152,7 @@ describe("build/asset-pipeline/CSSOptimizer", () => { }); }); - it("extracts critical and remaining CSS with parser-backed rules", async () => { + it("extracts critical and remaining CSS through the configured provider", async () => { await withProject(async (projectDir) => { const cssPath = join(projectDir, "styles/main.css"); await Deno.writeTextFile( @@ -139,10 +161,24 @@ describe("build/asset-pipeline/CSSOptimizer", () => { ".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
', + const result = await withTestCSSPurgingEngine( + createTestCSSPurgingEngine(() => + Promise.resolve({ + css: ".header { color: red; } " + + "@media (min-width: 40rem) { .header { display: flex; } }", + rejectedCSS: ".footer { color: blue; } " + + "@media (min-width: 40rem) { .aside { display: block; } }", + }) + ), + () => + new CSSOptimizer( + { projectDir, minify: true }, + undefined, + { optimizationEngine }, + ).extractCriticalCSS( + cssPath, + '
Title
', + ), ); assertEquals(result.critical.includes(".header"), true); @@ -167,7 +203,7 @@ describe("build/asset-pipeline/CSSOptimizer", () => { inputDir: "styles", inputFiles: ["standalone.css"], outputDir: ".veryfront/css", - }); + }, { optimizationEngine }); assertEquals(bundles.has("standalone.css"), true); assertEquals( @@ -189,7 +225,7 @@ describe("build/asset-pipeline/CSSOptimizer", () => { projectDir, inputDir: "styles", outputDir: ".veryfront/css", - }); + }, { optimizationEngine }); const manifest = await loadCSSManifest( join(projectDir, ".veryfront/css"), diff --git a/src/build/asset-pipeline/css-optimizer/ARCHITECTURE.md b/src/build/asset-pipeline/css-optimizer/ARCHITECTURE.md index 2e85281fd6..31f0f8f3dc 100644 --- a/src/build/asset-pipeline/css-optimizer/ARCHITECTURE.md +++ b/src/build/asset-pipeline/css-optimizer/ARCHITECTURE.md @@ -2,8 +2,9 @@ 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). +[`types/index.ts`](./types/index.ts), while core resource bounds remain defined +in [`constants.ts`](./constants.ts). Third-party implementation versions belong +to their independently published extension manifests. ## Contract @@ -14,10 +15,10 @@ 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. +3. If enabled, remove unused rules through one captured `CSSPurgingEngine` + session using validated project content. +4. Transform every stylesheet through one captured `CSSOptimizationEngine` + session, including 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 @@ -30,16 +31,25 @@ 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. +alternative strategies. The purging provider must see the uncompiled rule +structure and content evidence first. The configured optimization provider +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. + +Core owns the dependency-free `CSSOptimizationEngine` boundary. Parser, +minifier, browser-target, and vendor details belong to an explicitly composed +extension such as `@veryfront/ext-css-lightning`. The provider is captured once +per optimization operation, so registry replacement cannot change semantics +mid-publication. Its immutable `cacheIdentity` is captured with the runner so +derived caches never need to reread mutable provider state. + +Requested optimization rejects when its provider is missing, malformed, or +fails. A purging provider is independently required when purging or +critical-CSS extraction is requested. The recommended implementations are +`@veryfront/ext-css-lightning` and `@veryfront/ext-css-purgecss`; core never +imports or probes either package. There is no CDN import, regex, no-op, network, +workspace, or partial-success fallback. ## Filesystem and publication safety @@ -72,16 +82,17 @@ Purge output cannot currently be composed with a trustworthy source map, so 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. +requires the purging engine to return both retained and rejected parser-backed +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. +Configuration errors, unsafe paths, missing inputs, invalid CSS, provider +failures, malformed or unrequested source maps, exhausted resource bounds, +write failures, and manifest failures all reject the run. Provider requests and +results cross a descriptor-safe boundary and are snapshotted before use. 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 diff --git a/src/build/asset-pipeline/css-optimizer/constants.ts b/src/build/asset-pipeline/css-optimizer/constants.ts index e4bc2b894d..d1dbd34f98 100644 --- a/src/build/asset-pipeline/css-optimizer/constants.ts +++ b/src/build/asset-pipeline/css-optimizer/constants.ts @@ -1,9 +1,6 @@ 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; @@ -15,24 +12,20 @@ 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" + "projectDir" | "autoprefixer" | "browsers" > = { 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}", diff --git a/src/build/asset-pipeline/css-optimizer/critical-css.test.ts b/src/build/asset-pipeline/css-optimizer/critical-css.test.ts index 629fef725b..8e16b18e17 100644 --- a/src/build/asset-pipeline/css-optimizer/critical-css.test.ts +++ b/src/build/asset-pipeline/css-optimizer/critical-css.test.ts @@ -1,7 +1,25 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { createTestCSSOptimizationEngine } from "../../../../tests/_helpers/css-optimization-engine.ts"; +import { createTestCSSPurgingEngine } from "../../../../tests/_helpers/css-purging-engine.ts"; import { extractCriticalCSS } from "./critical-css.ts"; +import { createCSSPurgingSession } from "./purging-engine.ts"; + +function purgingSession(critical?: string, remaining = "") { + return createCSSPurgingSession( + createTestCSSPurgingEngine((request) => + Promise.resolve({ + css: critical ?? request.css, + rejectedCSS: remaining, + }) + ), + ); +} + +const optimizationEngine = createTestCSSOptimizationEngine((request) => ({ + css: request.css.replaceAll(" ", ""), +})); describe("build/asset-pipeline/css-optimizer/critical-css", () => { describe("extractCriticalCSS", () => { @@ -15,7 +33,17 @@ describe("build/asset-pipeline/css-optimizer/critical-css", () => { try { const html = `

Hello

`; - const result = await extractCriticalCSS(cssPath, html, { minify: false }); + const result = await extractCriticalCSS( + cssPath, + html, + { minify: false }, + { + purgingSession: purgingSession( + ".header { color: red; }", + ".footer { color: blue; } .sidebar { color: green; }", + ), + }, + ); assertExists(result.critical); assertExists(result.remaining); @@ -34,7 +62,12 @@ describe("build/asset-pipeline/css-optimizer/critical-css", () => { try { const html = `
Hi
`; - const result = await extractCriticalCSS(cssPath, html, { minify: true }); + const result = await extractCriticalCSS( + cssPath, + html, + { minify: true }, + { optimizationEngine, purgingSession: purgingSession() }, + ); // Minified result should have less whitespace assertExists(result.critical); @@ -52,7 +85,12 @@ describe("build/asset-pipeline/css-optimizer/critical-css", () => { try { const html = `
Test
`; - const result = await extractCriticalCSS(cssPath, html, {}); + const result = await extractCriticalCSS( + cssPath, + html, + {}, + { optimizationEngine, purgingSession: purgingSession() }, + ); // Should not throw, minify defaults to true assertExists(result.critical); @@ -67,7 +105,12 @@ describe("build/asset-pipeline/css-optimizer/critical-css", () => { await Deno.writeTextFile(cssPath, ""); try { - const result = await extractCriticalCSS(cssPath, "
hi
", { minify: false }); + const result = await extractCriticalCSS( + cssPath, + "
hi
", + { minify: false }, + { purgingSession: purgingSession() }, + ); assertEquals(result.criticalSize, 0); assertEquals(result.remainingSize, 0); } finally { @@ -86,7 +129,12 @@ h1 { font-size: 32px; }`, try { const html = `

Hello

`; - const result = await extractCriticalCSS(cssPath, html, { minify: false }); + const result = await extractCriticalCSS( + cssPath, + html, + { minify: false }, + { purgingSession: purgingSession() }, + ); assertEquals(result.critical.includes("p"), true); } finally { await Deno.remove(tmpDir, { recursive: true }); @@ -101,12 +149,45 @@ h1 { font-size: 32px; }`, try { const html = `
test
`; - const result = await extractCriticalCSS(cssPath, html, { minify: false }); + const result = await extractCriticalCSS( + cssPath, + html, + { minify: false }, + { + purgingSession: purgingSession( + ".crit { color: red; }", + ".noncrit { color: blue; }", + ), + }, + ); assertEquals(result.criticalSize > 0, true); assertEquals(result.remainingSize > 0, true); } finally { await Deno.remove(tmpDir, { recursive: true }); } }); + + it("does not invoke custom safelist iterators", async () => { + let iteratorCalls = 0; + const safelist = ["dynamic"]; + Object.defineProperty(safelist, Symbol.iterator, { + get() { + iteratorCalls++; + return Array.prototype[Symbol.iterator]; + }, + }); + await assertRejects( + () => + extractCriticalCSS( + "style.css", + "
", + { purgeSafelist: safelist }, + { optimizationEngine, purgingSession: purgingSession() }, + ), + TypeError, + "dense data-property array", + ); + assertEquals(iteratorCalls, 0); + }); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/critical-css.ts b/src/build/asset-pipeline/css-optimizer/critical-css.ts index c83aff2525..d366302fda 100644 --- a/src/build/asset-pipeline/css-optimizer/critical-css.ts +++ b/src/build/asset-pipeline/css-optimizer/critical-css.ts @@ -1,4 +1,4 @@ -import { DEPENDENCY_MISSING } from "#veryfront/errors"; +import type { CSSOptimizationEngine } from "#veryfront/extensions/css/index.ts"; import { createFileSystem, isNotFoundError } from "#veryfront/platform/compat/fs.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import { logger } from "#veryfront/utils"; @@ -10,58 +10,69 @@ import { MAX_CSS_PURGE_SAFELIST_ENTRIES, MAX_CSS_SELECTOR_TOKEN_CHARACTERS, MAX_CSS_TOTAL_OUTPUT_BYTES, - PURGE_CSS_MODULE_SPECIFIER, } from "./constants.ts"; +import { snapshotDenseDataArray } from "./data-snapshot.ts"; +import { + acquireConfiguredCSSPurging, + assertCSSPurgingSession, + type CSSPurgingSession, +} from "./purging-engine.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; - }; -} +import { + acquireConfiguredCSSOptimization, + createCSSOptimizationSession, +} from "./optimization-engine.ts"; const encoder = new TextEncoder(); -let purgeCSSModule: Promise | null = null; -function safelistToken(value: string): string { - return value.startsWith(".") || value.startsWith("#") ? value.slice(1) : value; +/** Explicit provider dependencies for one critical-CSS extraction. */ +export interface CriticalCSSDependencies { + readonly optimizationEngine?: CSSOptimizationEngine; + readonly purgingSession?: CSSPurgingSession; } -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; - }, - ); +function snapshotDependencies( + value: CriticalCSSDependencies, +): CriticalCSSDependencies { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError("Critical CSS dependencies must be an object"); + } + let descriptors: PropertyDescriptorMap; 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, + descriptors = Object.getOwnPropertyDescriptors(value); + } catch (cause) { + throw new TypeError("Critical CSS dependencies could not be inspected", { + cause, }); } + for (const key of Reflect.ownKeys(descriptors)) { + if (key !== "optimizationEngine" && key !== "purgingSession") { + throw new TypeError("Critical CSS dependencies contain unsupported properties"); + } + } + const read = (key: "optimizationEngine" | "purgingSession"): unknown => { + const descriptor = descriptors[key]; + if (descriptor === undefined) return undefined; + if (!Object.hasOwn(descriptor, "value")) { + throw new TypeError(`Critical CSS dependency ${key} must be a data property`); + } + return descriptor.value; + }; + return Object.freeze({ + optimizationEngine: read("optimizationEngine") as CSSOptimizationEngine | undefined, + purgingSession: read("purgingSession") as CSSPurgingSession | undefined, + }); +} + +function safelistToken(value: string): string { + return value.startsWith(".") || value.startsWith("#") ? value.slice(1) : value; } export function extractCriticalCSS( cssPath: string, htmlContent: string, options: CSSOptimizationOptions, + dependencies: CriticalCSSDependencies = {}, ): Promise { if ( typeof options !== "object" || @@ -82,6 +93,16 @@ export function extractCriticalCSS( throw new TypeError("Critical CSS minify must be a boolean"); } const shouldMinify = options.minify ?? true; + const capturedDependencies = snapshotDependencies(dependencies); + const optimizationSession = shouldMinify + ? (capturedDependencies.optimizationEngine === undefined + ? acquireConfiguredCSSOptimization() + : createCSSOptimizationSession(capturedDependencies.optimizationEngine)) + : undefined; + if (capturedDependencies.purgingSession !== undefined) { + assertCSSPurgingSession(capturedDependencies.purgingSession); + } + const purgingSession = capturedDependencies.purgingSession ?? acquireConfiguredCSSPurging(); return withSpan( "build.asset.extractCriticalCSS", @@ -94,16 +115,14 @@ export function extractCriticalCSS( `Critical CSS HTML content exceeds ${MAX_CSS_FILE_BYTES} bytes`, ); } + const safelistValues = options.purgeSafelist === undefined ? [] : snapshotDenseDataArray( + options.purgeSafelist, + MAX_CSS_PURGE_SAFELIST_ENTRIES, + "Critical CSS safelist", + ); 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) => + safelistValues.length > MAX_CSS_PURGE_SAFELIST_ENTRIES || + safelistValues.some((entry) => typeof entry !== "string" || entry.length === 0 || entry.length > MAX_CSS_SELECTOR_TOKEN_CHARACTERS || @@ -115,6 +134,7 @@ export function extractCriticalCSS( `Critical CSS safelist must contain at most ${MAX_CSS_PURGE_SAFELIST_ENTRIES} non-empty strings`, ); } + const safelist = safelistValues as string[]; const safelistTokens = safelist.map(safelistToken); if ( safelistTokens.some((entry) => @@ -159,25 +179,30 @@ export function extractCriticalCSS( ); } - const module = await loadPurgeCSS(); - const results = await new module.PurgeCSS().purge({ + const result = await purgingSession.run({ content: [{ raw: htmlContent, extension: "html" }], - css: [{ raw: css }], - rejectedCss: true, + css, + includeRejectedCSS: 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"); + if (result.rejectedCSS === undefined) { + throw new TypeError("CSS purging engine omitted rejected CSS"); } - const critical = shouldMinify ? basicMinify(result.css) : result.css; - const remaining = shouldMinify ? basicMinify(result.rejectedCss) : result.rejectedCss; + const critical = optimizationSession === undefined ? result.css : optimizationSession.run({ + css: result.css, + sourcePath: "critical.css", + minify: true, + sourceMap: false, + }).css; + const remaining = optimizationSession === undefined + ? result.rejectedCSS + : optimizationSession.run({ + css: result.rejectedCSS, + sourcePath: "remaining.css", + minify: true, + sourceMap: false, + }).css; const criticalSize = encoder.encode(critical).length; const remainingSize = encoder.encode(remaining).length; if ( 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 1e568f3b14..c96c2564aa 100644 --- a/src/build/asset-pipeline/css-optimizer/css-bundle-cache.ts +++ b/src/build/asset-pipeline/css-optimizer/css-bundle-cache.ts @@ -14,12 +14,8 @@ import { MAX_CSS_TOTAL_OUTPUT_BYTES, } from "./constants.ts"; import type { CSSBundle, CSSOptimizerStats } from "./types/index.ts"; -import { - calculateSavings, - getOutputPath, - isSafeCSSRelativePath, - validateCSSSourceMap, -} from "./utils.ts"; +import { validateCSSSourceMap } from "./optimization-engine.ts"; +import { calculateSavings, getOutputPath, isSafeCSSRelativePath } from "./utils.ts"; const encoder = new TextEncoder(); diff --git a/src/build/asset-pipeline/css-optimizer/data-snapshot.ts b/src/build/asset-pipeline/css-optimizer/data-snapshot.ts new file mode 100644 index 0000000000..0c693fb726 --- /dev/null +++ b/src/build/asset-pipeline/css-optimizer/data-snapshot.ts @@ -0,0 +1,141 @@ +/** Descriptor-only snapshots for values accepted by CSS optimizer boundaries. */ + +const arrayIsArray = Array.isArray; +const apply = Reflect.apply; +const executeRegularExpression = RegExp.prototype.exec; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const hasOwn = Object.hasOwn; +const isSafeInteger = Number.isSafeInteger; +const ownKeys = Reflect.ownKeys; +const ARRAY_INDEX_PATTERN = /^(?:0|[1-9]\d*)$/; + +function matches(pattern: RegExp, value: string): boolean { + return apply(executeRegularExpression, pattern, [value]) !== null; +} + +/** Brand-check an array without consulting its iterator or property getters. */ +export function isArrayValue(value: unknown, label: string): value is unknown[] { + try { + return arrayIsArray(value); + } catch (cause) { + throw new TypeError(`${label} could not be inspected`, { cause }); + } +} + +/** Capture every own descriptor without reading property values through accessors. */ +export function inspectOwnProperties( + value: object, + label: string, +): PropertyDescriptorMap { + try { + return getOwnPropertyDescriptors(value); + } catch (cause) { + throw new TypeError(`${label} properties could not be inspected`, { cause }); + } +} + +/** Retrieve one descriptor from a trusted descriptor snapshot. */ +export function inspectedProperty( + values: PropertyDescriptorMap, + key: PropertyKey, +): PropertyDescriptor | undefined { + return getOwnPropertyDescriptor(values, key)?.value as + | PropertyDescriptor + | undefined; +} + +/** Read a required own data property from a descriptor snapshot. */ +export function readOwnDataProperty( + values: PropertyDescriptorMap, + key: string, + label: string, + enumerable?: boolean, +): unknown { + const property = inspectedProperty(values, key); + if (property === undefined) { + throw new TypeError(`${label} must define ${key}`); + } + if (!hasOwn(property, "value")) { + throw new TypeError(`${label} ${key} must be an own data property`); + } + if (enumerable !== undefined && property.enumerable !== enumerable) { + throw new TypeError( + `${label} ${key} must be ${ + enumerable ? "an enumerable" : "a non-enumerable" + } own data property`, + ); + } + return property.value; +} + +/** Reject symbol and string properties outside a boundary's explicit schema. */ +export function rejectUnknownOwnProperties( + values: PropertyDescriptorMap, + allowed: readonly PropertyKey[], + label: string, +): void { + const keys = ownKeys(values); + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + const key = keys[keyIndex]!; + let isAllowed = false; + for (let allowedIndex = 0; allowedIndex < allowed.length; allowedIndex++) { + if (allowed[allowedIndex] === key) { + isAllowed = true; + break; + } + } + if (!isAllowed) { + throw new TypeError(`${label} contains unsupported properties`); + } + } +} + +/** + * Snapshot a bounded dense array using only own data descriptors. Custom + * iterators, sparse indices, accessors, symbols, and expando properties fail + * closed. + */ +export function snapshotDenseDataArray( + value: unknown, + maximum: number, + label: string, +): unknown[] { + if (!isArrayValue(value, label)) { + throw new TypeError(`${label} must be an array`); + } + const values = inspectOwnProperties(value, label); + const length = readOwnDataProperty(values, "length", label, false); + if ( + !isSafeInteger(length) || + (length as number) < 0 || + (length as number) > maximum + ) { + throw new TypeError(`${label} exceeds ${maximum} entries`); + } + + const keys = ownKeys(values); + for (let index = 0; index < keys.length; index++) { + const key = keys[index]!; + if ( + typeof key !== "string" || + (key !== "length" && !matches(ARRAY_INDEX_PATTERN, key)) + ) { + throw new TypeError(`${label} must be a dense data-property array`); + } + } + + const result: unknown[] = []; + for (let index = 0; index < (length as number); index++) { + result[index] = readOwnDataProperty( + values, + String(index), + label, + true, + ); + } + if (keys.length !== (length as number) + 1) { + throw new TypeError(`${label} must be a dense data-property array`); + } + return result; +} diff --git a/src/build/asset-pipeline/css-optimizer/index.ts b/src/build/asset-pipeline/css-optimizer/index.ts index 6a19cbdb55..78a82f1547 100644 --- a/src/build/asset-pipeline/css-optimizer/index.ts +++ b/src/build/asset-pipeline/css-optimizer/index.ts @@ -5,23 +5,20 @@ */ export type { - BrowserTargets, CriticalCSSResult, CSSBundle, CSSOptimizationOptions, CSSOptimizationStrategy, CSSOptimizerStats, CSSProcessingResult, - LightningCSSModule, - LightningCSSTransformOptions, - LightningCSSTransformResult, SelectorExtractionResult, } from "./types/index.ts"; export { CSSOptimizerService } from "./optimizer-service.ts"; export { CacheManager, loadCSSManifest } from "./css-bundle-cache.ts"; export { extractCriticalCSS } from "./critical-css.ts"; -export { LightningCSSStrategy, MinificationStrategy, PurgeStrategy } from "./strategies/index.ts"; +export type { CriticalCSSDependencies } from "./critical-css.ts"; +export { MinificationStrategy, PurgeStrategy } from "./strategies/index.ts"; export * as CSSUtils from "./utils.ts"; import type { @@ -31,20 +28,31 @@ import type { CSSOptimizerStats, } from "./types/index.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import type { CSSOptimizationEngine } from "#veryfront/extensions/css/index.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"; +/** Explicit provider dependency for direct CSS optimizer composition. */ +export interface CSSOptimizerDependencies { + optimizationEngine?: CSSOptimizationEngine; +} + export class CSSOptimizer { private service: CSSOptimizerService | null = null; private serviceInitialization: Promise | null = null; private adapter: RuntimeAdapter | null = null; private readonly baseDir: string; private readonly options: CSSOptimizationOptions; + private readonly optimizationEngine: CSSOptimizationEngine | undefined; - constructor(options: CSSOptimizationOptions = {}, baseDir?: string) { + constructor( + options: CSSOptimizationOptions = {}, + baseDir?: string, + dependencies: CSSOptimizerDependencies = {}, + ) { if ( typeof options !== "object" || options === null || @@ -52,10 +60,16 @@ export class CSSOptimizer { ) { 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"); + } for ( const [name, value] of [ ["inputFiles", options.inputFiles], - ["browsers", options.browsers], ["purgeContent", options.purgeContent], ["purgeSafelist", options.purgeSafelist], ] as const @@ -64,14 +78,22 @@ export class CSSOptimizer { throw new TypeError(`CSS ${name} must be an array`); } } + if ( + Object.hasOwn(options, "autoprefixer") || + Object.hasOwn(options, "browsers") + ) { + throw new TypeError( + "CSS autoprefixer/browsers options moved to the optimization extension; configure its browserQueries instead", + ); + } 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(); + this.optimizationEngine = dependencies.optimizationEngine; } private async ensureService(): Promise { @@ -84,7 +106,10 @@ export class CSSOptimizer { this.adapter, this.baseDir, this.options, - { publicationLock: nativeBuildPublicationLock }, + { + optimizationEngine: this.optimizationEngine, + publicationLock: nativeBuildPublicationLock, + }, ); this.service = service; return service; @@ -106,7 +131,12 @@ export class CSSOptimizer { async extractCriticalCSS(cssPath: string, htmlContent: string): Promise { const service = await this.ensureService(); - return extractCriticalCSSImpl(cssPath, htmlContent, service.getOptions()); + return extractCriticalCSSImpl( + cssPath, + htmlContent, + service.getOptions(), + { optimizationEngine: this.optimizationEngine }, + ); } async getStats(): Promise { @@ -114,6 +144,9 @@ export class CSSOptimizer { } } -export function optimizeCSS(options: CSSOptimizationOptions = {}): Promise> { - return new CSSOptimizer(options).optimize(); +export function optimizeCSS( + options: CSSOptimizationOptions = {}, + dependencies: CSSOptimizerDependencies = {}, +): Promise> { + return new CSSOptimizer(options, undefined, dependencies).optimize(); } diff --git a/src/build/asset-pipeline/css-optimizer/node18-compat.test.ts b/src/build/asset-pipeline/css-optimizer/node18-compat.test.ts new file mode 100644 index 0000000000..44a8f41457 --- /dev/null +++ b/src/build/asset-pipeline/css-optimizer/node18-compat.test.ts @@ -0,0 +1,52 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; + +describe("CSS optimizer Node 18 compatibility", () => { + it("does not require String.prototype.isWellFormed", async () => { + const descriptor = Object.getOwnPropertyDescriptor( + String.prototype, + "isWellFormed", + ); + Object.defineProperty(String.prototype, "isWellFormed", { + value: undefined, + configurable: true, + writable: true, + }); + + try { + const pathValidation = await import("./path-validation.ts?node18-compat"); + const optimization = await import("./optimization-engine.ts?node18-compat"); + const purging = await import("./purging-engine.ts?node18-compat"); + + assertEquals(pathValidation.isSafeCSSRelativePath("styles/app.css"), true); + optimization.validateCSSSourceMap( + JSON.stringify({ + version: 3, + sources: ["styles/app.css"], + names: [], + mappings: "AAAA", + }), + "styles/app.css", + ); + const session = purging.createCSSPurgingSession({ + cacheIdentity: "node18-purge@1", + async purge(request) { + return { css: request.css }; + }, + }); + const result = await session.run({ + css: ".kept { color: green; }", + content: [{ raw: '
', extension: "html" }], + safelist: ["kept"], + includeRejectedCSS: false, + }); + assertEquals(result.css, ".kept { color: green; }"); + } finally { + if (descriptor === undefined) { + delete (String.prototype as { isWellFormed?: unknown }).isWellFormed; + } else { + Object.defineProperty(String.prototype, "isWellFormed", descriptor); + } + } + }); +}); diff --git a/src/build/asset-pipeline/css-optimizer/optimization-engine.test.ts b/src/build/asset-pipeline/css-optimizer/optimization-engine.test.ts new file mode 100644 index 0000000000..ed656069b8 --- /dev/null +++ b/src/build/asset-pipeline/css-optimizer/optimization-engine.test.ts @@ -0,0 +1,468 @@ +import "#veryfront/schemas/_test-setup.ts"; +import type { CSSOptimizationEngine } from "#veryfront/extensions/css/index.ts"; +import { CSSOptimizationEngineName } from "#veryfront/extensions/css/index.ts"; +import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; +import { assert, assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createTestCSSOptimizationEngine, + createTestCSSSourceMap, +} from "../../../../tests/_helpers/css-optimization-engine.ts"; +import { MAX_CSS_FILE_BYTES, MAX_CSS_OUTPUT_FILE_BYTES } from "./constants.ts"; +import { + acquireConfiguredCSSOptimization, + createCSSOptimizationSession, + runConfiguredCSSOptimization, + runCSSOptimizationEngine, + validateCSSSourceMap, +} from "./optimization-engine.ts"; +import { basicMinify } from "./utils.ts"; + +const request = { + css: ".field { color: red; }", + sourcePath: "field.css", + minify: true, + sourceMap: false, +} as const; + +describe("CSS optimization engine boundary", () => { + it("keeps basicMinify synchronous and delegates without a fallback", () => { + const previous = tryResolve( + CSSOptimizationEngineName, + ); + unregister(CSSOptimizationEngineName); + register( + CSSOptimizationEngineName, + createTestCSSOptimizationEngine((received) => { + assertEquals(received, { + css: request.css, + sourcePath: "inline.css", + minify: true, + sourceMap: false, + }); + return { css: ".field{color:red}" }; + }), + ); + try { + const result = basicMinify(request.css); + assertEquals(result, ".field{color:red}"); + assertEquals(typeof result, "string"); + } finally { + unregister(CSSOptimizationEngineName); + if (previous !== undefined) { + register(CSSOptimizationEngineName, previous); + } + } + }); + + it("fails closed with the recommended extension when no engine is composed", () => { + const previous = tryResolve( + CSSOptimizationEngineName, + ); + unregister(CSSOptimizationEngineName); + try { + assertThrows( + () => runConfiguredCSSOptimization(request), + Error, + "deno add @veryfront/ext-css-lightning", + ); + } finally { + if (previous !== undefined) { + register(CSSOptimizationEngineName, previous); + } + } + }); + + it("snapshots primitive request data before crossing the boundary", () => { + const mutableRequest = { ...request } as { + css: string; + sourcePath: string; + minify: boolean; + sourceMap: boolean; + }; + const engine = createTestCSSOptimizationEngine((received) => { + mutableRequest.css = "mutated"; + assertEquals(received.css, request.css); + assert(Object.isFrozen(received)); + return { css: received.css }; + }); + + assertEquals(runCSSOptimizationEngine(engine, mutableRequest).css, request.css); + }); + + it("rejects request accessors, proxies, and unsupported state without invocation", () => { + let accessorCalls = 0; + let invoked = false; + const engine = createTestCSSOptimizationEngine(() => { + invoked = true; + return { css: "" }; + }); + const accessorRequest = { ...request } as Record; + Object.defineProperty(accessorRequest, "css", { + enumerable: true, + get() { + accessorCalls++; + return request.css; + }, + }); + assertThrows( + () => runCSSOptimizationEngine(engine, accessorRequest as never), + TypeError, + "css must be a data property", + ); + assertEquals(accessorCalls, 0); + + const trapped = new Proxy({ ...request }, { + ownKeys() { + throw new Error("blocked"); + }, + }); + assertThrows( + () => runCSSOptimizationEngine(engine, trapped), + TypeError, + "could not be inspected", + ); + assertThrows( + () => + runCSSOptimizationEngine(engine, { + ...request, + browserQueries: ["defaults"], + } as never), + TypeError, + "unsupported properties", + ); + assertEquals(invoked, false); + }); + + it("enforces canonical request values and input bounds", () => { + const engine = createTestCSSOptimizationEngine(); + for ( + const sourcePath of [ + "", + "/absolute.css", + "../escape.css", + "./field.css", + "styles//field.css", + "styles\\field.css", + "C:\\field.css", + "decomposed-e\u0301.css", + "line\nbreak.css", + "line\u2028break.css", + "unpaired\uD800.css", + ] + ) { + assertThrows( + () => runCSSOptimizationEngine(engine, { ...request, sourcePath }), + TypeError, + "safe canonical", + ); + } + assertThrows( + () => + runCSSOptimizationEngine(engine, { + ...request, + css: "x".repeat(MAX_CSS_FILE_BYTES + 1), + }), + TypeError, + "CSS input exceeds", + ); + assertThrows( + () => + runCSSOptimizationEngine(engine, { + ...request, + css: "unpaired\uD800", + }), + TypeError, + "well-formed string", + ); + }); + + it("rejects malformed engine and result shapes without invoking getters", () => { + assertThrows( + () => + runCSSOptimizationEngine( + { cacheIdentity: "", optimize: () => ({ css: "" }) }, + request, + ), + TypeError, + "cacheIdentity", + ); + assertThrows( + () => + runCSSOptimizationEngine( + createTestCSSOptimizationEngine( + (() => Promise.resolve({ css: "" })) as never, + ), + request, + ), + TypeError, + "must define css", + ); + + let resultReads = 0; + assertThrows( + () => + runCSSOptimizationEngine( + createTestCSSOptimizationEngine(() => + Object.defineProperty({}, "css", { + get() { + resultReads++; + return "hostile"; + }, + }) as never + ), + request, + ), + TypeError, + "css must be a data property", + ); + assertEquals(resultReads, 0); + + assertThrows( + () => + runCSSOptimizationEngine( + createTestCSSOptimizationEngine(() => + ({ + css: "ok", + ignored: true, + }) as never + ), + request, + ), + TypeError, + "unsupported properties", + ); + + let sourceMapReads = 0; + const accessorResult = { css: "ok" } as Record; + Object.defineProperty(accessorResult, "sourceMap", { + get() { + sourceMapReads++; + return undefined; + }, + }); + assertThrows( + () => + runCSSOptimizationEngine( + createTestCSSOptimizationEngine(() => accessorResult as never), + request, + ), + TypeError, + "sourceMap must be a data property", + ); + assertEquals(sourceMapReads, 0); + }); + + it("enforces requested source-map semantics and complete flat v3 shape", () => { + const mappedRequest = { ...request, sourceMap: true }; + assertThrows( + () => + runCSSOptimizationEngine( + createTestCSSOptimizationEngine(() => ({ css: "ok" })), + mappedRequest, + ), + TypeError, + "requested source map", + ); + assertThrows( + () => + runCSSOptimizationEngine( + createTestCSSOptimizationEngine(() => ({ + css: "ok", + sourceMap: createTestCSSSourceMap("field.css"), + })), + request, + ), + TypeError, + "unrequested source map", + ); + + for ( + const sourceMap of [ + JSON.stringify({ + version: 3, + sources: [], + names: [], + mappings: "AAAA", + }), + JSON.stringify({ + version: 3, + sources: ["../escape.css"], + names: [], + mappings: "AAAA", + }), + JSON.stringify({ + version: 3, + sources: ["field.css"], + names: [], + mappings: "not valid!", + }), + JSON.stringify({ + version: 3, + sources: ["field.css"], + names: [], + mappings: "B", + }), + JSON.stringify({ + version: 3, + sources: ["field.css"], + names: [], + mappings: "AAAA", + sourcesContent: [], + }), + JSON.stringify({ + version: 3, + sources: ["field.css"], + names: [], + mappings: "AAAA", + unknown: true, + }), + ] + ) { + assertThrows( + () => + runCSSOptimizationEngine( + createTestCSSOptimizationEngine(() => ({ css: "ok", sourceMap })), + mappedRequest, + ), + TypeError, + "source map is invalid", + ); + } + }); + + it("accepts valid source maps and freezes validated output", () => { + const sourceMap = JSON.stringify({ + version: 3, + sources: ["field.css"], + names: [], + mappings: "AAAA", + sourceRoot: "", + ignoreList: [0], + x_google_ignoreList: [0], + }); + validateCSSSourceMap(sourceMap, "field.css"); + const result = runCSSOptimizationEngine( + createTestCSSOptimizationEngine(() => ({ css: "ok", sourceMap })), + { ...request, sourceMap: true }, + ); + assertEquals(result, { css: "ok", sourceMap }); + assertEquals(Object.isFrozen(result), true); + }); + + it("rejects oversized output and preserves provider failures", () => { + assertThrows( + () => + runCSSOptimizationEngine( + createTestCSSOptimizationEngine(() => ({ + css: "x".repeat(MAX_CSS_OUTPUT_FILE_BYTES + 1), + })), + request, + ), + TypeError, + "CSS output exceeds", + ); + + const failure = new Error("engine failed"); + const engine = createTestCSSOptimizationEngine(() => { + throw failure; + }); + let thrown: unknown; + try { + runCSSOptimizationEngine(engine, request); + } catch (error) { + thrown = error; + } + assertEquals(thrown, failure); + }); + + it("captures one immutable provider session per operation", () => { + let firstCalls = 0; + let secondCalls = 0; + const first = createTestCSSOptimizationEngine((input) => { + firstCalls++; + return { css: `first:${input.css}` }; + }, "first@1"); + const second = createTestCSSOptimizationEngine((input) => { + secondCalls++; + return { css: `second:${input.css}` }; + }, "second@1"); + + const previous = tryResolve( + CSSOptimizationEngineName, + ); + unregister(CSSOptimizationEngineName); + try { + register(CSSOptimizationEngineName, first); + const firstSession = acquireConfiguredCSSOptimization(); + unregister(CSSOptimizationEngineName); + register(CSSOptimizationEngineName, second); + const secondSession = acquireConfiguredCSSOptimization(); + + assertEquals(firstSession.cacheIdentity, "first@1"); + assertEquals(secondSession.cacheIdentity, "second@1"); + assertEquals(firstSession.run(request).css, `first:${request.css}`); + assertEquals(secondSession.run(request).css, `second:${request.css}`); + assertEquals(firstCalls, 1); + assertEquals(secondCalls, 1); + } finally { + unregister(CSSOptimizationEngineName); + if (previous !== undefined) register(CSSOptimizationEngineName, previous); + } + }); + + it("uses captured validation and invocation intrinsics", () => { + const sourceMap = createTestCSSSourceMap(request.sourcePath); + const session = createCSSOptimizationSession( + createTestCSSOptimizationEngine((input) => ({ + css: input.css, + ...(input.sourceMap ? { sourceMap } : {}), + })), + ); + const originalApply = Reflect.apply; + const originalEncode = TextEncoder.prototype.encode; + const originalNormalize = String.prototype.normalize; + const originalFloor = Math.floor; + const originalArrayToJSON = Object.getOwnPropertyDescriptor( + Array.prototype, + "toJSON", + ); + let result: string | undefined; + try { + Reflect.apply = () => { + throw new Error("poisoned apply"); + }; + TextEncoder.prototype.encode = () => { + throw new Error("poisoned encode"); + }; + String.prototype.normalize = () => { + throw new Error("poisoned normalize"); + }; + Math.floor = () => { + throw new Error("poisoned floor"); + }; + Object.defineProperty(Array.prototype, "toJSON", { + configurable: true, + value() { + throw new Error("poisoned toJSON"); + }, + }); + result = session.run({ ...request, sourceMap: true }).css; + } finally { + Reflect.apply = originalApply; + TextEncoder.prototype.encode = originalEncode; + String.prototype.normalize = originalNormalize; + Math.floor = originalFloor; + if (originalArrayToJSON === undefined) { + delete (Array.prototype as unknown as Record).toJSON; + } else { + Object.defineProperty( + Array.prototype, + "toJSON", + originalArrayToJSON, + ); + } + } + assertEquals(result, request.css); + }); +}); diff --git a/src/build/asset-pipeline/css-optimizer/optimization-engine.ts b/src/build/asset-pipeline/css-optimizer/optimization-engine.ts new file mode 100644 index 0000000000..6ff102521c --- /dev/null +++ b/src/build/asset-pipeline/css-optimizer/optimization-engine.ts @@ -0,0 +1,631 @@ +/** + * Validated invocation boundary for extension-provided CSS optimizers. + * + * @module build/asset-pipeline/css-optimizer/optimization-engine + */ + +import { + captureCSSOptimizationEngine, + type CSSOptimizationEngine, + CSSOptimizationEngineName, + type CSSOptimizationRequest, + type CSSOptimizationResult, +} from "#veryfront/extensions/css/index.ts"; +import { resolve } from "#veryfront/extensions/contracts.ts"; +import { isWellFormedString } from "#veryfront/utils/is-well-formed-string.ts"; +import { + 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 { isSafeCSSRelativePath } from "./path-validation.ts"; + +const apply = Reflect.apply; +const arrayIsArray = Array.isArray; +const freeze = Object.freeze; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const hasOwn = Object.hasOwn; +const ownKeys = Reflect.ownKeys; +const parseJSON = JSON.parse; +const isSafeInteger = Number.isSafeInteger; +const floorNumber = Math.floor; +const encodeText = TextEncoder.prototype.encode; +const charCodeAtString = String.prototype.charCodeAt; +const indexOfString = String.prototype.indexOf; +const sliceString = String.prototype.slice; +const setHas = Set.prototype.has; +const setAdd = Set.prototype.add; +const arrayPush = Array.prototype.push; +const SetConstructor = Set; +const encoder = new TextEncoder(); + +const REQUEST_PROPERTIES = new SetConstructor([ + "css", + "sourcePath", + "minify", + "sourceMap", +]); +const RESULT_PROPERTIES = new SetConstructor(["css", "sourceMap"]); +const SOURCE_MAP_PROPERTIES = new SetConstructor([ + "version", + "sources", + "names", + "mappings", + "file", + "sourceRoot", + "sourcesContent", + "ignoreList", + "x_google_ignoreList", +]); +const SOURCE_MAP_BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** Immutable pairing of one captured engine identity and its validated runner. */ +export interface CSSOptimizationSession { + readonly cacheIdentity: string; + run(request: CSSOptimizationRequest): CSSOptimizationResult; +} + +function encodedLength(value: string): number { + return apply(encodeText, encoder, [value]).length; +} + +function hasControlOrLineSeparator(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = apply(charCodeAtString, value, [index]); + if ( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) || + code === 0x2028 || + code === 0x2029 + ) { + return true; + } + } + return false; +} + +function requireSafeSourcePath(value: unknown): asserts value is string { + if (!isSafeCSSRelativePath(value)) { + throw new TypeError( + "CSS optimization sourcePath must be a safe canonical non-empty path", + ); + } +} + +function ownDescriptors( + value: object, + label: string, +): PropertyDescriptorMap { + try { + return getOwnPropertyDescriptors(value); + } catch (cause) { + throw new TypeError(`${label} properties could not be inspected`, { cause }); + } +} + +function rejectUnknownProperties( + descriptors: PropertyDescriptorMap, + allowed: ReadonlySet, + label: string, +): void { + const keys = ownKeys(descriptors); + for (let index = 0; index < keys.length; index++) { + if (!apply(setHas, allowed, [keys[index]])) { + throw new TypeError(`${label} contains unsupported properties`); + } + } +} + +function readDataProperty( + descriptors: PropertyDescriptorMap, + property: string, + label: string, + optional = false, +): unknown { + const descriptor = descriptors[property]; + if (descriptor === undefined) { + if (optional) return undefined; + throw new TypeError(`${label} must define ${property}`); + } + if (!hasOwn(descriptor, "value")) { + throw new TypeError(`${label} ${property} must be a data property`); + } + return descriptor.value; +} + +function snapshotRequest( + request: CSSOptimizationRequest, +): CSSOptimizationRequest { + let requestIsArray: boolean; + try { + requestIsArray = arrayIsArray(request); + } catch (cause) { + throw new TypeError("CSS optimization request could not be inspected", { + cause, + }); + } + if (typeof request !== "object" || request === null || requestIsArray) { + throw new TypeError("CSS optimization request must be an object"); + } + + const descriptors = ownDescriptors(request, "CSS optimization request"); + rejectUnknownProperties( + descriptors, + REQUEST_PROPERTIES, + "CSS optimization request", + ); + const css = readDataProperty( + descriptors, + "css", + "CSS optimization request", + ); + const sourcePath = readDataProperty( + descriptors, + "sourcePath", + "CSS optimization request", + ); + const minify = readDataProperty( + descriptors, + "minify", + "CSS optimization request", + ); + const sourceMap = readDataProperty( + descriptors, + "sourceMap", + "CSS optimization request", + ); + + if ( + typeof css !== "string" || + !isWellFormedString(css) + ) { + throw new TypeError("CSS optimization input must be a well-formed string"); + } + if (encodedLength(css) > MAX_CSS_FILE_BYTES) { + throw new TypeError(`CSS input exceeds ${MAX_CSS_FILE_BYTES} bytes`); + } + requireSafeSourcePath(sourcePath); + if (typeof minify !== "boolean") { + throw new TypeError("CSS optimization minify must be a boolean"); + } + if (typeof sourceMap !== "boolean") { + throw new TypeError("CSS optimization sourceMap must be a boolean"); + } + + return freeze({ css, sourcePath, minify, sourceMap }); +} + +function isDenseArray( + value: unknown, + maximumLength: number, +): value is unknown[] { + let brandedArray: boolean; + try { + brandedArray = arrayIsArray(value); + } catch { + return false; + } + if (!brandedArray) return false; + + const descriptors = ownDescriptors(value as unknown[], "CSS source-map array"); + const length = readDataProperty( + descriptors, + "length", + "CSS source-map array", + ); + if ( + !isSafeInteger(length) || + (length as number) < 0 || + (length as number) > maximumLength + ) { + return false; + } + + const keys = ownKeys(descriptors); + if (keys.length !== (length as number) + 1) return false; + for (let index = 0; index < keys.length; index++) { + if (typeof keys[index] !== "string") return false; + } + for (let index = 0; index < (length as number); index++) { + const descriptor = descriptors[String(index)]; + if ( + descriptor === undefined || + !hasOwn(descriptor, "value") || + descriptor.enumerable !== true + ) { + return false; + } + } + return true; +} + +function invalidSourceMap(logicalPath: string): TypeError { + return new TypeError(`CSS source map is invalid for ${logicalPath}`); +} + +function decodeSourceMapVLQ(segment: string): number[] | undefined { + const values: number[] = []; + let accumulated = 0; + let shift = 0; + let continuing = false; + + for (let index = 0; index < segment.length; index++) { + const digit = apply(indexOfString, SOURCE_MAP_BASE64, [segment[index]!]); + if (digit < 0) return undefined; + const payload = digit % 32; + continuing = digit >= 32; + accumulated += payload * 2 ** shift; + if (!isSafeInteger(accumulated)) return undefined; + + if (continuing) { + shift += 5; + if (shift > 50) return undefined; + continue; + } + + const negative = accumulated % 2 === 1; + const magnitude = floorNumber(accumulated / 2); + if (negative && magnitude === 0) return undefined; + apply(arrayPush, values, [negative ? -magnitude : magnitude]); + accumulated = 0; + shift = 0; + } + + return continuing ? undefined : values; +} + +function validateSourceMapMappings( + mappings: string, + sourceCount: number, + nameCount: number, + logicalPath: string, +): void { + let previousSource = 0; + let previousOriginalLine = 0; + let previousOriginalColumn = 0; + let previousName = 0; + let segmentCount = 0; + let lineCount = 0; + let lineStart = 0; + + while (lineStart <= mappings.length) { + lineCount++; + if (lineCount > MAX_CSS_SELECTOR_TOKENS) { + throw invalidSourceMap(logicalPath); + } + const separator = apply(indexOfString, mappings, [";", lineStart]); + const lineEnd = separator < 0 ? mappings.length : separator; + let previousGeneratedColumn = 0; + let segmentStart = lineStart; + + while (segmentStart < lineEnd) { + const comma = apply(indexOfString, mappings, [",", segmentStart]); + const segmentEnd = comma < 0 || comma > lineEnd ? lineEnd : comma; + if (segmentEnd === segmentStart) throw invalidSourceMap(logicalPath); + const values = decodeSourceMapVLQ( + apply(sliceString, mappings, [segmentStart, segmentEnd]), + ); + segmentCount++; + if ( + values === undefined || + segmentCount > MAX_CSS_SELECTOR_TOKENS || + (values.length !== 1 && values.length !== 4 && values.length !== 5) + ) { + throw invalidSourceMap(logicalPath); + } + + const generatedDelta = values[0]!; + previousGeneratedColumn += generatedDelta; + if ( + generatedDelta < 0 || + !isSafeInteger(previousGeneratedColumn) + ) { + throw invalidSourceMap(logicalPath); + } + + if (values.length > 1) { + previousSource += values[1]!; + previousOriginalLine += values[2]!; + previousOriginalColumn += values[3]!; + if ( + !isSafeInteger(previousSource) || + previousSource < 0 || + previousSource >= sourceCount || + !isSafeInteger(previousOriginalLine) || + previousOriginalLine < 0 || + !isSafeInteger(previousOriginalColumn) || + previousOriginalColumn < 0 + ) { + throw invalidSourceMap(logicalPath); + } + } + + if (values.length === 5) { + previousName += values[4]!; + if ( + !isSafeInteger(previousName) || + previousName < 0 || + previousName >= nameCount + ) { + throw invalidSourceMap(logicalPath); + } + } + + if (segmentEnd === lineEnd) break; + segmentStart = segmentEnd + 1; + if (segmentStart === lineEnd) throw invalidSourceMap(logicalPath); + } + + if (separator < 0) break; + lineStart = lineEnd + 1; + } +} + +function validateIgnoreList( + value: unknown, + sourceCount: number, + logicalPath: string, +): void { + if (value === undefined) return; + if (!isDenseArray(value, sourceCount)) throw invalidSourceMap(logicalPath); + const seen = new SetConstructor(); + for (let index = 0; index < value.length; index++) { + const entry = value[index]; + if ( + !isSafeInteger(entry) || + (entry as number) < 0 || + (entry as number) >= sourceCount || + apply(setHas, seen, [entry]) + ) { + throw invalidSourceMap(logicalPath); + } + apply(setAdd, seen, [entry]); + } +} + +function ignoreListsEqual(left: unknown, right: unknown): boolean { + if (!arrayIsArray(left) || !arrayIsArray(right)) return false; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return false; + } + return true; +} + +/** Validate the complete flat source-map v3 shape accepted at CSS boundaries. */ +export function validateCSSSourceMap( + sourceMap: string, + logicalPath: string, +): void { + requireSafeSourcePath(logicalPath); + if (typeof sourceMap !== "string" || !isWellFormedString(sourceMap)) { + throw new TypeError( + `CSS source map must be a well-formed string for ${logicalPath}`, + ); + } + if (encodedLength(sourceMap) > 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 = apply(parseJSON, JSON, [sourceMap]); + } catch (cause) { + throw new TypeError(`CSS source map is malformed for ${logicalPath}`, { + cause, + }); + } + if ( + typeof parsed !== "object" || + parsed === null || + arrayIsArray(parsed) + ) { + throw invalidSourceMap(logicalPath); + } + + const descriptors = ownDescriptors(parsed, "CSS source map"); + rejectUnknownProperties( + descriptors, + SOURCE_MAP_PROPERTIES, + "CSS source map is invalid", + ); + const version = readDataProperty(descriptors, "version", "CSS source map"); + const sources = readDataProperty(descriptors, "sources", "CSS source map"); + const names = readDataProperty(descriptors, "names", "CSS source map"); + const mappings = readDataProperty(descriptors, "mappings", "CSS source map"); + const file = readDataProperty(descriptors, "file", "CSS source map", true); + const sourceRoot = readDataProperty( + descriptors, + "sourceRoot", + "CSS source map", + true, + ); + const sourcesContent = readDataProperty( + descriptors, + "sourcesContent", + "CSS source map", + true, + ); + const ignoreList = readDataProperty( + descriptors, + "ignoreList", + "CSS source map", + true, + ); + const googleIgnoreList = readDataProperty( + descriptors, + "x_google_ignoreList", + "CSS source map", + true, + ); + + if ( + version !== 3 || + !isDenseArray(sources, MAX_CSS_FILES) || + sources.length === 0 || + !isDenseArray(names, MAX_CSS_SELECTOR_TOKENS) || + typeof mappings !== "string" || + !isWellFormedString(mappings) + ) { + throw invalidSourceMap(logicalPath); + } + validateSourceMapMappings( + mappings, + sources.length, + names.length, + logicalPath, + ); + + for (let index = 0; index < sources.length; index++) { + if (!isSafeCSSRelativePath(sources[index])) { + throw invalidSourceMap(logicalPath); + } + } + for (let index = 0; index < names.length; index++) { + const name = names[index]; + if ( + typeof name !== "string" || + name.length > MAX_CSS_SELECTOR_TOKEN_CHARACTERS || + !isWellFormedString(name) || + hasControlOrLineSeparator(name) + ) { + throw invalidSourceMap(logicalPath); + } + } + if (file !== undefined && !isSafeCSSRelativePath(file)) { + throw invalidSourceMap(logicalPath); + } + if ( + sourceRoot !== undefined && + sourceRoot !== null && + sourceRoot !== "" && + !isSafeCSSRelativePath(sourceRoot) + ) { + throw invalidSourceMap(logicalPath); + } + if (sourcesContent !== undefined) { + if ( + !isDenseArray(sourcesContent, MAX_CSS_FILES) || + sourcesContent.length !== sources.length + ) { + throw invalidSourceMap(logicalPath); + } + for (let index = 0; index < sourcesContent.length; index++) { + const content = sourcesContent[index]; + if ( + content !== null && + (typeof content !== "string" || + !isWellFormedString(content) || + encodedLength(content) > MAX_CSS_FILE_BYTES) + ) { + throw invalidSourceMap(logicalPath); + } + } + } + validateIgnoreList(ignoreList, sources.length, logicalPath); + validateIgnoreList(googleIgnoreList, sources.length, logicalPath); + if ( + ignoreList !== undefined && + googleIgnoreList !== undefined && + !ignoreListsEqual(ignoreList, googleIgnoreList) + ) { + throw invalidSourceMap(logicalPath); + } +} + +function validateResult( + value: unknown, + request: CSSOptimizationRequest, +): CSSOptimizationResult { + let resultIsArray: boolean; + try { + resultIsArray = arrayIsArray(value); + } catch (cause) { + throw new TypeError("CSSOptimizationEngine result could not be inspected", { + cause, + }); + } + if (typeof value !== "object" || value === null || resultIsArray) { + throw new TypeError("CSSOptimizationEngine must return an object"); + } + + const descriptors = ownDescriptors(value, "CSSOptimizationEngine result"); + rejectUnknownProperties( + descriptors, + RESULT_PROPERTIES, + "CSSOptimizationEngine result", + ); + const css = readDataProperty( + descriptors, + "css", + "CSSOptimizationEngine result", + ); + const sourceMap = readDataProperty( + descriptors, + "sourceMap", + "CSSOptimizationEngine result", + true, + ); + if (typeof css !== "string" || !isWellFormedString(css)) { + throw new TypeError( + "CSSOptimizationEngine must return CSS as a well-formed string", + ); + } + if (encodedLength(css) > MAX_CSS_OUTPUT_FILE_BYTES) { + throw new TypeError(`CSS output exceeds ${MAX_CSS_OUTPUT_FILE_BYTES} bytes`); + } + if (request.sourceMap) { + if (typeof sourceMap !== "string") { + throw new TypeError( + "CSSOptimizationEngine did not return the requested source map", + ); + } + validateCSSSourceMap(sourceMap, request.sourcePath); + return freeze({ css, sourceMap }); + } + if (sourceMap !== undefined) { + throw new TypeError( + "CSSOptimizationEngine returned an unrequested source map", + ); + } + return freeze({ css }); +} + +/** Invoke one concrete engine through the validated, immutable boundary. */ +export function runCSSOptimizationEngine( + engine: CSSOptimizationEngine, + request: CSSOptimizationRequest, +): CSSOptimizationResult { + return createCSSOptimizationSession(engine).run(request); +} + +/** Capture one engine exactly once for both cache identity and execution. */ +export function createCSSOptimizationSession( + engine: CSSOptimizationEngine, +): CSSOptimizationSession { + const captured = captureCSSOptimizationEngine(engine); + return freeze({ + cacheIdentity: captured.cacheIdentity, + run(request: CSSOptimizationRequest): CSSOptimizationResult { + const input = snapshotRequest(request); + return validateResult(captured.optimize(input), input); + }, + }); +} + +/** Resolve the explicitly composed engine and invoke it synchronously. */ +export function runConfiguredCSSOptimization( + request: CSSOptimizationRequest, +): CSSOptimizationResult { + return acquireConfiguredCSSOptimization().run(request); +} + +/** Resolve and capture one configured engine/identity pair for an operation. */ +export function acquireConfiguredCSSOptimization(): CSSOptimizationSession { + const engine = resolve(CSSOptimizationEngineName); + return createCSSOptimizationSession(engine as CSSOptimizationEngine); +} 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 206088c6c9..458eb98045 100644 --- a/src/build/asset-pipeline/css-optimizer/optimizer-service.test.ts +++ b/src/build/asset-pipeline/css-optimizer/optimizer-service.test.ts @@ -3,9 +3,32 @@ 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 { createTestCSSPurgingEngine } from "../../../../tests/_helpers/css-purging-engine.ts"; import { CSSOptimizerService } from "./optimizer-service.ts"; -import { LightningCSSStrategy } from "./strategies/lightning-strategy.ts"; import { nativeBuildPublicationLock } from "../../production-build/build/build-publication.ts"; +import { + createTestCSSOptimizationEngine, + createTestCSSSourceMap, +} from "../../../../tests/_helpers/css-optimization-engine.ts"; + +const optimizationEngine = createTestCSSOptimizationEngine((request) => { + if (request.css.includes("@media (")) { + throw new TypeError("invalid CSS"); + } + const css = request.minify + ? request.css + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\s+/g, " ") + .replace(/\s*([{}:;,])\s*/g, "$1") + .replace(/;}/g, "}") + .trim() + : request.css; + return { + css, + ...(request.sourceMap ? { sourceMap: createTestCSSSourceMap(request.sourcePath) } : {}), + }; +}); +import { PurgeStrategy } from "./strategies/purge-strategy.ts"; async function withProject( callback: (projectDir: string) => Promise, @@ -32,7 +55,11 @@ async function createService( outputDir: ".veryfront/css", ...options, }, - { publicationLock: nativeBuildPublicationLock, ...dependencies }, + { + optimizationEngine, + publicationLock: nativeBuildPublicationLock, + ...dependencies, + }, ); } @@ -136,24 +163,25 @@ describe("build/asset-pipeline/css-optimizer/optimizer-service", () => { join(projectDir, "styles/main.css"), ".main { color: red; }", ); - const missingDependency = new LightningCSSStrategy(() => - Promise.reject(new Error("compiler unavailable")) - ); + const missingDependency = createTestCSSOptimizationEngine(() => { + throw new Error("compiler unavailable"); + }); const dependencyService = await createService( projectDir, {}, - { lightningStrategy: missingDependency }, + { optimizationEngine: missingDependency }, ); await assertRejects( () => dependencyService.optimize(), Error, - "requires npm:lightningcss", + "compiler unavailable", ); }); }); - it("runs parser-backed purge before minification", async () => { + it("captures one explicit purge provider before minification", async () => { await withProject(async (projectDir) => { + let providerCalls = 0; await Deno.mkdir(join(projectDir, "app")); await Deno.writeTextFile( join(projectDir, "app/page.tsx"), @@ -166,11 +194,67 @@ describe("build/asset-pipeline/css-optimizer/optimizer-service", () => { const service = await createService(projectDir, { purge: true, purgeContent: ["app/**/*.tsx"], + }, { + purgeStrategy: new PurgeStrategy({ + baseDir: projectDir, + purgingEngine: createTestCSSPurgingEngine((request) => { + providerCalls++; + assertEquals(request.content, [{ + raw: '
content
', + extension: "tsx", + }]); + return Promise.resolve({ css: ".used { color: green; }" }); + }), + }), }); const content = (await service.optimize()).get("main.css")!.content; assertEquals(content.includes(".used"), true); assertEquals(content.includes(".unused"), false); + assertEquals(providerCalls, 1); + }); + }); + + it("keeps one captured purge method across a concurrent publication", 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/first.css"), + ".first { color: red; }", + ); + await Deno.writeTextFile( + join(projectDir, "styles/second.css"), + ".second { color: blue; }", + ); + + let capturedCalls = 0; + let replacementCalls = 0; + const engine = createTestCSSPurgingEngine((request) => { + capturedCalls++; + engine.purge = (replacementRequest) => { + replacementCalls++; + return Promise.resolve({ css: replacementRequest.css }); + }; + return Promise.resolve({ css: request.css }); + }); + const service = await createService(projectDir, { + purge: true, + purgeContent: ["app/**/*.tsx"], + }, { + purgeStrategy: new PurgeStrategy({ + baseDir: projectDir, + purgingEngine: engine, + }), + }); + + const bundles = await service.optimize(); + assertEquals(bundles.size, 2); + assertEquals(capturedCalls, 2); + assertEquals(replacementCalls, 0); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/optimizer-service.ts b/src/build/asset-pipeline/css-optimizer/optimizer-service.ts index 9733660e99..7c065116b7 100644 --- a/src/build/asset-pipeline/css-optimizer/optimizer-service.ts +++ b/src/build/asset-pipeline/css-optimizer/optimizer-service.ts @@ -8,6 +8,7 @@ import { resolve, } from "#veryfront/compat/path/index.ts"; import { INITIALIZATION_ERROR } from "#veryfront/errors"; +import type { CSSOptimizationEngine } from "#veryfront/extensions/css/index.ts"; 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"; @@ -22,8 +23,6 @@ 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, @@ -33,7 +32,13 @@ import { MAX_CSS_TOTAL_OUTPUT_BYTES, } from "./constants.ts"; import { CacheManager } from "./css-bundle-cache.ts"; -import { LightningCSSStrategy } from "./strategies/lightning-strategy.ts"; +import { + acquireConfiguredCSSOptimization, + createCSSOptimizationSession, + type CSSOptimizationSession, + validateCSSSourceMap, +} from "./optimization-engine.ts"; +import type { CSSPurgingSession } from "./purging-engine.ts"; import { type PurgeContentSource, PurgeStrategy } from "./strategies/purge-strategy.ts"; import type { CSSBundle, CSSOptimizationOptions, CSSOptimizerStats } from "./types/index.ts"; import { @@ -42,7 +47,6 @@ import { getOutputPath, globFiles, isSafeCSSRelativePath, - validateCSSSourceMap, } from "./utils.ts"; const encoder = new TextEncoder(); @@ -55,11 +59,16 @@ interface PlannedCSSFile { } export interface CSSOptimizerServiceDependencies { - lightningStrategy?: LightningCSSStrategy; + optimizationEngine?: CSSOptimizationEngine; purgeStrategy?: PurgeStrategy; publicationLock?: BuildPublicationLock; } +type ResolvedCSSOptimizationOptions = Omit< + Required, + "autoprefixer" | "browsers" +>; + function portablePath(path: string): string { return path.replaceAll("\\", "/"); } @@ -112,9 +121,9 @@ function assertStringList( } export class CSSOptimizerService { - private options: Required; + private options: ResolvedCSSOptimizationOptions; private cacheManager = new CacheManager(); - private readonly lightningStrategy: LightningCSSStrategy; + private readonly optimizationEngine: CSSOptimizationEngine | undefined; private readonly purgeStrategy: PurgeStrategy; private readonly publicationLock?: BuildPublicationLock; private readonly adapter: RuntimeAdapter; @@ -141,22 +150,14 @@ export class CSSOptimizerService { ) { 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") + typeof dependencies.purgeStrategy.clearCache !== "function" || + typeof dependencies.purgeStrategy.createOperationSession !== "function") ) { throw new TypeError("CSS purge strategy dependency is invalid"); } @@ -168,6 +169,14 @@ export class CSSOptimizerService { ) { throw new TypeError("CSS publication lock dependency is invalid"); } + if ( + Object.hasOwn(options, "autoprefixer") || + Object.hasOwn(options, "browsers") + ) { + throw new TypeError( + "CSS autoprefixer/browsers options moved to the optimization extension; configure its browserQueries instead", + ); + } requireSafeConfiguredPath(baseDir, "CSS project directory"); if (!isAbsolute(baseDir)) { throw new TypeError("CSS project directory must be absolute"); @@ -189,7 +198,6 @@ export class CSSOptimizerService { const [name, value] of [ ["enabled", options.enabled], ["minify", options.minify], - ["autoprefixer", options.autoprefixer], ["purge", options.purge], ["criticalCSS", options.criticalCSS], ["sourceMap", options.sourceMap], @@ -202,9 +210,6 @@ export class CSSOptimizerService { 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) @@ -222,9 +227,6 @@ export class CSSOptimizerService { 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 @@ -236,9 +238,6 @@ export class CSSOptimizerService { 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], @@ -258,8 +257,7 @@ export class CSSOptimizerService { throwOnError: true, validationOptions: { followSymlinks: false }, }); - this.lightningStrategy = dependencies.lightningStrategy ?? - new LightningCSSStrategy(); + this.optimizationEngine = dependencies.optimizationEngine; this.purgeStrategy = dependencies.purgeStrategy ?? new PurgeStrategy({ baseDir: this.baseDir, @@ -276,13 +274,6 @@ export class CSSOptimizerService { "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", @@ -348,10 +339,17 @@ export class CSSOptimizerService { logger.info("CSS optimization is disabled"); return false; } - await this.lightningStrategy.init(); + this.createOperationOptimizationSession(); return true; } + private createOperationOptimizationSession(): CSSOptimizationSession { + if (this.optimizationEngine !== undefined) { + return createCSSOptimizationSession(this.optimizationEngine); + } + return acquireConfiguredCSSOptimization(); + } + optimize(): Promise> { const run = this.optimization ??= this.optimizeOnce().finally(() => { this.optimization = null; @@ -374,8 +372,11 @@ export class CSSOptimizerService { "CSS source maps cannot be composed safely with purge output", ); } + const purgingSession = this.options.purge + ? this.purgeStrategy.createOperationSession() + : undefined; await this.validateFilesystemCapabilities(); - await this.init(); + const optimizationSession = this.createOperationOptimizationSession(); const plans = await this.planFiles(); if (plans.length === 0) { @@ -394,7 +395,6 @@ export class CSSOptimizerService { outputDir: this.options.outputDir, files: plans.length, minify: this.options.minify, - autoprefixer: this.options.autoprefixer, purge: this.options.purge, }); @@ -419,7 +419,14 @@ export class CSSOptimizerService { ) { const settled = await Promise.allSettled( plans.slice(start, start + DEFAULT_BUILD_CONCURRENCY) - .map((plan) => this.optimizeFile(plan, publication.buildDir)), + .map((plan) => + this.optimizeFile( + plan, + publication.buildDir, + optimizationSession, + purgingSession, + ) + ), ); const errors = settled.flatMap((result) => result.status === "rejected" ? [result.reason] : [] @@ -612,6 +619,8 @@ export class CSSOptimizerService { private async optimizeFile( plan: PlannedCSSFile, stagingDir: string, + optimizationSession: CSSOptimizationSession, + purgingSession?: CSSPurgingSession, ): Promise { const content = await this.secureFs.readFile(plan.sourcePath); const originalSize = encoder.encode(content).length; @@ -623,6 +632,8 @@ export class CSSOptimizerService { const { optimized, sourceMap } = await this.processContent( content, plan.logicalPath, + optimizationSession, + purgingSession, ); let outputContent = optimized; @@ -672,20 +683,24 @@ export class CSSOptimizerService { private async processContent( content: string, logicalPath: string, + optimizationSession: CSSOptimizationSession, + purgingSession?: CSSPurgingSession, ): Promise<{ optimized: string; sourceMap?: string }> { const purged = this.options.purge ? (await this.purgeStrategy.process( content, logicalPath, this.options, + purgingSession, )).code : content; - const result = await this.lightningStrategy.process( - purged, - logicalPath, - this.options, - ); - return { optimized: result.code, sourceMap: result.sourceMap }; + const result = optimizationSession.run({ + css: purged, + sourcePath: logicalPath, + minify: this.options.minify, + sourceMap: this.options.sourceMap, + }); + return { optimized: result.css, sourceMap: result.sourceMap }; } private async collectPurgeContent( @@ -754,11 +769,10 @@ export class CSSOptimizerService { return this.cacheManager.getStats(); } - getOptions(): Required { + getOptions(): ResolvedCSSOptimizationOptions { return { ...this.options, inputFiles: [...this.options.inputFiles], - browsers: [...this.options.browsers], purgeContent: [...this.options.purgeContent], purgeSafelist: [...this.options.purgeSafelist], }; diff --git a/src/build/asset-pipeline/css-optimizer/path-validation.ts b/src/build/asset-pipeline/css-optimizer/path-validation.ts new file mode 100644 index 0000000000..ccd4f9e07a --- /dev/null +++ b/src/build/asset-pipeline/css-optimizer/path-validation.ts @@ -0,0 +1,60 @@ +import { MAX_PATH_LENGTH_CHARS } from "#veryfront/utils/constants/limits.ts"; +import { isWellFormedString } from "#veryfront/utils/is-well-formed-string.ts"; + +const apply = Reflect.apply; +const charCodeAtString = String.prototype.charCodeAt; +const normalizeString = String.prototype.normalize; +const indexOfString = String.prototype.indexOf; +const sliceString = String.prototype.slice; +const executeRegularExpression = RegExp.prototype.exec; +const WINDOWS_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/; + +function hasControlOrLineSeparator(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = apply(charCodeAtString, value, [index]); + if ( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) || + code === 0x2028 || + code === 0x2029 + ) { + return true; + } + } + return false; +} + +function hasUnsafeSegment(value: string): boolean { + let start = 0; + while (start <= value.length) { + const separator = apply(indexOfString, value, ["/", start]); + const end = separator < 0 ? value.length : separator; + const segment = apply(sliceString, value, [start, end]); + if (segment.length === 0 || segment === "." || segment === "..") { + return true; + } + if (separator < 0) return false; + start = separator + 1; + } + return true; +} + +/** Return whether a path is canonical, portable, project-relative CSS metadata. */ +export function isSafeCSSRelativePath(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_PATH_LENGTH_CHARS || + !isWellFormedString(value) || + value !== apply(normalizeString, value, ["NFC"]) || + hasControlOrLineSeparator(value) || + value[0] === "/" || + value[0] === "\\" || + apply(executeRegularExpression, WINDOWS_ABSOLUTE_PATH, [value]) !== null || + apply(indexOfString, value, ["\\"]) >= 0 + ) { + return false; + } + + return !hasUnsafeSegment(value); +} diff --git a/src/build/asset-pipeline/css-optimizer/purging-engine.test.ts b/src/build/asset-pipeline/css-optimizer/purging-engine.test.ts new file mode 100644 index 0000000000..be2ef3294c --- /dev/null +++ b/src/build/asset-pipeline/css-optimizer/purging-engine.test.ts @@ -0,0 +1,272 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { + CSSPurgingEngine, + CSSPurgingRequest, + CSSPurgingResult, +} from "#veryfront/extensions/css/index.ts"; +import { CSSPurgingEngineName } from "#veryfront/extensions/css/index.ts"; +import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; +import { MAX_CSS_OUTPUT_FILE_BYTES } from "./constants.ts"; +import { + acquireConfiguredCSSPurging, + assertCSSPurgingSession, + createCSSPurgingSession, +} from "./purging-engine.ts"; + +function engine( + run: (request: CSSPurgingRequest) => unknown | Promise, + cacheIdentity = "test-css-purging@1", +): CSSPurgingEngine { + return { + cacheIdentity, + purge: async (request): Promise => await run(request) as CSSPurgingResult, + }; +} + +function request(includeRejectedCSS = true): CSSPurgingRequest { + return { + css: ".used {} .unused {}", + content: [{ raw: '
', extension: "html" }], + safelist: [], + includeRejectedCSS, + }; +} + +describe("CSS purging engine boundary", () => { + it("captures one immutable provider and detached request per operation", async () => { + let received: CSSPurgingRequest | undefined; + const implementation = engine((value) => { + received = value; + return { css: ".used {}", rejectedCSS: ".unused {}" }; + }); + const session = createCSSPurgingSession(implementation); + assertCSSPurgingSession(session); + (implementation as { cacheIdentity: string }).cacheIdentity = "mutated@2"; + implementation.purge = () => + Promise.resolve({ css: "replacement", rejectedCSS: "replacement" }); + + const input = request(); + const result = await session.run(input); + assertEquals(session.cacheIdentity, "test-css-purging@1"); + assertEquals(result, { css: ".used {}", rejectedCSS: ".unused {}" }); + assertEquals(Object.isFrozen(session), true); + assertEquals(Object.isFrozen(result), true); + assertEquals(Object.isFrozen(received), true); + assertEquals(Object.isFrozen(received?.content), true); + assertEquals(Object.isFrozen(received?.content[0]), true); + assertEquals(Object.isFrozen(received?.safelist), true); + assertThrows( + () => + assertCSSPurgingSession({ + cacheIdentity: session.cacheIdentity, + run: session.run, + }), + TypeError, + "must be created by core", + ); + }); + + it("does not invoke request, content, array-iterator, or result accessors", async () => { + let requestGetterCalls = 0; + const session = createCSSPurgingSession( + engine(() => ({ css: ".used {}", rejectedCSS: ".unused {}" })), + ); + await assertRejects( + () => + session.run({ + get css() { + requestGetterCalls++; + return ".used {}"; + }, + content: request().content, + safelist: [], + includeRejectedCSS: true, + }), + TypeError, + "data property", + ); + assertEquals(requestGetterCalls, 0); + + let contentGetterCalls = 0; + await assertRejects( + () => + session.run({ + ...request(), + content: [{ + get raw() { + contentGetterCalls++; + return "
"; + }, + extension: "html", + }], + }), + TypeError, + "data property", + ); + assertEquals(contentGetterCalls, 0); + + let iteratorGetterCalls = 0; + const content = [...request().content]; + Object.defineProperty(content, Symbol.iterator, { + get() { + iteratorGetterCalls++; + return Array.prototype[Symbol.iterator]; + }, + }); + await assertRejects( + () => session.run({ ...request(), content }), + TypeError, + "dense data-property array", + ); + assertEquals(iteratorGetterCalls, 0); + + let resultGetterCalls = 0; + const hostileResult = createCSSPurgingSession(engine(() => ({ + get css() { + resultGetterCalls++; + return ".used {}"; + }, + rejectedCSS: ".unused {}", + }))); + await assertRejects( + () => hostileResult.run(request()), + TypeError, + "data property", + ); + assertEquals(resultGetterCalls, 0); + }); + + it("rejects sparse arrays, unknown properties, hostile proxies, and unsafe tokens", async () => { + const session = createCSSPurgingSession( + engine(() => ({ css: ".used {}", rejectedCSS: ".unused {}" })), + ); + const sparse = new Array(1) as CSSPurgingRequest["content"]; + await assertRejects( + () => session.run({ ...request(), content: sparse }), + TypeError, + "must define 0", + ); + await assertRejects( + () => + session.run({ + ...request(), + content: [{ + raw: "
", + extension: "HTML", + }], + }), + TypeError, + "malformed", + ); + await assertRejects( + () => + session.run({ + ...request(), + safelist: ["not normalized\u0301"], + }), + TypeError, + "unsafe token", + ); + await assertRejects( + () => + session.run({ + ...request(), + unexpected: true, + } as CSSPurgingRequest), + TypeError, + "unsupported properties", + ); + + const revoked = Proxy.revocable(request(), {}); + revoked.revoke(); + await assertRejects( + () => session.run(revoked.proxy), + TypeError, + "could not be inspected", + ); + }); + + it("rejects malformed, unrequested, and oversized provider output", async () => { + await assertRejects( + () => createCSSPurgingSession(engine(() => ({ css: ".used {}" }))).run(request()), + TypeError, + "must define rejectedCSS", + ); + await assertRejects( + () => + createCSSPurgingSession( + engine(() => ({ css: ".used {}", rejectedCSS: undefined })), + ).run(request(false)), + TypeError, + "unrequested", + ); + await assertRejects( + () => + createCSSPurgingSession( + engine(() => ({ + css: "x".repeat(MAX_CSS_OUTPUT_FILE_BYTES + 1), + })), + ).run(request(false)), + TypeError, + "resource limits", + ); + + const hostile = new Proxy({ + css: ".used {}", + rejectedCSS: ".unused {}", + }, { + ownKeys() { + throw new Error("descriptor trap"); + }, + }); + await assertRejects( + () => createCSSPurgingSession(engine(() => hostile)).run(request()), + TypeError, + "could not be inspected", + ); + }); + + it("rejects inherited identities and accessor methods without invoking them", () => { + assertThrows( + () => + createCSSPurgingSession(Object.create({ + cacheIdentity: "inherited@1", + purge: () => Promise.resolve({ css: "" }), + })), + TypeError, + "own data property", + ); + let getterCalls = 0; + assertThrows( + () => + createCSSPurgingSession({ + cacheIdentity: "accessor@1", + get purge() { + getterCalls++; + throw new Error("must not invoke"); + }, + } as never), + TypeError, + "data property", + ); + assertEquals(getterCalls, 0); + }); + + it("fails closed when no provider is configured", () => { + const previous = tryResolve(CSSPurgingEngineName); + unregister(CSSPurgingEngineName); + try { + assertThrows( + () => acquireConfiguredCSSPurging(), + Error, + 'Missing extension for contract "CSSPurgingEngine"', + ); + } finally { + if (previous !== undefined) { + register(CSSPurgingEngineName, previous); + } + } + }); +}); diff --git a/src/build/asset-pipeline/css-optimizer/purging-engine.ts b/src/build/asset-pipeline/css-optimizer/purging-engine.ts new file mode 100644 index 0000000000..8ac84a23c9 --- /dev/null +++ b/src/build/asset-pipeline/css-optimizer/purging-engine.ts @@ -0,0 +1,291 @@ +/** Validated invocation boundary for extension-provided CSS purging engines. */ + +import { + captureCSSPurgingEngine, + type CSSPurgeContentSource, + type CSSPurgingEngine, + CSSPurgingEngineName, + type CSSPurgingRequest, + type CSSPurgingResult, +} from "#veryfront/extensions/css/index.ts"; +import { resolve } from "#veryfront/extensions/contracts.ts"; +import { isWellFormedString } from "#veryfront/utils/is-well-formed-string.ts"; +import { + MAX_CSS_FILE_BYTES, + MAX_CSS_FILES, + MAX_CSS_OUTPUT_FILE_BYTES, + MAX_CSS_PURGE_SAFELIST_ENTRIES, + MAX_CSS_SELECTOR_TOKEN_CHARACTERS, + MAX_CSS_TOTAL_BYTES, + MAX_CSS_TOTAL_OUTPUT_BYTES, +} from "./constants.ts"; +import { + inspectedProperty, + inspectOwnProperties, + isArrayValue, + readOwnDataProperty, + rejectUnknownOwnProperties, + snapshotDenseDataArray, +} from "./data-snapshot.ts"; + +const apply = Reflect.apply; +const encode = TextEncoder.prototype.encode; +const executeRegularExpression = RegExp.prototype.exec; +const freeze = Object.freeze; +const normalize = String.prototype.normalize; +const stringCharacterCodeAt = String.prototype.charCodeAt; +const weakSetAdd = WeakSet.prototype.add; +const weakSetHas = WeakSet.prototype.has; +const encoder = new TextEncoder(); +const capturedSessions = new WeakSet(); +const CONTENT_EXTENSION_PATTERN = /^[a-z0-9]+$/; +const REQUEST_KEYS = freeze( + [ + "css", + "content", + "safelist", + "includeRejectedCSS", + ] as const, +); +const CONTENT_KEYS = freeze(["raw", "extension"] as const); +const RESULT_KEYS = freeze(["css", "rejectedCSS"] as const); + +export interface CSSPurgingSession { + /** Immutable provider/version identity captured with the implementation. */ + readonly cacheIdentity: string; + /** Run one operation through the captured implementation. */ + run(request: CSSPurgingRequest): Promise; +} + +/** Ensure callers cannot substitute a session that bypasses this boundary. */ +export function assertCSSPurgingSession( + value: unknown, +): asserts value is CSSPurgingSession { + if ( + typeof value !== "object" || + value === null || + !apply(weakSetHas, capturedSessions, [value]) + ) { + throw new TypeError("CSS purging session must be created by core"); + } +} + +function byteLength(value: string): number { + return apply(encode, encoder, [value]).byteLength; +} + +function matches(pattern: RegExp, value: string): boolean { + return apply(executeRegularExpression, pattern, [value]) !== null; +} + +function hasControlCharacters(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = apply(stringCharacterCodeAt, value, [index]) as number; + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true; + } + return false; +} + +function snapshotContent(value: unknown): readonly CSSPurgeContentSource[] { + const candidates = snapshotDenseDataArray( + value, + MAX_CSS_FILES, + "CSS purge content", + ); + if (candidates.length === 0) { + throw new TypeError("CSS purging requires at least one content source"); + } + + let totalBytes = 0; + const result: CSSPurgeContentSource[] = []; + for (let index = 0; index < candidates.length; index++) { + const candidate = candidates[index]; + if ( + typeof candidate !== "object" || + candidate === null || + isArrayValue(candidate, "CSS purge content source") + ) { + throw new TypeError("CSS purge content source is malformed"); + } + const values = inspectOwnProperties(candidate, "CSS purge content source"); + rejectUnknownOwnProperties( + values, + CONTENT_KEYS, + "CSS purge content source", + ); + const raw = readOwnDataProperty( + values, + "raw", + "CSS purge content source", + true, + ); + const extension = readOwnDataProperty( + values, + "extension", + "CSS purge content source", + true, + ); + if ( + typeof raw !== "string" || + typeof extension !== "string" || + extension.length === 0 || + extension.length > 32 || + !matches(CONTENT_EXTENSION_PATTERN, extension) + ) { + throw new TypeError("CSS purge content source is malformed"); + } + const bytes = byteLength(raw); + if (bytes > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_FILE_BYTES} bytes`, + ); + } + totalBytes += bytes; + if (totalBytes > MAX_CSS_TOTAL_BYTES) { + throw new TypeError( + `CSS purge content exceeds ${MAX_CSS_TOTAL_BYTES} total bytes`, + ); + } + result[result.length] = freeze({ raw, extension }); + } + return freeze(result); +} + +function snapshotSafelist(value: unknown): readonly string[] { + const candidates = snapshotDenseDataArray( + value, + MAX_CSS_PURGE_SAFELIST_ENTRIES, + "CSS purge safelist", + ); + const result: string[] = []; + for (let index = 0; index < candidates.length; index++) { + const candidate = candidates[index]; + if ( + typeof candidate !== "string" || + candidate.length === 0 || + candidate.length > MAX_CSS_SELECTOR_TOKEN_CHARACTERS || + !isWellFormedString(candidate) || + apply(normalize, candidate, ["NFC"]) !== candidate || + hasControlCharacters(candidate) || + matches(/\s/u, candidate) + ) { + throw new TypeError("CSS purge safelist contains an unsafe token"); + } + result[result.length] = candidate; + } + return freeze(result); +} + +function snapshotRequest(value: CSSPurgingRequest): CSSPurgingRequest { + if ( + typeof value !== "object" || + value === null || + isArrayValue(value, "CSS purging request") + ) { + throw new TypeError("CSS purging request must be an object"); + } + const values = inspectOwnProperties(value, "CSS purging request"); + rejectUnknownOwnProperties(values, REQUEST_KEYS, "CSS purging request"); + const css = readOwnDataProperty(values, "css", "CSS purging request", true); + const includeRejectedCSS = readOwnDataProperty( + values, + "includeRejectedCSS", + "CSS purging request", + true, + ); + if (typeof css !== "string" || byteLength(css) > MAX_CSS_FILE_BYTES) { + throw new TypeError( + `CSS purge input must be a string of at most ${MAX_CSS_FILE_BYTES} bytes`, + ); + } + if (typeof includeRejectedCSS !== "boolean") { + throw new TypeError("CSS purging includeRejectedCSS must be a boolean"); + } + return freeze({ + css, + content: snapshotContent( + readOwnDataProperty(values, "content", "CSS purging request", true), + ), + safelist: snapshotSafelist( + readOwnDataProperty(values, "safelist", "CSS purging request", true), + ), + includeRejectedCSS, + }); +} + +function snapshotResult( + value: unknown, + includeRejectedCSS: boolean, +): CSSPurgingResult { + if ( + typeof value !== "object" || + value === null || + isArrayValue(value, "CSS purging result") + ) { + throw new TypeError("CSSPurgingEngine returned an invalid result"); + } + const values = inspectOwnProperties(value, "CSS purging result"); + rejectUnknownOwnProperties(values, RESULT_KEYS, "CSS purging result"); + const css = readOwnDataProperty(values, "css", "CSS purging result", true); + const rejectedDescriptor = inspectedProperty(values, "rejectedCSS"); + if (typeof css !== "string") { + throw new TypeError("CSSPurgingEngine result css must be a string"); + } + + let rejectedCSS: string | undefined; + if (includeRejectedCSS) { + rejectedCSS = readOwnDataProperty( + values, + "rejectedCSS", + "CSS purging result", + true, + ) as string; + if (typeof rejectedCSS !== "string") { + throw new TypeError( + "CSSPurgingEngine did not return requested rejected CSS", + ); + } + } else if (rejectedDescriptor !== undefined) { + throw new TypeError("CSSPurgingEngine returned unrequested rejected CSS"); + } + + const cssBytes = byteLength(css); + const rejectedBytes = rejectedCSS === undefined ? 0 : byteLength(rejectedCSS); + if ( + cssBytes > MAX_CSS_OUTPUT_FILE_BYTES || + rejectedBytes > MAX_CSS_OUTPUT_FILE_BYTES || + cssBytes + rejectedBytes > MAX_CSS_TOTAL_OUTPUT_BYTES + ) { + throw new TypeError( + "CSS purging output exceeds the configured resource limits", + ); + } + return freeze({ + css, + ...(rejectedCSS === undefined ? {} : { rejectedCSS }), + }); +} + +/** Capture a provider and validate every request/result crossing its boundary. */ +export function createCSSPurgingSession( + engine: CSSPurgingEngine, +): CSSPurgingSession { + const captured = captureCSSPurgingEngine(engine); + const session: CSSPurgingSession = freeze({ + cacheIdentity: captured.cacheIdentity, + async run(request: CSSPurgingRequest): Promise { + const input = snapshotRequest(request); + const result = await captured.purge(input); + return snapshotResult(result, input.includeRejectedCSS); + }, + }); + apply(weakSetAdd, capturedSessions, [session]); + return session; +} + +/** Resolve and capture the currently configured provider for one operation. */ +export function acquireConfiguredCSSPurging(): CSSPurgingSession { + return createCSSPurgingSession( + resolve(CSSPurgingEngineName), + ); +} diff --git a/src/build/asset-pipeline/css-optimizer/strategies.test.ts b/src/build/asset-pipeline/css-optimizer/strategies.test.ts index 1760e75baf..5b3c38fba4 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies.test.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies.test.ts @@ -4,8 +4,10 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; 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 { MinificationStrategy, PurgeStrategy } from "./strategies/index.ts"; import type { CSSOptimizationOptions } from "./types/index.ts"; +import { createTestCSSOptimizationEngine } from "../../../../tests/_helpers/css-optimization-engine.ts"; +import { createTestCSSPurgingEngine } from "../../../../tests/_helpers/css-purging-engine.ts"; const TEST_DIR = "./.veryfront/test-strategies"; @@ -34,10 +36,13 @@ const TEST_CSS = ` display: none; } `; +const minificationEngine = createTestCSSOptimizationEngine((request) => ({ + css: request.css.includes("button") ? ".button{color:red}" : request.css, +})); describe("MinificationStrategy", () => { it("canProcess returns true when enabled and minify is true", () => { - const strategy = new MinificationStrategy(); + const strategy = new MinificationStrategy(minificationEngine); assertEquals(strategy.canProcess({ enabled: true, minify: true }), true); assertEquals(strategy.canProcess({ enabled: false }), false); @@ -45,7 +50,7 @@ describe("MinificationStrategy", () => { }); it("process removes comments", async () => { - const strategy = new MinificationStrategy(); + const strategy = new MinificationStrategy(minificationEngine); const options: CSSOptimizationOptions = { enabled: true, minify: true }; const result = await strategy.process(TEST_CSS, "test.css", options); @@ -55,7 +60,7 @@ describe("MinificationStrategy", () => { }); it("process removes whitespace", async () => { - const strategy = new MinificationStrategy(); + const strategy = new MinificationStrategy(minificationEngine); const options: CSSOptimizationOptions = { enabled: true, minify: true }; const css = ".button { color: red; }"; @@ -65,24 +70,11 @@ describe("MinificationStrategy", () => { }); }); -describe("LightningCSSStrategy", () => { - it("canProcess returns false when not initialized", () => { - const strategy = new LightningCSSStrategy(); - - assertEquals(strategy.canProcess({ enabled: true }), false); - }); - - it("init attempts to load", async () => { - const strategy = new LightningCSSStrategy(); - const success = await strategy.init(); - - assertEquals(typeof success, "boolean"); - }); -}); - describe("PurgeStrategy", () => { it("canProcess returns true when enabled and purge is true", () => { - const strategy = new PurgeStrategy(); + const strategy = new PurgeStrategy({ + purgingEngine: createTestCSSPurgingEngine(), + }); assertEquals(strategy.canProcess({ enabled: true, purge: true }), true); assertEquals(strategy.canProcess({ enabled: true, purge: false }), false); @@ -108,7 +100,7 @@ describe("PurgeStrategy", () => { await cleanupTestDir(); }); - it("process removes unused rules", async () => { + it("process routes analyzed content through the configured provider", async () => { await setupTestSrcDir(); await writeTextFile( @@ -121,7 +113,9 @@ describe("PurgeStrategy", () => { .unused { color: red; } `; - const strategy = new PurgeStrategy(); + const strategy = new PurgeStrategy({ + purgingEngine: createTestCSSPurgingEngine(), + }); const options: CSSOptimizationOptions = { enabled: true, purge: true, @@ -157,12 +151,9 @@ describe("PurgeStrategy", () => { describe("Legacy strategy metadata", () => { it("keeps priority values stable for direct strategy consumers", () => { - const lightning = new LightningCSSStrategy(); - const minification = new MinificationStrategy(); + const minification = new MinificationStrategy(minificationEngine); const purge = new PurgeStrategy(); - assertEquals(lightning.priority > purge.priority, true); - assertEquals(lightning.priority > minification.priority, true); assertEquals(purge.priority > minification.priority, true); }); }); diff --git a/src/build/asset-pipeline/css-optimizer/strategies/index.ts b/src/build/asset-pipeline/css-optimizer/strategies/index.ts index 445740cd21..47817efb64 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies/index.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies/index.ts @@ -4,6 +4,5 @@ * @module build/asset-pipeline/css-optimizer/strategies */ -export { LightningCSSStrategy } from "./lightning-strategy.ts"; export { MinificationStrategy } from "./minification-strategy.ts"; export { PurgeStrategy } from "./purge-strategy.ts"; 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 deleted file mode 100644 index 48243e8538..0000000000 --- a/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import { LightningCSSStrategy } from "./lightning-strategy.ts"; - -describe("build/asset-pipeline/css-optimizer/strategies/lightning-strategy", () => { - describe("LightningCSSStrategy", () => { - it("should have correct name and priority", () => { - const strategy = new LightningCSSStrategy(); - assertEquals(strategy.name, "lightning-css"); - assertEquals(strategy.priority, 100); - }); - - describe("isAvailable", () => { - it("should return false before initialization", () => { - const strategy = new LightningCSSStrategy(); - assertEquals(strategy.isAvailable(), false); - }); - }); - - describe("canProcess", () => { - it("should return false when lightningCSS is not loaded", () => { - const strategy = new LightningCSSStrategy(); - assertEquals(strategy.canProcess({}), false); - }); - - it("should return false when disabled", () => { - const strategy = new LightningCSSStrategy(); - assertEquals(strategy.canProcess({ enabled: false }), false); - }); - }); - - describe("process", () => { - it("should reject when not initialized", async () => { - const strategy = new LightningCSSStrategy(); - await assertRejects( - () => strategy.process("body{}", "test.css", {}), - Error, - "Lightning CSS not initialized", - ); - }); - }); - - describe("init", () => { - it("should load the required pinned compiler", async () => { - const strategy = new LightningCSSStrategy(); - const result = await strategy.init(); - assertEquals(result, true); - assertEquals(strategy.isAvailable(), true); - }); - - it("should not re-initialize on subsequent calls", async () => { - const strategy = new LightningCSSStrategy(); - const first = await strategy.init(); - 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 deleted file mode 100644 index 28472b3f9e..0000000000 --- a/src/build/asset-pipeline/css-optimizer/strategies/lightning-strategy.ts +++ /dev/null @@ -1,100 +0,0 @@ -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, - CSSProcessingResult, - LightningCSSModule, -} from "../types/index.ts"; -import { parseBrowserTargets } from "../utils.ts"; - -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 initialization: Promise | null = null; - private readonly loader: LightningCSSLoader; - - 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 { - return this.lightningCSS !== null && options.enabled !== false; - } - - async process( - content: string, - filename: string, - options: CSSOptimizationOptions, - ): Promise { - if (!this.lightningCSS) { - throw INITIALIZATION_ERROR.create({ - detail: "Lightning CSS not initialized", - }); - } - - 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 { - return this.lightningCSS !== null; - } -} diff --git a/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.test.ts b/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.test.ts index 6613f53d07..dd88214960 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.test.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.test.ts @@ -2,10 +2,15 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { MinificationStrategy } from "./minification-strategy.ts"; +import { createTestCSSOptimizationEngine } from "../../../../../tests/_helpers/css-optimization-engine.ts"; describe("build/asset-pipeline/css-optimizer/strategies/minification-strategy", () => { describe("MinificationStrategy", () => { - const strategy = new MinificationStrategy(); + const strategy = new MinificationStrategy( + createTestCSSOptimizationEngine((request) => ({ + css: request.css.replaceAll(/\s+/g, ""), + })), + ); it("should have correct name and priority", () => { assertEquals(strategy.name, "basic-minification"); 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 a6be77d637..40db8122d1 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies/minification-strategy.ts @@ -1,15 +1,25 @@ import { logger } from "#veryfront/utils"; +import type { CSSOptimizationEngine } from "#veryfront/extensions/css/index.ts"; import type { CSSOptimizationOptions, CSSOptimizationStrategy, CSSProcessingResult, } from "../types/index.ts"; -import { basicMinify } from "../utils.ts"; +import { + acquireConfiguredCSSOptimization, + createCSSOptimizationSession, +} from "../optimization-engine.ts"; export class MinificationStrategy implements CSSOptimizationStrategy { readonly name = "basic-minification"; readonly priority = 10; + readonly #engine: CSSOptimizationEngine | undefined; + + constructor(engine?: CSSOptimizationEngine) { + this.#engine = engine; + } + canProcess(options: CSSOptimizationOptions): boolean { return options.enabled !== false && options.minify !== false; } @@ -21,9 +31,16 @@ export class MinificationStrategy implements CSSOptimizationStrategy { ): Promise { logger.debug(`Using parser-backed minification for ${filename}`); + const session = this.#engine === undefined + ? acquireConfiguredCSSOptimization() + : createCSSOptimizationSession(this.#engine); return { - code: basicMinify(content), - sourceMap: undefined, + code: session.run({ + css: content, + sourcePath: filename, + minify: true, + sourceMap: false, + }).css, }; } } 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 3ac5adef08..9b01f906ce 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,150 +1,222 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import type { CSSPurgingEngine, CSSPurgingRequest } from "#veryfront/extensions/css/index.ts"; +import { CSSPurgingEngineName } from "#veryfront/extensions/css/index.ts"; +import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; +import { createTestCSSPurgingEngine } from "../../../../../tests/_helpers/css-purging-engine.ts"; import { PurgeStrategy } from "./purge-strategy.ts"; describe("build/asset-pipeline/css-optimizer/strategies/purge-strategy", () => { - describe("PurgeStrategy", () => { - it("should have correct name and priority", () => { - const strategy = new PurgeStrategy(); - assertEquals(strategy.name, "purge-css"); - assertEquals(strategy.priority, 50); - }); - - describe("canProcess", () => { - it("should return true when enabled and purge is true", () => { - const strategy = new PurgeStrategy(); - assertEquals(strategy.canProcess({ enabled: true, purge: true }), true); - }); - - it("should return true when purge is true and enabled is not set", () => { - const strategy = new PurgeStrategy(); - assertEquals(strategy.canProcess({ purge: true }), true); - }); - - it("should return false when purge is false", () => { - const strategy = new PurgeStrategy(); - assertEquals(strategy.canProcess({ purge: false }), false); - }); - - it("should return false when disabled", () => { - const strategy = new PurgeStrategy(); - assertEquals(strategy.canProcess({ enabled: false, purge: true }), false); - }); + it("keeps stable strategy metadata and activation rules", () => { + const strategy = new PurgeStrategy(); + assertEquals(strategy.name, "purge-css"); + assertEquals(strategy.priority, 50); + assertEquals(strategy.canProcess({ enabled: true, purge: true }), true); + assertEquals(strategy.canProcess({ purge: true }), true); + assertEquals(strategy.canProcess({ purge: false }), false); + assertEquals(strategy.canProcess({ enabled: false, purge: true }), false); + assertEquals(strategy.canProcess({}), false); + }); - it("should return false when purge is not set", () => { - const strategy = new PurgeStrategy(); - assertEquals(strategy.canProcess({}), false); - }); + it("clears analyzed selector and content evidence", async () => { + const strategy = new PurgeStrategy({ + collectContent: () => + Promise.resolve([{ + path: "page.tsx", + raw: '
', + extension: "tsx", + }]), }); + await strategy.analyzeContent(["app/**/*.tsx"]); + assertEquals(strategy.getUsedSelectors().has(".test-class"), true); + strategy.clearCache(); + assertEquals(strategy.getUsedSelectors().size, 0); + }); - describe("getUsedSelectors / clearCache", () => { - it("should start with empty used selectors", () => { - const strategy = new PurgeStrategy(); - assertEquals(strategy.getUsedSelectors().size, 0); - }); - - it("should clear used selectors cache", () => { - const strategy = new PurgeStrategy(); - strategy.getUsedSelectors().add(".test-class"); - - assertEquals(strategy.getUsedSelectors().size, 1); - - strategy.clearCache(); - - assertEquals(strategy.getUsedSelectors().size, 0); - }); + it("routes a detached selector-evidence request through the explicit provider", async () => { + let received: CSSPurgingRequest | undefined; + const strategy = new PurgeStrategy({ + purgingEngine: createTestCSSPurgingEngine((request) => { + received = request; + return Promise.resolve({ css: ".provider-output {}" }); + }), }); + strategy.getUsedSelectors().add(".used-class"); - describe("process", () => { - it("should process CSS when used selectors are pre-populated", async () => { - const strategy = new PurgeStrategy(); - strategy.getUsedSelectors().add(".used-class"); - - const css = `.used-class { color: red; } -.unused-class { color: blue; }`; - - const result = await strategy.process(css, "test.css", {}); - - assertEquals(typeof result.code, "string"); - assertEquals(result.code.includes(".used-class"), true); - assertEquals(result.code.includes(".unused-class"), false); - assertEquals(result.sourceMap, undefined); - }); - - it("should keep rules that match any used selector", async () => { - const strategy = new PurgeStrategy(); - strategy.getUsedSelectors().add("body"); - strategy.getUsedSelectors().add(".keep"); - - const css = `body { margin: 0; } -.keep { display: block; } -.remove { display: none; }`; - - const result = await strategy.process(css, "test.css", {}); + const result = await strategy.process( + ".used-class {} .unused-class {}", + "test.css", + { purgeSafelist: [".dynamic"] }, + ); - assertEquals(result.code.includes("body"), true); - assertEquals(result.code.includes(".keep"), true); - assertEquals(result.code.includes(".remove"), false); - }); + assertEquals(result, { + code: ".provider-output {}", + sourceMap: undefined, + }); + assertEquals(received, { + css: ".used-class {} .unused-class {}", + content: [{ raw: "used-class", extension: "html" }], + safelist: ["dynamic", "used-class"], + includeRejectedCSS: false, + }); + assertEquals(Object.isFrozen(received), true); + }); - 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("routes collected raw content without inventing selector safelist entries", async () => { + let received: CSSPurgingRequest | undefined; + const strategy = new PurgeStrategy({ + collectContent: () => + Promise.resolve([{ + path: "page.tsx", + raw: '
', + extension: "tsx", + }]), + purgingEngine: createTestCSSPurgingEngine((request) => { + received = request; + return Promise.resolve({ css: request.css }); + }), + }); + await strategy.analyzeContent(["app/**/*.tsx"]); + await strategy.process(".used {}", "test.css", { + purgeSafelist: ["dynamic"], + }); - 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); - }); + assertEquals(received?.content, [{ + raw: '
', + extension: "tsx", + }]); + assertEquals(received?.safelist, ["dynamic"]); + }); - it("should reject missing purge evidence", async () => { - const strategy = new PurgeStrategy(); - await assertRejects( - () => strategy.process(".x {}", "test.css", {}), - TypeError, - "requires non-empty", - ); - }); + it("rejects missing purge evidence and missing configured providers", async () => { + const injected = new PurgeStrategy({ + purgingEngine: createTestCSSPurgingEngine(), + }); + await assertRejects( + () => injected.process(".x {}", "test.css", {}), + TypeError, + "requires non-empty", + ); + + const previous = tryResolve(CSSPurgingEngineName); + unregister(CSSPurgingEngineName); + try { + const unconfigured = new PurgeStrategy(); + unconfigured.getUsedSelectors().add(".x"); + await assertRejects( + () => unconfigured.process(".x {}", "test.css", {}), + Error, + 'Missing extension for contract "CSSPurgingEngine"', + ); + } finally { + if (previous !== undefined) { + register(CSSPurgingEngineName, previous); + } + } + }); - 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", - ); + it("validates injected content and mutable selector evidence before invocation", 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({ + purgingEngine: createTestCSSPurgingEngine(), + }); + mutatedSelectors.getUsedSelectors().add("bad\nselector"); + await assertRejects( + () => mutatedSelectors.process(".bad {}", "test.css", {}), + TypeError, + "unsafe token", + ); + }); - const mutatedSelectors = new PurgeStrategy(); - mutatedSelectors.getUsedSelectors().add("bad\nselector"); - await assertRejects( - () => mutatedSelectors.process(".bad {}", "test.css", {}), - TypeError, - "unsafe token", - ); - }); + it("does not invoke custom iterators or content accessors while snapshotting inputs", async () => { + let patternIteratorCalls = 0; + const patterns = ["app/**/*.tsx"]; + Object.defineProperty(patterns, Symbol.iterator, { + get() { + patternIteratorCalls++; + return Array.prototype[Symbol.iterator]; + }, + }); + await assertRejects( + () => new PurgeStrategy().analyzeContent(patterns), + TypeError, + "dense data-property array", + ); + assertEquals(patternIteratorCalls, 0); + + let contentGetterCalls = 0; + const accessorContent = new PurgeStrategy({ + collectContent: () => + Promise.resolve([{ + path: "page.tsx", + get raw() { + contentGetterCalls++; + return "
"; + }, + extension: "tsx", + }]), + }); + await assertRejects( + () => accessorContent.analyzeContent(["app/**/*.tsx"]), + TypeError, + "own data property", + ); + assertEquals(contentGetterCalls, 0); + + let safelistIteratorCalls = 0; + const safelist = ["dynamic"]; + Object.defineProperty(safelist, Symbol.iterator, { + get() { + safelistIteratorCalls++; + return Array.prototype[Symbol.iterator]; + }, + }); + const safelistStrategy = new PurgeStrategy({ + purgingEngine: createTestCSSPurgingEngine(), + }); + safelistStrategy.getUsedSelectors().add(".used"); + await assertRejects( + () => + safelistStrategy.process(".used {}", "test.css", { + purgeSafelist: safelist, + }), + TypeError, + "dense data-property array", + ); + assertEquals(safelistIteratorCalls, 0); + + let selectorIteratorCalls = 0; + const selectorStrategy = new PurgeStrategy({ + purgingEngine: createTestCSSPurgingEngine((request) => Promise.resolve({ css: request.css })), + }); + const selectors = selectorStrategy.getUsedSelectors(); + selectors.add(".used"); + Object.defineProperty(selectors, Symbol.iterator, { + get() { + selectorIteratorCalls++; + return Set.prototype[Symbol.iterator]; + }, }); + const result = await selectorStrategy.process( + ".used {}", + "test.css", + {}, + ); + assertEquals(result.code, ".used {}"); + assertEquals(selectorIteratorCalls, 0); }); }); 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 6469e26cd8..5ea8452971 100644 --- a/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.ts +++ b/src/build/asset-pipeline/css-optimizer/strategies/purge-strategy.ts @@ -1,5 +1,5 @@ import { extname, resolve } from "#veryfront/compat/path/index.ts"; -import { DEPENDENCY_MISSING } from "#veryfront/errors"; +import type { CSSPurgingEngine } from "#veryfront/extensions/css/index.ts"; import { createFileSystem, type FileSystem } from "#veryfront/platform/compat/fs.ts"; import { cwd } from "#veryfront/platform/compat/process.ts"; import { logger } from "#veryfront/utils"; @@ -8,14 +8,25 @@ 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 { + inspectOwnProperties, + isArrayValue, + readOwnDataProperty, + rejectUnknownOwnProperties, + snapshotDenseDataArray, +} from "../data-snapshot.ts"; +import { + acquireConfiguredCSSPurging, + assertCSSPurgingSession, + createCSSPurgingSession, + type CSSPurgingSession, +} from "../purging-engine.ts"; import type { CSSOptimizationOptions, CSSOptimizationStrategy, @@ -23,29 +34,12 @@ import type { } from "../types/index.ts"; import { extractSelectors, globFiles } from "../utils.ts"; -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; @@ -54,11 +48,7 @@ export interface PurgeStrategyDependencies { baseDir?: string; fs?: FileSystem; collectContent?: ContentCollector; - loadPurgeCSS?: PurgeCSSLoader; -} - -function defaultPurgeCSSLoader(): Promise { - return import(PURGE_CSS_MODULE_SPECIFIER); + purgingEngine?: CSSPurgingEngine; } function selectorToken(selector: string): string { @@ -66,6 +56,14 @@ function selectorToken(selector: string): string { } const encoder = new TextEncoder(); +const apply = Reflect.apply; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const setAdd = Set.prototype.add; +const setClear = Set.prototype.clear; +const setForEach = Set.prototype.forEach; +const setHas = Set.prototype.has; +const setSize = getOwnPropertyDescriptor(Set.prototype, "size")?.get; +const CONTENT_SOURCE_KEYS = ["path", "raw", "extension"] as const; function isSafeBoundedString( value: unknown, @@ -77,14 +75,21 @@ function isSafeBoundedString( !hasControlCharacters(value); } +function setEntryCount(value: Set): number { + if (setSize === undefined) { + throw new TypeError("Set size accessor is unavailable"); + } + return apply(setSize, value, []) as number; +} + function validateSelectorEvidence(selectors: Set): string[] { - if (!(selectors instanceof Set) || selectors.size > MAX_CSS_SELECTOR_TOKENS) { + if (setEntryCount(selectors) > 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) { + const tokens: string[] = []; + apply(setForEach, selectors, [(selector: unknown) => { if ( !isSafeBoundedString(selector, MAX_CSS_SELECTOR_TOKEN_CHARACTERS) || /\s/u.test(selector) @@ -99,49 +104,84 @@ function validateSelectorEvidence(selectors: Set): string[] { throw new TypeError("CSS selector evidence contains an unsafe token"); } tokens.push(token); - } + }]); return tokens.sort(); } +function uniqueStrings(values: readonly string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (let index = 0; index < values.length; index++) { + const value = values[index]!; + if (apply(setHas, seen, [value])) continue; + apply(setAdd, seen, [value]); + result[result.length] = value; + } + return result; +} + 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`, - ); - } + const candidates = snapshotDenseDataArray( + value, + MAX_CSS_FILES, + "CSS purge content", + ); let totalBytes = 0; const paths = new Set(); const sources: PurgeContentSource[] = []; - for (let index = 0; index < value.length; index++) { - const candidate: unknown = value[index]; + for (let index = 0; index < candidates.length; index++) { + const candidate = candidates[index]; if ( typeof candidate !== "object" || candidate === null || - Array.isArray(candidate) + isArrayValue(candidate, "CSS purge content source") ) { throw new TypeError("CSS purge content source is malformed"); } - const source = candidate as Partial; + const values = inspectOwnProperties(candidate, "CSS purge content source"); + rejectUnknownOwnProperties( + values, + CONTENT_SOURCE_KEYS, + "CSS purge content source", + ); + const path = readOwnDataProperty( + values, + "path", + "CSS purge content source", + true, + ); + const raw = readOwnDataProperty( + values, + "raw", + "CSS purge content source", + true, + ); + const extension = readOwnDataProperty( + values, + "extension", + "CSS purge content source", + true, + ); if ( - !isSafeBoundedString(source.path, MAX_PATH_LENGTH_CHARS) || - typeof source.raw !== "string" || - !isSafeBoundedString(source.extension, 32) || - !/^[A-Za-z0-9]+$/.test(source.extension) + !isSafeBoundedString(path, MAX_PATH_LENGTH_CHARS) || + typeof raw !== "string" || + !isSafeBoundedString(extension, 32) || + !/^[A-Za-z0-9]+$/.test(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}`); + if (paths.has(path)) { + throw new TypeError(`Duplicate CSS purge content source: ${path}`); } - paths.add(source.path); + paths.add(path); - const bytes = encoder.encode(source.raw).length; + const bytes = encoder.encode(raw).length; if (bytes > MAX_CSS_FILE_BYTES) { throw new TypeError( - `CSS purge content exceeds ${MAX_CSS_FILE_BYTES} bytes: ${source.path}`, + `CSS purge content exceeds ${MAX_CSS_FILE_BYTES} bytes: ${path}`, ); } totalBytes += bytes; @@ -151,9 +191,9 @@ function validateContentSources( ); } sources.push({ - path: source.path, - raw: source.raw, - extension: source.extension.toLowerCase(), + path, + raw, + extension: extension.toLowerCase(), }); } return sources; @@ -166,11 +206,9 @@ export class PurgeStrategy implements CSSOptimizationStrategy { private readonly baseDir: string; private readonly fs: FileSystem; private readonly collectContentDependency: ContentCollector; - private readonly loadPurgeCSSDependency: PurgeCSSLoader; + private readonly purgingEngine: CSSPurgingEngine | undefined; private readonly usedSelectors = new Set(); private contentSources: PurgeContentSource[] = []; - private purgeCSSModule: Promise | null = null; - private purgeCSSLoadGeneration = 0; constructor(dependencies: PurgeStrategyDependencies = {}) { if ( @@ -186,18 +224,11 @@ export class PurgeStrategy implements CSSOptimizationStrategy { ) { 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; + this.purgingEngine = dependencies.purgingEngine; } canProcess(options: CSSOptimizationOptions): boolean { @@ -205,13 +236,14 @@ export class PurgeStrategy implements CSSOptimizationStrategy { } async analyzeContent(purgeContent: string[]): Promise { + const patterns = snapshotDenseDataArray( + purgeContent, + MAX_CSS_PURGE_PATTERNS, + "CSS purgeContent", + ); if ( - !Array.isArray(purgeContent) || - purgeContent.length === 0 || - purgeContent.length > MAX_CSS_PURGE_PATTERNS || - Array.from(purgeContent).some((pattern) => - !isSafeBoundedString(pattern, MAX_PATH_LENGTH_CHARS) - ) + patterns.length === 0 || + patterns.some((pattern) => !isSafeBoundedString(pattern, MAX_PATH_LENGTH_CHARS)) ) { throw new TypeError( `CSS purgeContent must contain from 1 through ${MAX_CSS_PURGE_PATTERNS} patterns`, @@ -220,7 +252,7 @@ export class PurgeStrategy implements CSSOptimizationStrategy { logger.debug("Analyzing content for CSS purging"); const contentSources = validateContentSources( - await this.collectContentDependency([...purgeContent]), + await this.collectContentDependency(patterns as string[]), ); if (contentSources.length === 0) { throw new TypeError("CSS purgeContent patterns matched no files"); @@ -229,26 +261,31 @@ export class PurgeStrategy implements CSSOptimizationStrategy { 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) { + apply(setForEach, selectors, [(selector: string) => { + apply(setAdd, usedSelectors, [selector]); + if (setEntryCount(usedSelectors) > MAX_CSS_SELECTOR_TOKENS) { throw new TypeError( `CSS selector evidence exceeds ${MAX_CSS_SELECTOR_TOKENS} entries`, ); } - } + }]); } 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`); + apply(setClear, this.usedSelectors, []); + apply(setForEach, usedSelectors, [(selector: string) => { + apply(setAdd, this.usedSelectors, [selector]); + }]); + logger.debug( + `Found ${setEntryCount(this.usedSelectors)} statically visible selectors`, + ); } async process( content: string, _filename: string, options: CSSOptimizationOptions, + operationSession?: CSSPurgingSession, ): Promise { if (typeof content !== "string") { throw new TypeError("CSS purge input must be a string"); @@ -260,23 +297,23 @@ export class PurgeStrategy implements CSSOptimizationStrategy { if (this.contentSources.length === 0) { if (options.purgeContent?.length) { await this.analyzeContent(options.purgeContent); - } else if (this.usedSelectors.size === 0) { + } else if (setEntryCount(this.usedSelectors) === 0) { throw new TypeError( "CSS purging requires non-empty purgeContent or an analyzed selector set", ); } } + const configuredSafelistValues = options.purgeSafelist === undefined + ? [] + : snapshotDenseDataArray( + options.purgeSafelist, + MAX_CSS_PURGE_SAFELIST_ENTRIES, + "CSS purgeSafelist", + ); 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) => + configuredSafelistValues.length > MAX_CSS_PURGE_SAFELIST_ENTRIES || + configuredSafelistValues.some((entry) => !isSafeBoundedString(entry, MAX_CSS_SELECTOR_TOKEN_CHARACTERS) ) ) { @@ -284,6 +321,7 @@ export class PurgeStrategy implements CSSOptimizationStrategy { `CSS purgeSafelist must contain at most ${MAX_CSS_PURGE_SAFELIST_ENTRIES} non-empty strings`, ); } + const configuredSafelist = configuredSafelistValues as string[]; const configuredSafelistTokens = configuredSafelist.map(selectorToken); if ( configuredSafelistTokens.some((entry) => @@ -294,15 +332,17 @@ export class PurgeStrategy implements CSSOptimizationStrategy { throw new TypeError("CSS purgeSafelist contains an unsafe selector token"); } - const module = await this.loadPurgeCSS(); + if (operationSession !== undefined) { + assertCSSPurgingSession(operationSession); + } + const session = operationSession ?? this.createOperationSession(); const selectorTokens = validateSelectorEvidence(this.usedSelectors); - const safelist = [ - ...new Set([ - ...configuredSafelistTokens, - ...(this.contentSources.length === 0 ? selectorTokens : []), - ]), - ]; - const results = await new module.PurgeCSS().purge({ + const safelist = uniqueStrings( + this.contentSources.length === 0 + ? [...configuredSafelistTokens, ...selectorTokens] + : configuredSafelistTokens, + ); + const result = await session.run({ content: this.contentSources.length > 0 ? this.contentSources.map(({ raw, extension }) => ({ raw, @@ -312,21 +352,11 @@ export class PurgeStrategy implements CSSOptimizationStrategy { raw: selectorTokens.join(" "), extension: "html", }], - css: [{ raw: content }], + css: content, safelist, + includeRejectedCSS: false, }); - 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 }; + return { code: result.css, sourceMap: undefined }; } getUsedSelectors(): Set { @@ -334,35 +364,15 @@ export class PurgeStrategy implements CSSOptimizationStrategy { } clearCache(): void { - this.usedSelectors.clear(); + apply(setClear, this.usedSelectors, []); 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; - } + /** Capture exactly one configured provider for a complete purge operation. */ + createOperationSession(): CSSPurgingSession { + return this.purgingEngine === undefined + ? acquireConfiguredCSSPurging() + : createCSSPurgingSession(this.purgingEngine); } private async collectContent( diff --git a/src/build/asset-pipeline/css-optimizer/types/index.ts b/src/build/asset-pipeline/css-optimizer/types/index.ts index f47b12a1d8..c9aa36e56a 100644 --- a/src/build/asset-pipeline/css-optimizer/types/index.ts +++ b/src/build/asset-pipeline/css-optimizer/types/index.ts @@ -4,38 +4,15 @@ * @module build/asset-pipeline/css-optimizer/types */ -export interface LightningCSSTransformOptions { - filename: string; - code: Uint8Array; - minify?: boolean; - sourceMap?: boolean; - targets?: BrowserTargets; - analyzeDependencies?: boolean; -} - -export interface LightningCSSTransformResult { - code: Uint8Array; - map?: Uint8Array | void; -} - -export interface LightningCSSModule { - transform: (options: LightningCSSTransformOptions) => LightningCSSTransformResult; - default?: unknown; -} - -export interface BrowserTargets { - [browser: string]: number | undefined; - chrome?: number; - firefox?: number; - safari?: number; - edge?: number; -} - export interface CSSOptimizationOptions { enabled?: boolean; /** Absolute project boundary for CSS inputs, content scans, and outputs. */ projectDir?: string; minify?: boolean; + /** + * @deprecated Compatibility transforms belong to the configured CSS + * optimization extension. Core rejects this vendor-specific legacy option. + */ autoprefixer?: boolean; purge?: boolean; /** @@ -46,9 +23,13 @@ export interface CSSOptimizationOptions { inputFiles?: string[]; inputDir?: string; outputDir?: string; + /** + * @deprecated Browser query grammar belongs to the configured CSS + * optimization extension. Pass browser queries to that extension instead. + */ browsers?: string[]; purgeContent?: string[]; - /** Literal selectors or tokens that PurgeCSS must retain. */ + /** Literal selectors or tokens that the configured purging engine must retain. */ purgeSafelist?: string[]; sourceMap?: boolean; } diff --git a/src/build/asset-pipeline/css-optimizer/utils.test.ts b/src/build/asset-pipeline/css-optimizer/utils.test.ts index 750f568738..26bffea0da 100644 --- a/src/build/asset-pipeline/css-optimizer/utils.test.ts +++ b/src/build/asset-pipeline/css-optimizer/utils.test.ts @@ -9,16 +9,13 @@ import { join } from "#veryfront/compat/path"; import { remove, writeTextFile } from "#veryfront/compat/fs.ts"; import { ensureDir } from "#veryfront/compat/std/fs.ts"; import { - basicMinify, calculateSavings, extractSelectors, findCSSFiles, getOutputPath, globFiles, matchPattern, - parseBrowserTargets, shouldKeepSelector, - validateCSSSourceMap, } from "./utils.ts"; const TEST_DIR = "./.veryfront/test-css-utils"; @@ -149,33 +146,6 @@ describe("CSS Optimizer Utils", () => { }); }); - describe("basicMinify", () => { - it("removes comments", () => { - const css = "/* Comment */ .button { color: red; }"; - const minified = basicMinify(css); - - assertEquals(minified.includes("/*"), false); - }); - - it("removes whitespace", () => { - const css = ".button { color: red; }"; - const minified = basicMinify(css); - - assertEquals(minified, ".button{color:red}"); - }); - - it("removes semicolons before braces", () => { - const css = ".button { color: red; }"; - const minified = basicMinify(css); - - assertEquals(minified, ".button{color:red}"); - }); - - it("rejects malformed CSS instead of applying regex rewrites", () => { - assertThrows(() => basicMinify("@media ( { .x { color: red }")); - }); - }); - describe("calculateSavings", () => { it("calculates percentage savings correctly", () => { assertEquals(calculateSavings(1000, 500), 50); @@ -240,53 +210,4 @@ describe("CSS Optimizer Utils", () => { } }); }); - - 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 5d435a5d8f..68fb44989f 100644 --- a/src/build/asset-pipeline/css-optimizer/utils.ts +++ b/src/build/asset-pipeline/css-optimizer/utils.ts @@ -13,21 +13,11 @@ 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"; +import { MAX_CSS_DIRECTORY_DEPTH, MAX_CSS_DIRECTORY_ENTRIES, MAX_CSS_FILES } from "./constants.ts"; +import { runConfiguredCSSOptimization } from "./optimization-engine.ts"; +import { isSafeCSSRelativePath } from "./path-validation.ts"; + +export { isSafeCSSRelativePath } from "./path-validation.ts"; interface DirectoryReader { readDir( @@ -49,8 +39,6 @@ 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 { @@ -251,27 +239,6 @@ export function matchPattern(path: string, pattern: string): boolean { }).test(portablePath(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 portableInput = portablePath(inputPath).normalize("NFC"); if (!isSafeCSSRelativePath(portableInput)) { @@ -362,22 +329,12 @@ export function shouldKeepSelector( * rewritten by regular expressions. */ export function basicMinify(css: string): string { - 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, + return runConfiguredCSSOptimization({ + css, + sourcePath: "inline.css", minify: true, - analyzeDependencies: false, - }); - return decoder.decode(result.code); + sourceMap: false, + }).css; } export function calculateSavings( @@ -395,119 +352,3 @@ export function calculateSavings( 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 === undefined) return undefined; - if ( - typeof targets === "object" && - targets !== null && - !Array.isArray(targets) - ) { - return validateBrowserTargets(targets); - } - - 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 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 0ea4f0260e..14cb5ddbbb 100644 --- a/src/build/asset-pipeline/image-optimizer/constants.test.ts +++ b/src/build/asset-pipeline/image-optimizer/constants.test.ts @@ -1,12 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { - DEFAULT_OPTIONS, - MANIFEST_FILENAME, - SHARP_MODULE_SPECIFIER, - SUPPORTED_EXTENSIONS, -} from "./constants.ts"; +import { DEFAULT_OPTIONS, MANIFEST_FILENAME, SUPPORTED_EXTENSIONS } from "./constants.ts"; describe("build/asset-pipeline/image-optimizer/constants", () => { describe("DEFAULT_OPTIONS", () => { @@ -52,12 +47,6 @@ describe("build/asset-pipeline/image-optimizer/constants", () => { }); }); - describe("SHARP_MODULE_SPECIFIER", () => { - it("pins the required npm dependency", () => { - assertEquals(SHARP_MODULE_SPECIFIER, "npm:sharp@0.34.5"); - }); - }); - describe("MANIFEST_FILENAME", () => { it("should be image-manifest.json", () => { assertEquals(MANIFEST_FILENAME, "image-manifest.json"); diff --git a/src/build/asset-pipeline/image-optimizer/constants.ts b/src/build/asset-pipeline/image-optimizer/constants.ts index 500dc06b3b..e265a339f2 100644 --- a/src/build/asset-pipeline/image-optimizer/constants.ts +++ b/src/build/asset-pipeline/image-optimizer/constants.ts @@ -16,10 +16,13 @@ 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_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; +export const MAX_IMAGE_INPUT_BYTES = 64 * 1024 * 1024; +export const MAX_IMAGE_DECODED_PIXELS = 100_000_000; +export const MAX_IMAGE_OUTPUT_BYTES_PER_VARIANT = 64 * 1024 * 1024; +export const MAX_IMAGE_TOTAL_OUTPUT_BYTES = 256 * 1024 * 1024; +export const MAX_IMAGE_PROVIDER_DURATION_MS = 120_000; diff --git a/src/build/asset-pipeline/image-optimizer/format-processor.test.ts b/src/build/asset-pipeline/image-optimizer/format-processor.test.ts deleted file mode 100644 index bb10a316e3..0000000000 --- a/src/build/asset-pipeline/image-optimizer/format-processor.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import "#veryfront/schemas/_test-setup.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"; - -function createMockSharp(): SharpInstance & { - lastCall: { method: string; args: unknown[] } | null; -} { - const mock: SharpInstance & { lastCall: { method: string; args: unknown[] } | null } = { - lastCall: null, - metadata: () => Promise.resolve({}), - clone: () => mock, - resize: () => mock, - webp: function (options?) { - mock.lastCall = { method: "webp", args: [options] }; - return mock; - }, - avif: function (options?) { - mock.lastCall = { method: "avif", args: [options] }; - return mock; - }, - jpeg: function (options?) { - mock.lastCall = { method: "jpeg", args: [options] }; - return mock; - }, - png: function (options?) { - mock.lastCall = { method: "png", args: [options] }; - return mock; - }, - toBuffer: () => Promise.resolve(new Uint8Array()), - }; - return mock; -} - -describe("build/asset-pipeline/image-optimizer/format-processor", () => { - describe("processFormat", () => { - it("should process webp format with quality", () => { - const mock = createMockSharp(); - const result = processFormat(mock, "webp", 80); - assertEquals(result, mock); - assertEquals(mock.lastCall?.method, "webp"); - assertEquals(mock.lastCall?.args, [{ quality: 80 }]); - }); - - it("should process avif format with quality", () => { - const mock = createMockSharp(); - processFormat(mock, "avif", 60); - assertEquals(mock.lastCall?.method, "avif"); - assertEquals(mock.lastCall?.args, [{ quality: 60 }]); - }); - - it("should process jpeg format with quality and progressive", () => { - const mock = createMockSharp(); - processFormat(mock, "jpeg", 90); - assertEquals(mock.lastCall?.method, "jpeg"); - assertEquals(mock.lastCall?.args, [{ quality: 90, progressive: true }]); - }); - - it("should process png format with compressionLevel and adaptiveFiltering", () => { - const mock = createMockSharp(); - processFormat(mock, "png", 80); - assertEquals(mock.lastCall?.method, "png"); - assertEquals(mock.lastCall?.args, [{ compressionLevel: 9, adaptiveFiltering: true }]); - }); - - it("should reject an unknown format", () => { - const mock = createMockSharp(); - assertThrows( - () => processFormat(mock, "bmp" as never, 80), - TypeError, - "Unsupported image output format", - ); - assertEquals(mock.lastCall, null); - }); - - it("should reject quality 0", () => { - const mock = createMockSharp(); - assertThrows( - () => processFormat(mock, "webp", 0), - TypeError, - "1 through 100", - ); - }); - - it("should handle quality 100", () => { - const mock = createMockSharp(); - processFormat(mock, "avif", 100); - assertEquals(mock.lastCall?.args, [{ quality: 100 }]); - }); - - it("should return the SharpInstance for chaining", () => { - const mock = createMockSharp(); - const result = processFormat(mock, "webp", 80); - assertEquals(typeof result.toBuffer, "function"); - assertEquals(typeof result.resize, "function"); - }); - }); -}); diff --git a/src/build/asset-pipeline/image-optimizer/format-processor.ts b/src/build/asset-pipeline/image-optimizer/format-processor.ts deleted file mode 100644 index 9c1525d1c9..0000000000 --- a/src/build/asset-pipeline/image-optimizer/format-processor.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ImageFormat, SharpInstance } from "./types.ts"; - -export function processFormat( - image: SharpInstance, - 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 }); - if (format === "png") { - return image.png({ compressionLevel: 9, adaptiveFiltering: true }); - } - - throw new TypeError(`Unsupported image output format: ${String(format)}`); -} diff --git a/src/build/asset-pipeline/image-optimizer/optimization-engine.test.ts b/src/build/asset-pipeline/image-optimizer/optimization-engine.test.ts new file mode 100644 index 0000000000..7a2c1fff7f --- /dev/null +++ b/src/build/asset-pipeline/image-optimizer/optimization-engine.test.ts @@ -0,0 +1,231 @@ +import "#veryfront/schemas/_test-setup.ts"; +import type { + ImageOptimizationEngine, + ImageOptimizationRequest, + ImageOptimizationResult, +} from "#veryfront/extensions/image/index.ts"; +import { ImageOptimizationEngineName } from "#veryfront/extensions/image/index.ts"; +import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; +import { + assertEquals, + assertNotStrictEquals, + assertRejects, + assertThrows, +} from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + acquireConfiguredImageOptimization, + createImageOptimizationSession, +} from "./optimization-engine.ts"; + +function request( + overrides: Partial = {}, +): ImageOptimizationRequest { + return { + input: new Uint8Array([1, 2, 3]), + targetWidths: [320], + formats: ["webp"], + quality: 80, + signal: new AbortController().signal, + ...overrides, + }; +} + +function result( + overrides: Partial = {}, +): ImageOptimizationResult { + return { + sourceWidth: 640, + sourceHeight: 480, + variants: [ + { + format: "webp", + width: 320, + height: 240, + data: new Uint8Array([3, 2, 1]), + }, + { + format: "webp", + width: 640, + height: 480, + data: new Uint8Array([6, 4, 0]), + }, + ], + ...overrides, + }; +} + +function engine( + optimize: ImageOptimizationEngine["optimize"] = () => Promise.resolve(result()), +): ImageOptimizationEngine { + return { cacheIdentity: "test-image-provider@1", optimize }; +} + +describe("image optimization engine boundary", () => { + it("fails closed with the recommended extension when no engine is composed", () => { + const previous = tryResolve( + ImageOptimizationEngineName, + ); + unregister(ImageOptimizationEngineName); + try { + assertThrows( + () => acquireConfiguredImageOptimization(), + Error, + "deno add @veryfront/ext-image-sharp", + ); + } finally { + if (previous !== undefined) { + register(ImageOptimizationEngineName, previous); + } + } + }); + + it("captures one provider and snapshots bytes in both directions", async () => { + let received: ImageOptimizationRequest | undefined; + const providerResult = result(); + const provider = engine((value) => { + received = value; + return Promise.resolve(providerResult); + }); + const session = createImageOptimizationSession(provider); + provider.optimize = () => Promise.reject(new Error("replacement invoked")); + + const input = new Uint8Array([1, 2, 3]); + const output = await session.run(request({ input })); + input[0] = 9; + providerResult.variants[0]!.data[0] = 9; + + assertEquals(session.cacheIdentity, "test-image-provider@1"); + assertNotStrictEquals(received!.input, input); + assertEquals([...received!.input], [1, 2, 3]); + assertEquals([...output.variants[0]!.data], [3, 2, 1]); + }); + + it("normalizes a complete provider matrix to requested deterministic order", async () => { + const session = createImageOptimizationSession( + engine(() => + Promise.resolve({ + sourceWidth: 640, + sourceHeight: 480, + variants: [ + { format: "avif", width: 640, height: 480, data: new Uint8Array([4]) }, + { format: "webp", width: 640, height: 480, data: new Uint8Array([3]) }, + { format: "avif", width: 320, height: 240, data: new Uint8Array([2]) }, + { format: "webp", width: 320, height: 240, data: new Uint8Array([1]) }, + ], + }) + ), + ); + + const output = await session.run(request({ formats: ["webp", "avif"] })); + assertEquals( + output.variants.map(({ width, format }) => `${width}:${format}`), + ["320:webp", "320:avif", "640:webp", "640:avif"], + ); + }); + + it("enforces the legacy no-enlargement aspect-ratio contract", async () => { + const invalid = result({ + variants: [ + { ...result().variants[0]!, height: 239 }, + result().variants[1]!, + ], + }); + await assertRejects( + () => + createImageOptimizationSession( + engine(() => Promise.resolve(invalid)), + ).run(request()), + TypeError, + "preserve the source aspect ratio", + ); + }); + + it("rejects request accessors and sparse arrays without invoking them", async () => { + const session = createImageOptimizationSession(engine()); + let accessorCalls = 0; + const hostile = Object.defineProperty(request(), "formats", { + enumerable: true, + get() { + accessorCalls++; + return ["webp"]; + }, + }); + await assertRejects( + () => session.run(hostile), + TypeError, + "own data property", + ); + assertEquals(accessorCalls, 0); + + const sparse = new Array(1); + await assertRejects( + () => session.run(request({ targetWidths: sparse })), + TypeError, + "dense data-property array", + ); + + const inherited = request({ targetWidths: [320] }); + Object.setPrototypeOf(inherited.targetWidths, null); + await assertRejects( + () => session.run(inherited), + TypeError, + "standard array prototype", + ); + }); + + it("rejects incomplete, duplicate, malformed, and accessor-backed output", async () => { + const invalidResults: unknown[] = [ + result({ variants: result().variants.slice(0, 1) }), + result({ variants: [result().variants[0]!, result().variants[0]!] }), + result({ sourceWidth: 0 }), + result({ + variants: [ + { ...result().variants[0]!, data: new Uint16Array([1]) as unknown as Uint8Array }, + result().variants[1]!, + ], + }), + ]; + for (const invalid of invalidResults) { + await assertRejects( + () => + createImageOptimizationSession( + engine(() => Promise.resolve(invalid as ImageOptimizationResult)), + ).run(request()), + TypeError, + ); + } + + let accessorCalls = 0; + const hostile = Object.defineProperty(result(), "variants", { + enumerable: true, + get() { + accessorCalls++; + return []; + }, + }); + await assertRejects( + () => + createImageOptimizationSession( + engine(() => Promise.resolve(hostile)), + ).run(request()), + TypeError, + "own data property", + ); + assertEquals(accessorCalls, 0); + }); + + it("rejects promptly when the caller aborts an uncooperative provider", async () => { + const controller = new AbortController(); + const session = createImageOptimizationSession( + engine(() => new Promise(() => undefined)), + ); + const pending = session.run(request({ signal: controller.signal })); + controller.abort(); + await assertRejects( + () => pending, + Error, + "cancelled or exceeded its deadline", + ); + }); +}); diff --git a/src/build/asset-pipeline/image-optimizer/optimization-engine.ts b/src/build/asset-pipeline/image-optimizer/optimization-engine.ts new file mode 100644 index 0000000000..8916f2d6bf --- /dev/null +++ b/src/build/asset-pipeline/image-optimizer/optimization-engine.ts @@ -0,0 +1,583 @@ +/** Validated invocation boundary for extension-provided image optimizers. */ + +import { + captureImageOptimizationEngine, + type ImageOptimizationEngine, + ImageOptimizationEngineName, + type ImageOptimizationFormat, + type ImageOptimizationRequest, + type ImageOptimizationResult, + type ImageOptimizationVariantResult, +} from "#veryfront/extensions/image/index.ts"; +import { resolve } from "#veryfront/extensions/contracts.ts"; +import { + MAX_IMAGE_DECODED_PIXELS, + MAX_IMAGE_DIMENSION, + MAX_IMAGE_INPUT_BYTES, + MAX_IMAGE_OUTPUT_BYTES_PER_VARIANT, + MAX_IMAGE_OUTPUT_SIZES, + MAX_IMAGE_TOTAL_OUTPUT_BYTES, + SUPPORTED_FORMATS, +} from "./constants.ts"; + +const apply = Reflect.apply; +const arrayIsArray = Array.isArray; +const arrayPush = Array.prototype.push; +const arraySort = Array.prototype.sort; +const freeze = Object.freeze; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const getPrototypeOf = Object.getPrototypeOf; +const hasOwn = Object.hasOwn; +const isSafeInteger = Number.isSafeInteger; +const ownKeys = Reflect.ownKeys; +const promiseResolve = Promise.resolve; +const promiseThen = Promise.prototype.then; +const PromiseConstructor = Promise; +const setAdd = Set.prototype.add; +const setHas = Set.prototype.has; +const SetConstructor = Set; +const setBytes = Uint8Array.prototype.set; +const Uint8ArrayConstructor = Uint8Array; +const standardArrayPrototype = Array.prototype; +const standardObjectPrototype = Object.prototype; +const typedArrayPrototype = getPrototypeOf(Uint8Array.prototype); +const byteLengthGetter = getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)?.get; +const typedArrayNameGetter = getOwnPropertyDescriptor( + typedArrayPrototype, + Symbol.toStringTag, +)?.get; +const abortedGetter = getOwnPropertyDescriptor( + AbortSignal.prototype, + "aborted", +)?.get; +const addEventListener = EventTarget.prototype.addEventListener; +const removeEventListener = EventTarget.prototype.removeEventListener; + +if (!byteLengthGetter || !typedArrayNameGetter || !abortedGetter) { + throw new TypeError("Required image optimization intrinsics are unavailable"); +} + +const REQUEST_PROPERTIES = freeze( + [ + "input", + "targetWidths", + "formats", + "quality", + "signal", + ] as const, +); +const RESULT_PROPERTIES = freeze( + [ + "sourceWidth", + "sourceHeight", + "variants", + ] as const, +); +const VARIANT_PROPERTIES = freeze( + [ + "format", + "width", + "height", + "data", + ] as const, +); +const MAX_IMAGE_VARIANTS = (MAX_IMAGE_OUTPUT_SIZES + 1) * SUPPORTED_FORMATS.length; + +interface RequestSnapshot extends ImageOptimizationRequest { + readonly targetWidths: readonly number[]; + readonly formats: readonly ImageOptimizationFormat[]; +} + +/** Immutable captured engine identity and validated operation runner. */ +export interface ImageOptimizationSession { + readonly cacheIdentity: string; + run(request: ImageOptimizationRequest): Promise; +} + +function ownDescriptors(value: object, label: string): PropertyDescriptorMap { + try { + return getOwnPropertyDescriptors(value); + } catch (cause) { + throw new TypeError(`${label} properties could not be inspected`, { cause }); + } +} + +function assertPlainObject(value: object, label: string): void { + let prototype: object | null; + try { + prototype = getPrototypeOf(value); + } catch (cause) { + throw new TypeError(`${label} prototype could not be inspected`, { cause }); + } + if (prototype !== standardObjectPrototype && prototype !== null) { + throw new TypeError(`${label} must not inherit custom behavior`); + } +} + +function readDataProperty( + descriptors: PropertyDescriptorMap, + property: string, + label: string, +): unknown { + const descriptor = descriptors[property]; + if (descriptor === undefined || !hasOwn(descriptor, "value")) { + throw new TypeError(`${label} ${property} must be an own data property`); + } + return descriptor.value; +} + +function assertExactProperties( + descriptors: PropertyDescriptorMap, + expected: readonly string[], + label: string, +): void { + const keys = ownKeys(descriptors); + if (keys.length !== expected.length) { + throw new TypeError(`${label} contains unsupported properties`); + } + for (let index = 0; index < expected.length; index++) { + if (!hasOwn(descriptors, expected[index]!)) { + throw new TypeError(`${label} contains unsupported properties`); + } + } +} + +function denseDataArray( + value: unknown, + maximum: number, + label: string, +): unknown[] { + let isArray: boolean; + try { + isArray = arrayIsArray(value); + } catch (cause) { + throw new TypeError(`${label} could not be inspected`, { cause }); + } + if (!isArray) throw new TypeError(`${label} must be an array`); + let prototype: object | null; + try { + prototype = getPrototypeOf(value); + } catch (cause) { + throw new TypeError(`${label} prototype could not be inspected`, { cause }); + } + if (prototype !== standardArrayPrototype) { + throw new TypeError(`${label} must use the standard array prototype`); + } + const descriptors = ownDescriptors(value as unknown[], label); + const length = readDataProperty(descriptors, "length", label); + if ( + !isSafeInteger(length) || + (length as number) < 0 || + (length as number) > maximum || + ownKeys(descriptors).length !== (length as number) + 1 + ) { + throw new TypeError(`${label} must be a bounded dense data-property array`); + } + const result: unknown[] = []; + for (let index = 0; index < (length as number); index++) { + const descriptor = descriptors[String(index)]; + if ( + descriptor === undefined || + !hasOwn(descriptor, "value") || + descriptor.enumerable !== true + ) { + throw new TypeError(`${label} must be a bounded dense data-property array`); + } + apply(arrayPush, result, [descriptor.value]); + } + return result; +} + +function byteLength(value: unknown, label: string): number { + try { + return apply(byteLengthGetter!, value, []) as number; + } catch (cause) { + throw new TypeError(`${label} must be a Uint8Array`, { cause }); + } +} + +function copyBytes( + value: unknown, + maximum: number, + label: string, +): Uint8Array { + let typedArrayName: unknown; + try { + typedArrayName = apply(typedArrayNameGetter!, value, []); + } catch (cause) { + throw new TypeError(`${label} must be a Uint8Array`, { cause }); + } + if (typedArrayName !== "Uint8Array") { + throw new TypeError(`${label} must be a Uint8Array`); + } + const length = byteLength(value, label); + if (length === 0) throw new TypeError(`${label} must not be empty`); + if (length > maximum) { + throw new TypeError(`${label} exceeds ${maximum} bytes`); + } + try { + const copy = new Uint8ArrayConstructor(length); + apply(setBytes, copy, [value]); + return copy; + } catch (cause) { + throw new TypeError(`${label} could not be copied`, { cause }); + } +} + +function isAborted(signal: AbortSignal): boolean { + try { + return apply(abortedGetter!, signal, []) as boolean; + } catch (cause) { + throw new TypeError("Image optimization signal must be an AbortSignal", { + cause, + }); + } +} + +function cancellationError(): Error { + return new Error("Image optimization was cancelled or exceeded its deadline"); +} + +async function awaitAbortable( + value: unknown, + signal: AbortSignal, +): Promise { + if (isAborted(signal)) throw cancellationError(); + const operation = apply(promiseResolve, PromiseConstructor, [value]) as Promise; + return await new PromiseConstructor((resolvePromise, rejectPromise) => { + let settled = false; + let listening = false; + const cleanup = (): void => { + if (!listening) return; + listening = false; + apply(removeEventListener, signal, ["abort", onAbort]); + }; + const settle = (callback: () => void): void => { + if (settled) return; + settled = true; + try { + cleanup(); + } catch (cause) { + rejectPromise( + new TypeError( + "Image optimization signal listener could not be removed", + { cause }, + ), + ); + return; + } + callback(); + }; + const onAbort = (): void => settle(() => rejectPromise(cancellationError())); + apply(promiseThen, operation, [ + (result: T) => settle(() => resolvePromise(result)), + (cause: unknown) => settle(() => rejectPromise(cause)), + ]); + try { + apply(addEventListener, signal, ["abort", onAbort, { once: true }]); + listening = true; + } catch (cause) { + settle(() => + rejectPromise( + new TypeError("Image optimization signal could not be observed", { + cause, + }), + ) + ); + return; + } + if (isAborted(signal)) { + onAbort(); + return; + } + }); +} + +function snapshotRequest(value: ImageOptimizationRequest): RequestSnapshot { + if (typeof value !== "object" || value === null || arrayIsArray(value)) { + throw new TypeError("Image optimization request must be an object"); + } + assertPlainObject(value, "Image optimization request"); + const descriptors = ownDescriptors(value, "Image optimization request"); + assertExactProperties(descriptors, REQUEST_PROPERTIES, "Image optimization request"); + const signal = readDataProperty( + descriptors, + "signal", + "Image optimization request", + ) as AbortSignal; + isAborted(signal); + const input = copyBytes( + readDataProperty(descriptors, "input", "Image optimization request"), + MAX_IMAGE_INPUT_BYTES, + "Image optimization input", + ); + + const widthEntries = denseDataArray( + readDataProperty(descriptors, "targetWidths", "Image optimization request"), + MAX_IMAGE_OUTPUT_SIZES, + "Image optimization target widths", + ); + const targetWidths: number[] = []; + const seenWidths = new SetConstructor(); + for (let index = 0; index < widthEntries.length; index++) { + const width = widthEntries[index]; + if ( + !isSafeInteger(width) || + (width as number) <= 0 || + (width as number) > MAX_IMAGE_DIMENSION || + apply(setHas, seenWidths, [width]) + ) { + throw new TypeError("Image optimization target widths must be unique supported integers"); + } + apply(setAdd, seenWidths, [width as number]); + apply(arrayPush, targetWidths, [width]); + } + + const formatEntries = denseDataArray( + readDataProperty(descriptors, "formats", "Image optimization request"), + SUPPORTED_FORMATS.length, + "Image optimization formats", + ); + if (formatEntries.length === 0) { + throw new TypeError("Image optimization formats must not be empty"); + } + const formats: ImageOptimizationFormat[] = []; + const seenFormats = new SetConstructor(); + for (let index = 0; index < formatEntries.length; index++) { + const format = formatEntries[index]; + if ( + (format !== "webp" && format !== "avif" && format !== "jpeg" && + format !== "png") || + apply(setHas, seenFormats, [format]) + ) { + throw new TypeError("Image optimization formats must be unique and supported"); + } + apply(setAdd, seenFormats, [format]); + apply(arrayPush, formats, [format]); + } + const quality = readDataProperty( + descriptors, + "quality", + "Image optimization request", + ); + if (!isSafeInteger(quality) || (quality as number) < 1 || (quality as number) > 100) { + throw new TypeError("Image optimization quality must be an integer from 1 through 100"); + } + return freeze({ + input, + targetWidths: freeze(targetWidths), + formats: freeze(formats), + quality: quality as number, + signal, + }); +} + +function assertDimension(value: unknown, label: string): asserts value is number { + if ( + !isSafeInteger(value) || + (value as number) <= 0 || + (value as number) > MAX_IMAGE_DIMENSION + ) { + throw new TypeError(`${label} must be a supported positive integer`); + } +} + +function expectedHeight( + width: number, + sourceWidth: number, + sourceHeight: number, +): number { + return Math.max(1, Math.round(sourceHeight * width / sourceWidth)); +} + +function expectedWidths( + configured: readonly number[], + sourceWidth: number, +): number[] { + const widths: number[] = []; + const seen = new SetConstructor(); + for (let index = 0; index < configured.length; index++) { + const width = configured[index]!; + if (width <= sourceWidth && !apply(setHas, seen, [width])) { + apply(setAdd, seen, [width]); + apply(arrayPush, widths, [width]); + } + } + if (!apply(setHas, seen, [sourceWidth])) { + apply(arrayPush, widths, [sourceWidth]); + } + apply(arraySort, widths, [(left: number, right: number) => left - right]); + return widths; +} + +function snapshotResult( + value: unknown, + request: RequestSnapshot, +): ImageOptimizationResult { + if (typeof value !== "object" || value === null || arrayIsArray(value)) { + throw new TypeError("ImageOptimizationEngine returned an invalid result"); + } + assertPlainObject(value, "Image optimization result"); + const descriptors = ownDescriptors(value, "Image optimization result"); + assertExactProperties(descriptors, RESULT_PROPERTIES, "Image optimization result"); + const sourceWidth = readDataProperty( + descriptors, + "sourceWidth", + "Image optimization result", + ); + const sourceHeight = readDataProperty( + descriptors, + "sourceHeight", + "Image optimization result", + ); + assertDimension(sourceWidth, "Image optimization source width"); + assertDimension(sourceHeight, "Image optimization source height"); + if (sourceWidth * sourceHeight > MAX_IMAGE_DECODED_PIXELS) { + throw new TypeError( + `Image optimization source exceeds ${MAX_IMAGE_DECODED_PIXELS} decoded pixels`, + ); + } + + const widths = expectedWidths(request.targetWidths, sourceWidth); + const expectedVariants = widths.length * request.formats.length; + const entries = denseDataArray( + readDataProperty(descriptors, "variants", "Image optimization result"), + MAX_IMAGE_VARIANTS, + "Image optimization variants", + ); + if (entries.length !== expectedVariants) { + throw new TypeError("ImageOptimizationEngine returned an incomplete variant matrix"); + } + + const expectedPairs = new SetConstructor(); + for (let widthIndex = 0; widthIndex < widths.length; widthIndex++) { + for (let formatIndex = 0; formatIndex < request.formats.length; formatIndex++) { + apply(setAdd, expectedPairs, [ + `${widths[widthIndex]}\0${request.formats[formatIndex]}`, + ]); + } + } + const variantsByPair = new Map(); + let totalOutputBytes = 0; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (typeof entry !== "object" || entry === null || arrayIsArray(entry)) { + throw new TypeError("Image optimization variant must be an object"); + } + assertPlainObject(entry, "Image optimization variant"); + const variantDescriptors = ownDescriptors(entry, "Image optimization variant"); + assertExactProperties( + variantDescriptors, + VARIANT_PROPERTIES, + "Image optimization variant", + ); + const format = readDataProperty( + variantDescriptors, + "format", + "Image optimization variant", + ); + const width = readDataProperty( + variantDescriptors, + "width", + "Image optimization variant", + ); + const height = readDataProperty( + variantDescriptors, + "height", + "Image optimization variant", + ); + assertDimension(width, "Image optimization variant width"); + assertDimension(height, "Image optimization variant height"); + if (height !== expectedHeight(width, sourceWidth, sourceHeight)) { + throw new TypeError( + "ImageOptimizationEngine did not preserve the source aspect ratio", + ); + } + if ( + format !== "webp" && format !== "avif" && format !== "jpeg" && + format !== "png" + ) { + throw new TypeError("ImageOptimizationEngine returned an unsupported format"); + } + const pair = `${width}\0${format}`; + if ( + !apply(setHas, expectedPairs, [pair]) || + variantsByPair.has(pair) + ) { + throw new TypeError("ImageOptimizationEngine returned an unexpected variant"); + } + const data = copyBytes( + readDataProperty( + variantDescriptors, + "data", + "Image optimization variant", + ), + MAX_IMAGE_OUTPUT_BYTES_PER_VARIANT, + "Image optimization variant data", + ); + totalOutputBytes += data.length; + if ( + !isSafeInteger(totalOutputBytes) || + totalOutputBytes > MAX_IMAGE_TOTAL_OUTPUT_BYTES + ) { + throw new TypeError( + `Image optimization outputs exceed ${MAX_IMAGE_TOTAL_OUTPUT_BYTES} bytes`, + ); + } + variantsByPair.set( + pair, + freeze({ + format, + width, + height, + data, + }), + ); + } + + const variants: ImageOptimizationVariantResult[] = []; + for (let widthIndex = 0; widthIndex < widths.length; widthIndex++) { + for (let formatIndex = 0; formatIndex < request.formats.length; formatIndex++) { + const pair = `${widths[widthIndex]}\0${request.formats[formatIndex]}`; + const variant = variantsByPair.get(pair); + if (variant === undefined) { + throw new TypeError("ImageOptimizationEngine omitted a requested variant"); + } + apply(arrayPush, variants, [variant]); + } + } + return freeze({ + sourceWidth, + sourceHeight, + variants: freeze(variants), + }); +} + +/** Capture a provider and validate every request/result crossing its boundary. */ +export function createImageOptimizationSession( + engine: ImageOptimizationEngine, +): ImageOptimizationSession { + const captured = captureImageOptimizationEngine(engine); + return freeze({ + cacheIdentity: captured.cacheIdentity, + async run(request: ImageOptimizationRequest): Promise { + const input = snapshotRequest(request); + const result = await awaitAbortable( + captured.optimize(input), + input.signal, + ); + return snapshotResult(result, input); + }, + }); +} + +/** Resolve and capture the configured image provider for one publication. */ +export function acquireConfiguredImageOptimization(): ImageOptimizationSession { + return createImageOptimizationSession( + resolve(ImageOptimizationEngineName), + ); +} 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 a0f4be268e..26e69327de 100644 --- a/src/build/asset-pipeline/image-optimizer/optimizer-core.test.ts +++ b/src/build/asset-pipeline/image-optimizer/optimizer-core.test.ts @@ -1,14 +1,12 @@ import "#veryfront/schemas/_test-setup.ts"; +import { FakeTime } from "#std/testing/time"; import { join } from "#veryfront/compat/path/index.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import type { ImageOptimizationEngine } from "#veryfront/extensions/image/index.ts"; +import { MAX_IMAGE_PROVIDER_DURATION_MS } from "./constants.ts"; import { chunkArray, ImageOptimizer } from "./optimizer-core.ts"; -import type { - OptimizedImageMetadata, - SharpConstructor, - SharpInstance, - SharpMetadata, -} from "./types.ts"; +import type { OptimizedImageMetadata } from "./types.ts"; function populateManifest( optimizer: ImageOptimizer, @@ -41,39 +39,37 @@ const sampleMetadata: OptimizedImageMetadata = { aspectRatio: 16 / 9, }; -function createSharpConstructor( +function createImageEngine( 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; +): ImageOptimizationEngine { + return { + cacheIdentity: "test-image-engine@1", + optimize(request) { + if (options.failEncoding) { + return Promise.reject(new Error("mock encoding failed")); + } + const sourceWidth = 640; + const sourceHeight = 480; + const widths = [ + ...new Set([ + ...request.targetWidths.filter((width) => width <= sourceWidth), + sourceWidth, + ]), + ].sort((left, right) => left - right); + return Promise.resolve({ + sourceWidth, + sourceHeight, + variants: widths.flatMap((width) => + request.formats.map((format) => ({ + format, + width, + height: Math.round(width / (sourceWidth / sourceHeight)), + data: new Uint8Array([1, 2, 3, 4]), + })) + ), + }); + }, }; - - return (input) => createInstance(encodedMetadata.get(input) ?? sourceMetadata); } async function withTempProject( @@ -327,7 +323,7 @@ describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { sizes: [320], preserveOriginal: true, }, - { loadSharp: () => Promise.resolve(createSharpConstructor()) }, + { engine: createImageEngine() }, ); const manifest = await optimizer.optimize(); @@ -375,7 +371,7 @@ describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { sizes: [320], }, { - loadSharp: () => Promise.resolve(createSharpConstructor({ failEncoding: true })), + engine: createImageEngine({ failEncoding: true }), }, ); @@ -404,6 +400,80 @@ describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { }); }); + it("never publishes a provider result that arrives after its deadline", async () => { + using time = new FakeTime(); + 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 started = Promise.withResolvers(); + const lateResult = Promise.withResolvers< + Awaited> + >(); + const optimizer = new ImageOptimizer( + { + projectDir, + inputDir: "public", + outputDir: ".veryfront/images", + formats: ["webp"], + sizes: [320], + }, + { + engine: { + cacheIdentity: "late-image-engine@1", + optimize() { + started.resolve(); + return lateResult.promise; + }, + }, + }, + ); + + const operation = optimizer.optimize(); + await started.promise; + await time.tickAsync(MAX_IMAGE_PROVIDER_DURATION_MS); + await assertRejects( + () => operation, + Error, + "cancelled or exceeded its deadline", + ); + + lateResult.resolve({ + sourceWidth: 640, + sourceHeight: 480, + variants: [ + { + format: "webp", + width: 320, + height: 240, + data: new Uint8Array([1]), + }, + { + format: "webp", + width: 640, + height: 480, + data: new Uint8Array([2]), + }, + ], + }); + await time.tickAsync(0); + + const outputEntries: string[] = []; + for await (const entry of Deno.readDir(outputDir)) { + outputEntries.push(entry.name); + } + outputEntries.sort(); + assertEquals(outputEntries, ["sentinel.txt"]); + assertEquals( + await Deno.readTextFile(join(outputDir, "sentinel.txt")), + "known good", + ); + assertEquals(optimizer.getStats().totalImages, 0); + }); + }); + it("rejects an empty enabled input without replacing existing output", async () => { await withTempProject(async (projectDir) => { const outputDir = join(projectDir, ".veryfront/images"); @@ -417,7 +487,7 @@ describe("build/asset-pipeline/image-optimizer/optimizer-core", () => { formats: ["webp"], sizes: [320], }, - { loadSharp: () => Promise.resolve(createSharpConstructor()) }, + { engine: createImageEngine() }, ); await assertRejects( diff --git a/src/build/asset-pipeline/image-optimizer/optimizer-core.ts b/src/build/asset-pipeline/image-optimizer/optimizer-core.ts index 0fa70ce16a..f2c43b52a3 100644 --- a/src/build/asset-pipeline/image-optimizer/optimizer-core.ts +++ b/src/build/asset-pipeline/image-optimizer/optimizer-core.ts @@ -7,6 +7,10 @@ import { resolve, } from "#veryfront/compat/path/index.ts"; import { INITIALIZATION_ERROR } from "#veryfront/errors"; +import type { + ImageOptimizationEngine, + ImageOptimizationResult, +} from "#veryfront/extensions/image/index.ts"; import { createFileSystem, type FileSystem, @@ -25,33 +29,39 @@ import { import { calculateRequiredAspectRatio, generateSrcSet, + getVariantPath, isContainedAssetPath, } from "../../utils/asset-utils.ts"; import { hasControlCharacters } from "../../utils/string-validation.ts"; import { DEFAULT_OPTIONS, MAX_IMAGE_DIMENSION, + MAX_IMAGE_INPUT_BYTES, MAX_IMAGE_OUTPUT_SIZES, + MAX_IMAGE_PROVIDER_DURATION_MS, 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 { + acquireConfiguredImageOptimization, + createImageOptimizationSession, + type ImageOptimizationSession, +} from "./optimization-engine.ts"; import type { ImageFormat, ImageOptimizationOptions, ImageOptimizationStats, + ImageVariant, OptimizedImageMetadata, - SharpConstructor, } from "./types.ts"; const supportedFormats = new Set(SUPPORTED_FORMATS); export interface ImageOptimizerDependencies { fs?: FileSystem; - loadSharp?: () => Promise; publicationLock?: BuildPublicationLock; + engine?: ImageOptimizationEngine; } /** @internal — exported for testing */ @@ -122,14 +132,51 @@ function cloneManifest( ); } +function snapshotDependencies( + dependencies: ImageOptimizerDependencies, +): ImageOptimizerDependencies { + if ( + typeof dependencies !== "object" || + dependencies === null || + Array.isArray(dependencies) + ) { + throw new TypeError("Image optimizer dependencies must be an object"); + } + let descriptors: PropertyDescriptorMap; + try { + descriptors = Object.getOwnPropertyDescriptors(dependencies); + } catch (cause) { + throw new TypeError("Image optimizer dependencies could not be inspected", { + cause, + }); + } + for (const property of Reflect.ownKeys(descriptors)) { + if (property !== "fs" && property !== "engine" && property !== "publicationLock") { + throw new TypeError("Image optimizer dependencies contain unsupported properties"); + } + } + const read = (property: "fs" | "engine" | "publicationLock"): unknown => { + const descriptor = descriptors[property]; + if (descriptor === undefined) return undefined; + if (!Object.hasOwn(descriptor, "value")) { + throw new TypeError(`Image optimizer dependency ${property} must be a data property`); + } + return descriptor.value; + }; + return Object.freeze({ + fs: read("fs") as FileSystem | undefined, + engine: read("engine") as ImageOptimizationEngine | undefined, + publicationLock: read("publicationLock") as BuildPublicationLock | undefined, + }); +} + export class ImageOptimizer { private options: Required; - private sharp: SharpConstructor | null = null; private imageManifest = new Map(); - private fs: FileSystem; - private loadSharpDependency: () => Promise; - private publicationLock?: BuildPublicationLock; - private outputUrlPath: string; + private readonly fs: FileSystem; + private readonly engine: ImageOptimizationEngine | undefined; + private readonly publicationLock: BuildPublicationLock | undefined; + private readonly outputUrlPath: string; constructor( options: ImageOptimizationOptions = {}, @@ -144,6 +191,7 @@ export class ImageOptimizer { if (options.sizes !== undefined && !Array.isArray(options.sizes)) { throw new TypeError("Image sizes must be an array"); } + const capturedDependencies = snapshotDependencies(dependencies); this.options = { enabled: options.enabled === undefined ? DEFAULT_OPTIONS.enabled : options.enabled, projectDir: options.projectDir === undefined ? cwd() : options.projectDir, @@ -156,10 +204,10 @@ export class ImageOptimizer { ? 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.fs = capturedDependencies.fs ?? createFileSystem(); + this.engine = capturedDependencies.engine; + this.publicationLock = capturedDependencies.publicationLock ?? + (capturedDependencies.fs === undefined ? nativeBuildPublicationLock : undefined); this.validateConfiguration(); this.outputUrlPath = portablePath( relative(this.options.projectDir, this.options.outputDir), @@ -277,6 +325,11 @@ export class ImageOptimizer { } } + /** + * Validate that an enabled optimizer can capture its configured provider. + * A publication captures a fresh immutable session in `optimize()` so a + * prior readiness check never pins stale registry state. + */ init(): Promise { return withSpan( "build.asset.ImageOptimizer.init", @@ -285,28 +338,26 @@ export class ImageOptimizer { logger.info("Image optimization is disabled"); return false; } - 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; - } + this.createOperationSession(); return true; }, { "optimizer.enabled": this.options.enabled }, ); } + private createOperationSession(): ImageOptimizationSession { + return this.engine === undefined + ? acquireConfiguredImageOptimization() + : createImageOptimizationSession(this.engine); + } + optimize(): Promise> { return withSpan( "build.asset.ImageOptimizer.optimize", async () => { if (!this.options.enabled) return new Map(); await this.validateFilesystemBoundaries(); - await this.init(); + const optimizationSession = this.createOperationSession(); logger.info("Starting image optimization", { inputDir: this.options.inputDir, @@ -340,6 +391,7 @@ export class ImageOptimizer { imagePath, publication.buildDir, outputOwners, + optimizationSession, ) ), ); @@ -418,6 +470,7 @@ export class ImageOptimizer { imagePath: string, outputDir: string, outputOwners: Map, + optimizationSession: ImageOptimizationSession, ): Promise<[string, OptimizedImageMetadata]> { const relativePath = portablePath( relative(this.options.inputDir, imagePath), @@ -429,12 +482,6 @@ export class ImageOptimizer { return withSpan( "build.asset.ImageOptimizer.optimizeImage", async () => { - 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) { @@ -443,33 +490,79 @@ export class ImageOptimizer { }); } const inputInfo = await lstat(imagePath); - if (!inputInfo.isFile || inputInfo.isSymlink) { + if ( + !inputInfo.isFile || + inputInfo.isSymlink || + !Number.isSafeInteger(inputInfo.size) || + inputInfo.size <= 0 + ) { throw new TypeError(`Image input must be a regular file: ${relativePath}`); } + if (inputInfo.size > MAX_IMAGE_INPUT_BYTES) { + throw new TypeError( + `Image input exceeds ${MAX_IMAGE_INPUT_BYTES} bytes: ${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, + if (imageBuffer.length > MAX_IMAGE_INPUT_BYTES) { + throw new TypeError( + `Image input exceeds ${MAX_IMAGE_INPUT_BYTES} bytes: ${relativePath}`, + ); + } + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + MAX_IMAGE_PROVIDER_DURATION_MS, ); + let result: ImageOptimizationResult; + try { + result = await optimizationSession.run({ + input: imageBuffer, + targetWidths: Object.freeze([...this.options.sizes]), + formats: Object.freeze([...this.options.formats]), + quality: this.options.quality, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } - for (const variant of variants) { + const plannedVariants = result.variants.map((variant) => { + const outputPath = getVariantPath( + outputDir, + relativePath, + variant.format, + variant.width, + ); + const manifestPath = portablePath(relative(outputDir, outputPath)) + .normalize("NFC"); + if (!isSafeImageManifestPath(manifestPath)) { + throw new TypeError(`Unsafe image variant path: ${manifestPath}`); + } this.registerOutput( outputOwners, - variant.path, + manifestPath, `${relativePath} ${variant.width}px ${variant.format}`, ); + return { variant, outputPath, manifestPath }; + }); + + const variants: ImageVariant[] = []; + for (const { variant, outputPath, manifestPath } of plannedVariants) { + await this.fs.mkdir(dirname(outputPath), { recursive: true }); + await this.fs.writeFile(outputPath, variant.data); + variants.push({ + format: variant.format, + size: variant.width, + width: variant.width, + height: variant.height, + path: manifestPath, + fileSize: variant.data.length, + }); } if (this.options.preserveOriginal) { this.registerOutput(outputOwners, relativePath, `${relativePath} original`); @@ -486,8 +579,8 @@ export class ImageOptimizer { variants, defaultFormat, aspectRatio: calculateRequiredAspectRatio( - metadata.width!, - metadata.height!, + result.sourceWidth, + result.sourceHeight, ), }, ]; diff --git a/src/build/asset-pipeline/image-optimizer/sharp-loader.ts b/src/build/asset-pipeline/image-optimizer/sharp-loader.ts deleted file mode 100644 index 9d1324c3df..0000000000 --- a/src/build/asset-pipeline/image-optimizer/sharp-loader.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { logger } from "#veryfront/utils"; -import { DEPENDENCY_MISSING } from "#veryfront/errors"; -import { SHARP_MODULE_SPECIFIER } from "./constants.ts"; -import type { SharpConstructor } from "./types.ts"; - -export async function loadSharp(): Promise { - try { - 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 as unknown as SharpConstructor; - } catch (error) { - throw DEPENDENCY_MISSING.create({ - detail: `Image optimization requires ${SHARP_MODULE_SPECIFIER}`, - cause: error, - }); - } -} diff --git a/src/build/asset-pipeline/image-optimizer/types.ts b/src/build/asset-pipeline/image-optimizer/types.ts index 5f1ac9991b..f2fb032b86 100644 --- a/src/build/asset-pipeline/image-optimizer/types.ts +++ b/src/build/asset-pipeline/image-optimizer/types.ts @@ -1,60 +1,3 @@ -export interface SharpMetadata { - width?: number; - height?: number; - format?: string; - size?: number; - space?: string; - channels?: number; - depth?: string; - density?: number; - chromaSubsampling?: string; - isProgressive?: boolean; - hasProfile?: boolean; - hasAlpha?: boolean; -} - -type SharpResizeFit = "cover" | "contain" | "fill" | "inside" | "outside"; - -interface SharpResizeOptions { - fit?: SharpResizeFit; - withoutEnlargement?: boolean; -} - -interface SharpWebpOptions { - quality?: number; -} - -interface SharpAvifOptions { - quality?: number; -} - -interface SharpJpegOptions { - quality?: number; - progressive?: boolean; -} - -interface SharpPngOptions { - compressionLevel?: number; - adaptiveFiltering?: boolean; -} - -export interface SharpInstance { - metadata(): Promise; - clone(): SharpInstance; - resize( - width: number | null, - height?: number | null, - options?: SharpResizeOptions, - ): SharpInstance; - webp(options?: SharpWebpOptions): SharpInstance; - avif(options?: SharpAvifOptions): SharpInstance; - jpeg(options?: SharpJpegOptions): SharpInstance; - png(options?: SharpPngOptions): SharpInstance; - toBuffer(): Promise; -} - -export type SharpConstructor = (input: Uint8Array) => SharpInstance; - export type ImageFormat = "webp" | "avif" | "jpeg" | "png"; export interface ImageOptimizationOptions { diff --git a/src/build/asset-pipeline/image-optimizer/variant-generator.test.ts b/src/build/asset-pipeline/image-optimizer/variant-generator.test.ts deleted file mode 100644 index b183f1435f..0000000000 --- a/src/build/asset-pipeline/image-optimizer/variant-generator.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; -import { generateImageVariants } from "./variant-generator.ts"; -import type { ImageFormat, SharpConstructor, SharpInstance, SharpMetadata } from "./types.ts"; - -function createMockSharpInstance( - metadata: SharpMetadata = { width: 1920, height: 1080 }, - bufferContent = new Uint8Array([1, 2, 3]), -): SharpInstance { - const instance: SharpInstance = { - metadata: () => Promise.resolve(metadata), - clone: () => createMockSharpInstance(metadata, bufferContent), - resize: (_w, _h, _opts) => instance, - webp: (_opts) => instance, - avif: (_opts) => instance, - jpeg: (_opts) => instance, - png: (_opts) => instance, - toBuffer: () => Promise.resolve(bufferContent), - }; - return instance; -} - -function createMockSharp( - metadata: SharpMetadata = { width: 1920, height: 1080 }, -): SharpConstructor { - return (_input: Uint8Array) => createMockSharpInstance(metadata); -} - -// We need a temp dir for filesystem writes that generateVariant does -async function withTempOutputDir(fn: (dir: string) => Promise): Promise { - const dir = await Deno.makeTempDir(); - try { - return await fn(dir); - } finally { - await Deno.remove(dir, { recursive: true }); - } -} - -describe("build/asset-pipeline/image-optimizer/variant-generator", () => { - describe("generateImageVariants", () => { - it("should generate variants for each format and size combination", async () => { - await withTempOutputDir(async (outputDir) => { - const sharp = createMockSharp(); - const image = createMockSharpInstance(); - const formats: ImageFormat[] = ["webp", "avif"]; - const sizes = [320, 640]; - const metadata: SharpMetadata = { width: 1920, height: 1080 }; - - const variants = await generateImageVariants( - sharp, - image, - "test.jpg", - metadata, - formats, - sizes, - 80, - outputDir, - ); - - // sizes [320, 640] + originalWidth [1920] = 3 sizes x 2 formats = 6 variants - assertEquals(variants.length, 6, "should produce variant for each size x format"); - }); - }); - - it("should filter out sizes larger than original image width", async () => { - await withTempOutputDir(async (outputDir) => { - const sharp = createMockSharp({ width: 500, height: 300 }); - const image = createMockSharpInstance({ width: 500, height: 300 }); - const formats: ImageFormat[] = ["webp"]; - const sizes = [320, 640, 1280]; // 640 and 1280 > 500 - - const variants = await generateImageVariants( - sharp, - image, - "small.jpg", - { width: 500, height: 300 }, - formats, - sizes, - 80, - outputDir, - ); - - // validSizes = [320] (only 320 <= 500), plus originalWidth [500] = 2 sizes x 1 format = 2 - assertEquals(variants.length, 2, "should filter sizes exceeding original width"); - }); - }); - - it("should include original width as a variant", async () => { - await withTempOutputDir(async (outputDir) => { - const sharp = createMockSharp({ width: 800, height: 600 }); - const image = createMockSharpInstance({ width: 800, height: 600 }); - const formats: ImageFormat[] = ["webp"]; - const sizes = [320]; - - const variants = await generateImageVariants( - sharp, - image, - "photo.jpg", - { width: 800, height: 600 }, - formats, - sizes, - 80, - outputDir, - ); - - // validSizes = [320] + originalWidth [800] = 2 sizes x 1 format = 2 - assertEquals(variants.length, 2, "should include original width variant"); - }); - }); - - it("should reject missing image dimensions", async () => { - await withTempOutputDir(async (outputDir) => { - const sharp = createMockSharp({ height: 600 }); // no width - const image = createMockSharpInstance({ height: 600 }); - await assertRejects( - () => - generateImageVariants( - sharp, - image, - "nowidth.jpg", - { height: 600 }, - ["webp"], - [320, 640], - 80, - outputDir, - ), - TypeError, - "positive integer dimensions", - ); - }); - }); - - it("should handle empty sizes array", async () => { - await withTempOutputDir(async (outputDir) => { - const sharp = createMockSharp(); - const image = createMockSharpInstance(); - const formats: ImageFormat[] = ["webp"]; - - const variants = await generateImageVariants( - sharp, - image, - "test.jpg", - { width: 1920, height: 1080 }, - formats, - [], // no additional sizes - 80, - outputDir, - ); - - // [] + originalWidth [1920] = 1 size x 1 format = 1 - assertEquals(variants.length, 1, "should still include original width variant"); - }); - }); - - it("should reject an empty formats array", async () => { - await withTempOutputDir(async (outputDir) => { - const sharp = createMockSharp(); - const image = createMockSharpInstance(); - - await assertRejects( - () => - generateImageVariants( - sharp, - image, - "test.jpg", - { width: 1920, height: 1080 }, - [], - [320], - 80, - outputDir, - ), - TypeError, - "non-empty", - ); - }); - }); - - 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", - ); - }); - }); - - it("should return variants with correct format field", async () => { - await withTempOutputDir(async (outputDir) => { - const sharp = createMockSharp({ width: 640, height: 480 }); - const image = createMockSharpInstance({ width: 640, height: 480 }); - const formats: ImageFormat[] = ["webp", "jpeg"]; - - const variants = await generateImageVariants( - sharp, - image, - "test.jpg", - { width: 640, height: 480 }, - formats, - [320], - 80, - outputDir, - ); - - const webpVariants = variants.filter((v) => v.format === "webp"); - const jpegVariants = variants.filter((v) => v.format === "jpeg"); - - assertEquals(webpVariants.length > 0, true, "should have webp variants"); - assertEquals(jpegVariants.length > 0, true, "should have jpeg variants"); - }); - }); - - it("should return variants with fileSize property", async () => { - await withTempOutputDir(async (outputDir) => { - const sharp = createMockSharp({ width: 640, height: 480 }); - const image = createMockSharpInstance({ width: 640, height: 480 }); - - const variants = await generateImageVariants( - sharp, - image, - "test.jpg", - { width: 640, height: 480 }, - ["webp"], - [320], - 80, - outputDir, - ); - - for (const variant of variants) { - assertEquals(typeof variant.fileSize, "number", "fileSize should be a number"); - assertEquals(variant.fileSize > 0, true, "fileSize should be positive"); - } - }); - }); - }); -}); diff --git a/src/build/asset-pipeline/image-optimizer/variant-generator.ts b/src/build/asset-pipeline/image-optimizer/variant-generator.ts deleted file mode 100644 index 0c4ba92c28..0000000000 --- a/src/build/asset-pipeline/image-optimizer/variant-generator.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { dirname, relative } from "#veryfront/compat/path/index.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 { getVariantPath } from "../../utils/asset-utils.ts"; -import { MAX_IMAGE_DIMENSION, MAX_IMAGE_OUTPUT_SIZES, SUPPORTED_FORMATS } from "./constants.ts"; -import type { - ImageFormat, - ImageVariant, - SharpConstructor, - SharpInstance, - SharpMetadata, -} from "./types.ts"; - -function generateVariant( - sharp: SharpConstructor, - image: SharpInstance, - relPath: string, - format: ImageFormat, - width: number, - quality: number, - outputDir: string, - fs: FileSystem, -): Promise { - return withSpan( - "build.asset.generateVariant", - async (): Promise => { - 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, - }); - - 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`, - ); - } - - 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, - "image.format": format, - "image.width": width, - "image.quality": quality, - }, - ); -} - -export function generateImageVariants( - sharp: SharpConstructor, - image: SharpInstance, - relPath: string, - metadata: SharpMetadata, - formats: ImageFormat[], - sizes: number[], - quality: number, - outputDir: string, - fs: FileSystem = createFileSystem(), -): Promise { - return withSpan( - "build.asset.generateImageVariants", - async (): Promise => { - 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 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) { - variants.push( - await generateVariant( - sharp, - image, - relPath, - format, - size, - quality, - outputDir, - fs, - ), - ); - } - } - - return variants; - }, - { - "image.path": relPath, - "image.formats": formats.join(","), - "image.sizesCount": sizes.length, - }, - ); -} - -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 7eb631a21c..ccefc607ce 100644 --- a/src/build/asset-pipeline/index.ts +++ b/src/build/asset-pipeline/index.ts @@ -5,28 +5,36 @@ */ export type { - BrowserTargets, CriticalCSSResult, CSSBundle, CSSOptimizationOptions, CSSOptimizationStrategy, + CSSOptimizerDependencies, CSSOptimizerStats, } from "./css-optimizer/index.ts"; export { CSSOptimizerService } from "./css-optimizer/index.ts"; export { CacheManager, loadCSSManifest } from "./css-optimizer/index.ts"; export { extractCriticalCSS } from "./css-optimizer/index.ts"; -export { - LightningCSSStrategy, - MinificationStrategy, - PurgeStrategy, -} from "./css-optimizer/index.ts"; +export { MinificationStrategy, PurgeStrategy } from "./css-optimizer/index.ts"; export { CSSUtils } from "./css-optimizer/index.ts"; 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 { getErrorMessage } from "#veryfront/errors"; +import { + captureCSSOptimizationEngine, + CSSOptimizationEngineName, +} from "#veryfront/extensions/css/index.ts"; +import { + captureImageOptimizationEngine, + ImageOptimizationEngineName, +} from "#veryfront/extensions/image/index.ts"; +import { tryResolve } from "#veryfront/extensions/contracts.ts"; +import { cwd } from "#veryfront/platform/compat/process.ts"; +import { logger } from "#veryfront/utils"; import { DEFAULT_CSS_OPTIONS } from "./css-optimizer/constants.ts"; +import { type CSSOptimizationOptions, CSSOptimizer } from "./css-optimizer/index.ts"; import { DEFAULT_OPTIONS as DEFAULT_IMAGE_OPTIONS } from "./image-optimizer/constants.ts"; +import { type ImageOptimizationOptions, ImageOptimizer } from "./image-optimizer/index.ts"; import { assertIndependentAssetStageOutputs, type AssetStageOutputPlan, @@ -35,9 +43,6 @@ 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"; @@ -76,15 +81,23 @@ export interface AssetPipelineResult { duration: number; } +/** Registered provider capabilities available to the asset pipeline. */ +export interface AssetPipelineDependencyStatus { + /** Whether a valid `ImageOptimizationEngine` is registered. */ + imageOptimization: boolean; + /** Whether a valid `CSSOptimizationEngine` is registered. */ + cssOptimization: boolean; +} + function configuredStageOutputs( options: AssetPipelineOptions, ): AssetStageOutputPlan[] { const outputs: AssetStageOutputPlan[] = []; - if (options.images?.enabled !== false) { + if (options.images !== undefined && options.images.enabled !== false) { outputs.push({ stage: "images", - projectDir: options.images?.projectDir ?? cwd(), - outputDir: options.images?.outputDir ?? DEFAULT_IMAGE_OPTIONS.outputDir, + projectDir: options.images.projectDir ?? cwd(), + outputDir: options.images.outputDir ?? DEFAULT_IMAGE_OPTIONS.outputDir, }); } if ( @@ -98,11 +111,11 @@ function configuredStageOutputs( outputDir: options.tailwind.outputDir ?? DEFAULT_TAILWIND_OUTPUT_DIR, }); } - if (options.css?.enabled !== false) { + if (options.css !== undefined && options.css.enabled !== false) { outputs.push({ stage: "css", - projectDir: options.css?.projectDir ?? cwd(), - outputDir: options.css?.outputDir ?? DEFAULT_CSS_OPTIONS.outputDir, + projectDir: options.css.projectDir ?? cwd(), + outputDir: options.css.outputDir ?? DEFAULT_CSS_OPTIONS.outputDir, }); } return outputs; @@ -123,7 +136,7 @@ export async function runAssetPipeline( duration: 0, }; - if (options.images?.enabled !== false) { + if (options.images !== undefined && options.images.enabled !== false) { try { const imageOptimizer = new ImageOptimizer(options.images); await imageOptimizer.optimize(); @@ -143,6 +156,7 @@ export async function runAssetPipeline( }); } catch (error) { logger.error("Image optimization failed", { error: getErrorMessage(error) }); + throw error; } } @@ -189,11 +203,12 @@ export async function runAssetPipeline( } } catch (error) { logger.error("Tailwind CSS processing failed", { error: getErrorMessage(error) }); + throw error; } } } - if (options.css?.enabled !== false) { + if (options.css !== undefined && options.css.enabled !== false) { try { const cssOptimizer = new CSSOptimizer(options.css); await cssOptimizer.optimize(); @@ -215,6 +230,7 @@ export async function runAssetPipeline( }); } catch (error) { logger.error("CSS optimization failed", { error: getErrorMessage(error) }); + throw error; } } @@ -230,24 +246,38 @@ export async function runAssetPipeline( return result; } -export async function checkAssetPipelineDependencies(): Promise<{ - sharp: boolean; - lightningCSS: boolean; -}> { - const dependencies = { sharp: false, lightningCSS: false }; - - try { - await import("https://esm.sh/sharp@0.33.0"); - dependencies.sharp = true; - } catch (error) { - logger.debug("Sharp image processing library not available:", error); +/** + * Inspect registered asset-provider contracts without probing packages or the + * network. The provider-neutral fields replace the removed `sharp` and + * `lightningCSS` fields. + */ +export async function checkAssetPipelineDependencies(): Promise< + AssetPipelineDependencyStatus +> { + const dependencies = { imageOptimization: false, cssOptimization: false }; + + const configuredImageOptimizer = tryResolve( + ImageOptimizationEngineName, + ); + if (configuredImageOptimizer !== undefined) { + try { + captureImageOptimizationEngine(configuredImageOptimizer); + dependencies.imageOptimization = true; + } catch (error) { + logger.debug("Configured image optimization engine is invalid:", error); + } } - try { - await import("https://esm.sh/lightningcss@1.29.2"); - dependencies.lightningCSS = true; - } catch (error) { - logger.debug("Lightning CSS not available:", error); + const configuredCSSOptimizer = tryResolve( + CSSOptimizationEngineName, + ); + if (configuredCSSOptimizer !== undefined) { + try { + captureCSSOptimizationEngine(configuredCSSOptimizer); + dependencies.cssOptimization = true; + } catch (error) { + logger.debug("Configured CSS optimization engine is invalid:", error); + } } return dependencies; @@ -264,19 +294,21 @@ export async function getAssetPipelineStatus(): Promise<{ const missing: string[] = []; const recommendations: string[] = []; - if (deps.sharp) { - available.push("Sharp image optimizer"); + if (deps.imageOptimization) { + available.push("Image optimization provider"); } else { - missing.push("Sharp"); - recommendations.push("Install Sharp for automatic image optimization: npm install sharp"); + missing.push("Image optimization provider"); + recommendations.push( + "Install and explicitly compose @veryfront/ext-image-sharp for image optimization", + ); } - if (deps.lightningCSS) { - available.push("Lightning CSS optimizer"); + if (deps.cssOptimization) { + available.push("CSS optimization provider"); } else { - missing.push("Lightning CSS"); + missing.push("CSS optimization provider"); recommendations.push( - "Install Lightning CSS for advanced CSS optimization: npm install lightningcss", + "Install and explicitly compose @veryfront/ext-css-lightning for CSS optimization", ); } diff --git a/src/build/asset-pipeline/tailwind-processor/batch-processor.ts b/src/build/asset-pipeline/tailwind-processor/batch-processor.ts index a759dc2c82..8a8c8553e6 100644 --- a/src/build/asset-pipeline/tailwind-processor/batch-processor.ts +++ b/src/build/asset-pipeline/tailwind-processor/batch-processor.ts @@ -1,7 +1,7 @@ import { join } from "#veryfront/compat/path/index.ts"; import { logger } from "#veryfront/utils"; import { runtime } from "#veryfront/platform/adapters/detect.ts"; -import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; +import { createFileSystem, isNotFoundError } from "#veryfront/platform/compat/fs.ts"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; import type { TailwindProcessorOptions, TailwindProcessResult } from "./types.ts"; import { TailwindProcessor } from "./processor.ts"; @@ -33,26 +33,38 @@ export function processTailwindCSSInDirectory( const fs = createFileSystem(); const adapter = await runtime.get(); + let directoryInfo; try { - for await (const entry of fs.readDir(cssPath)) { - if (!entry.isFile || !entry.name.endsWith(".css")) continue; + directoryInfo = fs.lstat ? await fs.lstat(cssPath) : await fs.stat(cssPath); + } catch (error) { + if (isNotFoundError(error)) { + logger.debug("Tailwind CSS source directory is absent", { + directory: cssPath, + }); + return []; + } + throw error; + } + if (!directoryInfo.isDirectory || directoryInfo.isSymlink) { + throw new TypeError("Tailwind CSS source path must be a real directory"); + } - const filePath = join(cssPath, entry.name); - if (!(await isTailwindV4File(filePath, projectDir, adapter))) continue; + for await (const entry of fs.readDir(cssPath)) { + if (!entry.isFile || !entry.name.endsWith(".css")) continue; - logger.info("Found Tailwind v4 file", { file: filePath }); + const filePath = join(cssPath, entry.name); + if (!(await isTailwindV4File(filePath, projectDir, adapter))) continue; - results.push( - await processTailwindCSS({ - projectDir, - adapter, - inputFile: filePath, - outputFile: join(projectDir, outputDir, entry.name), - }), - ); - } - } catch (error) { - logger.error("Error processing Tailwind CSS directory", error); + logger.info("Found Tailwind v4 file", { file: filePath }); + + results.push( + await processTailwindCSS({ + projectDir, + adapter, + inputFile: filePath, + outputFile: join(projectDir, outputDir, entry.name), + }), + ); } return results; diff --git a/src/build/asset-pipeline/tailwind-processor/css-utils.test.ts b/src/build/asset-pipeline/tailwind-processor/css-utils.test.ts index 7e6347caea..99c2496cf5 100644 --- a/src/build/asset-pipeline/tailwind-processor/css-utils.test.ts +++ b/src/build/asset-pipeline/tailwind-processor/css-utils.test.ts @@ -1,102 +1,9 @@ import "#veryfront/schemas/_test-setup.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { expect } from "#std/expect.ts"; -import { countUtilities, minifyCSS } from "./css-utils.ts"; +import { countUtilities } from "./css-utils.ts"; describe("css-utils", () => { - describe("minifyCSS", () => { - it("should remove CSS comments", () => { - const result = minifyCSS("/* Comment */ .container { padding: 1rem; }"); - expect(result).not.toContain("/*"); - expect(result).not.toContain("*/"); - expect(result).toContain(".container"); - }); - - it("should remove multiline comments", () => { - const result = minifyCSS(` - /* This is a - multiline comment */ - .btn { color: blue; } - `); - expect(result).not.toContain("/*"); - expect(result).toContain(".btn"); - }); - - it("should collapse whitespace", () => { - expect(minifyCSS(".container { padding: 1rem; }")).toBe( - ".container{padding:1rem;}", - ); - }); - - it("should remove spaces around punctuation", () => { - expect(minifyCSS(".btn { color : red ; }")).toBe(".btn{color:red;}"); - }); - - it("should trim leading and trailing whitespace", () => { - expect(minifyCSS(" .container { padding: 1rem; } ")).toBe( - ".container{padding:1rem;}", - ); - }); - - it("should handle empty CSS", () => { - expect(minifyCSS("")).toBe(""); - }); - - it("should handle CSS with only whitespace", () => { - expect(minifyCSS(" \n \t ")).toBe(""); - }); - - it("should handle CSS with only comments", () => { - expect(minifyCSS("/* Comment 1 */ /* Comment 2 */")).toBe(""); - }); - - it("should handle complex CSS", () => { - const result = minifyCSS(` - /* Header styles */ - .header { - display: flex; - padding: 1rem; - } - - /* Body styles */ - .body { - margin: 0; - } - `); - expect(result).toBe(".header{display:flex;padding:1rem;}.body{margin:0;}"); - }); - - it("should preserve CSS property values", () => { - expect(minifyCSS('.container { background: url("image.png"); }')).toContain( - 'url("image.png")', - ); - }); - - it("should handle CSS with newlines", () => { - expect(minifyCSS(".btn\n{\n color:\n red;\n}")).toBe(".btn{color:red;}"); - }); - - it("should handle CSS with tabs", () => { - expect(minifyCSS(".btn\t{\tcolor:\tred;\t}")).toBe(".btn{color:red;}"); - }); - - it("should remove spaces around commas", () => { - expect(minifyCSS(".btn , .link { color: red; }")).toBe( - ".btn,.link{color:red;}", - ); - }); - - it("should remove spaces around colons", () => { - expect(minifyCSS(".btn { color : red ; }")).toBe(".btn{color:red;}"); - }); - - it("should remove spaces around semicolons", () => { - expect(minifyCSS(".btn { color: red ; margin: 0 ; }")).toBe( - ".btn{color:red;margin:0;}", - ); - }); - }); - describe("countUtilities", () => { it("should count single class selector", () => { expect(countUtilities(".btn { color: blue; }")).toBe(1); diff --git a/src/build/asset-pipeline/tailwind-processor/css-utils.ts b/src/build/asset-pipeline/tailwind-processor/css-utils.ts index 156f38442c..50ce00344c 100644 --- a/src/build/asset-pipeline/tailwind-processor/css-utils.ts +++ b/src/build/asset-pipeline/tailwind-processor/css-utils.ts @@ -1,11 +1,3 @@ -export function minifyCSS(css: string): string { - return css - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\s+/g, " ") - .replace(/\s*([{}:;,])\s*/g, "$1") - .trim(); -} - export function countUtilities(css: string): number { const matches = css.match(/\.[a-zA-Z0-9_-]+/g); if (!matches) return 0; diff --git a/src/build/asset-pipeline/tailwind-processor/index.ts b/src/build/asset-pipeline/tailwind-processor/index.ts index d2fc1c46f9..cb20c659e6 100644 --- a/src/build/asset-pipeline/tailwind-processor/index.ts +++ b/src/build/asset-pipeline/tailwind-processor/index.ts @@ -5,7 +5,7 @@ */ export type { - LightningCSSOptions, + CSSOptimizationProcessOptions, TailwindProcessorOptions, TailwindProcessResult, } from "./types.ts"; @@ -13,5 +13,5 @@ export type { export { TailwindProcessor } from "./processor.ts"; export { processTailwindCSS, processTailwindCSSInDirectory } from "./batch-processor.ts"; export { autoDetectContentPaths, isTailwindV4File } from "./detector.ts"; -export { countUtilities, minifyCSS } from "./css-utils.ts"; -export { processWithLightningCSS } from "./lightning-processor.ts"; +export { countUtilities } from "./css-utils.ts"; +export { processWithCSSOptimization } from "./optimization-processor.ts"; diff --git a/src/build/asset-pipeline/tailwind-processor/lightning-processor.test.ts b/src/build/asset-pipeline/tailwind-processor/lightning-processor.test.ts deleted file mode 100644 index b7916ac0c4..0000000000 --- a/src/build/asset-pipeline/tailwind-processor/lightning-processor.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import { expect } from "#std/expect.ts"; -import { processWithLightningCSS } from "./lightning-processor.ts"; - -describe("build/asset-pipeline/tailwind-processor/lightning-processor", () => { - describe("processWithLightningCSS", () => { - it("should replace Tailwind v4 double-quoted import with comment", async () => { - const css = '@import "tailwindcss";\n.btn { color: red; }'; - const result = await processWithLightningCSS(css, { - filename: "test.css", - minify: false, - }); - expect(result).not.toContain('@import "tailwindcss"'); - expect(result).toContain(".btn"); - }); - - it("should replace Tailwind v4 single-quoted import with comment", async () => { - const css = "@import 'tailwindcss';\n.btn { color: red; }"; - const result = await processWithLightningCSS(css, { - filename: "test.css", - minify: false, - }); - expect(result).not.toContain("@import 'tailwindcss'"); - expect(result).toContain(".btn"); - }); - - it("should replace Tailwind import with trailing semicolon", async () => { - const css = '@import "tailwindcss";'; - const result = await processWithLightningCSS(css, { - filename: "test.css", - minify: false, - }); - expect(result).not.toContain("@import"); - }); - - it("should replace Tailwind import without trailing semicolon", async () => { - const css = '@import "tailwindcss"\n.btn { color: blue; }'; - const result = await processWithLightningCSS(css, { - filename: "test.css", - minify: false, - }); - expect(result).not.toContain('@import "tailwindcss"'); - }); - - it("should preserve non-Tailwind CSS content", async () => { - const css = ".container { display: flex; padding: 1rem; }"; - const result = await processWithLightningCSS(css, { - filename: "test.css", - minify: false, - }); - expect(result).toContain("display"); - expect(result).toContain("padding"); - }); - - it("should handle empty CSS input", async () => { - const result = await processWithLightningCSS("", { - filename: "test.css", - minify: false, - }); - expect(result).toBe(""); - }); - - it("should handle CSS with multiple Tailwind imports", async () => { - const css = '@import "tailwindcss";\n@import "tailwindcss";\n.btn { color: red; }'; - const result = await processWithLightningCSS(css, { - filename: "test.css", - minify: false, - }); - expect(result).not.toContain('@import "tailwindcss"'); - expect(result).toContain(".btn"); - }); - - it("should not replace non-Tailwind imports", async () => { - const css = '@import "other-library";\n.btn { color: red; }'; - const result = await processWithLightningCSS(css, { - filename: "test.css", - minify: false, - }); - // Fallback processor keeps non-tailwind imports (or LightningCSS processes them) - expect(result).toContain(".btn"); - }); - - it("should return processed CSS when minify is true", async () => { - const css = ".container { display: flex; padding: 1rem; }"; - const result = await processWithLightningCSS(css, { - filename: "test.css", - minify: true, - }); - expect(typeof result).toBe("string"); - expect(result.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/src/build/asset-pipeline/tailwind-processor/lightning-processor.ts b/src/build/asset-pipeline/tailwind-processor/lightning-processor.ts deleted file mode 100644 index 123da97efe..0000000000 --- a/src/build/asset-pipeline/tailwind-processor/lightning-processor.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { logger } from "#veryfront/utils"; -import type { LightningCSSOptions } from "./types.ts"; -import { minifyCSS } from "./css-utils.ts"; - -const BROWSER_VERSION_CHROME_90 = 90 << 16; -const BROWSER_VERSION_FIREFOX_88 = 88 << 16; -const BROWSER_VERSION_SAFARI_14 = 14 << 16; -const BROWSER_VERSION_EDGE_90 = 90 << 16; - -export async function processWithLightningCSS( - css: string, - options: LightningCSSOptions, -): Promise { - try { - const lightningCSS = await import("lightningcss"); - - if (typeof lightningCSS.default === "function") { - await lightningCSS.default(); - } - - const processedCSS = css.replace( - /@import\s+["']tailwindcss["'];?/g, - "/* Tailwind CSS v4 base - processed by Lightning CSS */", - ); - - const result = lightningCSS.transform({ - filename: options.filename, - code: new TextEncoder().encode(processedCSS), - minify: options.minify ?? true, - sourceMap: options.sourceMap ?? false, - targets: { - chrome: BROWSER_VERSION_CHROME_90, - firefox: BROWSER_VERSION_FIREFOX_88, - safari: BROWSER_VERSION_SAFARI_14, - edge: BROWSER_VERSION_EDGE_90, - }, - }); - - return new TextDecoder().decode(result.code); - } catch (error) { - logger.warn("Lightning CSS not available, using fallback processor", { - error: error instanceof Error ? error.message : String(error), - }); - - return processCSSFallback(css, options); - } -} - -function processCSSFallback(css: string, options: LightningCSSOptions): string { - const processed = css.replace( - /@import\s+["']tailwindcss["'];?/g, - "/* Tailwind CSS v4 - fallback processing */", - ); - - return options.minify ? minifyCSS(processed) : processed; -} diff --git a/src/build/asset-pipeline/tailwind-processor/optimization-processor.test.ts b/src/build/asset-pipeline/tailwind-processor/optimization-processor.test.ts new file mode 100644 index 0000000000..037ab98219 --- /dev/null +++ b/src/build/asset-pipeline/tailwind-processor/optimization-processor.test.ts @@ -0,0 +1,73 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createTestCSSOptimizationEngine, + createTestCSSSourceMap, +} from "../../../../tests/_helpers/css-optimization-engine.ts"; +import { processWithCSSOptimization } from "./optimization-processor.ts"; + +describe("legacy batch CSS optimization boundary", () => { + it("delegates the original stylesheet without regex preprocessing", () => { + const css = '@import "tailwindcss";\n.btn { color: red; }'; + const engine = createTestCSSOptimizationEngine((request) => { + assertEquals(request, { + css, + sourcePath: "styles/main.css", + minify: false, + sourceMap: false, + }); + assertEquals(Object.isFrozen(request), true); + return { css: request.css }; + }); + + assertEquals( + processWithCSSOptimization(css, { + sourcePath: "styles/main.css", + minify: false, + }, engine), + { css }, + ); + }); + + it("preserves requested source maps through the neutral contract", () => { + const sourceMap = createTestCSSSourceMap("styles/main.css"); + const engine = createTestCSSOptimizationEngine(() => ({ + css: ".btn{}", + sourceMap, + })); + + assertEquals( + processWithCSSOptimization(".btn {}", { + sourcePath: "styles/main.css", + sourceMap: true, + }, engine), + { css: ".btn{}", sourceMap }, + ); + }); + + it("surfaces provider failures without a fallback", () => { + const failure = new Error("provider failed"); + const engine = createTestCSSOptimizationEngine(() => { + throw failure; + }); + let thrown: unknown; + try { + processWithCSSOptimization(".btn {}", { + sourcePath: "styles/main.css", + }, engine); + } catch (error) { + thrown = error; + } + assertEquals(thrown, failure); + + assertThrows( + () => + processWithCSSOptimization(".btn {}", { + sourcePath: "../outside.css", + }, createTestCSSOptimizationEngine()), + TypeError, + "safe canonical", + ); + }); +}); diff --git a/src/build/asset-pipeline/tailwind-processor/optimization-processor.ts b/src/build/asset-pipeline/tailwind-processor/optimization-processor.ts new file mode 100644 index 0000000000..5c286572a0 --- /dev/null +++ b/src/build/asset-pipeline/tailwind-processor/optimization-processor.ts @@ -0,0 +1,29 @@ +import type { + CSSOptimizationEngine, + CSSOptimizationResult, +} from "#veryfront/extensions/css/index.ts"; +import { + acquireConfiguredCSSOptimization, + createCSSOptimizationSession, +} from "../css-optimizer/optimization-engine.ts"; +import type { CSSOptimizationProcessOptions } from "./types.ts"; + +/** + * Run the provider-neutral CSS optimization stage used by the legacy batch + * processor. Missing or failing providers are surfaced without substitution. + */ +export function processWithCSSOptimization( + css: string, + options: CSSOptimizationProcessOptions, + engine?: CSSOptimizationEngine, +): CSSOptimizationResult { + const session = engine === undefined + ? acquireConfiguredCSSOptimization() + : createCSSOptimizationSession(engine); + return session.run({ + css, + sourcePath: options.sourcePath, + minify: options.minify ?? true, + sourceMap: options.sourceMap ?? false, + }); +} diff --git a/src/build/asset-pipeline/tailwind-processor/processor.test.ts b/src/build/asset-pipeline/tailwind-processor/processor.test.ts index 8e17fa3cc2..d952967e23 100644 --- a/src/build/asset-pipeline/tailwind-processor/processor.test.ts +++ b/src/build/asset-pipeline/tailwind-processor/processor.test.ts @@ -3,6 +3,9 @@ import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { TailwindProcessor } from "./processor.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { createTestCSSOptimizationEngine } from "../../../../tests/_helpers/css-optimization-engine.ts"; + +const optimizationEngine = createTestCSSOptimizationEngine(); function createMockAdapter(_baseDir: string): RuntimeAdapter { return { @@ -37,6 +40,7 @@ describe("build/asset-pipeline/tailwind-processor/processor", () => { projectDir: tmpDir, adapter, inputFile: `${tmpDir}/styles/main.css`, + optimizationEngine, }); assertExists(processor); }); @@ -53,6 +57,7 @@ describe("build/asset-pipeline/tailwind-processor/processor", () => { adapter, inputFile: cssFile, minify: false, + optimizationEngine, }); const result = await processor.process(); assertExists(result.css); @@ -77,6 +82,7 @@ describe("build/asset-pipeline/tailwind-processor/processor", () => { inputFile: cssFile, outputFile, minify: false, + optimizationEngine, }); const result = await processor.process(); assertExists(result.css); diff --git a/src/build/asset-pipeline/tailwind-processor/processor.ts b/src/build/asset-pipeline/tailwind-processor/processor.ts index 2bdedd0a15..52e4b6cf8a 100644 --- a/src/build/asset-pipeline/tailwind-processor/processor.ts +++ b/src/build/asset-pipeline/tailwind-processor/processor.ts @@ -1,11 +1,12 @@ -import { dirname } from "#veryfront/compat/path/index.ts"; +import { dirname, relative } from "#veryfront/compat/path/index.ts"; import { logger } from "#veryfront/utils"; import type { TailwindProcessorOptions, TailwindProcessResult } from "./types.ts"; import { autoDetectContentPaths, isTailwindV4File } from "./detector.ts"; import { countUtilities } from "./css-utils.ts"; -import { processWithLightningCSS } from "./lightning-processor.ts"; +import { processWithCSSOptimization } from "./optimization-processor.ts"; import { createSecureFs } from "#veryfront/security"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; +import { isSafeCSSRelativePath } from "../css-optimizer/path-validation.ts"; export class TailwindProcessor { private options: TailwindProcessorOptions; @@ -15,7 +16,6 @@ export class TailwindProcessor { content: autoDetectContentPaths(options.projectDir), minify: true, sourceMap: false, - browserslist: ["defaults", "not IE 11"], ...options, }; } @@ -30,7 +30,7 @@ export class TailwindProcessor { content, minify, sourceMap, - browserslist, + optimizationEngine, projectDir, adapter, } = this.options; @@ -53,17 +53,25 @@ export class TailwindProcessor { }); } - const processedCSS = await processWithLightningCSS(inputCSS, { - filename: inputFile, + const sourcePath = relative(projectDir, inputFile).replaceAll("\\", "/") + .normalize("NFC"); + if (!isSafeCSSRelativePath(sourcePath)) { + throw new TypeError( + "Tailwind batch input must have a safe project-relative path", + ); + } + const optimized = processWithCSSOptimization(inputCSS, { + sourcePath, minify, sourceMap, - browserslist, - }); + }, optimizationEngine); + const processedCSS = optimized.css; const detectedUtilities = countUtilities(processedCSS); const result: TailwindProcessResult = { css: processedCSS, + ...(optimized.sourceMap === undefined ? {} : { sourceMap: optimized.sourceMap }), processedFiles: [inputFile, ...(content ?? [])], detectedUtilities, }; diff --git a/src/build/asset-pipeline/tailwind-processor/types.ts b/src/build/asset-pipeline/tailwind-processor/types.ts index 5c3ddaab1d..e8ea7b9145 100644 --- a/src/build/asset-pipeline/tailwind-processor/types.ts +++ b/src/build/asset-pipeline/tailwind-processor/types.ts @@ -1,4 +1,5 @@ import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import type { CSSOptimizationEngine } from "#veryfront/extensions/css/index.ts"; export interface TailwindProcessorOptions { projectDir: string; @@ -8,7 +9,7 @@ export interface TailwindProcessorOptions { content?: string[]; minify?: boolean; sourceMap?: boolean; - browserslist?: string[]; + optimizationEngine?: CSSOptimizationEngine; } export interface TailwindProcessResult { @@ -18,9 +19,8 @@ export interface TailwindProcessResult { detectedUtilities: number; } -export interface LightningCSSOptions { - filename: string; +export interface CSSOptimizationProcessOptions { + sourcePath: string; minify?: boolean; sourceMap?: boolean; - browserslist?: string[]; } diff --git a/src/build/binary-plugin-includes.test.ts b/src/build/binary-plugin-includes.test.ts deleted file mode 100644 index aa18e39891..0000000000 --- a/src/build/binary-plugin-includes.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import { - BINARY_TAILWIND_PLUGIN_PACKAGES, - getBinaryPluginBundleIncludes, - getTailwindPluginBundleUrl, - resolveTailwindPluginBundlePackage, -} from "./binary-plugin-includes.ts"; - -describe("build/binary-plugin-includes", () => { - it("builds bundle URLs that match the runtime plugin loader contract", () => { - assertEquals( - getTailwindPluginBundleUrl("tailwindcss-animate@1.0.7"), - "https://esm.sh/tailwindcss-animate@1.0.7?bundle&external=tailwindcss&target=denonext", - ); - }); - - it("resolves bare bundled plugin names to the pinned binary package", () => { - assertEquals( - resolveTailwindPluginBundlePackage("@tailwindcss/typography"), - "@tailwindcss/typography@0.5.19", - ); - assertEquals( - getTailwindPluginBundleUrl("@tailwindcss/typography"), - "https://esm.sh/@tailwindcss/typography@0.5.19?bundle&external=tailwindcss&target=denonext", - ); - }); - - it("keeps explicit plugin versions unchanged", () => { - assertEquals( - resolveTailwindPluginBundlePackage("@tailwindcss/typography@0.5.18"), - "@tailwindcss/typography@0.5.18", - ); - }); - - it("returns bundle includes for every pinned binary plugin", () => { - assertEquals( - getBinaryPluginBundleIncludes(), - BINARY_TAILWIND_PLUGIN_PACKAGES.map((pkg) => - `https://esm.sh/${pkg}?bundle&external=tailwindcss&target=denonext` - ), - ); - }); -}); diff --git a/src/build/binary-plugin-includes.ts b/src/build/binary-plugin-includes.ts deleted file mode 100644 index 6a12776038..0000000000 --- a/src/build/binary-plugin-includes.ts +++ /dev/null @@ -1,40 +0,0 @@ -const BINARY_TAILWIND_PLUGIN_PACKAGES = [ - "tailwindcss-animate@1.0.7", - "@tailwindcss/typography@0.5.19", - "@tailwindcss/forms@0.5.11", - "tailwind-scrollbar-hide@2.0.0", - "daisyui@5.5.14", -] as const; - -function barePackageName(spec: string): string { - if (spec.startsWith("@")) { - const versionIndex = spec.indexOf("@", 1); - return versionIndex === -1 ? spec : spec.slice(0, versionIndex); - } - - const versionIndex = spec.indexOf("@"); - return versionIndex === -1 ? spec : spec.slice(0, versionIndex); -} - -const BINARY_TAILWIND_PLUGIN_PACKAGE_BY_NAME = new Map( - BINARY_TAILWIND_PLUGIN_PACKAGES.map((pkg) => [barePackageName(pkg), pkg]), -); - -export function resolveTailwindPluginBundlePackage(packageName: string): string { - if (packageName.includes("@", packageName.startsWith("@") ? 1 : 0)) { - return packageName; - } - - return BINARY_TAILWIND_PLUGIN_PACKAGE_BY_NAME.get(packageName) ?? packageName; -} - -export function getTailwindPluginBundleUrl(packageName: string): string { - const resolvedPackage = resolveTailwindPluginBundlePackage(packageName); - return `https://esm.sh/${resolvedPackage}?bundle&external=tailwindcss&target=denonext`; -} - -export function getBinaryPluginBundleIncludes(): string[] { - return BINARY_TAILWIND_PLUGIN_PACKAGES.map(getTailwindPluginBundleUrl); -} - -export { BINARY_TAILWIND_PLUGIN_PACKAGES }; diff --git a/src/build/production-build/static-generation.test.ts b/src/build/production-build/static-generation.test.ts index e4490b2aad..6cc6424743 100644 --- a/src/build/production-build/static-generation.test.ts +++ b/src/build/production-build/static-generation.test.ts @@ -1,4 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; +import "#veryfront/html/styles-builder/__tests__/css-processor-setup.ts"; import { assertEquals, assertExists, diff --git a/src/build/production-build/static-generation.ts b/src/build/production-build/static-generation.ts index 315039638a..bc10c0700b 100644 --- a/src/build/production-build/static-generation.ts +++ b/src/build/production-build/static-generation.ts @@ -15,12 +15,12 @@ import { loadClientStyles } from "./asset-generation.ts"; import { buildImportMap } from "#veryfront/html/utils.ts"; import type { ReleaseAssetManifest } from "#veryfront/release-assets/manifest-schema.ts"; import { + acquireCSSGenerationSession, cacheCSSAsync, extractCandidatesFromFiles, generateTailwindCSS, hashCSS, } from "#veryfront/html/styles-builder/index.ts"; -import { DEFAULT_STYLESHEET } from "#veryfront/html/styles-builder/css-hash-cache.ts"; import { FRAMEWORK_CANDIDATES } from "#veryfront/server/handlers/dev/framework-candidates.generated.ts"; import { jsonForInlineScript } from "#veryfront/security/client/html-sanitizer.ts"; import { SSG_GENERATION_ERROR } from "#veryfront/errors"; @@ -191,23 +191,21 @@ async function prepareAppRouteStylesheet( }); for (const candidate of FRAMEWORK_CANDIDATES) candidates.add(candidate); - const generated = await generateTailwindCSS(stylesheet, candidates, { + const generationSession = acquireCSSGenerationSession(true); + const resolvedStylesheet = stylesheet ?? generationSession.compilationSession.defaultStylesheet; + const generated = await generateTailwindCSS(resolvedStylesheet, candidates, { minify: true, environment: "production", buildMode: "production", - }); - - if (generated.error) { - logger.error("Failed to generate App Router CSS:", generated.error); - return undefined; - } + }, { generationSession }); const hash = hashCSS(generated.css); if (!hash) return undefined; await cacheCSSAsync(generated.css, hash, { candidates, - stylesheet: stylesheet ?? DEFAULT_STYLESHEET, + stylesheet: resolvedStylesheet, + pipelineIdentity: generated.cacheIdentity, }); if (!options.dryRun) { diff --git a/src/config/schemas/config.schema.test.ts b/src/config/schemas/config.schema.test.ts index 3dfa14b206..f8b3e77a57 100644 --- a/src/config/schemas/config.schema.test.ts +++ b/src/config/schemas/config.schema.test.ts @@ -21,7 +21,6 @@ describe("configSchema", () => { enabled: true, projectDir, inputFiles: ["styles/main.css"], - browsers: ["defaults", "not IE 11"], purge: true, purgeContent: ["app/**/*.tsx"], purgeSafelist: ["dynamic"], @@ -34,7 +33,8 @@ describe("configSchema", () => { for ( const invalid of [ { projectDir: "relative/project" }, - { browsers: [] }, + { autoprefixer: true }, + { browsers: ["defaults"] }, { purgeSafelist: Array.from( { length: CSS_OPTIMIZATION.MAX_PURGE_SAFELIST_ENTRIES + 1 }, diff --git a/src/config/schemas/config.schema.ts b/src/config/schemas/config.schema.ts index 03c9e057d4..b6d6e63680 100644 --- a/src/config/schemas/config.schema.ts +++ b/src/config/schemas/config.schema.ts @@ -265,7 +265,6 @@ export const getVeryfrontConfigSchema = defineSchema((v) => ) .optional(), minify: v.boolean().optional(), - autoprefixer: v.boolean().optional(), purge: v.boolean().optional(), criticalCSS: v.boolean().optional(), inputFiles: v @@ -274,15 +273,6 @@ export const getVeryfrontConfigSchema = defineSchema((v) => .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) @@ -293,7 +283,7 @@ export const getVeryfrontConfigSchema = defineSchema((v) => .optional(), sourceMap: v.boolean().optional(), }) - .partial() + .strict() .optional(), }) .partial() diff --git a/src/extensions/builtin-extensions.ts b/src/extensions/builtin-extensions.ts index 54f1c88913..0ae97e92ca 100644 --- a/src/extensions/builtin-extensions.ts +++ b/src/extensions/builtin-extensions.ts @@ -134,13 +134,6 @@ export const OPTIONAL_BUILTIN_EXTENSIONS: OptionalBuiltinExtensionDefinition[] = contracts: { provides: ["ContentProcessor"] }, capabilities: [], }, - { - name: "ext-css-tailwind", - origin: "veryfront/ext-css-tailwind", - sourceDirectory: "ext-css-tailwind", - contracts: { provides: ["CSSProcessor"] }, - capabilities: [{ type: "net:outbound", hosts: ["esm.sh"] }], - }, { name: "ext-document-kreuzberg", origin: "veryfront/ext-document-kreuzberg", diff --git a/src/extensions/css/css-processor.test.ts b/src/extensions/css/css-processor.test.ts new file mode 100644 index 0000000000..e7bd9c7e91 --- /dev/null +++ b/src/extensions/css/css-processor.test.ts @@ -0,0 +1,149 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + assertCSSProcessor, + captureCSSCompiler, + captureCSSProcessor, + type CSSProcessor, + MAX_CSS_PROCESSOR_DEFAULT_STYLESHEET_CHARACTERS, + MAX_CSS_PROCESSOR_IDENTITY_CHARACTERS, +} from "./css-processor.ts"; + +interface StatefulCSSProcessor extends CSSProcessor { + readonly marker: string; +} + +function processor(): StatefulCSSProcessor { + return { + cacheIdentity: "test-css-processor@1", + defaultStylesheet: '@import "test";', + marker: "captured", + compile(stylesheet) { + const marker = this.marker; + return Promise.resolve({ + build(candidates) { + return `${marker}:${stylesheet}:${candidates.join(",")}`; + }, + }); + }, + }; +} + +describe("CSSProcessor contract", () => { + it("captures identity, default stylesheet, method, implementation, and compiler once", async () => { + const value = processor(); + const captured = captureCSSProcessor(value); + (value as { cacheIdentity: string }).cacheIdentity = "mutated"; + (value as { defaultStylesheet: string }).defaultStylesheet = "mutated"; + value.compile = () => Promise.resolve({ build: () => "replacement" }); + + const compiler = await captured.compile("input.css"); + assertEquals(captured.cacheIdentity, "test-css-processor@1"); + assertEquals(captured.defaultStylesheet, '@import "test";'); + assertEquals(compiler.build(["alpha", "beta"]), "captured:input.css:alpha,beta"); + }); + + it("rejects property accessors and Proxies without invoking their hooks", () => { + let identityReads = 0; + const accessor = Object.defineProperty( + { + defaultStylesheet: "input.css", + compile: processor().compile, + }, + "cacheIdentity", + { + get() { + identityReads++; + return "hostile@1"; + }, + }, + ); + assertThrows(() => assertCSSProcessor(accessor), TypeError, "own data property"); + assertEquals(identityReads, 0); + + let proxyHooks = 0; + const proxy = new Proxy(processor(), { + getOwnPropertyDescriptor() { + proxyHooks++; + throw new Error("must not run"); + }, + getPrototypeOf() { + proxyHooks++; + throw new Error("must not run"); + }, + }); + assertThrows(() => assertCSSProcessor(proxy), TypeError, "non-Proxy object"); + assertEquals(proxyHooks, 0); + }); + + it("rejects compiler accessors without invoking them and validates build output", () => { + let buildReads = 0; + const accessor = Object.defineProperty({}, "build", { + get() { + buildReads++; + return () => "hostile"; + }, + }); + assertThrows(() => captureCSSCompiler(accessor), TypeError, "data-property function"); + assertEquals(buildReads, 0); + + const invalid = captureCSSCompiler({ build: () => 123 as unknown as string }); + assertThrows(() => invalid.build([]), TypeError, "return CSS as a string"); + }); + + it("rejects unstable identities and unbounded or NUL-containing defaults", () => { + for ( + const cacheIdentity of [ + "", + " padded ", + "line\nbreak", + "x".repeat(MAX_CSS_PROCESSOR_IDENTITY_CHARACTERS + 1), + ] + ) { + assertThrows( + () => assertCSSProcessor({ ...processor(), cacheIdentity }), + TypeError, + "bounded stable cacheIdentity", + ); + } + + for ( + const defaultStylesheet of [ + "contains\0nul", + "x".repeat(MAX_CSS_PROCESSOR_DEFAULT_STYLESHEET_CHARACTERS + 1), + ] + ) { + assertThrows( + () => assertCSSProcessor({ ...processor(), defaultStylesheet }), + TypeError, + "bounded defaultStylesheet", + ); + } + }); + + it("uses captured inspection and invocation intrinsics", async () => { + const originalIndexOf = String.prototype.indexOf; + const originalApply = Reflect.apply; + let invalidDefaultError: unknown; + let css: string | undefined; + try { + String.prototype.indexOf = () => -1; + Reflect.apply = () => { + throw new Error("poisoned Reflect.apply"); + }; + try { + assertCSSProcessor({ ...processor(), defaultStylesheet: "contains\0nul" }); + } catch (error) { + invalidDefaultError = error; + } + css = (await captureCSSProcessor(processor()).compile("input.css")).build(["alpha"]); + } finally { + String.prototype.indexOf = originalIndexOf; + Reflect.apply = originalApply; + } + + assertEquals(invalidDefaultError instanceof TypeError, true); + assertEquals(css, "captured:input.css:alpha"); + }); +}); diff --git a/src/extensions/css/css-processor.ts b/src/extensions/css/css-processor.ts index eb39aca1a8..0bb43932b0 100644 --- a/src/extensions/css/css-processor.ts +++ b/src/extensions/css/css-processor.ts @@ -1,43 +1,37 @@ /** - * Contract interface for CSS processing engines (Tailwind-style compile - * pipelines). + * Contract interface for class-candidate CSS processing engines. * - * Default implementation: `@veryfront/ext-css-tailwind` + * Implementations are supplied by explicit extensions such as + * `@veryfront/ext-css-tailwind`. * - * The contract mirrors the Tailwind v4 `compile()` surface: a stateful + * The contract exposes a provider-neutral compile surface: a stateful * compiler is constructed once per stylesheet and emits CSS output for the * set of class-name candidates discovered at render time. Core scans the * rendered HTML for candidates and calls `CSSCompiler.build(candidates)` - * on each request; the compiler accumulates state across calls, so per- - * project isolation is the caller's responsibility (see - * `tailwind-compiler-cache.ts`). + * on each request; the compiler accumulates state across calls, so exact + * candidate-snapshot isolation is the caller's responsibility (see + * `css-compiler-cache.ts`). * * @module extensions/css/css-processor */ -/** A loaded stylesheet body with the base path used to resolve relative imports. */ -export interface CSSStylesheetSource { - content: string; - base: string; - path: string; -} +import { isProxy as isProxyWithoutHooks } from "node:util/types"; +import { + applyExtensionMethod, + findExtensionPropertyDescriptor, + freezeExtensionContract, + getExtensionOwnPropertyDescriptor, + isDataPropertyDescriptor, + isExtensionArray, + isStableExtensionCacheIdentity, +} from "../property-inspection.ts"; -/** A loaded module (Tailwind plugin). `module` is the plugin's default export. */ -export interface CSSModuleSource { - module: unknown; - base: string; - path: string; -} +/** Registry name used for the CSS compiler extension contract. */ +export const CSSProcessorName = "CSSProcessor" as const; +export const MAX_CSS_PROCESSOR_IDENTITY_CHARACTERS = 512; +export const MAX_CSS_PROCESSOR_DEFAULT_STYLESHEET_CHARACTERS = 1024 * 1024; -/** Options passed to {@link CSSProcessor.compile}. */ -export interface CSSCompileOptions { - /** Base path used to resolve relative `@import` specifiers. */ - base: string; - /** Resolver invoked when the compiler encounters an `@import` it doesn't recognize. */ - loadStylesheet(id: string): Promise; - /** Resolver invoked for `@plugin` directives. */ - loadModule(id: string): Promise; -} +const stringIndexOf = String.prototype.indexOf; /** Stateful compiler returned by {@link CSSProcessor.compile}. */ export interface CSSCompiler { @@ -52,10 +46,143 @@ export interface CSSCompiler { /** * CSSProcessor contract interface. * - * Implementations wire a utility-class compiler (Tailwind, UnoCSS, etc.) so + * Implementations wire a class-candidate CSS compiler so * core's styles-builder can emit per-request CSS without importing the * underlying engine directly. */ export interface CSSProcessor { - compile(stylesheet: string, options: CSSCompileOptions): Promise; + /** Stable identity for every processor/compiler input that can change emitted CSS. */ + readonly cacheIdentity: string; + /** Provider-owned stylesheet used when an application does not supply one. */ + readonly defaultStylesheet: string; + /** + * Compile a stylesheet. Implementations own all vendor imports, base + * stylesheets, module resolution, and plugin loading behind this operation. + */ + compile(stylesheet: string): Promise; +} + +function assertContractObject(value: unknown, label: string): asserts value is object { + if ( + typeof value !== "object" || + value === null || + isExtensionArray(value) || + isProxyWithoutHooks(value) + ) { + throw new TypeError(`${label} must be a non-Proxy object`); + } +} + +function readCSSCompiler(value: unknown): { + implementation: object; + build: CSSCompiler["build"]; +} { + assertContractObject(value, "CSSCompiler"); + + let buildDescriptor: PropertyDescriptor | undefined; + try { + buildDescriptor = findExtensionPropertyDescriptor(value, "build"); + } catch (cause) { + throw new TypeError("CSSCompiler properties could not be inspected", { cause }); + } + if (!isDataPropertyDescriptor(buildDescriptor) || typeof buildDescriptor.value !== "function") { + throw new TypeError("CSSCompiler build must be a data-property function"); + } + return { + implementation: value, + build: buildDescriptor.value as CSSCompiler["build"], + }; +} + +/** Capture a compiler method once so accessors and later mutation cannot redirect a build. */ +export function captureCSSCompiler(value: unknown): CSSCompiler { + const captured = readCSSCompiler(value); + return freezeExtensionContract({ + build(candidates: string[]): string { + const css = applyExtensionMethod(captured.build, captured.implementation, [candidates]); + if (typeof css !== "string") { + throw new TypeError("CSSCompiler build must return CSS as a string"); + } + return css; + }, + }); +} + +function readCSSProcessor(value: unknown): { + implementation: object; + cacheIdentity: string; + defaultStylesheet: string; + compile: CSSProcessor["compile"]; +} { + assertContractObject(value, "CSSProcessor"); + + let identityDescriptor: PropertyDescriptor | undefined; + let defaultStylesheetDescriptor: PropertyDescriptor | undefined; + let compileDescriptor: PropertyDescriptor | undefined; + try { + identityDescriptor = getExtensionOwnPropertyDescriptor(value, "cacheIdentity"); + defaultStylesheetDescriptor = getExtensionOwnPropertyDescriptor(value, "defaultStylesheet"); + compileDescriptor = findExtensionPropertyDescriptor(value, "compile"); + } catch (cause) { + throw new TypeError("CSSProcessor properties could not be inspected", { cause }); + } + + if (!isDataPropertyDescriptor(identityDescriptor)) { + throw new TypeError("CSSProcessor cacheIdentity must be an own data property"); + } + if (!isDataPropertyDescriptor(defaultStylesheetDescriptor)) { + throw new TypeError("CSSProcessor defaultStylesheet must be an own data property"); + } + if ( + !isDataPropertyDescriptor(compileDescriptor) || typeof compileDescriptor.value !== "function" + ) { + throw new TypeError("CSSProcessor compile must be a data-property function"); + } + if ( + !isStableExtensionCacheIdentity( + identityDescriptor.value, + MAX_CSS_PROCESSOR_IDENTITY_CHARACTERS, + ) + ) { + throw new TypeError("CSSProcessor must declare a bounded stable cacheIdentity"); + } + if ( + typeof defaultStylesheetDescriptor.value !== "string" || + defaultStylesheetDescriptor.value.length > MAX_CSS_PROCESSOR_DEFAULT_STYLESHEET_CHARACTERS || + applyExtensionMethod(stringIndexOf, defaultStylesheetDescriptor.value, ["\0"]) !== -1 + ) { + throw new TypeError("CSSProcessor must declare a bounded defaultStylesheet"); + } + + return { + implementation: value, + cacheIdentity: identityDescriptor.value, + defaultStylesheet: defaultStylesheetDescriptor.value, + compile: compileDescriptor.value as CSSProcessor["compile"], + }; +} + +/** Validate an implementation received through the dynamic extension registry. */ +export function assertCSSProcessor(value: unknown): asserts value is CSSProcessor { + readCSSProcessor(value); +} + +/** + * Capture the complete processor surface once. A registry or implementation + * mutation can therefore affect only a subsequently acquired operation. + */ +export function captureCSSProcessor(value: unknown): CSSProcessor { + const captured = readCSSProcessor(value); + return freezeExtensionContract({ + cacheIdentity: captured.cacheIdentity, + defaultStylesheet: captured.defaultStylesheet, + async compile(stylesheet: string): Promise { + const compiler = await applyExtensionMethod( + captured.compile, + captured.implementation, + [stylesheet], + ); + return captureCSSCompiler(compiler); + }, + }); } diff --git a/src/extensions/css/index.ts b/src/extensions/css/index.ts index 42a8ccd6f5..4b41d25964 100644 --- a/src/extensions/css/index.ts +++ b/src/extensions/css/index.ts @@ -1,15 +1,17 @@ /** - * CSS category barrel — CSS processor and compiler contracts. + * CSS category barrel — CSS compilation and optimization contracts. * * @module extensions/css */ -export type { - CSSCompileOptions, - CSSCompiler, - CSSModuleSource, - CSSProcessor, - CSSStylesheetSource, +export type { CSSCompiler, CSSProcessor } from "./css-processor.ts"; +export { + assertCSSProcessor, + captureCSSCompiler, + captureCSSProcessor, + CSSProcessorName, + MAX_CSS_PROCESSOR_DEFAULT_STYLESHEET_CHARACTERS, + MAX_CSS_PROCESSOR_IDENTITY_CHARACTERS, } from "./css-processor.ts"; export type { diff --git a/src/extensions/image/image-optimization-engine.ts b/src/extensions/image/image-optimization-engine.ts index c0bed81df7..e081eb3444 100644 --- a/src/extensions/image/image-optimization-engine.ts +++ b/src/extensions/image/image-optimization-engine.ts @@ -59,7 +59,15 @@ export interface ImageOptimizationResult { readonly variants: readonly ImageOptimizationVariantResult[]; } -/** Image decoder, resizer, and encoder implemented by an explicit extension. */ +/** + * Image decoder, resizer, and encoder implemented by an explicit extension. + * + * Implementations are long-lived, concurrency-safe services. They must not + * require caller-managed lifecycle cleanup and must release operation-scoped + * resources when the Promise returned by `optimize()` settles. Core may stop + * awaiting an operation after aborting its signal, so a late result must not + * rely on caller publication or cleanup behavior. + */ export interface ImageOptimizationEngine { /** * Includes every implementation/version input capable of changing output. diff --git a/src/extensions/property-inspection.ts b/src/extensions/property-inspection.ts index f36654ed51..fa17ebdf52 100644 --- a/src/extensions/property-inspection.ts +++ b/src/extensions/property-inspection.ts @@ -1,5 +1,7 @@ /** Bounded, descriptor-only inspection for extension contract implementations. */ +import { isWellFormedString } from "#veryfront/utils/is-well-formed-string.ts"; + const MAX_EXTENSION_PROTOTYPE_DEPTH = 32; const apply = Reflect.apply; const freeze = Object.freeze; @@ -7,7 +9,6 @@ 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; @@ -40,21 +41,6 @@ export function isDataPropertyDescriptor( 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, @@ -63,7 +49,7 @@ export function isStableExtensionCacheIdentity( return typeof value === "string" && value.length > 0 && value.length <= maxCharacters && - isWellFormedExtensionString(value) && + isWellFormedString(value) && apply(trimString, value, []) === value && apply(normalizeString, value, ["NFC"]) === value && apply(executeRegularExpression, CONTROL_OR_LINE_SEPARATOR_PATTERN, [value]) === null; diff --git a/src/html/styles-builder/__tests__/css-processor-setup.ts b/src/html/styles-builder/__tests__/css-processor-setup.ts index ec211120bd..b7ceeed214 100644 --- a/src/html/styles-builder/__tests__/css-processor-setup.ts +++ b/src/html/styles-builder/__tests__/css-processor-setup.ts @@ -1,10 +1,9 @@ /** * Shared test helper: activates the `@veryfront/ext-css-tailwind` extension so * core tests that exercise the Tailwind compile path can resolve the - * `CSSProcessor` contract and — for tests that dynamically load plugins - * from esm.sh — find the `__tailwindPluginShim` / `__tailwindDefaultThemeShim` - * / `__tailwindColorsShim` globals that plugin-loader rewrites plugin - * bundle imports against. + * `CSSProcessor` contract. Minified test paths also receive an explicit, + * identity-bearing no-op CSSOptimizationEngine; neither provider is discovered + * or auto-registered by production core. * * Import this module (for side effects) from any test that exercises the * Tailwind compile path via `getCompiler` / `generateTailwindCSS` / @@ -18,7 +17,12 @@ * @module html/styles-builder/__tests__/css-processor-setup */ -import { register as registerContract } from "#veryfront/extensions/contracts.ts"; +import { + register as registerContract, + tryResolve as tryResolveContract, +} from "#veryfront/extensions/contracts.ts"; +import { CSSOptimizationEngineName } from "#veryfront/extensions/css/index.ts"; +import { createTestCSSOptimizationEngine } from "../../../../tests/_helpers/css-optimization-engine.ts"; import extTailwindFactory from "../../../../extensions/ext-css-tailwind/src/index.ts"; const noopLogger = { @@ -40,6 +44,9 @@ export async function registerTailwindExtension(): Promise { }, }; await ext.setup?.(ctx as never); + if (tryResolveContract(CSSOptimizationEngineName) === undefined) { + registerContract(CSSOptimizationEngineName, createTestCSSOptimizationEngine()); + } } await registerTailwindExtension(); diff --git a/src/html/styles-builder/candidate-extractor.ts b/src/html/styles-builder/candidate-extractor.ts index 8668cc2bf3..0b33faf78d 100644 --- a/src/html/styles-builder/candidate-extractor.ts +++ b/src/html/styles-builder/candidate-extractor.ts @@ -1,5 +1,5 @@ /** - * Tailwind CSS candidate extraction from source files. + * Provider-neutral CSS candidate extraction from source files. * * Extracts class name candidates from source code for Tailwind CSS compilation. * @@ -8,64 +8,102 @@ import type { StyleScopeProfile } from "./style-scope-profile.ts"; import { shouldIncludeStylePath } from "./style-scope-profile.ts"; +import { extractCandidatesWithByteLength } from "./candidate-tokenizer.ts"; +import { + MAX_CSS_FILES, + MAX_CSS_SELECTOR_TOKENS, + MAX_CSS_TOTAL_BYTES, +} from "#veryfront/utils/constants/css.ts"; +import { isProxy as isProxyWithoutHooks } from "node:util/types"; -/** - * Extract potential Tailwind class name candidates from source code content. - * Uses a comprehensive regex pattern matching Tailwind v4 utility patterns. - */ -export function extractCandidates(content: string): string[] { - const pattern = /!?-?@?(?:[a-zA-Z0-9]|\[&?)[a-zA-Z0-9_\-:\/\.\[\]%#,()!'=<>$@{}|*+?;^~]*/g; - return [...new Set(content.match(pattern) ?? [])]; -} +const apply = Reflect.apply; +const NativeTypeError = TypeError; +const SetConstructor = Set; +const StringConstructor = String; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const isArray = Array.isArray; +const setAdd = Set.prototype.add; +const setHas = Set.prototype.has; +const stringEndsWith = String.prototype.endsWith; +const sourceExtensions = [".tsx", ".jsx", ".ts", ".js", ".mdx"] as const; + +export { extractCandidates } from "./candidate-tokenizer.ts"; +export { hashCandidates, hashCSS, hashString } from "./css-identity.ts"; export function extractCandidatesFromFiles( - files: Array<{ path: string; content?: string }>, + files: readonly { path: string; content?: string }[], options: { projectDir?: string; styleProfile?: StyleScopeProfile; } = {}, ): Set { - const candidates = new Set(); - const sourceExtensions = [".tsx", ".jsx", ".ts", ".js", ".mdx"]; + if (isProxyWithoutHooks(files) || !isArray(files) || files.length > MAX_CSS_FILES) { + throw new NativeTypeError( + `CSS candidate files must be an array of at most ${MAX_CSS_FILES} files`, + ); + } + const candidates = new SetConstructor(); + let candidateCount = 0; + let sourceBytes = 0; - for (const file of files) { - if (!file.content) continue; + for (let index = 0; index < files.length; index++) { + const fileDescriptor = getOwnPropertyDescriptor(files, StringConstructor(index)); + if (!fileDescriptor || !("value" in fileDescriptor)) { + throw new NativeTypeError("CSS candidate files must be a dense data-property array"); + } + const file = fileDescriptor.value; + if (isProxyWithoutHooks(file) || typeof file !== "object" || file === null) { + throw new NativeTypeError("CSS candidate file entries must be non-Proxy objects"); + } + const pathDescriptor = getOwnPropertyDescriptor(file, "path"); + const contentDescriptor = getOwnPropertyDescriptor(file, "content"); + if ( + !pathDescriptor || !("value" in pathDescriptor) || typeof pathDescriptor.value !== "string" + ) { + throw new NativeTypeError("CSS candidate file path must be an own string data property"); + } + if (contentDescriptor && !("value" in contentDescriptor)) { + throw new NativeTypeError("CSS candidate file content must be a data property"); + } + const path = pathDescriptor.value; + const content = contentDescriptor?.value; + if (content === undefined || content === "") continue; + if (typeof content !== "string") { + throw new NativeTypeError("CSS candidate file content must be a string when present"); + } if ( options.styleProfile && - !shouldIncludeStylePath(options.styleProfile, file.path, options.projectDir) + !shouldIncludeStylePath(options.styleProfile, path, options.projectDir) ) { continue; } - if (!sourceExtensions.some((ext) => file.path.endsWith(ext))) continue; + let isSourceFile = false; + for (let extensionIndex = 0; extensionIndex < sourceExtensions.length; extensionIndex++) { + if (apply(stringEndsWith, path, [sourceExtensions[extensionIndex]])) { + isSourceFile = true; + break; + } + } + if (!isSourceFile) continue; - for (const candidate of extractCandidates(file.content)) { - candidates.add(candidate); + const extracted = extractCandidatesWithByteLength(content, `CSS candidate source ${path}`); + if (extracted.sourceBytes > MAX_CSS_TOTAL_BYTES - sourceBytes) { + throw new NativeTypeError(`CSS candidate sources exceed ${MAX_CSS_TOTAL_BYTES} total bytes`); + } + sourceBytes += extracted.sourceBytes; + for (let candidateIndex = 0; candidateIndex < extracted.candidates.length; candidateIndex++) { + const candidate = extracted.candidates[candidateIndex]; + if (!apply(setHas, candidates, [candidate]) && candidateCount >= MAX_CSS_SELECTOR_TOKENS) { + throw new NativeTypeError( + `CSS candidate extraction cannot exceed ${MAX_CSS_SELECTOR_TOKENS} candidates`, + ); + } + if (!apply(setHas, candidates, [candidate])) { + apply(setAdd, candidates, [candidate]); + candidateCount++; + } } } return candidates; } - -/** - * Simple DJB2-style hash function. - */ -export function hashString(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = ((hash << 5) - hash) + str.charCodeAt(i); - hash = hash & hash; - } - return hash.toString(36); -} - -export function hashCSS(css: string): string { - return hashString(css).slice(0, 8); -} - -/** - * Hash a set of candidates for cache key generation. - * Uses sorted array to ensure consistent hash regardless of Set iteration order. - */ -export function hashCandidates(candidates: Set): string { - return hashString(Array.from(candidates).sort().join(",")); -} diff --git a/src/html/styles-builder/candidate-tokenizer.ts b/src/html/styles-builder/candidate-tokenizer.ts new file mode 100644 index 0000000000..4e31d9a9dc --- /dev/null +++ b/src/html/styles-builder/candidate-tokenizer.ts @@ -0,0 +1,77 @@ +/** Bounded, dependency-free CSS candidate tokenization. */ + +import { + MAX_CSS_SELECTOR_TOKEN_CHARACTERS, + MAX_CSS_SELECTOR_TOKENS, +} from "#veryfront/utils/constants/css.ts"; +import { assertCSSFileContent } from "#veryfront/utils/css-content-admission.ts"; + +const candidateBodyCharacterClass = "[a-zA-Z0-9_\\-:\\/\\.\\[\\]%#,()!'=<>$@{}|*+?;^~]"; +const candidatePattern = new RegExp( + `!?-?@?(?:[a-zA-Z0-9]|\\[&?)${candidateBodyCharacterClass}{0,${ + MAX_CSS_SELECTOR_TOKEN_CHARACTERS - 1 + }}`, + "g", +); +const candidateContinuationPattern = new RegExp(candidateBodyCharacterClass); +const apply = Reflect.apply; +const NativeTypeError = TypeError; +const arrayPush = Array.prototype.push; +const regExpExec = RegExp.prototype.exec; +const setAdd = Set.prototype.add; +const setHas = Set.prototype.has; +const SetConstructor = Set; +const stringCharAt = String.prototype.charAt; + +function hasCandidateContinuation(content: string, index: number): boolean { + if (index >= content.length) return false; + const nextCharacter = apply(stringCharAt, content, [index]) as string; + return apply(regExpExec, candidateContinuationPattern, [nextCharacter]) !== null; +} + +export interface ExtractedCSSCandidates { + readonly candidates: string[]; + readonly sourceBytes: number; +} + +/** Extract a bounded, deduplicated candidate snapshot and admitted byte count. */ +export function extractCandidatesWithByteLength( + content: string, + label = "CSS candidate source", +): ExtractedCSSCandidates { + const sourceBytes = assertCSSFileContent(content, label); + const candidates: string[] = []; + const seenCandidates = new SetConstructor(); + candidatePattern.lastIndex = 0; + try { + let match: RegExpExecArray | null; + while ( + (match = apply(regExpExec, candidatePattern, [content]) as RegExpExecArray | null) !== null + ) { + const candidate = match[0]; + if ( + candidate.length > MAX_CSS_SELECTOR_TOKEN_CHARACTERS || + hasCandidateContinuation(content, candidatePattern.lastIndex) + ) { + throw new NativeTypeError( + `CSS candidate tokens cannot exceed ${MAX_CSS_SELECTOR_TOKEN_CHARACTERS} characters`, + ); + } + if (apply(setHas, seenCandidates, [candidate])) continue; + if (candidates.length >= MAX_CSS_SELECTOR_TOKENS) { + throw new NativeTypeError( + `CSS source cannot contain more than ${MAX_CSS_SELECTOR_TOKENS} candidates`, + ); + } + apply(setAdd, seenCandidates, [candidate]); + apply(arrayPush, candidates, [candidate]); + } + } finally { + candidatePattern.lastIndex = 0; + } + return { candidates, sourceBytes }; +} + +export function extractCandidates(content: string): string[] { + return extractCandidatesWithByteLength(content).candidates; +} diff --git a/src/html/styles-builder/css-hash-cache.ts b/src/html/styles-builder/css-hash-cache.ts index a010003e07..ae7a20a13b 100644 --- a/src/html/styles-builder/css-hash-cache.ts +++ b/src/html/styles-builder/css-hash-cache.ts @@ -1,393 +1,316 @@ /** - * CSS hash-based distributed cache. + * Content-addressed CSS cache with provider-identified JIT inputs. * - * Manages CSS caching by content hash, supporting both local in-memory - * and distributed (API/Redis) backends. Provides unified cache entries - * that store CSS alongside its generation inputs for JIT regeneration. + * CSS payloads are keyed by their full SHA-256 identity. Regeneration inputs + * live in the same versioned entry and are usable only by the exact captured + * CSS pipeline identity that created them; the former split legacy-input + * fallback is intentionally unsupported. * * @module html/styles-builder/css-hash-cache */ -import { - type CacheBackend, - createCacheBackend, - MemoryCacheBackend, -} from "#veryfront/cache/backend.ts"; -import { serverLogger } from "#veryfront/utils"; +import { type CacheBackend, createCacheBackend } from "#veryfront/cache/backend.ts"; import { SpanNames } from "#veryfront/observability"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { hashCSS } from "./candidate-extractor.ts"; +import { assertCSSPipelineIdentity, serverLogger } from "#veryfront/utils"; +import { normalizeCSSCandidates } from "#veryfront/utils/css-candidate-admission.ts"; import { - buildCSSCacheEntry, - parseCSSCacheEntry, - resolveStylesheet, -} from "./tailwind-compiler-utils.ts"; - -const logger = serverLogger.component("tailwind"); + assertCSSFileContent, + assertCSSOutputContent, +} from "#veryfront/utils/css-content-admission.ts"; +import { utf8ByteLength } from "#veryfront/utils/utf8-byte-length.ts"; +import { + detachRetainedString, + estimateRetainedStringBytes, +} from "#veryfront/utils/retained-string.ts"; +import { assertCSSContentIdentity, hashCSS, isCSSContentHash } from "./css-identity.ts"; -// ============================================================================ -// Types -// ============================================================================ +const logger = serverLogger.component("css-cache"); +const CSS_CACHE_SCHEMA = "v3"; +const CSS_CACHE_TTL_SECONDS = 24 * 3600; +const LOCAL_CACHE_MAX_ENTRIES = 100; +const LOCAL_CACHE_MAX_BYTES = 64 * 1024 * 1024; +const MAX_SERIALIZED_CSS_CACHE_BYTES = 128 * 1024 * 1024; -/** - * Unified CSS cache entry - stores CSS and inputs together. - * This ensures CSS and its regeneration inputs always expire together, - * enabling reliable JIT regeneration across pods. - */ export interface CSSCacheEntry { - css: string; - candidates: string[]; - stylesheet: string; + readonly css: string; + readonly candidates: string[]; + readonly stylesheet: string; + readonly pipelineIdentity?: string; } -/** - * CSS inputs cache entry - stores the inputs needed to regenerate CSS. - * Keyed by CSS hash, stores candidates and stylesheet for JIT regeneration. - */ -interface CSSInputsCacheEntry { - candidates: string[]; - stylesheet: string; +export interface CSSRegenerationInputs { + readonly candidates: string[]; + readonly stylesheet: string; + readonly pipelineIdentity: string; } -// ============================================================================ -// Constants -// ============================================================================ - -export const DEFAULT_STYLESHEET = `@import "tailwindcss"; -@plugin "@tailwindcss/typography"; -@custom-variant dark (&:is(.dark, [data-theme="dark"]) *, &:is(.dark, [data-theme="dark"]));`; - -// CSS cache TTL: 24 hours (API maximum) for content-addressed immutable resources. -const CSS_CACHE_TTL_SECONDS = 24 * 3600; - -const LOCAL_CACHE_MAX_SIZE = 100; -const LOCAL_CSS_INPUTS_CACHE_MAX = 50; - -// ============================================================================ -// Distributed cache initialization infrastructure -// ============================================================================ - -interface DistributedCacheInitOptions { - getCache: () => CacheBackend | null; - getCacheInitPromise: () => Promise | null; - setCache: (cache: CacheBackend) => void; - setCacheInitPromise: (promise: Promise) => void; - keyPrefix: string; - localFallbackSize: number; - initializedLog: string; - initFailureLog: string; +interface LocalCSSCacheEntry { + readonly value: CSSCacheEntry; + readonly retainedBytes: number; } -async function getOrInitializeDistributedCache( - options: DistributedCacheInitOptions, -): Promise { - const existing = options.getCache(); - if (existing) return existing; - - const pending = options.getCacheInitPromise(); - if (pending) return pending; - - const initPromise = (async () => { - try { - const backend = await createCacheBackend({ keyPrefix: options.keyPrefix }); - options.setCache(backend); - logger.debug(options.initializedLog, { type: backend.type }); - return backend; - } catch (error) { - logger.warn(options.initFailureLog, { error }); - const fallback = new MemoryCacheBackend(options.localFallbackSize); - options.setCache(fallback); - return fallback; - } - })(); +let cssCache: CacheBackend | null = null; +let cssCacheInitPromise: Promise | null = null; +let localCacheBytes = 0; +const localCssCache = new Map(); - options.setCacheInitPromise(initPromise); - return initPromise; +function getVersionedCacheKey(hash: string): string { + return `${CSS_CACHE_SCHEMA}:${hash}`; } -// ============================================================================ -// Bounded local cache utility -// ============================================================================ - -function storeInBoundedLocalCache( - cache: Map, - maxSize: number, - key: string, - entry: T, -): void { - if (cache.has(key)) return; - - if (cache.size >= maxSize) { - const firstKey = cache.keys().next().value as string | undefined; - if (firstKey) cache.delete(firstKey); +async function getCssCache(): Promise { + if (cssCache) return cssCache; + if (cssCacheInitPromise) return await cssCacheInitPromise; + const pending = createCacheBackend({ keyPrefix: "css" }); + cssCacheInitPromise = pending; + try { + cssCache = await pending; + return cssCache; + } finally { + if (cssCacheInitPromise === pending) cssCacheInitPromise = null; } - - cache.set(key, entry); } -// ============================================================================ -// CSS cache state -// ============================================================================ - -let cssCache: CacheBackend | null = null; -let cssCacheInitPromise: Promise | null = null; - -const localCssCache = new Map(); - -const cssCacheOptions: DistributedCacheInitOptions = { - getCache: () => cssCache, - getCacheInitPromise: () => cssCacheInitPromise, - setCache: (cache) => { - cssCache = cache; - }, - setCacheInitPromise: (promise) => { - cssCacheInitPromise = promise; - }, - keyPrefix: "css", - localFallbackSize: LOCAL_CACHE_MAX_SIZE, - initializedLog: "[tailwind] CSS cache initialized", - initFailureLog: "[tailwind] Failed to initialize distributed CSS cache, using memory", -}; +function estimateEntryBytes(hash: string, entry: CSSCacheEntry): number { + let bytes = estimateRetainedStringBytes(hash) + estimateRetainedStringBytes(entry.css) + + estimateRetainedStringBytes(entry.stylesheet) + + (entry.pipelineIdentity === undefined + ? 0 + : estimateRetainedStringBytes(entry.pipelineIdentity)); + for (const candidate of entry.candidates) bytes += estimateRetainedStringBytes(candidate) + 8; + return bytes + 128; +} -function getCssCache(): Promise { - return getOrInitializeDistributedCache(cssCacheOptions); +function removeLocalEntry(hash: string): void { + const existing = localCssCache.get(hash); + if (!existing) return; + localCssCache.delete(hash); + localCacheBytes -= existing.retainedBytes; } function storeInLocalCache(hash: string, entry: CSSCacheEntry): void { - storeInBoundedLocalCache(localCssCache, LOCAL_CACHE_MAX_SIZE, hash, entry); + const retainedHash = detachRetainedString(hash); + const retainedBytes = estimateEntryBytes(retainedHash, entry); + removeLocalEntry(hash); + while ( + localCssCache.size >= LOCAL_CACHE_MAX_ENTRIES || + localCacheBytes + retainedBytes > LOCAL_CACHE_MAX_BYTES + ) { + const oldest = localCssCache.keys().next().value as string | undefined; + if (oldest === undefined) break; + removeLocalEntry(oldest); + } + if (retainedBytes > LOCAL_CACHE_MAX_BYTES) return; + localCssCache.set(retainedHash, { value: entry, retainedBytes }); + localCacheBytes += retainedBytes; } -function touchLocalCache(hash: string, entry: CSSCacheEntry): void { +function touchLocalEntry(hash: string, entry: LocalCSSCacheEntry): CSSCacheEntry { localCssCache.delete(hash); localCssCache.set(hash, entry); + return entry.value; } -// ============================================================================ -// CSS inputs cache state -// ============================================================================ - -let cssInputsCache: CacheBackend | null = null; -let cssInputsCacheInitPromise: Promise | null = null; -const localCssInputsCache = new Map(); - -const cssInputsCacheOptions: DistributedCacheInitOptions = { - getCache: () => cssInputsCache, - getCacheInitPromise: () => cssInputsCacheInitPromise, - setCache: (cache) => { - cssInputsCache = cache; +function createCSSCacheEntry( + css: unknown, + inputs?: { + candidates: string[] | Set; + stylesheet: string; + pipelineIdentity: string; }, - setCacheInitPromise: (promise) => { - cssInputsCacheInitPromise = promise; - }, - keyPrefix: "css-inputs", - localFallbackSize: LOCAL_CSS_INPUTS_CACHE_MAX, - initializedLog: "[tailwind] CSS inputs cache initialized", - initFailureLog: "[tailwind] Failed to initialize CSS inputs cache, using memory", -}; - -function getCssInputsCache(): Promise { - return getOrInitializeDistributedCache(cssInputsCacheOptions); +): CSSCacheEntry { + if (typeof css !== "string") throw new TypeError("Cached CSS output must be a string"); + assertCSSOutputContent(css, "Cached CSS output"); + const admittedCandidates = inputs === undefined ? [] : normalizeCSSCandidates(inputs.candidates); + const candidates = new Array(admittedCandidates.length); + for (let index = 0; index < admittedCandidates.length; index++) { + candidates[index] = detachRetainedString(admittedCandidates[index]!); + } + const stylesheet = detachRetainedString(inputs?.stylesheet ?? ""); + assertCSSFileContent(stylesheet, "Cached CSS regeneration stylesheet"); + const pipelineIdentity = inputs === undefined ? undefined : detachRetainedString( + assertCSSPipelineIdentity(inputs.pipelineIdentity, "CSS regeneration pipeline identity"), + ); + return Object.freeze({ + css: detachRetainedString(css), + candidates: Object.freeze(candidates) as unknown as string[], + stylesheet, + pipelineIdentity, + }); } -function storeInLocalCssInputsCache(hash: string, entry: CSSInputsCacheEntry): void { - storeInBoundedLocalCache(localCssInputsCache, LOCAL_CSS_INPUTS_CACHE_MAX, hash, entry); +function serializeCSSCacheEntry(entry: CSSCacheEntry): string { + const serialized = JSON.stringify(entry); + if (utf8ByteLength(serialized, MAX_SERIALIZED_CSS_CACHE_BYTES) > MAX_SERIALIZED_CSS_CACHE_BYTES) { + throw new TypeError( + `Serialized CSS cache entry exceeds ${MAX_SERIALIZED_CSS_CACHE_BYTES} bytes`, + ); + } + return serialized; } -// ============================================================================ -// Public API - CSS cache operations -// ============================================================================ - -/** - * Cache CSS with its generation inputs for JIT regeneration. - * Stores CSS and inputs together so they expire at the same time, - * ensuring any pod can regenerate the CSS if needed. - */ -export async function cacheCSSAsync( - css: string, - hash?: string, - inputs?: { candidates: string[] | Set; stylesheet: string }, -): Promise { - const resolvedHash = hash ?? hashCSS(css); - const entry: CSSCacheEntry = buildCSSCacheEntry(css, inputs, DEFAULT_STYLESHEET); +function readOwnDataProperty(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} - storeInLocalCache(resolvedHash, entry); +function parseCSSCacheEntry(raw: string): CSSCacheEntry | undefined { + if ( + typeof raw !== "string" || + utf8ByteLength(raw, MAX_SERIALIZED_CSS_CACHE_BYTES) > MAX_SERIALIZED_CSS_CACHE_BYTES + ) return undefined; + let parsed: unknown; try { - const cache = await getCssCache(); - await cache.set(resolvedHash, JSON.stringify(entry), CSS_CACHE_TTL_SECONDS); - } catch (error) { - logger.debug("Failed to store CSS in distributed cache", { - hash: resolvedHash, - error, - }); + parsed = JSON.parse(raw); + } catch { + return undefined; } - - return resolvedHash; -} - -export function getCSSByHash(hash: string): string | undefined { - const entry = localCssCache.get(hash); - if (entry) { - touchLocalCache(hash, entry); - return entry.css; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + const css = readOwnDataProperty(parsed, "css"); + const rawCandidates = readOwnDataProperty(parsed, "candidates"); + const stylesheet = readOwnDataProperty(parsed, "stylesheet"); + const pipelineIdentity = readOwnDataProperty(parsed, "pipelineIdentity"); + if ( + typeof css !== "string" || + !Array.isArray(rawCandidates) || + typeof stylesheet !== "string" + ) return undefined; + let entry: CSSCacheEntry; + try { + if (rawCandidates.length > 0 && typeof pipelineIdentity !== "string") return undefined; + entry = createCSSCacheEntry( + css, + rawCandidates.length === 0 && pipelineIdentity === undefined ? undefined : { + candidates: rawCandidates, + stylesheet, + pipelineIdentity: assertCSSPipelineIdentity(pipelineIdentity), + }, + ); + } catch { + return undefined; } - return undefined; -} - -export async function getCSSByHashAsync(hash: string): Promise { - return await withSpan( - SpanNames.HTML_GET_CSS_BY_HASH, - async () => { - const local = localCssCache.get(hash); - if (local) { - touchLocalCache(hash, local); - return local.css; - } - - try { - const cache = await getCssCache(); - const raw = await cache.get(hash); - if (!raw) return undefined; - - const entry = parseCSSCacheEntry(raw, DEFAULT_STYLESHEET); - - storeInLocalCache(hash, entry); - logger.debug("CSS cache hit from distributed cache", { hash }); - return entry.css; - } catch (error) { - logger.debug("Failed to read from distributed CSS cache", { hash, error }); - return undefined; - } - }, - { "css.hash": hash }, - ); + return entry; } -export function clearCSSCache(): void { - localCssCache.clear(); - localCssInputsCache.clear(); +function isEntryForHash(entry: CSSCacheEntry, hash: string): boolean { + if (!isCSSContentHash(hash)) return false; + try { + assertCSSContentIdentity(entry.css, hash); + return true; + } catch { + return false; + } } -/** - * Cache legacy CSS regeneration inputs by hash. - * Maintains backward compatibility with older cache layouts that stored inputs separately. - */ -export async function cacheCSSInputsAsync( - hash: string, - inputs: { candidates: string[] | Set; stylesheet: string }, -): Promise { - const entry: CSSInputsCacheEntry = { - candidates: Array.isArray(inputs.candidates) ? inputs.candidates : [...inputs.candidates], - stylesheet: resolveStylesheet(inputs.stylesheet, DEFAULT_STYLESHEET), - }; - - storeInLocalCssInputsCache(hash, entry); - +async function readDistributedEntry(hash: string): Promise { try { - const cache = await getCssInputsCache(); - await cache.set(hash, JSON.stringify(entry), CSS_CACHE_TTL_SECONDS); + const cache = await getCssCache(); + const raw = await cache.get(getVersionedCacheKey(hash)); + if (!raw) return undefined; + const entry = parseCSSCacheEntry(raw); + if (!entry || !isEntryForHash(entry, hash)) return undefined; + storeInLocalCache(hash, entry); + return entry; } catch (error) { - logger.debug("Failed to store CSS inputs in distributed cache", { - hash, - error, - }); + logger.debug("Failed to read CSS cache entry", { hash, error }); + return undefined; } } -// ============================================================================ -// JIT regeneration helpers -// ============================================================================ - -/** - * Get CSS cache entry with inputs for JIT regeneration. - * Returns the full entry (CSS + inputs) if available. - */ async function getCSSCacheEntry(hash: string): Promise { + if (!isCSSContentHash(hash)) return undefined; const local = localCssCache.get(hash); - if (local && local.candidates.length > 0) { - touchLocalCache(hash, local); - return local; + if (local) { + const entry = touchLocalEntry(hash, local); + if (isEntryForHash(entry, hash)) return entry; + removeLocalEntry(hash); } + return await readDistributedEntry(hash); +} +/** Store CSS and, when supplied, the exact inputs needed for JIT regeneration. */ +export async function cacheCSSAsync( + css: string, + hash?: string, + inputs?: { + candidates: string[] | Set; + stylesheet: string; + pipelineIdentity: string; + }, +): Promise { + const entry = createCSSCacheEntry(css, inputs); + const resolvedHash = hashCSS(entry.css); + if (hash !== undefined) assertCSSContentIdentity(entry.css, hash); + storeInLocalCache(resolvedHash, entry); try { const cache = await getCssCache(); - const raw = await cache.get(hash); - if (!raw) return undefined; - - const entry = parseCSSCacheEntry(raw, DEFAULT_STYLESHEET); - storeInLocalCache(hash, entry); - return entry; + await cache.set( + getVersionedCacheKey(resolvedHash), + serializeCSSCacheEntry(entry), + CSS_CACHE_TTL_SECONDS, + ); } catch (error) { - logger.debug("Failed to read CSS cache entry", { hash, error }); + logger.debug("Failed to store CSS in distributed cache", { hash: resolvedHash, error }); } + return resolvedHash; +} +export function getCSSByHash(hash: string): string | undefined { + if (!isCSSContentHash(hash)) return undefined; + const local = localCssCache.get(hash); + if (!local) return undefined; + const entry = touchLocalEntry(hash, local); + if (isEntryForHash(entry, hash)) return entry.css; + removeLocalEntry(hash); return undefined; } -/** - * Get CSS generation inputs by hash for JIT regeneration. - */ -async function getCSSInputsByHash(hash: string): Promise { - const local = localCssInputsCache.get(hash); - if (local) return local; - - try { - const cache = await getCssInputsCache(); - const raw = await cache.get(hash); - if (!raw) return undefined; - - const entry = JSON.parse(raw) as CSSInputsCacheEntry; - storeInLocalCssInputsCache(hash, entry); - logger.debug("CSS inputs cache hit from distributed cache", { hash }); - return entry; - } catch (error) { - logger.debug("Failed to read CSS inputs from distributed cache", { hash, error }); - return undefined; - } +export async function getCSSByHashAsync(hash: string): Promise { + if (!isCSSContentHash(hash)) return undefined; + return await withSpan( + SpanNames.HTML_GET_CSS_BY_HASH, + async () => (await getCSSCacheEntry(hash))?.css, + { "css.hash": hash }, + ); } -function toCSSInputsEntry(cacheEntry: CSSCacheEntry | undefined): CSSInputsCacheEntry | undefined { - if (!cacheEntry || cacheEntry.candidates.length === 0) return undefined; - return { - candidates: cacheEntry.candidates, - stylesheet: cacheEntry.stylesheet, - }; +export function clearCSSCache(): void { + localCssCache.clear(); + localCacheBytes = 0; } -/** - * Resolve regeneration inputs from unified or legacy cache. - * Tries unified cache (CSS + inputs together) first, then falls back to - * legacy separate inputs cache for backward compatibility. - */ +/** Resolve JIT inputs only when they match the currently captured pipeline. */ export async function resolveRegenerationInputs( expectedHash: string, -): Promise { - const unifiedEntry = await getCSSCacheEntry(expectedHash); - const unifiedInputs = toCSSInputsEntry(unifiedEntry); - if (unifiedInputs) { - logger.debug("Found inputs in unified CSS cache", { hash: expectedHash }); - return unifiedInputs; - } - - return await getCSSInputsByHash(expectedHash); + pipelineIdentity: string, +): Promise { + const expectedPipelineIdentity = assertCSSPipelineIdentity(pipelineIdentity); + const entry = await getCSSCacheEntry(expectedHash); + if ( + !entry || + entry.candidates.length === 0 || + entry.pipelineIdentity !== expectedPipelineIdentity + ) return undefined; + return { + candidates: [...entry.candidates], + stylesheet: entry.stylesheet, + pipelineIdentity: expectedPipelineIdentity, + }; } -/** - * Persist a regenerated CSS entry to both local and distributed caches. - */ +/** Persist a verified regenerated entry under its immutable content identity. */ export async function persistRegeneratedCSSEntry( hash: string, entry: CSSCacheEntry, ): Promise { - storeInLocalCache(hash, entry); - - try { - const cache = await getCssCache(); - await cache.set(hash, JSON.stringify(entry), CSS_CACHE_TTL_SECONDS); - } catch (error) { - logger.error("CSS cache write failed", { - hash: hash.slice(-20), - error: error instanceof Error ? error.message : String(error), - }); + if (entry.pipelineIdentity === undefined) { + throw new TypeError("Regenerated CSS entry requires a pipeline identity"); } + await cacheCSSAsync(entry.css, hash, { + candidates: entry.candidates, + stylesheet: entry.stylesheet, + pipelineIdentity: entry.pipelineIdentity, + }); } diff --git a/src/html/styles-builder/css-identity.ts b/src/html/styles-builder/css-identity.ts new file mode 100644 index 0000000000..2fa07b5cdb --- /dev/null +++ b/src/html/styles-builder/css-identity.ts @@ -0,0 +1,78 @@ +import { createHash } from "node:crypto"; +import { + assertCSSPipelineIdentity, + assertStyleProfileHash, + isStyleProfileHash, +} from "#veryfront/utils/css-artifact-identity.ts"; +import { normalizeCSSCandidates } from "#veryfront/utils/css-candidate-admission.ts"; + +const CANDIDATE_IDENTITY_SCHEMA = "veryfront.css-candidates.v1"; +const STYLE_ARTIFACT_IDENTITY_SCHEMA = "veryfront.css-style-artifact.v1"; + +/** Canonical URL shape for immutable, content-addressed runtime CSS. */ +const CSS_ASSET_PATH_PATTERN = /^\/_vf\/css\/([a-f0-9]{64})\.css$/; +const arraySort = Array.prototype.sort; +const apply = Reflect.apply; +const jsonStringify = JSON.stringify; +const regExpExec = RegExp.prototype.exec; + +/** Compute a synchronous, deterministic SHA-256 digest of the exact UTF-8 string. */ +export function hashString(input: string): string { + return createHash("sha256").update(input, "utf8").digest("hex"); +} + +/** Compute the authoritative identity for an immutable CSS payload. */ +export function hashCSS(css: string): string { + return hashString(css); +} + +/** + * Hash a candidate set using an unambiguous, versioned serialization. + * Sorting makes Set insertion order irrelevant; JSON preserves string boundaries. + */ +export function hashCandidates(candidates: string[] | Set): string { + const canonicalCandidates = normalizeCSSCandidates(candidates); + apply(arraySort, canonicalCandidates, []); + return hashString(apply(jsonStringify, JSON, [ + [CANDIDATE_IDENTITY_SCHEMA, canonicalCandidates], + ]) as string); +} + +export function isCSSContentHash(value: unknown): value is string { + return isStyleProfileHash(value); +} + +export function extractCSSAssetHash(pathname: string): string | undefined { + if (typeof pathname !== "string") return undefined; + return (apply(regExpExec, CSS_ASSET_PATH_PATTERN, [pathname]) as RegExpExecArray | null)?.[1]; +} + +/** Return an isolated route matcher so consumers cannot mutate shared identity state. */ +export function createCSSAssetPathPattern(): RegExp { + return new RegExp(CSS_ASSET_PATH_PATTERN.source); +} + +/** Reject a forged or stale identity before it can enter a content-addressed cache. */ +export function assertCSSContentIdentity(css: string, expectedHash: string): void { + if (!isCSSContentHash(expectedHash)) { + throw new TypeError("CSS hash must be a full lowercase SHA-256 digest"); + } + + const actualHash = hashCSS(css); + if (actualHash !== expectedHash) { + throw new TypeError("CSS hash does not match the CSS content identity"); + } +} + +/** Bind a style-scope profile to the exact compiler/optimizer pipeline. */ +export function composeCSSStyleProfileHash( + styleProfileHash: string, + pipelineIdentity: string, +): string { + const serialized = apply(jsonStringify, JSON, [[ + STYLE_ARTIFACT_IDENTITY_SCHEMA, + assertStyleProfileHash(styleProfileHash), + assertCSSPipelineIdentity(pipelineIdentity), + ]]) as string; + return hashString(serialized); +} diff --git a/src/html/styles-builder/css-pregeneration.ts b/src/html/styles-builder/css-pregeneration.ts index e618dbade9..33968d6e5f 100644 --- a/src/html/styles-builder/css-pregeneration.ts +++ b/src/html/styles-builder/css-pregeneration.ts @@ -9,7 +9,13 @@ import { serverLogger } from "#veryfront/utils"; import { join } from "#veryfront/compat/path/index.ts"; import { createFileSystem, type FileSystem } from "#veryfront/platform/compat/fs.ts"; -import { extractCandidatesFromFiles, getProjectCSS } from "./tailwind-compiler.ts"; +import { + acquireCSSGenerationSession, + type CSSGenerationSession, + extractCandidatesFromFiles, + getProjectCSS, +} from "./tailwind-compiler.ts"; +import { hashCandidates } from "./css-identity.ts"; import { createPreparedProjectCSSContext, storePreparedProjectCSS, @@ -66,35 +72,62 @@ export async function buildPreparedCSSArtifactFromFiles( options: CSSPregenerationOptions, ): Promise { const { - projectSlug, - projectVersion, projectDir, files, styleProfile, stylesheet, stylesheetPath, minify = true, - environment = "preview", - buildMode = "production", } = options; - const resolvedStylesheet = stylesheet ?? findStylesheetFromFiles(files, stylesheetPath); const candidates = extractCandidatesFromFiles(files, { projectDir, styleProfile, }); + const session = acquireCSSGenerationSession(minify); + const resolvedStylesheet = stylesheet ?? findStylesheetFromFiles(files, stylesheetPath) ?? + session.compilationSession.defaultStylesheet; + + return await buildPreparedCSSArtifactFromSnapshot( + options, + resolvedStylesheet, + candidates, + session, + ); +} + +async function buildPreparedCSSArtifactFromSnapshot( + options: CSSPregenerationOptions, + resolvedStylesheet: string, + candidates: Set, + session: CSSGenerationSession, +): Promise { + const { + projectSlug, + projectVersion, + styleProfile, + minify = true, + environment = "preview", + buildMode = "production", + } = options; const result = await getProjectCSS(projectSlug, resolvedStylesheet, candidates, { minify, environment, buildMode, - }); + }, { generationSession: session }); const context = createPreparedProjectCSSContext( projectSlug, projectVersion, resolvedStylesheet, styleProfile.hash, - { minify, environment, buildMode }, + { + cssPipelineIdentity: session.cacheIdentity, + candidatesHash: hashCandidates(candidates), + minify, + environment, + buildMode, + }, ); await storePreparedProjectCSS(context, { css: result.css, hash: result.hash }); @@ -184,15 +217,24 @@ export async function readLocalProjectStylesheet( export async function warmPreparedCSSArtifactFromFiles( options: CSSPregenerationOptions, ): Promise { + const minify = options.minify ?? true; + const candidates = extractCandidatesFromFiles(options.files, { + projectDir: options.projectDir, + styleProfile: options.styleProfile, + }); + const session = acquireCSSGenerationSession(minify); const stylesheet = options.stylesheet ?? - findStylesheetFromFiles(options.files, options.stylesheetPath); + findStylesheetFromFiles(options.files, options.stylesheetPath) ?? + session.compilationSession.defaultStylesheet; const context = createPreparedProjectCSSContext( options.projectSlug, options.projectVersion, stylesheet, options.styleProfile.hash, { - minify: options.minify ?? true, + cssPipelineIdentity: session.cacheIdentity, + candidatesHash: hashCandidates(candidates), + minify, environment: options.environment ?? "preview", buildMode: options.buildMode ?? "production", }, @@ -201,10 +243,12 @@ export async function warmPreparedCSSArtifactFromFiles( if (await tryGetPreparedProjectCSS(context)) return false; if (inFlightPreparedCSSBuilds.has(context.cacheKey)) return false; - const task = buildPreparedCSSArtifactFromFiles({ - ...options, + const task = buildPreparedCSSArtifactFromSnapshot( + { ...options, stylesheet }, stylesheet, - }).then(() => { + candidates, + session, + ).then(() => { logger.debug("Warm prepared CSS complete", { projectSlug: options.projectSlug, projectVersion: options.projectVersion, diff --git a/src/html/styles-builder/css-provider-session.test.ts b/src/html/styles-builder/css-provider-session.test.ts new file mode 100644 index 0000000000..5657875c35 --- /dev/null +++ b/src/html/styles-builder/css-provider-session.test.ts @@ -0,0 +1,198 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { register, reset as resetContracts } from "#veryfront/extensions/contracts.ts"; +import { + type CSSOptimizationEngine, + CSSOptimizationEngineName, + type CSSProcessor, + CSSProcessorName, +} from "#veryfront/extensions/css/index.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; +import { + acquireCSSGenerationSession, + clearCSSCache, + generateTailwindCSS, + getCompilerCacheStats, + getProjectCSS, + invalidateCompiler, + invalidateProjectCSS, +} from "./tailwind-compiler.ts"; + +interface ProcessorCounters { + compile: number; + build: number; +} + +function createProcessor( + marker: string, + defaultStylesheet = `default:${marker}`, + counters: ProcessorCounters = { compile: 0, build: 0 }, +): CSSProcessor { + return { + cacheIdentity: `test-processor:${marker}`, + defaultStylesheet, + async compile(stylesheet) { + counters.compile++; + return { + build(candidates) { + counters.build++; + return `${marker}|${stylesheet}|${candidates.join(",")}`; + }, + }; + }, + }; +} + +function createOptimizer(marker: string): CSSOptimizationEngine { + return { + cacheIdentity: `test-optimizer:${marker}`, + optimize(request) { + return { css: `${marker}[${request.css}]` }; + }, + }; +} + +function installProcessor(processor: CSSProcessor): void { + register(CSSProcessorName, processor); +} + +describe("styles-builder CSS provider sessions", () => { + beforeEach(() => { + resetContracts(); + clearCSSCache(); + invalidateCompiler(); + }); + + afterEach(() => { + resetContracts(); + clearCSSCache(); + invalidateCompiler(); + }); + + it("keeps an acquired provider stable while registry changes affect only later operations", async () => { + installProcessor(createProcessor("A")); + const captured = acquireCSSGenerationSession(false); + installProcessor(createProcessor("B")); + + const fromCaptured = await generateTailwindCSS("sheet", ["alpha"], { minify: false }, { + generationSession: captured, + }); + const fromCurrent = await generateTailwindCSS("sheet", ["alpha"], { minify: false }); + + assertEquals(fromCaptured.css, "A|sheet|alpha"); + assertEquals(fromCurrent.css, "B|sheet|alpha"); + assertEquals(fromCaptured.cacheIdentity === fromCurrent.cacheIdentity, false); + }); + + it("keeps a provider stable across an await even when the registry changes", async () => { + let releaseCompile: (() => void) | undefined; + const compileGate = new Promise((resolve) => { + releaseCompile = resolve; + }); + const processorA = createProcessor("await-A"); + installProcessor({ + ...processorA, + async compile(stylesheet) { + await compileGate; + return await processorA.compile(stylesheet); + }, + }); + + const pending = generateTailwindCSS("sheet-await", ["alpha"], { minify: false }); + installProcessor(createProcessor("await-B")); + releaseCompile!(); + + assertEquals((await pending).css, "await-A|sheet-await|alpha"); + assertEquals( + (await generateTailwindCSS("sheet-await", ["alpha"], { minify: false })).css, + "await-B|sheet-await|alpha", + ); + }); + + it("single-flights only exact candidate snapshots and never leaks compiler state", async () => { + const counters = { compile: 0, build: 0 }; + installProcessor(createProcessor("exact", "default:exact", counters)); + const session = acquireCSSGenerationSession(false); + + const [first, duplicate] = await Promise.all([ + generateTailwindCSS("sheet", ["alpha", "beta"], { minify: false }, { + generationSession: session, + }), + generateTailwindCSS("sheet", new Set(["beta", "alpha"]), { minify: false }, { + generationSession: session, + }), + ]); + const isolated = await generateTailwindCSS("sheet", ["gamma"], { minify: false }, { + generationSession: session, + }); + + assertEquals(first.css, duplicate.css); + assertEquals(first.css, "exact|sheet|alpha,beta"); + assertEquals(isolated.css, "exact|sheet|gamma"); + assertEquals(isolated.css.includes("alpha"), false); + assertEquals(counters, { compile: 2, build: 2 }); + }); + + it("does not repopulate the compiler cache from an invalidated in-flight build", async () => { + let releaseCompile: (() => void) | undefined; + const compileGate = new Promise((resolve) => { + releaseCompile = resolve; + }); + const processor = createProcessor("invalidation"); + installProcessor({ + ...processor, + async compile(stylesheet) { + await compileGate; + return await processor.compile(stylesheet); + }, + }); + + const pending = generateTailwindCSS("sheet-invalidation", ["alpha"], { minify: false }); + invalidateCompiler(); + releaseCompile!(); + await pending; + + assertEquals(getCompilerCacheStats().size, 0); + }); + + it("partitions project output by provider default and optimizer identity", async () => { + const projectSlug = `css-provider-partition-${crypto.randomUUID()}`; + try { + installProcessor(createProcessor("default-A", "sheet-A")); + const first = await getProjectCSS(projectSlug, undefined, ["alpha"], { minify: false }); + + installProcessor(createProcessor("default-B", "sheet-B")); + const second = await getProjectCSS(projectSlug, undefined, ["alpha"], { minify: false }); + assertEquals(first.fromCache, false); + assertEquals(second.fromCache, false); + assertEquals(first.css === second.css, false); + + installProcessor(createProcessor("optimized", "sheet-optimized")); + register(CSSOptimizationEngineName, createOptimizer("optimizer-A")); + const optimizedA = await getProjectCSS(projectSlug, undefined, ["alpha"], { minify: true }); + + register(CSSOptimizationEngineName, createOptimizer("optimizer-B")); + const optimizedB = await getProjectCSS(projectSlug, undefined, ["alpha"], { minify: true }); + assertEquals(optimizedA.fromCache, false); + assertEquals(optimizedB.fromCache, false); + assertEquals(optimizedA.css, "optimizer-A[optimized|sheet-optimized|alpha]"); + assertEquals(optimizedB.css, "optimizer-B[optimized|sheet-optimized|alpha]"); + } finally { + invalidateProjectCSS(projectSlug); + } + }); + + it("fails closed when minification is requested without an optimizer", async () => { + installProcessor(createProcessor("missing-optimizer")); + assertThrows( + () => acquireCSSGenerationSession(true), + Error, + 'Missing extension for contract "CSSOptimizationEngine"', + ); + await assertRejects( + () => generateTailwindCSS("sheet", ["alpha"], { minify: true }), + Error, + 'Missing extension for contract "CSSOptimizationEngine"', + ); + }); +}); diff --git a/src/html/styles-builder/index.ts b/src/html/styles-builder/index.ts index 9ac606b91e..353f6bddf9 100644 --- a/src/html/styles-builder/index.ts +++ b/src/html/styles-builder/index.ts @@ -6,6 +6,7 @@ export { getDevStyles } from "./dev-styles.ts"; export { + acquireCSSGenerationSession, cacheCSSAsync, clearCSSCache, extractCandidates, @@ -18,4 +19,9 @@ export { invalidateCompiler, invalidateProjectCSS, } from "./tailwind-compiler.ts"; -export type { CSSErrorInfo, GenerateOptions, TailwindResult } from "./tailwind-compiler.ts"; +export type { + CSSErrorInfo, + CSSGenerationSession, + GenerateOptions, + TailwindResult, +} from "./tailwind-compiler.ts"; diff --git a/src/html/styles-builder/plugin-loader.test.ts b/src/html/styles-builder/plugin-loader.test.ts deleted file mode 100644 index edb8be33bf..0000000000 --- a/src/html/styles-builder/plugin-loader.test.ts +++ /dev/null @@ -1,516 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; -import { - assert, - assertEquals, - assertRejects, - assertStringIncludes, -} from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import { VeryfrontError } from "#veryfront/errors"; -import { - loadModuleFromEsmSh, - loadPlugin, - rewriteEsmShRootRelativeImports, -} from "./plugin-loader.ts"; -import { - bareName, - PACKAGE_SPEC_RE, - TAILWIND_PLUGIN_ALLOWLIST, -} from "./tailwind-plugin-allowlist.ts"; - -describe("styles-builder/plugin-loader", () => { - it("throws when esm.sh stub request fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = - (() => Promise.resolve(new Response("upstream failure", { status: 503 }))) as typeof fetch; - - try { - await assertRejects( - () => loadModuleFromEsmSh("@tailwindcss/typography@0.5.19"), - Error, - "Failed to fetch stub: 503", - ); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("throws when esm.sh stub has no bundle path", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = - (() => - Promise.resolve(new Response(`export * from "react";`, { status: 200 }))) as typeof fetch; - - try { - await assertRejects( - () => loadModuleFromEsmSh("@tailwindcss/forms@0.5.11"), - Error, - "Could not find bundle path in esm.sh response", - ); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("throws when esm.sh bundle fetch fails", async () => { - const originalFetch = globalThis.fetch; - let callCount = 0; - globalThis.fetch = (() => { - callCount++; - if (callCount === 1) { - return Promise.resolve( - new Response(`export * from "/v1/bad-package.bundle.mjs";`, { status: 200 }), - ); - } - return Promise.resolve(new Response("bundle failure", { status: 500 })); - }) as typeof fetch; - - try { - await assertRejects( - () => loadModuleFromEsmSh("tailwindcss-animate@1.0.7"), - Error, - "Failed to fetch bundle: 500", - ); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("throws when esm.sh bundle responds with HTML", async () => { - const originalFetch = globalThis.fetch; - let callCount = 0; - globalThis.fetch = (() => { - callCount++; - if (callCount === 1) { - return Promise.resolve( - new Response(`export * from "/v1/html-package.bundle.mjs";`, { status: 200 }), - ); - } - return Promise.resolve(new Response("not javascript", { status: 200 })); - }) as typeof fetch; - - try { - await assertRejects( - () => loadModuleFromEsmSh("@tailwindcss/aspect-ratio@0.4.2"), - Error, - "returned HTML instead of JavaScript", - ); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("returns cached plugin error without refetching", async () => { - const originalFetch = globalThis.fetch; - let fetchCallCount = 0; - globalThis.fetch = ((..._args: Parameters) => { - fetchCallCount++; - return Promise.reject(new Error("fetch should not be called")); - }) as typeof fetch; - - try { - const pluginCache = new Map(); - const pluginErrors = new Map(); - pluginErrors.set( - "@tailwindcss/typography", - new VeryfrontError("cached plugin load failure", { - slug: "network-error", - category: "SERVER", - status: 502, - title: "Network operation failed", - detail: "cached plugin load failure", - }), - ); - - await assertRejects( - () => loadPlugin("@tailwindcss/typography", pluginCache, pluginErrors), - Error, - "cached plugin load failure", - ); - assertEquals(fetchCallCount, 0); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("preserves structured upstream errors when plugin loading fails", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = - (() => Promise.resolve(new Response("upstream failure", { status: 503 }))) as typeof fetch; - - try { - try { - await loadPlugin("@tailwindcss/typography@0.5.19", new Map(), new Map()); - throw new Error("Expected loadPlugin to throw"); - } catch (error) { - assertEquals(error instanceof VeryfrontError, true); - if (!(error instanceof VeryfrontError)) throw error; - - assertEquals(error.slug, "network-error"); - assertEquals(error.status, 502); - assertEquals( - error.message.includes('Failed to load plugin "@tailwindcss/typography@0.5.19"'), - true, - ); - } - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("uses plugin cache on subsequent successful loads", async () => { - const originalFetch = globalThis.fetch; - let fetchCallCount = 0; - globalThis.fetch = (() => { - fetchCallCount++; - if (fetchCallCount === 1) { - return Promise.resolve( - new Response(`export * from "/v1/good-plugin.bundle.mjs";`, { status: 200 }), - ); - } - return Promise.resolve( - new Response(`export default { id: "good-plugin", handler() {} };`, { status: 200 }), - ); - }) as typeof fetch; - - try { - const pluginCache = new Map(); - const pluginErrors = new Map(); - - const first = await loadPlugin( - "@tailwindcss/typography@0.5.19", - pluginCache, - pluginErrors, - ); - const second = await loadPlugin( - "@tailwindcss/typography@0.5.19", - pluginCache, - pluginErrors, - ); - - assertEquals(typeof first, "object"); - assertEquals(second === first, true); - assertEquals(fetchCallCount, 2); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("loads bare bundled plugin names through pinned binary bundle URLs", async () => { - const originalFetch = globalThis.fetch; - const fetchedUrls: string[] = []; - - globalThis.fetch = ((input: Parameters[0]) => { - const url = String(input); - fetchedUrls.push(url); - - if (url.includes("?bundle")) { - return Promise.resolve( - new Response(`export * from "/v1/pinned-typography.bundle.mjs";`, { status: 200 }), - ); - } - - return Promise.resolve( - new Response(`export default { id: "pinned-typography" };`, { status: 200 }), - ); - }) as typeof fetch; - - try { - const plugin = await loadModuleFromEsmSh("@tailwindcss/typography"); - - assertEquals((plugin as { default?: { id?: string } }).default?.id, "pinned-typography"); - assertEquals( - fetchedUrls[0], - "https://esm.sh/@tailwindcss/typography@0.5.19?bundle&external=tailwindcss&target=denonext", - ); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("does not depend on Deno.makeTempFile for plugin module imports", async () => { - const originalFetch = globalThis.fetch; - const originalMakeTempFile = Deno.makeTempFile; - let fetchCallCount = 0; - - globalThis.fetch = (() => { - fetchCallCount++; - if (fetchCallCount === 1) { - return Promise.resolve( - new Response(`export * from "/v1/temp-dir-plugin.bundle.mjs";`, { status: 200 }), - ); - } - return Promise.resolve( - new Response(`export default { id: "temp-dir-plugin" };`, { status: 200 }), - ); - }) as typeof fetch; - - Deno.makeTempFile = (() => - Promise.reject( - new Error("Deno.makeTempFile must not be used for plugin module imports"), - )) as typeof Deno.makeTempFile; - - try { - const plugin = await loadModuleFromEsmSh("@tailwindcss/typography"); - assertEquals((plugin as { default?: { id?: string } }).default?.id, "temp-dir-plugin"); - } finally { - globalThis.fetch = originalFetch; - Deno.makeTempFile = originalMakeTempFile; - } - }); - - it("uses the non-Deno import path in Node-like runtimes with Deno shims", async () => { - const originalFetch = globalThis.fetch; - const originalProcess = Object.getOwnPropertyDescriptor(globalThis, "process"); - let fetchCallCount = 0; - - Object.defineProperty(globalThis, "process", { - configurable: true, - value: { versions: { node: "22.0.0" } }, - }); - - globalThis.fetch = (() => { - fetchCallCount++; - if (fetchCallCount === 1) { - return Promise.resolve( - new Response(`export * from "/v1/node-like-plugin.bundle.mjs";`, { status: 200 }), - ); - } - return Promise.resolve( - new Response(`export default { id: "node-like-plugin" };`, { status: 200 }), - ); - }) as typeof fetch; - - try { - const plugin = await loadModuleFromEsmSh("@tailwindcss/typography@0.5.19"); - assertEquals((plugin as { default?: { id?: string } }).default?.id, "node-like-plugin"); - } finally { - globalThis.fetch = originalFetch; - if (originalProcess) { - Object.defineProperty(globalThis, "process", originalProcess); - } else { - delete (globalThis as { process?: unknown }).process; - } - } - }); - - it("rewrites esm.sh root-relative bundle imports before temp-file import", () => { - const code = [ - `import parser from "/postcss-selector-parser@6.0.10/denonext/dist/processor.mjs";`, - `import plugin from '/tailwindcss@4.0.0/denonext/plugin.mjs';`, - `export * from "/@tailwindcss/forms@0.5.11/denonext/forms.mjs";`, - `const local = "/not-an-import";`, - ].join("\n"); - - assertEquals( - rewriteEsmShRootRelativeImports(code), - [ - `import parser from "https://esm.sh/postcss-selector-parser@6.0.10/denonext/dist/processor.mjs";`, - `import plugin from 'https://esm.sh/tailwindcss@4.0.0/denonext/plugin.mjs';`, - `export * from "https://esm.sh/@tailwindcss/forms@0.5.11/denonext/forms.mjs";`, - `const local = "/not-an-import";`, - ].join("\n"), - ); - }); -}); - -describe("styles-builder/plugin-loader allowlist enforcement (VULN-FS-1)", () => { - // All tests in this block must reject BEFORE any fetch happens. Install a - // fetch stub that fails the test if called. - function withFailFastFetch(run: () => Promise): Promise { - const originalFetch = globalThis.fetch; - let called = false; - globalThis.fetch = ((..._args: Parameters) => { - called = true; - return Promise.reject(new Error("fetch must not be called for rejected specs")); - }) as typeof fetch; - return run().finally(() => { - globalThis.fetch = originalFetch; - if (called) { - throw new Error( - "Allowlist enforcement leaked a fetch: rejection must happen before network I/O", - ); - } - }); - } - - const rejectionCases: Array<[string, string]> = [ - ["non-allowlisted package", "evil-package@1.0.0"], - ["path-traversal specifier", "../../etc/passwd"], - ["URL-like specifier", "https://evil.com/x"], - ["shell-injection specifier", "pkg;evil"], - ["NUL-byte specifier", "pkg\0"], - ["empty string", ""], - ["unicode homoglyph (fullwidth @)", "\uFF20tailwindcss/typography"], - ["allowlisted-name with extra suffix", "@tailwindcss/typography-evil"], - ["leading whitespace", " @tailwindcss/typography"], - ["trailing whitespace", "@tailwindcss/typography "], - ["uppercase variant of allowlisted scope", "@TAILWINDCSS/typography"], - ["parent-directory jump in bare name", "foo/../bar"], - ["absolute path", "/etc/passwd"], - ["backslash path", "evil\\pkg"], - ]; - - for (const [label, spec] of rejectionCases) { - it(`loadModuleFromEsmSh rejects ${label}`, () => { - return withFailFastFetch(async () => { - await assertRejects( - () => loadModuleFromEsmSh(spec), - Error, - ); - }); - }); - - it(`loadPlugin rejects ${label}`, () => { - return withFailFastFetch(async () => { - await assertRejects( - () => loadPlugin(spec, new Map(), new Map()), - Error, - ); - }); - }); - } - - it("loadModuleFromEsmSh error mentions invalid specifier or allowlist", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (() => { - throw new Error("must not fetch"); - }) as typeof fetch; - try { - const err = await assertRejects( - () => loadModuleFromEsmSh("evil-package@1.0.0"), - Error, - ) as Error; - const combined = err.message; - const mentionsAllowlist = combined.includes("allowlist") || - combined.includes("Invalid Tailwind plugin specifier"); - assert( - mentionsAllowlist, - `Expected error to mention allowlist or invalid specifier, got: ${combined}`, - ); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("loadPlugin rejects an unknown package id (not on allowlist)", async () => { - await assertRejects( - () => loadPlugin("definitely-not-an-allowed-plugin", new Map(), new Map()), - Error, - ); - }); -}); - -describe("styles-builder/plugin-loader allowlist positive cases", () => { - // Verify each allowlisted package is accepted past the guard by letting the - // fetch fail downstream. The acceptance is proven by the *specific* error - // message: if the guard rejected, we would never see a fetch error. - for (const pkg of TAILWIND_PLUGIN_ALLOWLIST) { - it(`accepts ${pkg} bare name (passes allowlist guard)`, async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (() => - Promise.resolve( - new Response("downstream failure", { status: 503 }), - )) as typeof fetch; - try { - const err = await assertRejects( - () => loadModuleFromEsmSh(pkg), - Error, - ) as Error; - assertStringIncludes(err.message, "Failed to fetch stub: 503"); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it(`accepts ${pkg}@1.2.3 versioned spec (passes allowlist guard)`, async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = (() => - Promise.resolve( - new Response("downstream failure", { status: 503 }), - )) as typeof fetch; - try { - const err = await assertRejects( - () => loadModuleFromEsmSh(`${pkg}@1.2.3`), - Error, - ) as Error; - assertStringIncludes(err.message, "Failed to fetch stub: 503"); - } finally { - globalThis.fetch = originalFetch; - } - }); - } -}); - -describe("styles-builder/plugin-loader bareName", () => { - it("returns an unscoped name unchanged", () => { - assertEquals(bareName("pkg"), "pkg"); - }); - - it("strips @version from an unscoped name", () => { - assertEquals(bareName("pkg@1"), "pkg"); - assertEquals(bareName("pkg@1.2.3-rc.4"), "pkg"); - }); - - it("returns a scoped name unchanged", () => { - assertEquals(bareName("@scope/pkg"), "@scope/pkg"); - }); - - it("strips @version from a scoped name", () => { - assertEquals(bareName("@scope/pkg@1.0.0"), "@scope/pkg"); - assertEquals(bareName("@tailwindcss/typography@0.5.19"), "@tailwindcss/typography"); - }); -}); - -describe("styles-builder/plugin-loader PACKAGE_SPEC_RE", () => { - const rejects: Array<[string, string]> = [ - ["empty string", ""], - ["URL-like", "https://evil.com/x"], - ["path traversal", "../../etc/passwd"], - ["shell-injection", "pkg;evil"], - ["NUL byte", "pkg\0"], - ["fullwidth @ homoglyph", "\uFF20tailwindcss/typography"], - ["leading whitespace", " pkg"], - ["trailing whitespace", "pkg "], - ["internal whitespace", "pk g"], - ["absolute path", "/etc/passwd"], - ["backslash", "evil\\pkg"], - ["scope without name", "@scope/"], - ["name starting with dot", ".hidden"], - ["name starting with dash", "-pkg"], - ]; - - for (const [label, spec] of rejects) { - it(`rejects ${label}`, () => { - assertEquals( - PACKAGE_SPEC_RE.test(spec), - false, - `expected rejection for ${JSON.stringify(spec)}`, - ); - }); - } - - const accepts = [ - "pkg", - "pkg@1", - "pkg@1.2.3", - "pkg@1.2.3-rc.4", - "pkg@1.2.3+build.5", - "@scope/pkg", - "@scope/pkg@1.0.0", - "@tailwindcss/typography", - "@tailwindcss/typography@0.5.19", - "tailwindcss-animate", - "tailwindcss-animate@1.0.7", - ]; - - for (const spec of accepts) { - it(`accepts ${spec}`, () => { - assertEquals( - PACKAGE_SPEC_RE.test(spec), - true, - `expected acceptance for ${JSON.stringify(spec)}`, - ); - }); - } -}); diff --git a/src/html/styles-builder/plugin-loader.ts b/src/html/styles-builder/plugin-loader.ts deleted file mode 100644 index cd2dfe0d13..0000000000 --- a/src/html/styles-builder/plugin-loader.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Tailwind CSS plugin loading for compiled Deno binaries. - * - * Handles dynamic loading of Tailwind plugins from esm.sh with - * import rewriting for compiled binary compatibility. - * - * @module html/styles-builder/plugin-loader - */ - -import { encodeBase64Bytes, serverLogger } from "#veryfront/utils"; -import { - type ErrorSlug, - getErrorBySlug, - IMPORT_RESOLUTION_ERROR, - NETWORK_ERROR, - SECURITY_VIOLATION, - VeryfrontError, -} from "#veryfront/errors"; -import { getTailwindPluginBundleUrl } from "#veryfront/build/binary-plugin-includes.ts"; -import { getDenoRuntime, isDeno } from "#veryfront/platform/compat/runtime.ts"; -import { join, toFileUrl } from "#veryfront/platform/compat/path/index.ts"; -import { - bareName, - PACKAGE_SPEC_RE, - TAILWIND_PLUGIN_ALLOWLIST, -} from "./tailwind-plugin-allowlist.ts"; - -const logger = serverLogger.component("tailwind"); - -/** - * Enforce the Tailwind plugin allowlist (VULN-FS-1). - * - * Called at the top of every entry point that can load third-party plugin - * code. Rejects anything that is not a syntactically valid npm package - * specifier, and anything whose bare name is not on the allowlist. - */ -function assertPluginAllowed(spec: string): void { - if (!PACKAGE_SPEC_RE.test(spec)) { - throw SECURITY_VIOLATION.create({ detail: `Invalid Tailwind plugin specifier: ${spec}` }); - } - const name = bareName(spec); - if (!TAILWIND_PLUGIN_ALLOWLIST.has(name)) { - throw SECURITY_VIOLATION.create({ - detail: `Package "${name}" is not on the Tailwind plugin allowlist. ` + - `See src/html/styles-builder/tailwind-plugin-allowlist.ts.`, - }); - } -} - -// Provide localStorage shim for plugins that use util-deprecate (which checks localStorage) -// This prevents "LocalStorage is not supported in this context" errors in Deno. -try { - void (globalThis as Record).localStorage; -} catch { - const localStorageShim = { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, - clear: () => {}, - key: () => null, - length: 0, - }; - Object.defineProperty(globalThis, "localStorage", { - value: localStorageShim, - writable: true, - configurable: true, - enumerable: true, - }); -} - -// Global shims for `tailwindcss/plugin`, `tailwindcss/defaultTheme`, and -// `tailwindcss/colors` used by dynamically loaded plugin bundles are installed -// by the `@veryfront/ext-css-tailwind` extension's `setup()` hook — they depend on -// tailwindcss imports that live in the extension package, not in core. - -/** - * esm.sh bundles can contain root-relative nested imports. Once the bundle is - * written to a temp file, Deno resolves those as local file paths unless they - * are normalized back to esm.sh URLs. - */ -export function rewriteEsmShRootRelativeImports(code: string): string { - return code - .replace( - /\b(from\s*)(["'])(\/(?!\/)[^"']+)\2/g, - (_match, prefix: string, quote: string, specifier: string) => - `${prefix}${quote}https://esm.sh${specifier}${quote}`, - ) - .replace( - /\b(import\s*)(["'])(\/(?!\/)[^"']+)\2/g, - (_match, prefix: string, quote: string, specifier: string) => - `${prefix}${quote}https://esm.sh${specifier}${quote}`, - ) - .replace( - /\b(import\s*\(\s*)(["'])(\/(?!\/)[^"']+)\2/g, - (_match, prefix: string, quote: string, specifier: string) => - `${prefix}${quote}https://esm.sh${specifier}${quote}`, - ); -} - -async function importBundledModule(code: string): Promise { - if (!isDeno) { - // Encode as UTF-8 bytes: the data: URL importer decodes UTF-8, and btoa on - // the raw string would emit Latin-1 bytes for chars in [0x80, 0xFF]. - const dataUrl = `data:text/javascript;base64,${ - encodeBase64Bytes(new TextEncoder().encode(code)) - }`; - return await import(dataUrl); - } - - const deno = getDenoRuntime(); - if (!deno) { - throw IMPORT_RESOLUTION_ERROR.create({ - detail: "Deno runtime was expected while importing a Tailwind plugin module", - }); - } - - const tempDir = await deno.makeTempDir({ prefix: "vf_tw_plugin_" }); - const tempPath = join(tempDir, "plugin.mjs"); - await deno.writeTextFile(tempPath, code); - logger.debug("Wrote plugin to temp file", { path: tempPath }); - - try { - return await import(toFileUrl(tempPath).href); - } finally { - await deno.remove(tempDir, { recursive: true }).catch((error) => { - logger.error("Failed to clean up temp plugin directory", { - path: tempDir, - error: error instanceof Error ? error.message : String(error), - }); - }); - } -} - -/** - * Dynamically load a module from esm.sh in a compiled Deno binary. - * - * Works around the limitation that compiled Deno binaries cannot do - * dynamic imports from URLs. Fetches bundled code, rewrites imports, loads via temp file. - */ -export async function loadModuleFromEsmSh(packageName: string): Promise { - assertPluginAllowed(packageName); - - const stubUrl = getTailwindPluginBundleUrl(packageName); - logger.debug("Fetching esm.sh stub", { url: stubUrl }); - - const stubResponse = await fetch(stubUrl); - if (!stubResponse.ok) { - throw NETWORK_ERROR.create({ detail: `Failed to fetch stub: ${stubResponse.status}` }); - } - const stubCode = await stubResponse.text(); - - const bundleMatch = stubCode.match(/from\s*["'](\/[^"']+\.bundle\.mjs)["']/); - if (!bundleMatch) { - throw IMPORT_RESOLUTION_ERROR.create({ - detail: `Could not find bundle path in esm.sh response: ${stubCode.substring(0, 200)}`, - }); - } - - const bundleUrl = `https://esm.sh${bundleMatch[1]}`; - logger.debug("Fetching actual bundle", { url: bundleUrl }); - - const bundleResponse = await fetch(bundleUrl); - if (!bundleResponse.ok) { - throw NETWORK_ERROR.create({ detail: `Failed to fetch bundle: ${bundleResponse.status}` }); - } - let code = await bundleResponse.text(); - code = rewriteEsmShRootRelativeImports(code); - - // Step 3: Verify it's actually JavaScript (not an HTML error page) - if (code.trimStart().startsWith(" = { - "tailwindcss/plugin": "__tailwindPluginShim", - "tailwindcss/defaultTheme": "__tailwindDefaultThemeShim", - "tailwindcss/colors": "__tailwindColorsShim", - }; - - for (const [importPath, shimName] of Object.entries(shimMap)) { - const importRegex = new RegExp( - `import\\*as\\s+(__\\d+\\$)\\s+from["']${importPath.replace("/", "\\/")}["']`, - "g", - ); - code = code.replace(importRegex, (_, varName) => { - logger.debug(`Rewrote ${importPath} import to use global shim`, { varName }); - return `const ${varName} = globalThis.${shimName}`; - }); - } - - // Step 4b: Patch out localStorage access from util-deprecate - code = code.replace( - /globalThis\.localStorage/g, - "(globalThis.__localStorageShim||(globalThis.__localStorageShim={getItem:()=>null,setItem:()=>{},length:0}))", - ); - - return await importBundledModule(code); -} - -export async function loadPlugin( - id: string, - pluginCache: Map, - pluginErrors: Map, -): Promise { - // Enforce the allowlist before consulting any caches so a disallowed id can - // never be served from a pre-seeded or stale cache entry — defence-in-depth - // against future changes that might pre-populate these maps. - assertPluginAllowed(id); - - const cachedError = pluginErrors.get(id); - if (cachedError) throw cachedError; - - if (pluginCache.has(id)) { - return pluginCache.get(id); - } - - try { - let mod: unknown; - - if (isDeno) { - logger.debug("Loading plugin via dynamic esm.sh fetch", { id }); - mod = await loadModuleFromEsmSh(id); - } else { - logger.debug("Loading plugin from node_modules", { id }); - try { - mod = await import(id); - } catch { - logger.debug("Plugin not found in node_modules, falling back to esm.sh", { id }); - mod = await loadModuleFromEsmSh(id); - } - } - - const pluginExport = (mod as { default?: unknown }).default ?? mod; - pluginCache.set(id, pluginExport); - return pluginExport; - } catch (error) { - const wrappedError = wrapPluginError(id, error); - logger.warn(wrappedError.message); - pluginErrors.set(id, wrappedError); - throw wrappedError; - } -} - -function wrapPluginError(id: string, error: unknown): Error { - const detail = `Failed to load plugin "${id}": ${ - error instanceof Error ? error.message : String(error) - }`; - - if (error instanceof VeryfrontError) { - return getErrorBySlug(error.slug as ErrorSlug).create({ - detail, - cause: error.cause, - context: error.context, - instance: error.instance, - status: error.status, - }); - } - - if (error instanceof Error) { - return new Error(detail, { cause: error }); - } - - return IMPORT_RESOLUTION_ERROR.create({ detail }); -} diff --git a/src/html/styles-builder/prepared-project-css-cache.test.ts b/src/html/styles-builder/prepared-project-css-cache.test.ts new file mode 100644 index 0000000000..87ac26a2d6 --- /dev/null +++ b/src/html/styles-builder/prepared-project-css-cache.test.ts @@ -0,0 +1,141 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + createPreparedProjectCSSContext, + invalidatePreparedProjectCSS, + storePreparedProjectCSS, + tryGetPreparedProjectCSS, +} from "./prepared-project-css-cache.ts"; +import { hashCandidates, hashCSS, hashString } from "./css-identity.ts"; + +describe("styles-builder/prepared-project-css-cache", () => { + it("partitions prepared artifacts by every output-affecting identity", () => { + const baseProfile = { + cssPipelineIdentity: "pipeline-A", + candidatesHash: hashCandidates(["alpha"]), + minify: true, + environment: "preview", + buildMode: "production" as const, + }; + const styleProfile = hashString("style-profile-A"); + const base = createPreparedProjectCSSContext( + "project", + "version-A", + "sheet-A", + styleProfile, + baseProfile, + ); + const variants = [ + createPreparedProjectCSSContext("project", "version-B", "sheet-A", styleProfile, baseProfile), + createPreparedProjectCSSContext("project", "version-A", "sheet-B", styleProfile, baseProfile), + createPreparedProjectCSSContext( + "project", + "version-A", + "sheet-A", + hashString("style-profile-B"), + baseProfile, + ), + createPreparedProjectCSSContext("project", "version-A", "sheet-A", styleProfile, { + ...baseProfile, + candidatesHash: hashCandidates(["beta"]), + }), + createPreparedProjectCSSContext("project", "version-A", "sheet-A", styleProfile, { + ...baseProfile, + cssPipelineIdentity: "pipeline-B", + }), + ]; + + for (const variant of variants) { + assertEquals(variant.cacheKey === base.cacheKey, false); + } + const safelyFramed = createPreparedProjectCSSContext( + "project:*:scope", + "version:*:value", + "sheet-A", + styleProfile, + { ...baseProfile, environment: "preview:*:environment" }, + ); + assertEquals(safelyFramed.cacheKey.includes("project:*:scope"), false); + assertEquals(safelyFramed.cacheKey.includes("version:*:value"), false); + assertEquals(safelyFramed.cacheKey.includes("preview:*:environment"), false); + assertEquals(safelyFramed.cacheKey.startsWith("v3:"), true); + }); + + it("serves only content-verified entries from the exact prepared context", async () => { + const projectSlug = `prepared-css-${crypto.randomUUID()}`; + const context = createPreparedProjectCSSContext( + projectSlug, + "version", + "sheet", + hashString("style-profile"), + { + cssPipelineIdentity: "pipeline", + candidatesHash: hashCandidates(["alpha"]), + }, + ); + const css = ".alpha{display:block}"; + try { + await storePreparedProjectCSS(context, { css, hash: hashCSS(css) }); + assertEquals(await tryGetPreparedProjectCSS(context), { + css, + hash: hashCSS(css), + fromCache: true, + }); + + await assertRejects( + () => storePreparedProjectCSS(context, { css, hash: hashCSS("forged") }), + TypeError, + "does not match", + ); + } finally { + invalidatePreparedProjectCSS(projectSlug); + } + }); + + it("rejects abbreviated profile and candidate identities", () => { + assertThrows( + () => + createPreparedProjectCSSContext("project", "version", "sheet", "short", { + cssPipelineIdentity: "pipeline", + candidatesHash: hashCandidates(["alpha"]), + }), + TypeError, + "full lowercase SHA-256", + ); + assertThrows( + () => + createPreparedProjectCSSContext( + "project", + "version", + "sheet", + hashString("style-profile"), + { + cssPipelineIdentity: "pipeline", + candidatesHash: "short", + }, + ), + TypeError, + "full lowercase SHA-256", + ); + }); + + it("does not repopulate prepared CSS from a pre-invalidation context", async () => { + const projectSlug = `stale-prepared-context-${crypto.randomUUID()}`; + const context = createPreparedProjectCSSContext( + projectSlug, + "version", + "sheet", + hashString("style-profile"), + { + cssPipelineIdentity: "pipeline", + candidatesHash: hashCandidates(["alpha"]), + }, + ); + const css = ".alpha{display:block}"; + + invalidatePreparedProjectCSS(projectSlug); + await storePreparedProjectCSS(context, { css, hash: hashCSS(css) }); + assertEquals(await tryGetPreparedProjectCSS(context), undefined); + }); +}); diff --git a/src/html/styles-builder/prepared-project-css-cache.ts b/src/html/styles-builder/prepared-project-css-cache.ts index 8e014600e8..88bd2231fb 100644 --- a/src/html/styles-builder/prepared-project-css-cache.ts +++ b/src/html/styles-builder/prepared-project-css-cache.ts @@ -1,12 +1,16 @@ -import { - type CacheBackend, - createCacheBackend, - MemoryCacheBackend, -} from "#veryfront/cache/backend.ts"; +import { type CacheBackend, createCacheBackend } from "#veryfront/cache/backend.ts"; import { registerCache } from "#veryfront/utils/memory/index.ts"; -import { serverLogger } from "#veryfront/utils"; -import { DEFAULT_STYLESHEET } from "./css-hash-cache.ts"; -import { resolveStylesheet } from "./tailwind-compiler-utils.ts"; +import { assertCSSPipelineIdentity, assertStyleProfileHash, serverLogger } from "#veryfront/utils"; +import { + assertCSSFileContent, + assertCSSOutputContent, +} from "#veryfront/utils/css-content-admission.ts"; +import { + detachRetainedString, + estimateRetainedStringBytes, +} from "#veryfront/utils/retained-string.ts"; +import { utf8ByteLength } from "#veryfront/utils/utf8-byte-length.ts"; +import { assertCSSContentIdentity, hashString, isCSSContentHash } from "./css-identity.ts"; const logger = serverLogger.component("prepared-project-css-cache"); @@ -17,9 +21,12 @@ interface PreparedProjectCSSCacheEntry { interface PreparedProjectCSSLocalEntry extends PreparedProjectCSSCacheEntry { expiresAt: number; + retainedBytes: number; } interface PreparedProjectCSSProfile { + cssPipelineIdentity: string; + candidatesHash: string; minify?: boolean; environment?: string; buildMode?: "development" | "production"; @@ -30,14 +37,18 @@ export interface PreparedProjectCSSRequestContext { projectVersion: string; stylesheet: string; stylesheetHash: string; + candidatesHash: string; styleProfileHash: string; environment: string; profileHash: string; cacheKey: string; + cacheEpoch: number; } const PREPARED_PROJECT_CSS_CACHE_TTL_SECONDS = 24 * 3600; +const PREPARED_PROJECT_CSS_CACHE_SCHEMA = "v3"; const PREPARED_PROJECT_CSS_LOCAL_MAX = 50; +const PREPARED_PROJECT_CSS_LOCAL_MAX_BYTES = 64 * 1024 * 1024; const PREPARED_PROJECT_CSS_LOCAL_TTL_MS = PREPARED_PROJECT_CSS_CACHE_TTL_SECONDS * 1000; let preparedProjectCSSBackend: CacheBackend | null = null; @@ -45,53 +56,89 @@ let preparedProjectCSSInitialized = false; let preparedProjectCSSInitPromise: Promise | null = null; const localPreparedProjectCSS = new Map(); +let localPreparedProjectCSSBytes = 0; +let preparedProjectCSSCacheEpoch = 0; registerCache("prepared-project-css-cache", () => ({ name: "prepared-project-css-cache", entries: localPreparedProjectCSS.size, maxEntries: PREPARED_PROJECT_CSS_LOCAL_MAX, + estimatedSizeBytes: localPreparedProjectCSSBytes, backend: preparedProjectCSSBackend?.type ?? "uninitialized", })); -function hashValue(input: string): string { - let hash = 0; - for (let index = 0; index < input.length; index++) { - hash = ((hash << 5) - hash) + input.charCodeAt(index); - hash |= 0; - } - return hash.toString(36); +function estimateLocalEntryBytes(key: string, entry: PreparedProjectCSSCacheEntry): number { + return estimateRetainedStringBytes(key) + estimateRetainedStringBytes(entry.css) + + estimateRetainedStringBytes(entry.hash) + 128; +} + +function removeLocalEntry(key: string): void { + const existing = localPreparedProjectCSS.get(key); + if (!existing) return; + localPreparedProjectCSS.delete(key); + localPreparedProjectCSSBytes -= existing.retainedBytes; } function setLocalEntry(key: string, entry: PreparedProjectCSSCacheEntry): void { - localPreparedProjectCSS.set(key, { - ...entry, + assertCSSOutputContent(entry.css, "Cached prepared CSS output"); + assertCSSContentIdentity(entry.css, entry.hash); + const retainedKey = detachRetainedString(key); + const retainedEntry = { + css: detachRetainedString(entry.css), + hash: detachRetainedString(entry.hash), + }; + const retainedBytes = estimateLocalEntryBytes(retainedKey, retainedEntry); + removeLocalEntry(key); + while ( + localPreparedProjectCSS.size >= PREPARED_PROJECT_CSS_LOCAL_MAX || + localPreparedProjectCSSBytes + retainedBytes > PREPARED_PROJECT_CSS_LOCAL_MAX_BYTES + ) { + const oldestKey = localPreparedProjectCSS.keys().next().value as string | undefined; + if (oldestKey === undefined) break; + removeLocalEntry(oldestKey); + } + if (retainedBytes > PREPARED_PROJECT_CSS_LOCAL_MAX_BYTES) return; + localPreparedProjectCSS.set(retainedKey, { + css: retainedEntry.css, + hash: retainedEntry.hash, expiresAt: Date.now() + PREPARED_PROJECT_CSS_LOCAL_TTL_MS, + retainedBytes, }); + localPreparedProjectCSSBytes += retainedBytes; +} - if (localPreparedProjectCSS.size <= PREPARED_PROJECT_CSS_LOCAL_MAX) return; - - const keys = localPreparedProjectCSS.keys(); - while (localPreparedProjectCSS.size > PREPARED_PROJECT_CSS_LOCAL_MAX) { - const result = keys.next(); - if (result.done) break; - localPreparedProjectCSS.delete(result.value); - } +function readOwnDataProperty(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; } function parsePreparedProjectCSSCacheEntry( raw: string, ): PreparedProjectCSSCacheEntry | null { try { - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.css !== "string" || typeof parsed.hash !== "string") return null; - return { css: parsed.css, hash: parsed.hash }; + if ( + utf8ByteLength(raw, PREPARED_PROJECT_CSS_LOCAL_MAX_BYTES) > + PREPARED_PROJECT_CSS_LOCAL_MAX_BYTES + ) { + return null; + } + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; + const css = readOwnDataProperty(parsed, "css"); + const hash = readOwnDataProperty(parsed, "hash"); + if (typeof css !== "string" || !isCSSContentHash(hash)) return null; + assertCSSOutputContent(css, "Cached prepared CSS output"); + assertCSSContentIdentity(css, hash); + return { css, hash }; } catch { return null; } } export async function initializePreparedProjectCSSCache(): Promise { - if (preparedProjectCSSInitialized) return preparedProjectCSSBackend?.type !== "memory"; + if (preparedProjectCSSInitialized) { + return preparedProjectCSSBackend !== null && preparedProjectCSSBackend.type !== "memory"; + } if (!preparedProjectCSSInitPromise) { preparedProjectCSSInitPromise = (async () => { @@ -101,8 +148,8 @@ export async function initializePreparedProjectCSSCache(): Promise { }); logger.debug("Initialized", { backend: preparedProjectCSSBackend.type }); } catch (error) { - logger.warn("Backend init failed, using memory", { error }); - preparedProjectCSSBackend = new MemoryCacheBackend(PREPARED_PROJECT_CSS_LOCAL_MAX); + logger.warn("Shared backend unavailable; bounded local cache remains active", { error }); + preparedProjectCSSBackend = null; } finally { preparedProjectCSSInitialized = true; } @@ -112,51 +159,84 @@ export async function initializePreparedProjectCSSCache(): Promise { await preparedProjectCSSInitPromise; preparedProjectCSSInitPromise = null; - return preparedProjectCSSBackend?.type !== "memory"; + return preparedProjectCSSBackend !== null && preparedProjectCSSBackend.type !== "memory"; } export function createPreparedProjectCSSContext( projectSlug: string, projectVersion: string, - stylesheet: string | undefined, + stylesheet: string, styleProfileHash: string, - profile?: PreparedProjectCSSProfile, + profile: PreparedProjectCSSProfile, ): PreparedProjectCSSRequestContext { - const resolvedStylesheet = resolveStylesheet(stylesheet, DEFAULT_STYLESHEET); - const stylesheetHash = hashValue(resolvedStylesheet); - const environment = profile?.environment ?? "preview"; - const profileHash = hashValue( + const capturedProjectSlug = assertCSSPipelineIdentity( + projectSlug, + "Prepared CSS project scope", + ); + const capturedProjectVersion = assertCSSPipelineIdentity( + projectVersion, + "Prepared CSS project version", + ); + const capturedStyleProfileHash = assertStyleProfileHash(styleProfileHash); + const cssPipelineIdentity = assertCSSPipelineIdentity(profile.cssPipelineIdentity); + if (!isCSSContentHash(profile.candidatesHash)) { + throw new TypeError("Prepared CSS candidatesHash must be a full lowercase SHA-256 digest"); + } + assertCSSFileContent(stylesheet, "Prepared project CSS stylesheet"); + const stylesheetHash = hashString(stylesheet); + const environment = assertCSSPipelineIdentity( + profile?.environment ?? "preview", + "Prepared CSS environment", + ); + const profileHash = hashString( JSON.stringify({ - cacheSchema: "v1", + cacheSchema: PREPARED_PROJECT_CSS_CACHE_SCHEMA, + cssPipelineIdentity, minify: profile?.minify ?? false, buildMode: profile?.buildMode ?? "production", environment, }), ); + const preparedIdentityHash = hashString(JSON.stringify([ + capturedProjectVersion, + stylesheetHash, + profile.candidatesHash, + capturedStyleProfileHash, + profileHash, + ])); return { - projectSlug, - projectVersion, - stylesheet: resolvedStylesheet, + projectSlug: capturedProjectSlug, + projectVersion: capturedProjectVersion, + stylesheet, stylesheetHash, - styleProfileHash, + candidatesHash: profile.candidatesHash, + styleProfileHash: capturedStyleProfileHash, environment, profileHash, - cacheKey: - `${projectSlug}:${environment}:prepared:${projectVersion}:${stylesheetHash}:${styleProfileHash}:${profileHash}`, + cacheKey: getPreparedProjectCSSCacheScopePrefix(capturedProjectSlug) + + hashString(environment) + ":" + preparedIdentityHash, + cacheEpoch: preparedProjectCSSCacheEpoch, }; } +function getPreparedProjectCSSCacheScopePrefix(projectSlug: string): string { + return `${PREPARED_PROJECT_CSS_CACHE_SCHEMA}:${hashString(projectSlug)}:`; +} + export async function tryGetPreparedProjectCSS( context: PreparedProjectCSSRequestContext, ): Promise<{ css: string; hash: string; fromCache: true } | undefined> { + if (context.cacheEpoch !== preparedProjectCSSCacheEpoch) return undefined; const local = localPreparedProjectCSS.get(context.cacheKey); if (local && local.expiresAt > Date.now()) { + localPreparedProjectCSS.delete(context.cacheKey); + localPreparedProjectCSS.set(context.cacheKey, local); return { css: local.css, hash: local.hash, fromCache: true }; } if (local) { - localPreparedProjectCSS.delete(context.cacheKey); + removeLocalEntry(context.cacheKey); } if (!preparedProjectCSSInitialized) { @@ -167,6 +247,7 @@ export async function tryGetPreparedProjectCSS( try { const raw = await preparedProjectCSSBackend.get(context.cacheKey); + if (context.cacheEpoch !== preparedProjectCSSCacheEpoch) return undefined; if (!raw) return undefined; const entry = parsePreparedProjectCSSCacheEntry(raw); @@ -187,42 +268,71 @@ export async function storePreparedProjectCSS( context: PreparedProjectCSSRequestContext, entry: PreparedProjectCSSCacheEntry, ): Promise { + assertCSSContentIdentity(entry.css, entry.hash); + if (context.cacheEpoch !== preparedProjectCSSCacheEpoch) return; if (!preparedProjectCSSInitialized) { await initializePreparedProjectCSSCache(); } + if (context.cacheEpoch !== preparedProjectCSSCacheEpoch) return; setLocalEntry(context.cacheKey, entry); if (!preparedProjectCSSBackend) return; - preparedProjectCSSBackend - .set(context.cacheKey, JSON.stringify(entry), PREPARED_PROJECT_CSS_CACHE_TTL_SECONDS) - .catch((error) => { - logger.debug("Failed to store prepared project CSS", { - cacheKey: context.cacheKey, - error, - }); + try { + await preparedProjectCSSBackend.set( + context.cacheKey, + JSON.stringify(entry), + PREPARED_PROJECT_CSS_CACHE_TTL_SECONDS, + ); + if (context.cacheEpoch !== preparedProjectCSSCacheEpoch) { + removeLocalEntry(context.cacheKey); + await preparedProjectCSSBackend.del(context.cacheKey); + } + } catch (error) { + logger.debug("Failed to store prepared project CSS", { + cacheKey: context.cacheKey, + error, }); + } } export function invalidatePreparedProjectCSS(projectSlug: string): void { + const projectPrefix = getPreparedProjectCSSCacheScopePrefix( + assertCSSPipelineIdentity(projectSlug, "Prepared CSS project scope"), + ); + preparedProjectCSSCacheEpoch++; for (const key of localPreparedProjectCSS.keys()) { - if (key.startsWith(`${projectSlug}:`)) { - localPreparedProjectCSS.delete(key); + if (key.startsWith(projectPrefix)) { + removeLocalEntry(key); } } - invalidatePreparedProjectCSSAsync(projectSlug).catch((error) => { + invalidatePreparedProjectCSSBackend(projectSlug, projectPrefix).catch((error) => { logger.debug("Failed to invalidate prepared project CSS", { projectSlug, error }); }); } export async function invalidatePreparedProjectCSSAsync(projectSlug: string): Promise { - if (!preparedProjectCSSBackend?.delByPattern) return; + const projectPrefix = getPreparedProjectCSSCacheScopePrefix( + assertCSSPipelineIdentity(projectSlug, "Prepared CSS project scope"), + ); + preparedProjectCSSCacheEpoch++; + for (const key of localPreparedProjectCSS.keys()) { + if (key.startsWith(projectPrefix)) removeLocalEntry(key); + } + await invalidatePreparedProjectCSSBackend(projectSlug, projectPrefix); +} +async function invalidatePreparedProjectCSSBackend( + projectSlug: string, + projectPrefix: string, +): Promise { + if (!preparedProjectCSSBackend?.delByPattern) return; try { - await preparedProjectCSSBackend.delByPattern(`${projectSlug}:*`); + await preparedProjectCSSBackend.delByPattern(`${projectPrefix}*`); } catch (error) { logger.debug("Failed to delete prepared project CSS", { projectSlug, error }); + throw error; } } diff --git a/src/html/styles-builder/project-css-cache.test.ts b/src/html/styles-builder/project-css-cache.test.ts index 9d5ab55396..3db9092bca 100644 --- a/src/html/styles-builder/project-css-cache.test.ts +++ b/src/html/styles-builder/project-css-cache.test.ts @@ -9,28 +9,64 @@ import { invalidateCompiler, invalidateProjectCSS, } from "./tailwind-compiler.ts"; +import { + createProjectCSSRequestContext, + storeProjectCSS, + tryGetProjectCSSFromLocalFallback, +} from "./project-css-cache.ts"; +import { hashCSS } from "./css-identity.ts"; -// Simple stylesheet without plugins — avoids loading @tailwindcss/typography from esm.sh in tests +// Simple provider-owned stylesheet without plugin directives. const TEST_STYLESHEET = `@import "tailwindcss";`; +function forbidNetwork(): () => void { + const originalFetch = globalThis.fetch; + globalThis.fetch = + (() => Promise.reject(new Error("CSS project cache tests must not fetch"))) as typeof fetch; + return () => { + globalThis.fetch = originalFetch; + }; +} + describe("styles-builder/project-css-cache", () => { + it("hash-frames project and environment segments before cache-key use", () => { + const context = createProjectCSSRequestContext( + "project:*:scope", + TEST_STYLESHEET, + ["alpha"], + { + cssPipelineIdentity: "pipeline", + environment: "preview:*:environment", + }, + ); + + assertEquals(context.cacheKey.includes("project:*:scope"), false); + assertEquals(context.cacheKey.includes("preview:*:environment"), false); + assertEquals(context.cacheKey.startsWith("v4:"), true); + }); + + it("does not repopulate a project cache from a pre-invalidation context", async () => { + const projectSlug = `stale-project-context-${crypto.randomUUID()}`; + const candidates = ["alpha"]; + const context = createProjectCSSRequestContext( + projectSlug, + TEST_STYLESHEET, + candidates, + { cssPipelineIdentity: "pipeline" }, + ); + const css = ".alpha{display:block}"; + + invalidateProjectCSS(projectSlug); + await storeProjectCSS( + context, + { css, hash: hashCSS(css), candidatesHash: context.candidatesHash }, + candidates, + ); + assertEquals(await tryGetProjectCSSFromLocalFallback(context, candidates), undefined); + }); + it("populates hash-level cache on fresh generation so other pods can serve CSS", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = ((input: URL | Request | string) => { - const url = typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - - if (!url.includes("tailwindcss")) { - return Promise.reject(new Error(`Unexpected fetch URL during test: ${url}`)); - } - - return Promise.resolve( - new Response("@layer theme, base, components, utilities;", { status: 200 }), - ); - }) as typeof fetch; + const restoreFetch = forbidNetwork(); const projectSlug = `hash-cache-test-${crypto.randomUUID()}`; @@ -51,7 +87,7 @@ describe("styles-builder/project-css-cache", () => { assertEquals(typeof cached, "string"); assertEquals(cached!.length > 0, true); } finally { - globalThis.fetch = originalFetch; + restoreFetch(); clearCSSCache(); invalidateCompiler(); invalidateProjectCSS(projectSlug); @@ -59,22 +95,7 @@ describe("styles-builder/project-css-cache", () => { }); it("invalidates project CSS cache when candidates change or explicit invalidation runs", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = ((input: URL | Request | string) => { - const url = typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - - if (!url.includes("tailwindcss")) { - return Promise.reject(new Error(`Unexpected fetch URL during test: ${url}`)); - } - - return Promise.resolve( - new Response("@layer theme, base, components, utilities;", { status: 200 }), - ); - }) as typeof fetch; + const restoreFetch = forbidNetwork(); const projectSlug = `cache-test-${crypto.randomUUID()}`; const stylesheet = TEST_STYLESHEET; @@ -106,7 +127,7 @@ describe("styles-builder/project-css-cache", () => { const afterInvalidation = await getProjectCSS(projectSlug, stylesheet, candidatesB, options); assertEquals(afterInvalidation.fromCache, false); } finally { - globalThis.fetch = originalFetch; + restoreFetch(); clearCSSCache(); invalidateCompiler(); invalidateProjectCSS(projectSlug); diff --git a/src/html/styles-builder/project-css-cache.ts b/src/html/styles-builder/project-css-cache.ts index 2a58de60f8..277ef07c05 100644 --- a/src/html/styles-builder/project-css-cache.ts +++ b/src/html/styles-builder/project-css-cache.ts @@ -8,17 +8,29 @@ * @module html/styles-builder/project-css-cache */ -import { type CacheBackend, CacheBackends, MemoryCacheBackend } from "#veryfront/cache/backend.ts"; -import { serverLogger as logger } from "#veryfront/utils"; +import { type CacheBackend, CacheBackends } from "#veryfront/cache/backend.ts"; +import { assertCSSPipelineIdentity, serverLogger as logger } from "#veryfront/utils"; +import { normalizeCSSCandidates } from "#veryfront/utils/css-candidate-admission.ts"; +import { + assertCSSFileContent, + assertCSSOutputContent, +} from "#veryfront/utils/css-content-admission.ts"; import { registerCache } from "#veryfront/utils/memory/index.ts"; -import { hashCandidates, hashString } from "./candidate-extractor.ts"; +import { + detachRetainedString, + estimateRetainedStringBytes, +} from "#veryfront/utils/retained-string.ts"; +import { + assertCSSContentIdentity, + hashCandidates, + hashString, + isCSSContentHash, +} from "./css-identity.ts"; import { evaluateProjectCSSLocalCacheState, parseProjectCSSCacheEntry, - resolveStylesheet, } from "./tailwind-compiler-utils.ts"; -import { cacheCSSAsync, DEFAULT_STYLESHEET } from "./css-hash-cache.ts"; -import { TAILWIND_VERSION } from "#veryfront/utils/constants/cdn.ts"; +import { cacheCSSAsync } from "./css-hash-cache.ts"; const projectCssCacheLog = logger.component("project-css-cache"); const tailwindLog = logger.component("tailwind"); @@ -35,18 +47,22 @@ interface ProjectCSSCacheEntry { interface ProjectCSSLocalEntry extends ProjectCSSCacheEntry { expiresAt: number; + retainedBytes: number; } -interface ProjectCSSRequestContext { +export interface ProjectCSSRequestContext { projectSlug: string; stylesheet: string; candidatesHash: string; profileHash: string; environment: string; + cssPipelineIdentity: string; cacheKey: string; + cacheEpoch: number; } interface ProjectCSSProfile { + cssPipelineIdentity: string; minify?: boolean; environment?: string; buildMode?: "development" | "production"; @@ -57,7 +73,9 @@ interface ProjectCSSProfile { // ============================================================================ const PROJECT_CSS_CACHE_TTL_SECONDS = 24 * 3600; +const PROJECT_CSS_CACHE_SCHEMA = "v4"; const PROJECT_CSS_LOCAL_FALLBACK_MAX = 50; +const PROJECT_CSS_LOCAL_FALLBACK_MAX_BYTES = 64 * 1024 * 1024; const PROJECT_CSS_LOCAL_TTL_MS = PROJECT_CSS_CACHE_TTL_SECONDS * 1000; // ============================================================================ @@ -69,11 +87,14 @@ let projectCSSInitialized = false; let projectCSSInitPromise: Promise | null = null; const projectCSSLocalFallback = new Map(); +let projectCSSLocalFallbackBytes = 0; +let projectCSSCacheEpoch = 0; registerCache("project-css-cache", () => ({ name: "project-css-cache", entries: projectCSSLocalFallback.size, maxEntries: PROJECT_CSS_LOCAL_FALLBACK_MAX, + estimatedSizeBytes: projectCSSLocalFallbackBytes, backend: projectCSSBackend?.type ?? "uninitialized", })); @@ -88,7 +109,9 @@ registerCache("project-css-cache", () => ({ * @returns true if distributed backend was successfully initialized */ export async function initializeProjectCSSCache(): Promise { - if (projectCSSInitialized) return projectCSSBackend?.type !== "memory"; + if (projectCSSInitialized) { + return projectCSSBackend !== null && projectCSSBackend.type !== "memory"; + } if (!projectCSSInitPromise) { projectCSSInitPromise = (async () => { @@ -96,8 +119,10 @@ export async function initializeProjectCSSCache(): Promise { projectCSSBackend = await CacheBackends.projectCSS(); projectCssCacheLog.debug("Initialized", { backend: projectCSSBackend.type }); } catch (error) { - projectCssCacheLog.warn("Backend init failed, using memory", { error }); - projectCSSBackend = new MemoryCacheBackend(100); + projectCssCacheLog.warn("Shared backend unavailable; bounded local cache remains active", { + error, + }); + projectCSSBackend = null; } finally { projectCSSInitialized = true; } @@ -107,7 +132,7 @@ export async function initializeProjectCSSCache(): Promise { await projectCSSInitPromise; projectCSSInitPromise = null; - return projectCSSBackend?.type !== "memory"; + return projectCSSBackend !== null && projectCSSBackend.type !== "memory"; } /** @@ -123,18 +148,27 @@ export function isProjectCSSCacheDistributed(): boolean { export function createProjectCSSRequestContext( projectSlug: string, - stylesheet: string | undefined, - candidates: Set, - profile?: ProjectCSSProfile, + stylesheet: string, + candidates: string[] | Set, + profile: ProjectCSSProfile, ): ProjectCSSRequestContext { - const resolvedStylesheet = resolveStylesheet(stylesheet, DEFAULT_STYLESHEET); - const stylesheetHash = hashString(resolvedStylesheet); - const candidatesHash = hashCandidates(candidates); - const environment = profile?.environment ?? "preview"; + const capturedProjectSlug = assertCSSPipelineIdentity( + projectSlug, + "Project CSS project scope", + ); + const cssPipelineIdentity = assertCSSPipelineIdentity(profile.cssPipelineIdentity); + assertCSSFileContent(stylesheet, "Project CSS stylesheet"); + const admittedCandidates = normalizeCSSCandidates(candidates); + const stylesheetHash = hashString(stylesheet); + const candidatesHash = hashCandidates(admittedCandidates); + const environment = assertCSSPipelineIdentity( + profile?.environment ?? "preview", + "Project CSS environment", + ); const profileHash = hashString( JSON.stringify({ - cacheSchema: "v2", - tailwindVersion: TAILWIND_VERSION, + cacheSchema: PROJECT_CSS_CACHE_SCHEMA, + cssPipelineIdentity, minify: profile?.minify ?? false, buildMode: profile?.buildMode ?? "production", environment, @@ -142,44 +176,91 @@ export function createProjectCSSRequestContext( ); return { - projectSlug, - stylesheet: resolvedStylesheet, + projectSlug: capturedProjectSlug, + stylesheet, candidatesHash, profileHash, environment, - cacheKey: `${projectSlug}:${environment}:${stylesheetHash}:${candidatesHash}:${profileHash}`, + cssPipelineIdentity, + cacheKey: getProjectCSSCacheScopePrefix(capturedProjectSlug) + hashString(environment) + + `:${stylesheetHash}:${candidatesHash}:${profileHash}`, + cacheEpoch: projectCSSCacheEpoch, }; } +function getProjectCSSCacheScopePrefix(projectSlug: string): string { + return `${PROJECT_CSS_CACHE_SCHEMA}:${hashString(projectSlug)}:`; +} + // ============================================================================ // Local fallback management // ============================================================================ -function setProjectCSSLocalFallback(key: string, entry: ProjectCSSCacheEntry): void { - projectCSSLocalFallback.set(key, { ...entry, expiresAt: Date.now() + PROJECT_CSS_LOCAL_TTL_MS }); - if (projectCSSLocalFallback.size > PROJECT_CSS_LOCAL_FALLBACK_MAX) { - pruneProjectCSSLocalFallback(); - } +function estimateProjectCSSLocalEntryBytes(key: string, entry: ProjectCSSCacheEntry): number { + return estimateRetainedStringBytes(key) + estimateRetainedStringBytes(entry.css) + + estimateRetainedStringBytes(entry.hash) + + estimateRetainedStringBytes(entry.candidatesHash) + 128; } -function pruneProjectCSSLocalFallback(): void { - const excess = projectCSSLocalFallback.size - PROJECT_CSS_LOCAL_FALLBACK_MAX; - if (excess <= 0) return; +function removeProjectCSSLocalFallback(key: string): void { + const existing = projectCSSLocalFallback.get(key); + if (!existing) return; + projectCSSLocalFallback.delete(key); + projectCSSLocalFallbackBytes -= existing.retainedBytes; +} - const keys = projectCSSLocalFallback.keys(); - for (let i = 0; i < excess; i++) { - const result = keys.next(); - if (result.done) break; - projectCSSLocalFallback.delete(result.value); +function setProjectCSSLocalFallback(key: string, entry: ProjectCSSCacheEntry): void { + assertCSSOutputContent(entry.css, "Cached project CSS output"); + assertCSSContentIdentity(entry.css, entry.hash); + const retainedKey = detachRetainedString(key); + const retainedEntry = { + css: detachRetainedString(entry.css), + hash: detachRetainedString(entry.hash), + candidatesHash: detachRetainedString(entry.candidatesHash), + }; + const retainedBytes = estimateProjectCSSLocalEntryBytes(retainedKey, retainedEntry); + removeProjectCSSLocalFallback(key); + while ( + projectCSSLocalFallback.size >= PROJECT_CSS_LOCAL_FALLBACK_MAX || + projectCSSLocalFallbackBytes + retainedBytes > PROJECT_CSS_LOCAL_FALLBACK_MAX_BYTES + ) { + const oldestKey = projectCSSLocalFallback.keys().next().value as string | undefined; + if (oldestKey === undefined) break; + removeProjectCSSLocalFallback(oldestKey); } + if (retainedBytes > PROJECT_CSS_LOCAL_FALLBACK_MAX_BYTES) return; + projectCSSLocalFallback.set(retainedKey, { + css: retainedEntry.css, + hash: retainedEntry.hash, + candidatesHash: retainedEntry.candidatesHash, + expiresAt: Date.now() + PROJECT_CSS_LOCAL_TTL_MS, + retainedBytes, + }); + projectCSSLocalFallbackBytes += retainedBytes; } async function cacheProjectCSSEntryByHash( entry: ProjectCSSCacheEntry, - candidates: Set, + candidates: string[] | Set, stylesheet: string, + cssPipelineIdentity: string, ): Promise { - await cacheCSSAsync(entry.css, entry.hash, { candidates, stylesheet }); + await cacheCSSAsync(entry.css, entry.hash, { + candidates, + stylesheet, + pipelineIdentity: cssPipelineIdentity, + }); +} + +function isValidProjectCSSCacheEntry(entry: ProjectCSSCacheEntry): boolean { + if (!isCSSContentHash(entry.hash) || !isCSSContentHash(entry.candidatesHash)) return false; + try { + assertCSSOutputContent(entry.css, "Cached project CSS output"); + assertCSSContentIdentity(entry.css, entry.hash); + return true; + } catch { + return false; + } } // ============================================================================ @@ -188,35 +269,49 @@ async function cacheProjectCSSEntryByHash( export async function tryGetProjectCSSFromLocalFallback( context: ProjectCSSRequestContext, - candidates: Set, + candidates: string[] | Set, ): Promise<{ css: string; hash: string; fromCache: true } | undefined> { + if (context.cacheEpoch !== projectCSSCacheEpoch) return undefined; const localCached = projectCSSLocalFallback.get(context.cacheKey); const localState = evaluateProjectCSSLocalCacheState(localCached, context.candidatesHash); if (localState === "expired" || localState === "mismatch") { - projectCSSLocalFallback.delete(context.cacheKey); + removeProjectCSSLocalFallback(context.cacheKey); return undefined; } if (localState !== "hit" || !localCached) return undefined; + if (!isValidProjectCSSCacheEntry(localCached)) { + removeProjectCSSLocalFallback(context.cacheKey); + return undefined; + } tailwindLog.debug("Project CSS cache hit (local)", { projectSlug: context.projectSlug, hash: localCached.hash, }); - - await cacheProjectCSSEntryByHash(localCached, candidates, context.stylesheet); + projectCSSLocalFallback.delete(context.cacheKey); + projectCSSLocalFallback.set(context.cacheKey, localCached); + + await cacheProjectCSSEntryByHash( + localCached, + candidates, + context.stylesheet, + context.cssPipelineIdentity, + ); return { css: localCached.css, hash: localCached.hash, fromCache: true }; } export async function tryGetProjectCSSFromDistributedCache( context: ProjectCSSRequestContext, - candidates: Set, + candidates: string[] | Set, ): Promise<{ css: string; hash: string; fromCache: true } | undefined> { + if (context.cacheEpoch !== projectCSSCacheEpoch) return undefined; if (!projectCSSBackend) return undefined; try { const raw = await projectCSSBackend.get(context.cacheKey); + if (context.cacheEpoch !== projectCSSCacheEpoch) return undefined; if (!raw) return undefined; const entry = parseProjectCSSCacheEntry(raw); @@ -226,6 +321,12 @@ export async function tryGetProjectCSSFromDistributedCache( }); return undefined; } + if (!isValidProjectCSSCacheEntry(entry)) { + tailwindLog.warn("Rejected project CSS cache entry with invalid content identity", { + cacheKey: context.cacheKey, + }); + return undefined; + } if (entry.candidatesHash !== context.candidatesHash) { tailwindLog.debug("Project CSS cache miss (candidates changed)", { @@ -242,7 +343,12 @@ export async function tryGetProjectCSSFromDistributedCache( }); setProjectCSSLocalFallback(context.cacheKey, entry); - await cacheProjectCSSEntryByHash(entry, candidates, context.stylesheet); + await cacheProjectCSSEntryByHash( + entry, + candidates, + context.stylesheet, + context.cssPipelineIdentity, + ); return { css: entry.css, hash: entry.hash, fromCache: true }; } catch (error) { tailwindLog.debug("Failed to read from project CSS cache", { @@ -260,24 +366,45 @@ export async function tryGetProjectCSSFromDistributedCache( export async function storeProjectCSS( context: ProjectCSSRequestContext, entry: ProjectCSSCacheEntry, - candidates: Set, + candidates: string[] | Set, ): Promise { + assertCSSContentIdentity(entry.css, entry.hash); + if (entry.candidatesHash !== context.candidatesHash) { + throw new TypeError("Project CSS candidates hash does not match its request context"); + } + if (context.cacheEpoch !== projectCSSCacheEpoch) return; if (projectCSSBackend) { - projectCSSBackend.set(context.cacheKey, JSON.stringify(entry), PROJECT_CSS_CACHE_TTL_SECONDS) - .catch((error) => { - tailwindLog.debug("Failed to store in project CSS cache", { - cacheKey: context.cacheKey, - error, - }); + try { + await projectCSSBackend.set( + context.cacheKey, + JSON.stringify(entry), + PROJECT_CSS_CACHE_TTL_SECONDS, + ); + if (context.cacheEpoch !== projectCSSCacheEpoch) { + await projectCSSBackend.del(context.cacheKey); + return; + } + } catch (error) { + tailwindLog.debug("Failed to store in project CSS cache", { + cacheKey: context.cacheKey, + error, }); + } } + if (context.cacheEpoch !== projectCSSCacheEpoch) return; + setProjectCSSLocalFallback(context.cacheKey, entry); // Await the hash-level cache write so other pods can serve // /_vf/css/{hash}.css immediately. Without awaiting, the browser's // CSS request may hit a different pod before the write completes. - await cacheProjectCSSEntryByHash(entry, candidates, context.stylesheet); + await cacheProjectCSSEntryByHash( + entry, + candidates, + context.stylesheet, + context.cssPipelineIdentity, + ); } /** @@ -295,13 +422,17 @@ export function isProjectCSSInitialized(): boolean { * Invalidate project CSS cache for a specific project. */ export function invalidateProjectCSS(projectSlug: string): void { + const projectPrefix = getProjectCSSCacheScopePrefix( + assertCSSPipelineIdentity(projectSlug, "Project CSS project scope"), + ); + projectCSSCacheEpoch++; for (const key of projectCSSLocalFallback.keys()) { - if (key.startsWith(`${projectSlug}:`)) { - projectCSSLocalFallback.delete(key); + if (key.startsWith(projectPrefix)) { + removeProjectCSSLocalFallback(key); } } - invalidateProjectCSSAsync(projectSlug).catch((error) => { + invalidateProjectCSSBackend(projectSlug, projectPrefix).catch((error) => { tailwindLog.debug("Failed to invalidate project CSS cache", { projectSlug, error }); }); } @@ -310,12 +441,26 @@ export function invalidateProjectCSS(projectSlug: string): void { * Invalidate project CSS cache for a specific project (async version). */ export async function invalidateProjectCSSAsync(projectSlug: string): Promise { - if (!projectCSSBackend?.delByPattern) return; + const projectPrefix = getProjectCSSCacheScopePrefix( + assertCSSPipelineIdentity(projectSlug, "Project CSS project scope"), + ); + projectCSSCacheEpoch++; + for (const key of projectCSSLocalFallback.keys()) { + if (key.startsWith(projectPrefix)) removeProjectCSSLocalFallback(key); + } + await invalidateProjectCSSBackend(projectSlug, projectPrefix); +} +async function invalidateProjectCSSBackend( + projectSlug: string, + projectPrefix: string, +): Promise { + if (!projectCSSBackend?.delByPattern) return; try { - const deleted = await projectCSSBackend.delByPattern(`${projectSlug}:*`); + const deleted = await projectCSSBackend.delByPattern(`${projectPrefix}*`); tailwindLog.debug("Cleared project CSS cache", { projectSlug, deleted }); } catch (error) { tailwindLog.debug("Failed to clear project CSS cache", { projectSlug, error }); + throw error; } } diff --git a/src/html/styles-builder/style-scope-profile.ts b/src/html/styles-builder/style-scope-profile.ts index 8f3a9e0f87..1bd068dcfc 100644 --- a/src/html/styles-builder/style-scope-profile.ts +++ b/src/html/styles-builder/style-scope-profile.ts @@ -1,4 +1,5 @@ import type { VeryfrontConfig } from "#veryfront/config"; +import { createHash } from "node:crypto"; const DEFAULT_IGNORED_ROOTS = [ "knowledge", @@ -59,12 +60,7 @@ function getParentDirectory(path: string): string | null { } function stableHash(input: string): string { - let hash = 0; - for (let index = 0; index < input.length; index++) { - hash = ((hash << 5) - hash) + input.charCodeAt(index); - hash |= 0; - } - return hash.toString(36); + return createHash("sha256").update(input, "utf8").digest("hex"); } function addNormalizedPath(target: Set, value: string | null | undefined): void { diff --git a/src/html/styles-builder/tailwind-compiler-cache.ts b/src/html/styles-builder/tailwind-compiler-cache.ts index 2ec4827787..b1c5509831 100644 --- a/src/html/styles-builder/tailwind-compiler-cache.ts +++ b/src/html/styles-builder/tailwind-compiler-cache.ts @@ -1,242 +1,249 @@ /** - * Tailwind compiler LRU cache management. + * Provider-neutral, exact-snapshot CSS compilation cache. * - * Manages a bounded cache of compiled Tailwind CSS compilers, keyed by - * stylesheet hash. Prevents race conditions when concurrent requests use - * different stylesheets. + * Core captures one explicitly registered CSSProcessor for a complete + * operation. Compilation and the first stateful build are single-flight and + * cached only under the exact provider, stylesheet, project, and candidate + * identities that produced the output. * - * The actual tailwindcss `compile()` call is routed through the - * `CSSProcessor` extension contract (default implementation: - * `@veryfront/ext-css-tailwind`). When no `CSSProcessor` is registered, the - * compile path returns a no-op compiler that emits empty CSS and logs an - * actionable install message. - * - * @module html/styles-builder/tailwind-compiler-cache + * @module html/styles-builder/css-compiler-cache */ +import { resolve } from "#veryfront/extensions/contracts.ts"; +import { + captureCSSProcessor, + type CSSProcessor, + CSSProcessorName, +} from "#veryfront/extensions/css/index.ts"; +import { assertCSSPipelineIdentity, serverLogger } from "#veryfront/utils"; +import { normalizeCSSCandidates } from "#veryfront/utils/css-candidate-admission.ts"; import { - register as registerContract, - tryResolve as tryResolveContract, -} from "#veryfront/extensions/contracts.ts"; -import { importFirstPartyExtensionModule } from "#veryfront/extensions/first-party-import.ts"; -import type { ExtensionFactory } from "veryfront/extensions"; -import type { CSSCompiler, CSSProcessor } from "#veryfront/extensions/css/index.ts"; -import { serverLogger } from "#veryfront/utils"; -import { DEPENDENCY_MISSING, INITIALIZATION_ERROR, NETWORK_ERROR } from "#veryfront/errors"; -import { getTailwindCSSUrl } from "#veryfront/utils/constants/cdn.ts"; + assertCSSFileContent, + assertCSSOutputContent, +} from "#veryfront/utils/css-content-admission.ts"; import { registerCache } from "#veryfront/utils/memory/index.ts"; -import { hashString } from "./candidate-extractor.ts"; -import { loadPlugin } from "./plugin-loader.ts"; - -const logger = serverLogger.component("tailwind"); - -type CssTailwindExtensionModule = { - default: ExtensionFactory; -}; - -/** - * LRU cache for Tailwind compilers, keyed by stylesheet hash. - * Each entry stores the compiler and its associated plugin state. - */ -interface CompilerCacheEntry { - compiler: CSSCompiler; - createdAt: number; - pluginCache: Map; - pluginErrors: Map; +import { + detachRetainedString, + estimateRetainedStringBytes, +} from "#veryfront/utils/retained-string.ts"; +import { hashCandidates, hashString } from "./css-identity.ts"; + +const logger = serverLogger.component("css-compiler"); +const freeze = Object.freeze; +const arrayJoin = Array.prototype.join; +const arraySort = Array.prototype.sort; +const apply = Reflect.apply; +const now = Date.now; +const weakSetAdd = WeakSet.prototype.add; +const weakSetHas = WeakSet.prototype.has; + +interface CompilationCacheEntry { + readonly css: string; + readonly retainedBytes: number; + readonly createdAt: number; } -const compilerCache = new Map(); -const MAX_CACHED_COMPILERS = 10; - -let tailwindBaseCSS: string | null = null; +/** One immutable processor snapshot acquired for a complete CSS operation. */ +export interface CSSCompilationSession { + readonly cacheIdentity: string; + readonly defaultStylesheet: string; + build( + stylesheet: string, + projectSlug: string | undefined, + candidates: string[] | Set, + ): Promise; +} -registerCache("tailwind-compiler-cache", () => ({ - name: "tailwind-compiler-cache", - entries: compilerCache.size, - maxEntries: MAX_CACHED_COMPILERS, +const compilationCache = new Map(); +const inFlightCompilations = new Map>(); +const inFlightCompilationOwners = new Map(); +const cssCompilationSessions = new WeakSet(); +const MAX_CACHED_COMPILATIONS = 10; +const MAX_CACHED_COMPILATION_BYTES = 64 * 1024 * 1024; +const CSS_COMPILATION_IDENTITY_SCHEMA = "veryfront.css-compilation.v3"; +let cachedCompilationBytes = 0; +let compilationCacheEpoch = 0; + +registerCache("css-compiler-cache", () => ({ + name: "css-compiler-cache", + entries: compilationCache.size, + maxEntries: MAX_CACHED_COMPILATIONS, + estimatedSizeBytes: cachedCompilationBytes, })); -async function getTailwindBaseCSS(): Promise { - if (tailwindBaseCSS) return tailwindBaseCSS; - - const url = getTailwindCSSUrl(); - logger.debug("Fetching base CSS", { url }); - - try { - const response = await fetch(url); - if (!response.ok) { - throw NETWORK_ERROR.create({ - detail: `Failed to fetch Tailwind CSS: ${response.status} ${response.statusText}`, - }); - } - tailwindBaseCSS = await response.text(); - } catch (error) { - logger.warn("Failed to fetch Tailwind base CSS, using empty fallback", { - error: error instanceof Error ? error.message : String(error), - }); - tailwindBaseCSS = ""; - } - - return tailwindBaseCSS; +function createCSSCompilationCacheIdentity(processor: CSSProcessor): string { + assertCSSFileContent( + processor.defaultStylesheet, + "CSSProcessor default stylesheet", + ); + return assertCSSPipelineIdentity( + `${CSS_COMPILATION_IDENTITY_SCHEMA}:${hashString(processor.cacheIdentity)}:${ + hashString(processor.defaultStylesheet) + }`, + "CSS compilation identity", + ); } -async function resolveCSSProcessor(): Promise { - const registeredProcessor = tryResolveContract("CSSProcessor"); - if (registeredProcessor) return registeredProcessor; - - try { - const { default: createTailwindExtension } = await importFirstPartyExtensionModule< - CssTailwindExtensionModule - >( - "ext-css-tailwind", - "@veryfront/ext-css-tailwind", - ); - const extension = createTailwindExtension(); - await extension.setup?.({ - config: {}, - logger, - provide: (name: string, impl: unknown) => registerContract(name, impl), - get: () => undefined, - require: (name: string): T => { - const contract = tryResolveContract(name); - if (contract === undefined) { - throw INITIALIZATION_ERROR.create({ - detail: `Missing required extension contract: ${name}`, - }); - } - return contract; - }, - }); - } catch (error) { - logger.warn("Failed to register built-in CSSProcessor extension", { - error: error instanceof Error ? error.message : String(error), - }); - } - - return tryResolveContract("CSSProcessor"); +function snapshotCandidates(value: string[] | Set): string[] { + const snapshot = normalizeCSSCandidates(value); + apply(arraySort, snapshot, []); + return snapshot; } -function evictOldestCompiler(): void { - if (compilerCache.size < MAX_CACHED_COMPILERS) return; +function removeCachedCompilation(key: string): void { + const entry = compilationCache.get(key); + if (!entry) return; + compilationCache.delete(key); + cachedCompilationBytes -= entry.retainedBytes; +} - let oldestKey: string | null = null; - let oldestTime = Infinity; +function touchCachedCompilation(key: string, entry: CompilationCacheEntry): void { + compilationCache.delete(key); + compilationCache.set(key, entry); +} - for (const [key, entry] of compilerCache) { - if (entry.createdAt < oldestTime) { - oldestTime = entry.createdAt; - oldestKey = key; - } +function storeCachedCompilation( + key: string, + css: string, +): void { + const retainedKey = detachRetainedString(key); + const retainedCSS = detachRetainedString(css); + const retainedBytes = estimateRetainedStringBytes(retainedKey) + + estimateRetainedStringBytes(retainedCSS) + 128; + removeCachedCompilation(key); + while ( + compilationCache.size >= MAX_CACHED_COMPILATIONS || + cachedCompilationBytes + retainedBytes > MAX_CACHED_COMPILATION_BYTES + ) { + const oldestKey = compilationCache.keys().next().value as string | undefined; + if (oldestKey === undefined) break; + removeCachedCompilation(oldestKey); } - - if (!oldestKey) return; - - compilerCache.delete(oldestKey); - logger.debug("Evicted oldest compiler from cache", { hash: oldestKey }); + if (retainedBytes > MAX_CACHED_COMPILATION_BYTES) return; + compilationCache.set(retainedKey, { + css: retainedCSS, + retainedBytes, + createdAt: apply(now, Date, []) as number, + }); + cachedCompilationBytes += retainedBytes; } -export async function getCompiler( +async function buildForProcessor( + processor: CSSProcessor, + compilationIdentity: string, stylesheet: string, - projectSlug?: string, -): Promise { - // Tailwind v4's compile().build() is stateful — it accumulates candidates - // across calls. Without per-project isolation, projects sharing the same - // stylesheet on the shared pool contaminate each other's CSS output. - const stylesheetHash = hashString(stylesheet); - const hash = projectSlug ? `${projectSlug}:${stylesheetHash}` : stylesheetHash; - - const cached = compilerCache.get(hash); + projectSlug: string | undefined, + rawCandidates: string[] | Set, +): Promise { + assertCSSFileContent(stylesheet, "CSS compilation stylesheet"); + const candidates = snapshotCandidates(rawCandidates); + const keyParts = [ + "css-compile-v2", + hashString(compilationIdentity), + projectSlug === undefined ? "shared" : hashString(projectSlug), + hashString(stylesheet), + hashCandidates(candidates), + ]; + const key = apply(arrayJoin, keyParts, [":"]) as string; + + const cached = compilationCache.get(key); if (cached) { - logger.debug("Compiler cache hit", { hash, projectSlug }); - return cached.compiler; + touchCachedCompilation(key, cached); + logger.debug("CSS compilation cache hit", { key, projectSlug }); + return cached.css; } - logger.debug("Creating new compiler", { hash, projectSlug }); - - const processor = await resolveCSSProcessor(); - if (!processor) { - logger.warn( - "No CSSProcessor extension registered — CSS output will be empty. Install it with: deno add @veryfront/ext-css-tailwind", - ); - const noopCompiler: CSSCompiler = { build: () => "" }; - compilerCache.set(hash, { - compiler: noopCompiler, - createdAt: Date.now(), - pluginCache: new Map(), - pluginErrors: new Map(), - }); - return noopCompiler; + const pending = inFlightCompilations.get(key); + if (pending) { + logger.debug("CSS compilation single-flight hit", { key, projectSlug }); + return await pending; } - const tailwindBase = await getTailwindBaseCSS(); - const pluginCache = new Map(); - const pluginErrors = new Map(); - - const newCompiler = await processor.compile(stylesheet, { - base: "/", - loadStylesheet: (id: string) => { - if (id === "tailwindcss") { - return Promise.resolve({ content: tailwindBase, base: "/", path: "/" }); - } - logger.debug("Unknown stylesheet import", { id }); - return Promise.resolve({ content: "", base: "/", path: "/" }); - }, - loadModule: async (id: string) => { - const loaded = await loadPlugin(id, pluginCache, pluginErrors); - if (!loaded) { - throw DEPENDENCY_MISSING.create({ - detail: `Failed to load plugin "${id}": plugin not installed`, - }); - } - return { module: loaded, base: "/", path: "/" }; - }, - }); + const compilation = (async () => { + const cacheEpoch = compilationCacheEpoch; + const compiler = await processor.compile(stylesheet); + const css = compiler.build(candidates); + assertCSSOutputContent(css, "CSS compiler output"); + if (cacheEpoch === compilationCacheEpoch) { + storeCachedCompilation(key, css); + } + return css; + })(); + const owner = {}; + inFlightCompilations.set(key, compilation); + inFlightCompilationOwners.set(key, owner); + try { + return await compilation; + } finally { + if (inFlightCompilationOwners.get(key) === owner) { + inFlightCompilations.delete(key); + inFlightCompilationOwners.delete(key); + } + } +} - evictOldestCompiler(); +/** Capture the currently registered provider before any cache lookup awaits. */ +export function acquireCSSCompilationSession(): CSSCompilationSession { + const processor = captureCSSProcessor(resolve(CSSProcessorName)); + const cacheIdentity = createCSSCompilationCacheIdentity(processor); + const session: CSSCompilationSession = { + cacheIdentity, + defaultStylesheet: processor.defaultStylesheet, + build(stylesheet, projectSlug, candidates) { + return buildForProcessor( + processor, + cacheIdentity, + stylesheet, + projectSlug, + candidates, + ); + }, + }; + apply(weakSetAdd, cssCompilationSessions, [session]); + return freeze(session); +} - compilerCache.set(hash, { - compiler: newCompiler, - createdAt: Date.now(), - pluginCache, - pluginErrors, - }); +/** Identity of all captured provider-owned inputs that can change emitted CSS. */ +export function getCSSCompilationCacheIdentity(): string { + return acquireCSSCompilationSession().cacheIdentity; +} - return newCompiler; +/** Compile with an authentic, already captured session. */ +export function buildCSSWithSession( + session: CSSCompilationSession, + stylesheet: string, + projectSlug: string | undefined, + candidates: string[] | Set, +): Promise { + if (!apply(weakSetHas, cssCompilationSessions, [session])) { + throw new TypeError("CSS compilation session was not acquired by core"); + } + return session.build(stylesheet, projectSlug, candidates); } export function invalidateCompiler(): void { - compilerCache.clear(); - logger.debug("All compilers invalidated"); + compilationCacheEpoch++; + compilationCache.clear(); + inFlightCompilations.clear(); + inFlightCompilationOwners.clear(); + cachedCompilationBytes = 0; + logger.debug("All CSS compilations invalidated"); } -/** - * Get compiler cache statistics for monitoring. - */ +/** Get bounded compilation-cache statistics for monitoring and tests. */ export function getCompilerCacheStats(): { size: number; maxSize: number; - entries: Array<{ hash: string; createdAt: number; pluginCount: number }>; + estimatedSizeBytes: number; + entries: Array<{ hash: string; createdAt: number }>; } { - const entries = Array.from(compilerCache.entries()).map(([hash, entry]) => ({ - hash, - createdAt: entry.createdAt, - pluginCount: entry.pluginCache.size, - })); - - return { size: compilerCache.size, maxSize: MAX_CACHED_COMPILERS, entries }; -} - -export function clearPluginCache(id?: string): void { - if (id) { - for (const entry of compilerCache.values()) { - entry.pluginCache.delete(id); - entry.pluginErrors.delete(id); - } - return; - } - - for (const entry of compilerCache.values()) { - entry.pluginCache.clear(); - entry.pluginErrors.clear(); - } + return { + size: compilationCache.size, + maxSize: MAX_CACHED_COMPILATIONS, + estimatedSizeBytes: cachedCompilationBytes, + entries: Array.from(compilationCache, ([hash, entry]) => ({ + hash, + createdAt: entry.createdAt, + })), + }; } diff --git a/src/html/styles-builder/tailwind-compiler-regression.test.ts b/src/html/styles-builder/tailwind-compiler-regression.test.ts index 67c6db0b13..02e2d08603 100644 --- a/src/html/styles-builder/tailwind-compiler-regression.test.ts +++ b/src/html/styles-builder/tailwind-compiler-regression.test.ts @@ -4,7 +4,6 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; import { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { cacheCSSAsync, - cacheCSSInputsAsync, clearCSSCache, generateTailwindCSS, getCompilerCacheStats, @@ -13,23 +12,10 @@ import { regenerateCSSByHash, } from "./tailwind-compiler.ts"; -const MOCK_TAILWIND_BASE_CSS = "@layer theme, base, components, utilities;"; - -function mockTailwindFetch(): () => void { +function forbidNetwork(): () => void { const originalFetch = globalThis.fetch; - globalThis.fetch = ((input: URL | Request | string) => { - const url = typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - - if (!url.includes("tailwindcss")) { - return Promise.reject(new Error(`Unexpected fetch URL during test: ${url}`)); - } - - return Promise.resolve(new Response(MOCK_TAILWIND_BASE_CSS, { status: 200 })); - }) as typeof fetch; + globalThis.fetch = + (() => Promise.reject(new Error("CSS compilation must not fetch"))) as typeof fetch; return () => { globalThis.fetch = originalFetch; @@ -49,17 +35,19 @@ describe("styles-builder/tailwind-compiler regressions", () => { describe("regenerateCSSByHash", () => { it("regenerates CSS when inputs exist in unified CSS cache entry", async () => { - const restoreFetch = mockTailwindFetch(); + const restoreFetch = forbidNetwork(); try { const stylesheet = '@import "tailwindcss";/*vf-unified-regression*/'; const candidates = ["text-red-500", "font-bold"]; const generated = await generateTailwindCSS(stylesheet, candidates, { minify: true }); - assertEquals(generated.error, undefined); - const hash = hashCSS(generated.css); - await cacheCSSAsync(generated.css, hash, { candidates, stylesheet }); + await cacheCSSAsync(generated.css, hash, { + candidates, + stylesheet, + pipelineIdentity: generated.cacheIdentity, + }); const regenerated = await regenerateCSSByHash(hash, "vf-unified-regression"); assertEquals(regenerated, generated.css); @@ -68,23 +56,20 @@ describe("styles-builder/tailwind-compiler regressions", () => { } }); - it("falls back to legacy inputs cache when unified entry is missing", async () => { - const restoreFetch = mockTailwindFetch(); + it("does not revive unsupported split legacy regeneration inputs", async () => { + const restoreFetch = forbidNetwork(); try { - const stylesheet = '@import "tailwindcss";/*vf-legacy-fallback-regression*/'; + const stylesheet = '@import "tailwindcss";/*vf-no-legacy-fallback*/'; const candidates = ["text-blue-500", "underline"]; const generated = await generateTailwindCSS(stylesheet, candidates, { minify: true }); - assertEquals(generated.error, undefined); - const hash = hashCSS(generated.css); - // Seed a unified cache entry without inputs to simulate legacy split-cache state. + // An output-only entry is servable by content hash but cannot be regenerated. await cacheCSSAsync(generated.css, hash); - await cacheCSSInputsAsync(hash, { candidates, stylesheet }); - const regenerated = await regenerateCSSByHash(hash, "vf-legacy-fallback-regression"); - assertEquals(regenerated, generated.css); + const regenerated = await regenerateCSSByHash(hash, "vf-no-legacy-fallback"); + assertEquals(regenerated, undefined); } finally { restoreFetch(); } @@ -96,7 +81,7 @@ describe("styles-builder/tailwind-compiler regressions", () => { }); it("isolates JIT regeneration by project to avoid cross-project compiler contamination", async () => { - const restoreFetch = mockTailwindFetch(); + const restoreFetch = forbidNetwork(); try { const stylesheet = '@import "tailwindcss";/*vf-project-isolation-regression*/'; @@ -109,19 +94,23 @@ describe("styles-builder/tailwind-compiler regressions", () => { minify: true, projectSlug: projectA, }); - assertEquals(generatedA.error, undefined); - const generatedB = await generateTailwindCSS(stylesheet, candidatesB, { minify: true, projectSlug: projectB, }); - assertEquals(generatedB.error, undefined); - const hashA = hashCSS(generatedA.css); const hashB = hashCSS(generatedB.css); - await cacheCSSAsync(generatedA.css, hashA, { candidates: candidatesA, stylesheet }); - await cacheCSSAsync(generatedB.css, hashB, { candidates: candidatesB, stylesheet }); + await cacheCSSAsync(generatedA.css, hashA, { + candidates: candidatesA, + stylesheet, + pipelineIdentity: generatedA.cacheIdentity, + }); + await cacheCSSAsync(generatedB.css, hashB, { + candidates: candidatesB, + stylesheet, + pipelineIdentity: generatedB.cacheIdentity, + }); const regeneratedA = await regenerateCSSByHash(hashA, projectA); const regeneratedB = await regenerateCSSByHash(hashB, projectB); @@ -136,12 +125,11 @@ describe("styles-builder/tailwind-compiler regressions", () => { describe("compiler cache capacity", () => { it("evicts the oldest compiler when cache exceeds max size", async () => { - const restoreFetch = mockTailwindFetch(); + const restoreFetch = forbidNetwork(); try { const firstStylesheet = '@import "tailwindcss";/*vf-compiler-cache-0*/'; - const firstResult = await generateTailwindCSS(firstStylesheet, [], { minify: false }); - assertEquals(firstResult.error, undefined); + await generateTailwindCSS(firstStylesheet, [], { minify: false }); const initialStats = getCompilerCacheStats(); assertEquals(initialStats.size, 1); @@ -150,8 +138,7 @@ describe("styles-builder/tailwind-compiler regressions", () => { for (let i = 1; i <= initialStats.maxSize; i++) { const stylesheet = `@import "tailwindcss";/*vf-compiler-cache-${i}*/`; - const result = await generateTailwindCSS(stylesheet, [], { minify: false }); - assertEquals(result.error, undefined); + await generateTailwindCSS(stylesheet, [], { minify: false }); } const stats = getCompilerCacheStats(); diff --git a/src/html/styles-builder/tailwind-compiler-utils.test.ts b/src/html/styles-builder/tailwind-compiler-utils.test.ts index 7b55003f38..885ae1f86c 100644 --- a/src/html/styles-builder/tailwind-compiler-utils.test.ts +++ b/src/html/styles-builder/tailwind-compiler-utils.test.ts @@ -2,86 +2,25 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { - buildCSSCacheEntry, evaluateProjectCSSLocalCacheState, formatCSSErrorMessage, - parseCSSCacheEntry, parseProjectCSSCacheEntry, - resolveStylesheet, } from "./tailwind-compiler-utils.ts"; +import { hashCSS, hashString } from "./css-identity.ts"; describe("styles-builder/tailwind-compiler-utils", () => { - describe("resolveStylesheet", () => { - it("uses fallback when stylesheet is undefined", () => { - assertEquals(resolveStylesheet(undefined, "default"), "default"); - }); - - it("keeps provided stylesheet when present", () => { - assertEquals(resolveStylesheet("custom", "default"), "custom"); - }); - }); - - describe("buildCSSCacheEntry", () => { - it("normalizes Set candidates to an array", () => { - const entry = buildCSSCacheEntry("body{}", { - candidates: new Set(["mt-4", "p-2"]), - stylesheet: "custom", - }, "default"); - - assertEquals(entry.css, "body{}"); - assertEquals(entry.candidates, ["mt-4", "p-2"]); - assertEquals(entry.stylesheet, "custom"); - }); - - it("uses defaults when inputs are missing", () => { - const entry = buildCSSCacheEntry("body{}", undefined, "default"); - assertEquals(entry.css, "body{}"); - assertEquals(entry.candidates, []); - assertEquals(entry.stylesheet, "default"); - }); - }); - - describe("parseCSSCacheEntry", () => { - it("parses structured JSON entries", () => { - const raw = JSON.stringify({ - css: ".foo{color:red}", - candidates: ["foo", "bar"], - stylesheet: "custom", - }); - const entry = parseCSSCacheEntry(raw, "default"); - assertEquals(entry.css, ".foo{color:red}"); - assertEquals(entry.candidates, ["foo", "bar"]); - assertEquals(entry.stylesheet, "custom"); - }); - - it("falls back to defaults when optional JSON fields are missing", () => { - const raw = JSON.stringify({ css: ".foo{color:red}" }); - const entry = parseCSSCacheEntry(raw, "default"); - assertEquals(entry.css, ".foo{color:red}"); - assertEquals(entry.candidates, []); - assertEquals(entry.stylesheet, "default"); - }); - - it("treats malformed JSON as legacy plain CSS", () => { - const raw = "{not valid json"; - const entry = parseCSSCacheEntry(raw, "default"); - assertEquals(entry.css, raw); - assertEquals(entry.candidates, []); - assertEquals(entry.stylesheet, "default"); - }); - }); - describe("parseProjectCSSCacheEntry", () => { it("returns parsed entry when JSON shape is valid", () => { + const css = ".foo{color:red}"; const raw = JSON.stringify({ - css: ".foo{color:red}", - hash: "abcd1234", - candidatesHash: "candidates123", + css, + hash: hashCSS(css), + candidatesHash: hashString("candidates"), }); assertEquals(parseProjectCSSCacheEntry(raw), { - css: ".foo{color:red}", - hash: "abcd1234", - candidatesHash: "candidates123", + css, + hash: hashCSS(css), + candidatesHash: hashString("candidates"), }); }); @@ -124,23 +63,23 @@ describe("styles-builder/tailwind-compiler-utils", () => { formatCSSErrorMessage('The plugin "@tailwindcss/forms" does not accept options'), { title: "Plugin Options Not Supported", - message: "@tailwindcss/forms does not accept options in Tailwind CSS v4", - suggestion: 'Remove the options block from @plugin. Use: @plugin "@tailwindcss/forms";', + message: "@tailwindcss/forms does not accept options", + suggestion: 'Remove the options block from @plugin "@tailwindcss/forms".', }, ); }); it("formats plugin load errors with single quotes", () => { const formatted = formatCSSErrorMessage("Failed to load plugin 'my-plugin'"); - assertEquals(formatted.title, "Plugin Not Found"); - assertEquals(formatted.message, "Could not load plugin: my-plugin"); + assertEquals(formatted.title, "Plugin Not Available"); + assertEquals(formatted.message, "The configured CSS processor could not load: my-plugin"); }); it("falls back to generic formatting", () => { assertEquals(formatCSSErrorMessage("Something else"), { - title: "Tailwind CSS Error", + title: "CSS Compilation Error", message: "Something else", - suggestion: "Check your stylesheet for errors", + suggestion: "Check the stylesheet and explicit CSS provider configuration.", }); }); }); diff --git a/src/html/styles-builder/tailwind-compiler-utils.ts b/src/html/styles-builder/tailwind-compiler-utils.ts index 42dc19627c..50344ddaec 100644 --- a/src/html/styles-builder/tailwind-compiler-utils.ts +++ b/src/html/styles-builder/tailwind-compiler-utils.ts @@ -1,12 +1,8 @@ -/** - * Pure helper utilities for Tailwind compiler cache parsing and error classification. - */ +/** Pure helpers for provider-neutral CSS cache parsing and diagnostics. */ -interface ParsedCSSCacheEntry { - css: string; - candidates: string[]; - stylesheet: string; -} +import { isCSSContentHash } from "./css-identity.ts"; +import { assertCSSOutputContent } from "#veryfront/utils/css-content-admission.ts"; +import { utf8ByteLength } from "#veryfront/utils/utf8-byte-length.ts"; interface ParsedProjectCSSCacheEntry { css: string; @@ -22,103 +18,41 @@ interface CSSErrorDescriptor { type ProjectCSSLocalCacheState = "miss" | "expired" | "mismatch" | "hit"; -interface RawCSSCacheEntry { - css?: unknown; - candidates?: unknown; - stylesheet?: unknown; -} - -interface RawProjectCSSCacheEntry { - css?: unknown; - hash?: unknown; - candidatesHash?: unknown; -} - -interface CSSErrorRule { - matches: (message: string) => boolean; - format: (message: string) => CSSErrorDescriptor; -} - -export function resolveStylesheet( - stylesheet: string | undefined, - defaultStylesheet: string, -): string { - return stylesheet ?? defaultStylesheet; -} - -export function buildCSSCacheEntry( - css: string, - inputs: { candidates: string[] | Set; stylesheet: string } | undefined, - defaultStylesheet: string, -): ParsedCSSCacheEntry { - return { - css, - candidates: inputs ? normalizeCandidates(inputs.candidates) : [], - stylesheet: resolveStylesheet(inputs?.stylesheet, defaultStylesheet), - }; -} +const MAX_PROJECT_CSS_CACHE_ENTRY_BYTES = 40 * 1024 * 1024; -function normalizeCandidates(candidates: string[] | Set): string[] { - return Array.isArray(candidates) ? candidates : [...candidates]; +function readOwnDataProperty(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; } -export function parseCSSCacheEntry(raw: string, defaultStylesheet: string): ParsedCSSCacheEntry { - const parsed = tryParseStructuredCSSCacheEntry(raw, defaultStylesheet); - if (parsed) return parsed; - - // Legacy format: plain CSS string (no inputs available) - return { - css: raw, - candidates: [], - stylesheet: defaultStylesheet, - }; -} - -function tryParseStructuredCSSCacheEntry( +export function parseProjectCSSCacheEntry( raw: string, - defaultStylesheet: string, -): ParsedCSSCacheEntry | undefined { - if (!raw.startsWith("{")) return undefined; - +): ParsedProjectCSSCacheEntry | undefined { + if ( + typeof raw !== "string" || + utf8ByteLength(raw, MAX_PROJECT_CSS_CACHE_ENTRY_BYTES) > MAX_PROJECT_CSS_CACHE_ENTRY_BYTES + ) return undefined; + let parsed: unknown; try { - const parsed = JSON.parse(raw) as RawCSSCacheEntry; - if (typeof parsed.css !== "string") return undefined; - - return { - css: parsed.css, - candidates: isStringArray(parsed.candidates) ? parsed.candidates : [], - stylesheet: typeof parsed.stylesheet === "string" ? parsed.stylesheet : defaultStylesheet, - }; - } catch (_) { - /* expected: malformed JSON in CSS cache entry */ + parsed = JSON.parse(raw); + } catch { return undefined; } -} - -export function parseProjectCSSCacheEntry(raw: string): ParsedProjectCSSCacheEntry | undefined { + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; + const css = readOwnDataProperty(parsed, "css"); + const hash = readOwnDataProperty(parsed, "hash"); + const candidatesHash = readOwnDataProperty(parsed, "candidatesHash"); + if ( + typeof css !== "string" || + !isCSSContentHash(hash) || + !isCSSContentHash(candidatesHash) + ) return undefined; try { - const parsed = JSON.parse(raw) as RawProjectCSSCacheEntry; - if ( - typeof parsed.css !== "string" || - typeof parsed.hash !== "string" || - typeof parsed.candidatesHash !== "string" - ) { - return undefined; - } - - return { - css: parsed.css, - hash: parsed.hash, - candidatesHash: parsed.candidatesHash, - }; - } catch (_) { - /* expected: malformed JSON in project CSS cache entry */ + assertCSSOutputContent(css, "Cached project CSS output"); + } catch { return undefined; } -} - -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item) => typeof item === "string"); + return { css, hash, candidatesHash }; } export function evaluateProjectCSSLocalCacheState( @@ -132,68 +66,49 @@ export function evaluateProjectCSSLocalCacheState( return "hit"; } -const CSS_ERROR_RULES: CSSErrorRule[] = [ - { - matches: (message) => message.includes("does not accept options"), - format: (message) => { - const pluginName = extractQuotedToken(message) ?? "unknown plugin"; - return { - title: "Plugin Options Not Supported", - message: `${pluginName} does not accept options in Tailwind CSS v4`, - suggestion: `Remove the options block from @plugin. Use: @plugin "${pluginName}";`, - }; - }, - }, - { - matches: (message) => - message.includes("Could not resolve") || message.includes("Failed to load plugin"), - format: (message) => { - const pluginName = extractPluginName(message) ?? "unknown"; - return { - title: "Plugin Not Found", - message: `Could not load plugin: ${pluginName}`, - suggestion: `Check the plugin name is correct. Try: https://esm.sh/${pluginName}`, - }; - }, - }, - { - matches: (message) => message.includes("@theme") || message.includes("Invalid theme"), - format: (message) => ({ - title: "Invalid @theme", - message, - suggestion: "Check @theme syntax: @theme { --color-name: value; }", - }), - }, - { - matches: (message) => message.includes("Unexpected") || message.includes("Expected"), - format: (message) => ({ - title: "CSS Syntax Error", - message, - suggestion: "Check for missing semicolons, brackets, or typos", - }), - }, -]; - function extractQuotedToken(message: string): string | undefined { - const match = message.match(/"([^"]+)"/); - return match?.[1]; -} - -function extractPluginName(message: string): string | undefined { - const pluginMatch = message.match(/plugin\s*["']([^"']+)["']/i) ?? message.match(/"([^"]+)"/); - return pluginMatch?.[1]; + return /["']([^"']+)["']/.exec(message)?.[1]; } export function formatCSSErrorMessage(message: string): CSSErrorDescriptor { - for (const rule of CSS_ERROR_RULES) { - if (rule.matches(message)) { - return rule.format(message); - } + if (message.includes("does not accept options")) { + const pluginName = extractQuotedToken(message) ?? "configured plugin"; + return { + title: "Plugin Options Not Supported", + message: `${pluginName} does not accept options`, + suggestion: `Remove the options block from @plugin "${pluginName}".`, + }; + } + if ( + message.includes("Could not resolve") || + message.includes("cannot resolve") || + message.includes("Failed to load plugin") || + message.includes("not an audited") + ) { + const pluginName = extractQuotedToken(message) ?? "unknown"; + return { + title: "Plugin Not Available", + message: `The configured CSS processor could not load: ${pluginName}`, + suggestion: "Use a plugin and exact version supported by the explicitly registered provider.", + }; + } + if (message.includes("@theme") || message.includes("Invalid theme")) { + return { + title: "Invalid CSS Theme", + message, + suggestion: "Check the configured processor's theme syntax.", + }; + } + if (message.includes("Unexpected") || message.includes("Expected")) { + return { + title: "CSS Syntax Error", + message, + suggestion: "Check the stylesheet syntax accepted by the configured CSS processor.", + }; } - return { - title: "Tailwind CSS Error", + title: "CSS Compilation Error", message, - suggestion: "Check your stylesheet for errors", + suggestion: "Check the stylesheet and explicit CSS provider configuration.", }; } diff --git a/src/html/styles-builder/tailwind-compiler.test.ts b/src/html/styles-builder/tailwind-compiler.test.ts index b8261b8acb..101009fa3b 100644 --- a/src/html/styles-builder/tailwind-compiler.test.ts +++ b/src/html/styles-builder/tailwind-compiler.test.ts @@ -1,7 +1,8 @@ import "#veryfront/schemas/_test-setup.ts"; import "./__tests__/css-processor-setup.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertRejects, assertThrows } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { MAX_CSS_SELECTOR_TOKEN_CHARACTERS } from "#veryfront/utils/constants/css.ts"; import { clearCSSCache, extractCandidates, @@ -11,7 +12,6 @@ import { getCSSByHash, getProjectCSS, hashCSS, - loadModuleFromEsmSh, } from "./tailwind-compiler.ts"; describe("styles-builder/tailwind-compiler", () => { @@ -97,6 +97,18 @@ describe("styles-builder/tailwind-compiler", () => { const candidates = extractCandidates('class="bg-[var(--color)]"'); assertEquals(candidates.includes("bg-[var(--color)]"), true); }); + + it("rejects an overlong candidate as one token instead of extracting fragments", () => { + const admitted = "a".repeat(MAX_CSS_SELECTOR_TOKEN_CHARACTERS); + const overlong = `${admitted}a`; + + assertEquals(extractCandidates(`class="${admitted}"`).includes(admitted), true); + assertThrows( + () => extractCandidates(`class="${overlong}"`), + TypeError, + `cannot exceed ${MAX_CSS_SELECTOR_TOKEN_CHARACTERS} characters`, + ); + }); }); describe("extractCandidatesFromFiles", () => { @@ -219,9 +231,10 @@ describe("styles-builder/tailwind-compiler", () => { assertEquals(hash1 !== hash2, true); }); - it("should return max 8 characters", () => { + it("returns a full lowercase SHA-256 content identity", () => { const hash = hashCSS("some long css content with many rules .a .b .c {}"); - assertEquals(hash.length <= 8, true); + assertEquals(hash.length, 64); + assertEquals(/^[a-f0-9]{64}$/.test(hash), true); }); it("should handle empty string", () => { @@ -244,32 +257,32 @@ describe("styles-builder/tailwind-compiler", () => { const result = formatCSSError( 'Could not resolve plugin "tailwindcss-animate"', ); - assertEquals(result.title, "Plugin Not Found"); + assertEquals(result.title, "Plugin Not Available"); assertEquals(result.message.includes("tailwindcss-animate"), true); - assertEquals(result.suggestion.includes("esm.sh"), true); + assertEquals(result.suggestion.includes("explicitly registered provider"), true); }); it("should format failed to load plugin error", () => { const result = formatCSSError('Failed to load plugin "my-plugin"'); - assertEquals(result.title, "Plugin Not Found"); + assertEquals(result.title, "Plugin Not Available"); assertEquals(result.message.includes("my-plugin"), true); }); it("should format invalid @theme error", () => { const result = formatCSSError("Invalid theme value for --color-primary"); - assertEquals(result.title, "Invalid @theme"); - assertEquals(result.suggestion.includes("@theme"), true); + assertEquals(result.title, "Invalid CSS Theme"); + assertEquals(result.suggestion.includes("processor"), true); }); it("should format @theme keyword error", () => { const result = formatCSSError("@theme block has syntax error"); - assertEquals(result.title, "Invalid @theme"); + assertEquals(result.title, "Invalid CSS Theme"); }); it("should format unexpected token error", () => { const result = formatCSSError("Unexpected token at line 5"); assertEquals(result.title, "CSS Syntax Error"); - assertEquals(result.suggestion.includes("semicolons"), true); + assertEquals(result.suggestion.includes("configured CSS processor"), true); }); it("should format expected token error", () => { @@ -279,7 +292,7 @@ describe("styles-builder/tailwind-compiler", () => { it("should format generic error", () => { const result = formatCSSError("Something went wrong"); - assertEquals(result.title, "Tailwind CSS Error"); + assertEquals(result.title, "CSS Compilation Error"); assertEquals(result.message, "Something went wrong"); assertEquals(result.suggestion.includes("stylesheet"), true); }); @@ -302,7 +315,6 @@ describe("styles-builder/tailwind-compiler", () => { await assertRejects( () => getProjectCSS("test-project", badCss, new Set(), { minify: false }), Error, - "Suggestion:", ); }); }); @@ -337,67 +349,4 @@ describe("styles-builder/tailwind-compiler", () => { assertEquals(stats.size >= 0, true); }); }); - - describe("loadModuleFromEsmSh", () => { - it("should dynamically load tailwindcss-animate plugin from esm.sh", async () => { - const originalFetch = globalThis.fetch; - let fetchCallCount = 0; - globalThis.fetch = ((input: URL | Request | string) => { - const url = typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - - fetchCallCount++; - if (fetchCallCount === 1) { - assertEquals(url.includes("tailwindcss-animate@1.0.7?bundle"), true); - return Promise.resolve( - new Response(`export * from "/v1/tailwindcss-animate.bundle.mjs";`, { status: 200 }), - ); - } - - assertEquals(url.includes("/v1/tailwindcss-animate.bundle.mjs"), true); - return Promise.resolve( - new Response(`export default { handler() {} };`, { status: 200 }), - ); - }) as typeof fetch; - - try { - const mod = await loadModuleFromEsmSh("tailwindcss-animate@1.0.7"); - - assertEquals(typeof mod, "object"); - assertEquals(mod !== null, true); - const modObj = mod as { default?: unknown }; - assertEquals("default" in modObj, true); - - const plugin = modObj.default; - assertEquals(typeof plugin, "object"); - assertEquals(plugin !== null, true); - assertEquals("handler" in (plugin as object), true); - assertEquals(fetchCallCount, 2); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it("should reject a non-allowlisted package before any network call (VULN-FS-1)", async () => { - const originalFetch = globalThis.fetch; - let fetchCallCount = 0; - globalThis.fetch = (() => { - fetchCallCount++; - return Promise.reject(new Error("fetch must not be called")); - }) as typeof fetch; - - try { - await assertRejects( - () => loadModuleFromEsmSh("is-odd@3.0.1"), - Error, - ); - assertEquals(fetchCallCount, 0); - } finally { - globalThis.fetch = originalFetch; - } - }); - }); }); diff --git a/src/html/styles-builder/tailwind-compiler.ts b/src/html/styles-builder/tailwind-compiler.ts index 050f820778..016abd1a89 100644 --- a/src/html/styles-builder/tailwind-compiler.ts +++ b/src/html/styles-builder/tailwind-compiler.ts @@ -1,17 +1,44 @@ -import { serverLogger } from "#veryfront/utils"; -import { COMPILATION_ERROR } from "#veryfront/errors"; +/** + * Provider-neutral CSS generation and cache orchestration. + * + * Tailwind-named exports remain as compatibility aliases, but core neither + * imports nor discovers Tailwind. An explicit CSSProcessor owns compilation; + * an explicit CSSOptimizationEngine owns minification. + */ + +import { resolve } from "#veryfront/extensions/contracts.ts"; +import { + captureCSSOptimizationEngine, + type CSSOptimizationEngine, + CSSOptimizationEngineName, +} from "#veryfront/extensions/css/index.ts"; +import { + freezeExtensionContract, + getExtensionOwnPropertyDescriptor, + isDataPropertyDescriptor, + isExtensionArray, +} from "#veryfront/extensions/property-inspection.ts"; import { SpanNames } from "#veryfront/observability"; import { withSpan } from "#veryfront/observability/tracing/otlp-setup.ts"; -import { minifyCSS } from "#veryfront/build/asset-pipeline/tailwind-processor/css-utils.ts"; -import { hashCSS } from "./candidate-extractor.ts"; +import { assertCSSPipelineIdentity, serverLogger } from "#veryfront/utils"; +import { normalizeCSSCandidates } from "#veryfront/utils/css-candidate-admission.ts"; +import { + assertCSSFileContent, + assertCSSOutputContent, +} from "#veryfront/utils/css-content-admission.ts"; +import { isProxy as isProxyWithoutHooks } from "node:util/types"; import { formatCSSErrorMessage } from "./tailwind-compiler-utils.ts"; -import { getCompiler } from "./tailwind-compiler-cache.ts"; +import { + acquireCSSCompilationSession, + buildCSSWithSession, + type CSSCompilationSession, +} from "./tailwind-compiler-cache.ts"; import { type CSSCacheEntry, - DEFAULT_STYLESHEET, persistRegeneratedCSSEntry, resolveRegenerationInputs, } from "./css-hash-cache.ts"; +import { hashCSS, hashString, isCSSContentHash } from "./css-identity.ts"; import { createProjectCSSRequestContext, initializeProjectCSSCache, @@ -21,21 +48,15 @@ import { tryGetProjectCSSFromLocalFallback, } from "./project-css-cache.ts"; -// Re-export extracted modules for backward compatibility -export { extractCandidates, extractCandidatesFromFiles, hashCSS } from "./candidate-extractor.ts"; -export { loadModuleFromEsmSh } from "./plugin-loader.ts"; +// Re-export provider-neutral helpers under their established public names. +export { extractCandidates, extractCandidatesFromFiles } from "./candidate-extractor.ts"; +export { hashCSS } from "./css-identity.ts"; export { - clearPluginCache, getCompilerCacheStats, + getCSSCompilationCacheIdentity, invalidateCompiler, } from "./tailwind-compiler-cache.ts"; -export { - cacheCSSAsync, - cacheCSSInputsAsync, - clearCSSCache, - getCSSByHash, - getCSSByHashAsync, -} from "./css-hash-cache.ts"; +export { cacheCSSAsync, clearCSSCache, getCSSByHash, getCSSByHashAsync } from "./css-hash-cache.ts"; export { initializeProjectCSSCache, invalidateProjectCSS, @@ -43,229 +64,319 @@ export { isProjectCSSCacheDistributed, } from "./project-css-cache.ts"; -const logger = serverLogger.component("tailwind"); +const logger = serverLogger.component("css-compiler"); +const apply = Reflect.apply; +const weakSetAdd = WeakSet.prototype.add; +const weakSetHas = WeakSet.prototype.has; +const CSS_PIPELINE_IDENTITY_SCHEMA = "veryfront.css-pipeline.v2"; +const cssGenerationSessions = new WeakSet(); const inFlightProjectCSS = new Map< string, Promise<{ css: string; hash: string; fromCache: boolean }> >(); +const inFlightProjectCSSOwners = new Map(); const inFlightRegeneration = new Map>(); +const inFlightRegenerationOwners = new Map(); -export interface TailwindResult { - css: string; - error?: string; +export interface CSSGenerationResult { + readonly css: string; + readonly cacheIdentity: string; } -export interface GenerateOptions { +/** Backward-compatible name for a provider-neutral CSS generation result. */ +export type TailwindResult = CSSGenerationResult; + +export interface CSSGenerationOptions { minify?: boolean; environment?: string; buildMode?: "development" | "production"; projectSlug?: string; } +/** Backward-compatible name for provider-neutral CSS generation options. */ +export type GenerateOptions = CSSGenerationOptions; + export interface CSSErrorInfo { title: string; message: string; suggestion: string; } -// ============================================================================ +export interface CSSGenerationSession { + readonly minify: boolean; + readonly cacheIdentity: string; + readonly compilationSession: CSSCompilationSession; + readonly optimizationEngine?: CSSOptimizationEngine; +} + +export interface CSSGenerationDependencies { + readonly generationSession?: CSSGenerationSession; +} + +function getCSSPipelineCacheIdentity( + compilationIdentity: string, + optimizationIdentity: string | undefined, +): string { + return assertCSSPipelineIdentity( + `${CSS_PIPELINE_IDENTITY_SCHEMA}:${hashString(compilationIdentity)}:${ + optimizationIdentity === undefined ? "unminified" : hashString(optimizationIdentity) + }`, + "CSS pipeline identity", + ); +} + +/** Capture all output-affecting providers before the operation performs an await. */ +export function acquireCSSGenerationSession(minify: boolean): CSSGenerationSession { + const compilationSession = acquireCSSCompilationSession(); + const optimizationEngine = minify + ? captureCSSOptimizationEngine(resolve(CSSOptimizationEngineName)) + : undefined; + const session: CSSGenerationSession = { + minify, + compilationSession, + optimizationEngine, + cacheIdentity: getCSSPipelineCacheIdentity( + compilationSession.cacheIdentity, + optimizationEngine?.cacheIdentity, + ), + }; + apply(weakSetAdd, cssGenerationSessions, [session]); + return freezeExtensionContract(session); +} + +function resolveGenerationSession( + minify: boolean, + session: CSSGenerationSession | undefined, +): CSSGenerationSession { + const resolved = session ?? acquireCSSGenerationSession(minify); + if (!apply(weakSetHas, cssGenerationSessions, [resolved])) { + throw new TypeError("CSS generation session was not acquired by core"); + } + if (resolved.minify !== minify) { + throw new TypeError("CSS generation session minification mode does not match the request"); + } + return resolved; +} + +function readOptimizedCSS(value: unknown): string { + if ( + typeof value !== "object" || + value === null || + isExtensionArray(value) || + isProxyWithoutHooks(value) + ) { + throw new TypeError("CSSOptimizationEngine result must be a non-Proxy object"); + } + let cssDescriptor: PropertyDescriptor | undefined; + let sourceMapDescriptor: PropertyDescriptor | undefined; + try { + cssDescriptor = getExtensionOwnPropertyDescriptor(value, "css"); + sourceMapDescriptor = getExtensionOwnPropertyDescriptor(value, "sourceMap"); + } catch (cause) { + throw new TypeError("CSSOptimizationEngine result could not be inspected", { cause }); + } + if (!isDataPropertyDescriptor(cssDescriptor) || typeof cssDescriptor.value !== "string") { + throw new TypeError("CSSOptimizationEngine result.css must be an own string data property"); + } + if ( + sourceMapDescriptor !== undefined && + (!isDataPropertyDescriptor(sourceMapDescriptor) || + (sourceMapDescriptor.value !== undefined && typeof sourceMapDescriptor.value !== "string")) + ) { + throw new TypeError( + "CSSOptimizationEngine result.sourceMap must be an own string data property when present", + ); + } + assertCSSOutputContent(cssDescriptor.value, "CSS optimizer output"); + return cssDescriptor.value; +} + +function optimizeCSS( + engine: CSSOptimizationEngine, + css: string, + projectSlug: string | undefined, +): string { + const request = freezeExtensionContract({ + css, + sourcePath: projectSlug === undefined + ? "veryfront://runtime/styles.css" + : `veryfront://project/${hashString(projectSlug)}/styles.css`, + minify: true, + sourceMap: false, + }); + return readOptimizedCSS(engine.optimize(request)); +} + +// --------------------------------------------------------------------------- // Project CSS orchestration -// ============================================================================ +// --------------------------------------------------------------------------- export async function getProjectCSS( projectSlug: string, stylesheet: string | undefined, - candidates: Set, - options?: GenerateOptions, + candidates: string[] | Set, + options?: CSSGenerationOptions, + dependencies: CSSGenerationDependencies = {}, ): Promise<{ css: string; hash: string; fromCache: boolean }> { - const context = createProjectCSSRequestContext(projectSlug, stylesheet, candidates, { - minify: options?.minify, - environment: options?.environment, - buildMode: options?.buildMode, - }); + const admittedCandidates = normalizeCSSCandidates(candidates); + const minify = options?.minify === true; + const generationSession = resolveGenerationSession( + minify, + dependencies.generationSession, + ); + const resolvedStylesheet = stylesheet ?? generationSession.compilationSession.defaultStylesheet; + assertCSSFileContent(resolvedStylesheet, "Project CSS stylesheet"); + const context = createProjectCSSRequestContext( + projectSlug, + resolvedStylesheet, + admittedCandidates, + { + cssPipelineIdentity: generationSession.cacheIdentity, + minify, + environment: options?.environment, + buildMode: options?.buildMode, + }, + ); - const localHit = await tryGetProjectCSSFromLocalFallback(context, candidates); + const localHit = await tryGetProjectCSSFromLocalFallback(context, admittedCandidates); if (localHit) return localHit; - if (!isProjectCSSInitialized()) { - await initializeProjectCSSCache(); - } - - const distributedHit = await tryGetProjectCSSFromDistributedCache(context, candidates); + if (!isProjectCSSInitialized()) await initializeProjectCSSCache(); + const distributedHit = await tryGetProjectCSSFromDistributedCache( + context, + admittedCandidates, + ); if (distributedHit) return distributedHit; - const inFlight = inFlightProjectCSS.get(context.cacheKey); - if (inFlight) { - logger.debug("Project CSS compile single-flight hit", { - projectSlug: context.projectSlug, - cacheKeySuffix: context.cacheKey.slice(-24), - }); - return inFlight; - } - - const generationPromise = (async () => { - // Generate fresh CSS - const result = await generateTailwindCSS(context.stylesheet, candidates, { - ...options, - projectSlug, - }); - - if (result.error) { - const formatted = formatCSSError(result.error); - logger.error("Project CSS generation failed", { - projectSlug: context.projectSlug, - error: formatted.message, - suggestion: formatted.suggestion, - }); - throw COMPILATION_ERROR.create({ - detail: - `[tailwind] ${formatted.title}: ${formatted.message} Suggestion: ${formatted.suggestion}`, - }); - } + const pending = inFlightProjectCSS.get(context.cacheKey); + if (pending) return await pending; + const generation = (async () => { + const result = await generateTailwindCSS( + context.stylesheet, + admittedCandidates, + { ...options, projectSlug }, + { generationSession }, + ); const hash = hashCSS(result.css); await storeProjectCSS( context, { css: result.css, hash, candidatesHash: context.candidatesHash }, - candidates, + admittedCandidates, ); - - logger.debug("Project CSS generated", { - projectSlug: context.projectSlug, - hash, - cssLength: result.css.length, - candidateCount: candidates.size, - }); - return { css: result.css, hash, fromCache: false }; })(); - - inFlightProjectCSS.set(context.cacheKey, generationPromise); - + const owner = {}; + inFlightProjectCSS.set(context.cacheKey, generation); + inFlightProjectCSSOwners.set(context.cacheKey, owner); try { - return await generationPromise; + return await generation; } finally { - inFlightProjectCSS.delete(context.cacheKey); + if (inFlightProjectCSSOwners.get(context.cacheKey) === owner) { + inFlightProjectCSS.delete(context.cacheKey); + inFlightProjectCSSOwners.delete(context.cacheKey); + } } } -// ============================================================================ +// --------------------------------------------------------------------------- // CSS JIT regeneration -// ============================================================================ +// --------------------------------------------------------------------------- -/** - * Regenerate CSS by hash using cached inputs. - * This is the JIT regeneration path - any pod can regenerate without fetching files. - * - * Tries unified cache (CSS + inputs together) first, then falls back to legacy - * separate inputs cache for backward compatibility with existing cached data. - * - * @param expectedHash - The CSS hash to regenerate - * @returns The regenerated CSS if inputs are cached and hash matches, undefined otherwise - */ export async function regenerateCSSByHash( expectedHash: string, projectSlug: string | undefined, ): Promise { - const inFlight = inFlightRegeneration.get(expectedHash); - if (inFlight) return await inFlight; + if (!isCSSContentHash(expectedHash)) return undefined; + const generationSession = acquireCSSGenerationSession(true); + const inFlightKey = `${hashString(generationSession.cacheIdentity)}:${expectedHash}`; + const pending = inFlightRegeneration.get(inFlightKey); + if (pending) return await pending; - const regenerationPromise = withSpan( + const regeneration = withSpan( SpanNames.HTML_REGENERATE_CSS_BY_HASH, async () => { - const inputs = await resolveRegenerationInputs(expectedHash); - if (!inputs || inputs.candidates.length === 0) { - logger.debug("Cannot regenerate CSS - no cached inputs", { hash: expectedHash }); - return undefined; - } - - const result = await generateTailwindCSS(inputs.stylesheet, inputs.candidates, { - minify: true, - projectSlug, - }); - - if (result.error) { - logger.warn("CSS regeneration failed", { - hash: expectedHash, - error: result.error, - }); - return undefined; - } - - const regeneratedHash = hashCSS(result.css); - if (regeneratedHash !== expectedHash) { - logger.debug("CSS regeneration hash mismatch", { - expected: expectedHash, - got: regeneratedHash, - }); - return undefined; - } - - const regeneratedEntry: CSSCacheEntry = { + const inputs = await resolveRegenerationInputs( + expectedHash, + generationSession.cacheIdentity, + ); + if (!inputs || inputs.candidates.length === 0) return undefined; + + const result = await generateTailwindCSS( + inputs.stylesheet, + inputs.candidates, + { minify: true, projectSlug }, + { generationSession }, + ); + if (hashCSS(result.css) !== expectedHash) return undefined; + + const entry: CSSCacheEntry = { css: result.css, candidates: inputs.candidates, stylesheet: inputs.stylesheet, + pipelineIdentity: generationSession.cacheIdentity, }; - await persistRegeneratedCSSEntry(regeneratedHash, regeneratedEntry); - - logger.info("CSS regenerated via JIT", { - hash: expectedHash, - cssLength: result.css.length, - candidateCount: inputs.candidates.length, - }); - + await persistRegeneratedCSSEntry(expectedHash, entry); return result.css; }, { "css.hash": expectedHash }, ); - - inFlightRegeneration.set(expectedHash, regenerationPromise); - + const owner = {}; + inFlightRegeneration.set(inFlightKey, regeneration); + inFlightRegenerationOwners.set(inFlightKey, owner); try { - return await regenerationPromise; + return await regeneration; } finally { - inFlightRegeneration.delete(expectedHash); + if (inFlightRegenerationOwners.get(inFlightKey) === owner) { + inFlightRegeneration.delete(inFlightKey); + inFlightRegenerationOwners.delete(inFlightKey); + } } } -// ============================================================================ -// Core Tailwind CSS generation -// ============================================================================ +// --------------------------------------------------------------------------- +// Provider-neutral generation (legacy name retained for API compatibility) +// --------------------------------------------------------------------------- export async function generateTailwindCSS( stylesheet: string | undefined, candidates: string[] | Set, - options?: GenerateOptions, -): Promise { - const candidateArray = Array.isArray(candidates) ? candidates : [...candidates]; + options?: CSSGenerationOptions, + dependencies: CSSGenerationDependencies = {}, +): Promise { + const admittedCandidates = normalizeCSSCandidates(candidates); + const minify = options?.minify === true; + const generationSession = resolveGenerationSession( + minify, + dependencies.generationSession, + ); + const resolvedStylesheet = stylesheet ?? generationSession.compilationSession.defaultStylesheet; + assertCSSFileContent(resolvedStylesheet, "CSS generation stylesheet"); return await withSpan( SpanNames.HTML_GENERATE_TAILWIND_CSS, async () => { - const css = stylesheet ?? DEFAULT_STYLESHEET; - - try { - const comp = await getCompiler(css, options?.projectSlug); - let output = comp.build(candidateArray); - - if (options?.minify) output = minifyCSS(output); - - logger.debug("Generated CSS", { - candidateCount: candidateArray.length, - outputLength: output.length, - }); - - return { css: output }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error("Compilation failed", { error: errorMessage }); - return { css: "", error: errorMessage }; - } + const compiled = await buildCSSWithSession( + generationSession.compilationSession, + resolvedStylesheet, + options?.projectSlug, + admittedCandidates, + ); + const css = generationSession.optimizationEngine + ? optimizeCSS(generationSession.optimizationEngine, compiled, options?.projectSlug) + : compiled; + assertCSSOutputContent(css, "Generated CSS output"); + logger.debug("Generated CSS", { + candidateCount: admittedCandidates.length, + outputLength: css.length, + minified: minify, + }); + return { css, cacheIdentity: generationSession.cacheIdentity }; }, { - "tailwind.candidate_count": candidateArray.length, - "tailwind.has_stylesheet": !!stylesheet, - "tailwind.minify": options?.minify ?? false, + "tailwind.candidate_count": admittedCandidates.length, + "tailwind.has_stylesheet": stylesheet !== undefined, + "tailwind.minify": minify, }, ); } diff --git a/src/html/styles-builder/tailwind-default-processor.test.ts b/src/html/styles-builder/tailwind-default-processor.test.ts index f5609a03fa..b6035b6a83 100644 --- a/src/html/styles-builder/tailwind-default-processor.test.ts +++ b/src/html/styles-builder/tailwind-default-processor.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 { afterEach, beforeEach, describe, it } from "#veryfront/testing/bdd.ts"; import { reset as resetContracts, @@ -8,53 +8,39 @@ import { import type { CSSProcessor } from "#veryfront/extensions/css/index.ts"; import { generateTailwindCSS, invalidateCompiler } from "./tailwind-compiler.ts"; -const MOCK_TAILWIND_BASE_CSS = "@layer theme, base, components, utilities;"; - -function mockTailwindFetch(): () => void { - const originalFetch = globalThis.fetch; - globalThis.fetch = ((input: URL | Request | string) => { - const url = typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - - if (!url.includes("tailwindcss")) { - return Promise.reject(new Error(`Unexpected fetch URL during test: ${url}`)); - } - - return Promise.resolve(new Response(MOCK_TAILWIND_BASE_CSS, { status: 200 })); - }) as typeof fetch; - - return () => { - globalThis.fetch = originalFetch; - }; -} - -describe("styles-builder/tailwind default CSSProcessor", () => { - let restoreFetch: (() => void) | undefined; +describe("styles-builder explicit CSSProcessor", () => { + let originalFetch: typeof fetch; + let fetchCalls = 0; beforeEach(() => { resetContracts(); invalidateCompiler(); - restoreFetch = mockTailwindFetch(); + originalFetch = globalThis.fetch; + fetchCalls = 0; + globalThis.fetch = (() => { + fetchCalls++; + return Promise.reject(new Error("missing-provider path must not fetch")); + }) as typeof fetch; }); afterEach(() => { - restoreFetch?.(); - restoreFetch = undefined; + globalThis.fetch = originalFetch; resetContracts(); invalidateCompiler(); }); - it("uses the built-in Tailwind processor when no project extension registered one", async () => { - const result = await generateTailwindCSS( - '@import "tailwindcss";/*vf-default-css-processor*/', - ["text-red-500"], - { minify: true, projectSlug: "vf-default-css-processor" }, + it("fails closed without discovering, registering, or fetching a provider", async () => { + await assertRejects( + () => + generateTailwindCSS( + '@import "tailwindcss";', + ["text-red-500"], + { minify: false, projectSlug: "vf-missing-css-processor" }, + ), + Error, + 'Missing extension for contract "CSSProcessor"', ); - - assertEquals(result.error, undefined); - assertEquals(tryResolveContract("CSSProcessor") !== undefined, true); + assertEquals(tryResolveContract("CSSProcessor"), undefined); + assertEquals(fetchCalls, 0); }); }); diff --git a/src/html/styles-builder/tailwind-plugin-allowlist.test.ts b/src/html/styles-builder/tailwind-plugin-allowlist.test.ts deleted file mode 100644 index f79f7918a3..0000000000 --- a/src/html/styles-builder/tailwind-plugin-allowlist.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; -import { assert } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import { BINARY_TAILWIND_PLUGIN_PACKAGES } from "#veryfront/build/binary-plugin-includes.ts"; -import { bareName, TAILWIND_PLUGIN_ALLOWLIST } from "./tailwind-plugin-allowlist.ts"; - -describe("styles-builder/tailwind-plugin-allowlist drift", () => { - it("every bundled binary plugin is on the allowlist", () => { - const missing: string[] = []; - for (const spec of BINARY_TAILWIND_PLUGIN_PACKAGES) { - const name = bareName(spec); - if (!TAILWIND_PLUGIN_ALLOWLIST.has(name)) missing.push(name); - } - assert( - missing.length === 0, - `BINARY_TAILWIND_PLUGIN_PACKAGES includes package(s) not on the ` + - `Tailwind plugin allowlist: ${missing.join(", ")}. Bundling a plugin ` + - `without allowlisting it makes it unloadable at runtime. Add the ` + - `missing name(s) to TAILWIND_PLUGIN_ALLOWLIST after review.`, - ); - }); -}); diff --git a/src/html/styles-builder/tailwind-plugin-allowlist.ts b/src/html/styles-builder/tailwind-plugin-allowlist.ts deleted file mode 100644 index 7d49da28a7..0000000000 --- a/src/html/styles-builder/tailwind-plugin-allowlist.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Allowlist of Tailwind CSS plugin package names that may be dynamically loaded - * via `loadPlugin` / `loadModuleFromEsmSh`. - * - * Tailwind v4 stylesheets can request plugins through the `@plugin "..."` - * directive. Without restriction, this turns arbitrary project CSS into a - * remote-code-execution vector because the loader fetches and imports code - * from https://esm.sh. Only the packages listed here may be loaded. - * - * @module html/styles-builder/tailwind-plugin-allowlist - */ - -export const TAILWIND_PLUGIN_ALLOWLIST: ReadonlySet = new Set([ - // Bundled into the compiled binary (see src/build/binary-plugin-includes.ts). - // Must stay in sync with BINARY_TAILWIND_PLUGIN_PACKAGES — an invariant - // enforced by tailwind-plugin-allowlist.test.ts. - "@tailwindcss/typography", - "@tailwindcss/forms", - "tailwindcss-animate", - "tailwind-scrollbar-hide", - "daisyui", - // Allowlisted but NOT bundled: loaded from esm.sh on first use. Adding an - // entry here is arbitrary-code-exec surface; review each one explicitly. - "@tailwindcss/aspect-ratio", - "@tailwindcss/container-queries", -]); - -/** - * Matches npm package specifiers, optionally scoped and optionally suffixed - * with an `@version` range. Deliberately restrictive: ASCII only, no path - * separators, no whitespace, no control characters. - */ -export const PACKAGE_SPEC_RE = /^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(?:@[\w.+-]+)?$/i; - -/** - * Return the bare package name (without any `@version` suffix) for a spec. - * - * Examples: - * bareName("pkg") -> "pkg" - * bareName("pkg@1.0.0") -> "pkg" - * bareName("@scope/pkg") -> "@scope/pkg" - * bareName("@scope/pkg@1.0") -> "@scope/pkg" - */ -export function bareName(spec: string): string { - if (spec.startsWith("@")) { - const idx = spec.indexOf("@", 1); - return idx === -1 ? spec : spec.slice(0, idx); - } - const idx = spec.indexOf("@"); - return idx === -1 ? spec : spec.slice(0, idx); -} diff --git a/src/react/README.md b/src/react/README.md index a2615d4c0a..270ae6972b 100644 --- a/src/react/README.md +++ b/src/react/README.md @@ -387,19 +387,24 @@ const serverHTML = renderToString(); ### Image Optimization Not Working ```typescript -// Check if Sharp is installed for image optimization -import { OptimizedImage } from "veryfront"; - -; +// veryfront.config.ts +import extImageSharp from "@veryfront/ext-image-sharp"; +import { defineConfig } from "veryfront"; + +export default defineConfig({ + extensions: [extImageSharp()], + assetPipeline: { + images: { enabled: true }, + }, +}); ``` +`OptimizedImage` consumes the manifest produced by the build pipeline. Core +does not probe for Sharp or infer an environment-variable fallback. If image +optimization is enabled, explicitly compose an `ImageOptimizationEngine` such +as `@veryfront/ext-image-sharp`; a missing or invalid provider fails the build +before replacing the last known-good image output. + ### Link Prefetch Not Working ```typescript diff --git a/src/release-assets/build-executor.ts b/src/release-assets/build-executor.ts index c89a6d04f5..675c66d18e 100644 --- a/src/release-assets/build-executor.ts +++ b/src/release-assets/build-executor.ts @@ -207,8 +207,8 @@ export interface ReleaseAssetBuildClient { * * Receives the Tailwind class candidates extracted from the release source * plus the resolved project stylesheet (so the implementation can compile - * without re-fetching the file set). Returns `null` on any failure so the - * executor keeps a CSS gap and proceeds. + * without re-fetching the file set). Returns `null` only when no CSS input + * exists; failures reject and the executor records an explicit CSS gap. */ compileProjectCss?( candidates: Set, diff --git a/src/release-assets/css-compile.test.ts b/src/release-assets/css-compile.test.ts index 4dedfc882e..63575b5891 100644 --- a/src/release-assets/css-compile.test.ts +++ b/src/release-assets/css-compile.test.ts @@ -3,8 +3,10 @@ import "#veryfront/schemas/_test-setup.ts"; // `generateTailwindCSS` compile path resolves a real compiler. import "#veryfront/html/styles-builder/__tests__/css-processor-setup.ts"; -import { assert, assertEquals, assertExists } from "#veryfront/testing/assert.ts"; +import { assert, assertEquals, assertExists, assertRejects } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; +import { type CSSProcessor, CSSProcessorName } from "#veryfront/extensions/css/index.ts"; import { createCompileProjectCss } from "./css-compile.ts"; describe("release-assets/css-compile", () => { @@ -42,25 +44,19 @@ describe("release-assets/css-compile", () => { assert(result.css.length > 0, "stylesheet-only compile produced CSS"); }); - it("returns null (keeps the CSS gap) when the compiler throws — never propagates", async () => { - // A candidate set that is non-empty but a stylesheet that triggers a - // compile error still resolves to null rather than throwing. We simulate a - // hostile stylesheet; whatever the compiler does, the contract is: no throw. + it("propagates missing-provider failures so the executor records an explicit gap", async () => { const compile = createCompileProjectCss({ projectScope: "css-compile-fail" }); const candidates = new Set(["p-4"]); - - let threw = false; - let result: Awaited> = null; + const previous = tryResolve(CSSProcessorName); + unregister(CSSProcessorName); try { - // An unterminated at-rule / malformed stylesheet. The compiler reports an - // error (result.error) → createCompileProjectCss maps it to null. - result = await compile(candidates, "@import "); - } catch { - threw = true; + await assertRejects( + () => compile(candidates, '@import "tailwindcss";'), + Error, + 'Missing extension for contract "CSSProcessor"', + ); + } finally { + if (previous !== undefined) register(CSSProcessorName, previous); } - assertEquals(threw, false, "compileProjectCss must never throw"); - // Either an empty/error compile (null) or a degraded-but-valid compile is - // acceptable; the load-bearing guarantee is that it did not throw. - assert(result === null || typeof result.css === "string"); }); }); diff --git a/src/release-assets/css-compile.ts b/src/release-assets/css-compile.ts index fd47bbaac1..e06fdaa2e7 100644 --- a/src/release-assets/css-compile.ts +++ b/src/release-assets/css-compile.ts @@ -2,8 +2,8 @@ * Release Asset Manifest — production CSS compiler. * * Provides the `compileProjectCss` implementation injected into the build - * executor's client. It compiles a project's Tailwind CSS directly through the - * core compiler (`generateTailwindCSS`) against the candidates the executor + * executor's client. It compiles project CSS through an explicitly registered + * processor (`generateTailwindCSS` is retained as a compatibility name) against the candidates * extracted from the materialized release file set, using the project * stylesheet the executor resolved from that same file set. * @@ -12,21 +12,26 @@ * the distributed/project-CSS cache (`initializeProjectCSSCache`, * prepared-project-css, style-artifact resolution) — the very machinery whose * per-route candidate contract and distributed-cache init motivated deferring - * CSS from the builder. `generateTailwindCSS` is the pure compile primitive: - * it resolves the `CSSProcessor` extension (auto-registering the built-in - * `@veryfront/ext-css-tailwind` on first use) and calls - * `compiler.build(candidates)` with no cross-request/distributed state. + * CSS from the builder. The compile primitive captures explicit compiler and + * optimizer providers once and performs no discovery, network loading, or + * empty-output fallback. * - Work is bounded: one compile over the candidate set the executor already * gathered, output minified, no background tasks. - * - It is defensive by construction: every failure path returns `null` so the - * executor keeps its `css:no-pipeline` / `css:compile-failed` gap and proceeds. + * - `null` means only that there is no stylesheet and no candidate to compile. + * Provider/compiler failures propagate to the executor, which records its + * explicit `css:compile-failed` gap. * * @module release-assets/css-compile */ import { serverLogger } from "#veryfront/utils"; import type { VeryfrontConfig } from "#veryfront/config"; -import { generateTailwindCSS, hashCSS } from "#veryfront/html/styles-builder/tailwind-compiler.ts"; +import { + acquireCSSGenerationSession, + generateTailwindCSS, + hashCSS, +} from "#veryfront/html/styles-builder/tailwind-compiler.ts"; +import { composeCSSStyleProfileHash } from "#veryfront/html/styles-builder/css-identity.ts"; import { createStyleScopeProfile } from "#veryfront/html/styles-builder/style-scope-profile.ts"; const logger = serverLogger.component("release-asset-css-compile"); @@ -52,9 +57,9 @@ export interface CompileProjectCssRuntimeOptions { * Build a `compileProjectCss` function bound to a specific release build. * * The returned function matches the build executor's injected client signature: - * `(candidates, stylesheet, options) => Promise<{ css, styleProfileHash } | null>`. It - * NEVER throws — any failure resolves to `null` so the executor records a CSS - * gap and proceeds. + * `(candidates, stylesheet, options) => Promise<{ css, styleProfileHash } | null>`. + * Missing providers and compilation failures reject; only a genuinely empty + * CSS input resolves to `null`. */ export function createCompileProjectCss( options: CompileProjectCssOptions, @@ -68,50 +73,45 @@ export function createCompileProjectCss( stylesheet: string | undefined, runtimeOptions?: CompileProjectCssRuntimeOptions, ): Promise => { - try { - // A stylesheet can emit base/custom CSS without any utility candidates - // (CSS variables, global rules), so only skip when there is neither a - // stylesheet nor any candidates to compile. - if (candidates.size === 0 && !stylesheet) { - logger.debug("No CSS candidates or stylesheet for release; skipping compile", { - projectScope: options.projectScope, - }); - return null; - } - - const styleProfile = createStyleScopeProfile(runtimeOptions?.config ?? options.config); - - const result = await generateTailwindCSS(stylesheet, candidates, { - minify: true, - environment: "production", - buildMode: "production", - projectSlug: options.projectScope, + // A stylesheet can emit base/custom CSS without any utility candidates + // (CSS variables, global rules), so only skip when there is neither a + // stylesheet nor any candidates to compile. + if (candidates.size === 0 && !stylesheet) { + logger.debug("No CSS candidates or stylesheet for release; skipping compile", { + projectScope: options.projectScope, }); + return null; + } - if (result.error || !result.css) { - logger.warn("Release asset CSS compile produced no output", { - projectScope: options.projectScope, - error: result.error, - }); - return null; - } + const styleProfile = createStyleScopeProfile(runtimeOptions?.config ?? options.config); + const generationSession = acquireCSSGenerationSession(true); + const resolvedStylesheet = stylesheet ?? + generationSession.compilationSession.defaultStylesheet; - logger.debug("Release asset CSS compiled", { - projectScope: options.projectScope, - candidateCount: candidates.size, - cssLength: result.css.length, - cssHash: hashCSS(result.css), - styleProfileHash: styleProfile.hash, - }); + const result = await generateTailwindCSS(resolvedStylesheet, candidates, { + minify: true, + environment: "production", + buildMode: "production", + projectSlug: options.projectScope, + }, { generationSession }); - return { css: result.css, styleProfileHash: styleProfile.hash }; - } catch (error) { - // Defensive: any failure → null so the executor keeps the CSS gap. - logger.warn("Release asset CSS compile failed (returning null)", { - projectScope: options.projectScope, - error: error instanceof Error ? error.message : String(error), - }); - return null; + if (result.css.length === 0) { + throw new TypeError("Release asset CSS compiler produced an empty output"); } + + const styleArtifactProfileHash = composeCSSStyleProfileHash( + styleProfile.hash, + result.cacheIdentity, + ); + + logger.debug("Release asset CSS compiled", { + projectScope: options.projectScope, + candidateCount: candidates.size, + cssLength: result.css.length, + cssHash: hashCSS(result.css), + styleProfileHash: styleArtifactProfileHash, + }); + + return { css: result.css, styleProfileHash: styleArtifactProfileHash }; }; } diff --git a/src/rendering/orchestrator/pipeline-helpers.ts b/src/rendering/orchestrator/pipeline-helpers.ts index 451807b2a5..f0b6444d1b 100644 --- a/src/rendering/orchestrator/pipeline-helpers.ts +++ b/src/rendering/orchestrator/pipeline-helpers.ts @@ -1,11 +1,13 @@ import type { LayoutItem } from "#veryfront/types"; import { extractRelativePath as extractRelativePathShared } from "#veryfront/utils/route-path-utils.ts"; +import { isCSSContentHash } from "#veryfront/html/styles-builder/css-identity.ts"; -const RENDERED_CSS_HASH_RE = /href="\/_vf\/css\/([a-z0-9-]{1,16})\.css"/i; +const RENDERED_CSS_HASH_RE = /href="\/_vf\/css\/([a-f0-9]{64})\.css"/; const RENDERED_RELEASE_ASSET_CSS_RE = /href="\/_vf\/assets\/([a-f0-9]{64})\.css"/i; export function extractRenderedCssHash(html: string): string | undefined { - return html.match(RENDERED_CSS_HASH_RE)?.[1]; + const value = RENDERED_CSS_HASH_RE.exec(html)?.[1]; + return isCSSContentHash(value) ? value : undefined; } export function hasRenderedReleaseAssetCss(html: string): boolean { diff --git a/src/rendering/orchestrator/pipeline.behavior.test.ts b/src/rendering/orchestrator/pipeline.behavior.test.ts index 1c210bd0fb..600b1e5c84 100644 --- a/src/rendering/orchestrator/pipeline.behavior.test.ts +++ b/src/rendering/orchestrator/pipeline.behavior.test.ts @@ -6,7 +6,7 @@ import { RenderPipeline, type RenderPipelineConfig } from "./pipeline.ts"; import type { RenderOptions } from "./types.ts"; import { markBuildFailure } from "./module-loader/build-failure.ts"; import { cachePageCss, getPageCssCacheKey } from "./css-cache.ts"; -import { cacheCSSAsync } from "#veryfront/html/styles-builder/index.ts"; +import { cacheCSSAsync, hashCSS } from "#veryfront/html/styles-builder/index.ts"; import { RELEASE_ASSET_MANIFEST_ENV_FLAG } from "#veryfront/release-assets/constants.ts"; import { clearReleaseAssetManifestCache, @@ -1115,8 +1115,8 @@ describe("RenderPipeline behavior", () => { const projectId = "proj-css-data-reuse"; const pagePath = "/project/pages/behavior-css-data-reuse.tsx"; const layoutPath = "/project/layouts/root.tsx"; - const cssHash = "cssdata1"; const expectedCss = ".from-data{color:blue}"; + const cssHash = hashCSS(expectedCss); let pageDataCalls = 0; let layoutDataCalls = 0; let ssrOptions: Record | undefined; @@ -1173,10 +1173,7 @@ describe("RenderPipeline behavior", () => { } as any, }); - await cacheCSSAsync(expectedCss, cssHash, { - candidates: ["from-data"], - stylesheet: '@import "tailwindcss";', - }); + await cacheCSSAsync(expectedCss, cssHash); (pipeline as any).loadModule = async (path: string) => { if (path === pagePath) { @@ -1224,13 +1221,10 @@ describe("RenderPipeline behavior", () => { const slug = "/behavior-ssr-css"; const projectId = "proj-ssr-css"; const pipeline = createPipeline("/project/pages/behavior-ssr-css.tsx"); - const cssHash = "abc12345"; const expectedCss = ".from-ssr{color:red}"; + const cssHash = hashCSS(expectedCss); - await cacheCSSAsync(expectedCss, cssHash, { - candidates: ["from-ssr"], - stylesheet: '@import "tailwindcss";', - }); + await cacheCSSAsync(expectedCss, cssHash); (pipeline as any).loadModule = async () => ({}); (pipeline as any).renderPage = async () => ({ diff --git a/src/rendering/orchestrator/pipeline.test.ts b/src/rendering/orchestrator/pipeline.test.ts index db7b1d9f89..e74a4b13dc 100644 --- a/src/rendering/orchestrator/pipeline.test.ts +++ b/src/rendering/orchestrator/pipeline.test.ts @@ -30,9 +30,10 @@ function cachePageCss(cacheKey: string, css: string): void { describe("RenderPipeline helpers", () => { describe("pipeline-helpers", () => { it("extractRenderedCssHash returns the page css hash when present", () => { + const hash = "a".repeat(64); assertEquals( - extractRenderedCssHash(''), - "abc123", + extractRenderedCssHash(``), + hash, ); }); diff --git a/src/server/handlers/dev/styles-css.handler.test.ts b/src/server/handlers/dev/styles-css.handler.test.ts index f5d347b9dc..cd87e0aa0e 100644 --- a/src/server/handlers/dev/styles-css.handler.test.ts +++ b/src/server/handlers/dev/styles-css.handler.test.ts @@ -2,6 +2,11 @@ import "#veryfront/schemas/_test-setup.ts"; import "../../../html/styles-builder/__tests__/css-processor-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; +import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; +import { + type CSSOptimizationEngine, + CSSOptimizationEngineName, +} from "#veryfront/extensions/css/index.ts"; import { createMockAdapter, type MockRuntimeAdapter } from "#veryfront/platform/adapters/mock.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import type { VeryfrontApiClient } from "#veryfront/platform/adapters/veryfront-api-client/index.ts"; @@ -103,6 +108,46 @@ function makeCtx(adapter: RuntimeAdapter, overrides: Partial = { } describe("server/handlers/dev/styles-css.handler", () => { + it("serves Tailwind-only development CSS without an optimization provider", async () => { + const previousEngine = tryResolve(CSSOptimizationEngineName); + unregister(CSSOptimizationEngineName); + const handler = new StylesCSSHandler(); + const adapter = createHandlerAdapter( + [{ + path: "/project/pages/index.tsx", + content: '
Hello
', + }], + null, + ); + const ctx = makeCtx(adapter); + const req = new Request("http://localhost/_vf_styles/styles.css"); + + try { + clearCSSCache(); + invalidateCompiler(); + invalidateProjectCSS(PROJECT_SLUG); + invalidatePreparedProjectCSS(PROJECT_SLUG); + invalidateProjectCandidateManifests(PROJECT_SLUG); + + const result = await handler.handle(req, ctx); + const body = await result.response!.text(); + + assertEquals(result.response!.status, 200); + assertEquals(body.includes("StylesCSSHandler error"), false); + assertEquals(body.includes("text-cyan-500"), true); + } finally { + clearCSSCache(); + invalidateCompiler(); + invalidateProjectCSS(PROJECT_SLUG); + invalidatePreparedProjectCSS(PROJECT_SLUG); + invalidateProjectCandidateManifests(PROJECT_SLUG); + unregister(CSSOptimizationEngineName); + if (previousEngine !== undefined) { + register(CSSOptimizationEngineName, previousEngine); + } + } + }); + it("serves project CSS from the project cache after the first request", async () => { const fetchMock = mockTailwindFetch(); const handler = new StylesCSSHandler(); @@ -127,7 +172,7 @@ describe("server/handlers/dev/styles-css.handler", () => { assertEquals(first.continue, false); assertEquals(first.response!.status, 200); assertEquals(firstBody.length > 0, true); - assertEquals(initialFetchCount > 0, true); + assertEquals(initialFetchCount, 0); invalidateCompiler(); @@ -147,7 +192,7 @@ describe("server/handlers/dev/styles-css.handler", () => { } }); - it("serves prepared CSS without rescanning files after the first request", async () => { + it("does not reuse prepared CSS after the candidate snapshot changes", async () => { const fetchMock = mockTailwindFetch(); const handler = new StylesCSSHandler(); const adapter = createHandlerAdapter( @@ -183,7 +228,8 @@ describe("server/handlers/dev/styles-css.handler", () => { const secondBody = await second.response!.text(); assertEquals(second.response!.status, 200); - assertEquals(secondBody, firstBody); + assertEquals(secondBody === firstBody, false); + assertEquals(secondBody.includes(".text-fuchsia-500"), false); assertEquals(fetchMock.getCallCount(), initialFetchCount); } finally { fetchMock.restore(); diff --git a/src/server/handlers/dev/styles-css.handler.ts b/src/server/handlers/dev/styles-css.handler.ts index b5ccadf783..8e209a9721 100644 --- a/src/server/handlers/dev/styles-css.handler.ts +++ b/src/server/handlers/dev/styles-css.handler.ts @@ -10,12 +10,17 @@ import type { HandlerContext, HandlerMetadata, HandlerPriority, HandlerResult } import { HTTP_OK, PRIORITY_HIGH_DEV } from "#veryfront/utils/constants/index.ts"; import { joinPath } from "#veryfront/utils/path-utils.ts"; import { + acquireCSSGenerationSession, + type CSSGenerationSession, formatCSSError, getCSSByHashAsync, getProjectCSS, regenerateCSSByHash, } from "#veryfront/html/styles-builder/tailwind-compiler.ts"; -import { DEFAULT_STYLESHEET } from "#veryfront/html/styles-builder/css-hash-cache.ts"; +import { + composeCSSStyleProfileHash, + hashCandidates, +} from "#veryfront/html/styles-builder/css-identity.ts"; import { resolveStyleContentVersion } from "#veryfront/html/styles-builder/content-version.ts"; import { createPreparedProjectCSSContext, @@ -58,15 +63,7 @@ export class StylesCSSHandler extends BaseHandler { const projectScope = ctx.projectSlug ?? ctx.projectDir; const styleProfile = createStyleScopeProfile(ctx.config); const contentContext = this.getContentContext(ctx); - let rawCss: string; - try { - rawCss = await profilePhase("css.load_stylesheet", () => this.loadStylesheet(ctx)); - } catch (error) { - logger.error("Failed to load stylesheet", { - error: error instanceof Error ? error.message : String(error), - }); - rawCss = DEFAULT_STYLESHEET; - } + let rawCss = await profilePhase("css.load_stylesheet", () => this.loadStylesheet(ctx)); // Production SSR merges CSS imported by modules (`import "./styles.css"` // in a layout) into the page stylesheet during module loading. This // route has no module-loading pass, so discover those imports from the @@ -85,7 +82,7 @@ export class StylesCSSHandler extends BaseHandler { fs: ctx.adapter.fs, logger, projectDir: ctx.projectDir, - globalCSS: rawCss, + globalCSS: rawCss ?? "", cssImports, stylesheetPath: ctx.config?.tailwind?.stylesheet ?? "globals.css", }), @@ -96,11 +93,33 @@ export class StylesCSSHandler extends BaseHandler { logger.error("Failed to merge module CSS imports", { error: error instanceof Error ? error.message : String(error), }); + throw error; + } + + let candidates: Set; + try { + candidates = await profilePhase( + "css.extract_candidates", + () => extractProjectCandidates(ctx), + ); + } catch (error) { + logger.error("Failed to extract candidates", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; } + const generationSession = acquireCSSGenerationSession(false); + const resolvedCss = rawCss ?? generationSession.compilationSession.defaultStylesheet; + const artifactStyleProfileHash = composeCSSStyleProfileHash( + styleProfile.hash, + generationSession.cacheIdentity, + ); const preparedContext = this.createPreparedCSSContext( projectScope, - rawCss, - styleProfile.hash, + resolvedCss, + candidates, + generationSession, + artifactStyleProfileHash, contentContext, ctx, ); @@ -114,7 +133,7 @@ export class StylesCSSHandler extends BaseHandler { logger.debug("Prepared CSS cache hit", { projectScope, projectVersion: preparedContext.projectVersion, - styleProfileHash: styleProfile.hash, + styleProfileHash: artifactStyleProfileHash, cssHash: prepared.hash, }); @@ -130,7 +149,7 @@ export class StylesCSSHandler extends BaseHandler { this.tryResolveRemotePreparedCSS( ctx, projectScope, - styleProfile.hash, + artifactStyleProfileHash, contentContext, preparedContext, ), @@ -138,7 +157,7 @@ export class StylesCSSHandler extends BaseHandler { if (remotePrepared) { logger.debug("Prepared CSS resolved via style artifact metadata", { projectScope, - styleProfileHash: styleProfile.hash, + styleProfileHash: artifactStyleProfileHash, cssHash: remotePrepared.hash, }); @@ -147,23 +166,11 @@ export class StylesCSSHandler extends BaseHandler { ); } - let candidates: Set; - try { - candidates = await profilePhase( - "css.extract_candidates", - () => extractProjectCandidates(ctx), - ); - } catch (error) { - logger.error("Failed to extract candidates", { - error: error instanceof Error ? error.message : String(error), - }); - candidates = new Set(); - } let result: GeneratedStylesResult; try { result = await profilePhase( "css.generate_stylesheet", - () => this.generateStylesheet(ctx, rawCss, candidates), + () => this.generateStylesheet(ctx, resolvedCss, candidates, generationSession), ); } catch (error) { const formatted = formatCSSError(error instanceof Error ? error : String(error)); @@ -230,7 +237,7 @@ body::before { if ("hash" in result) { await this.registerPreparedCSSArtifact( ctx, - styleProfile.hash, + artifactStyleProfileHash, contentContext, result.hash, ); @@ -257,7 +264,7 @@ body::before { } } - private async loadStylesheet(ctx: HandlerContext): Promise { + private async loadStylesheet(ctx: HandlerContext): Promise { const configuredPath = ctx.config?.tailwind?.stylesheet; if (configuredPath) { @@ -270,8 +277,8 @@ body::before { return await ctx.adapter.fs.readFile(globalsPath); } catch (_) { /* expected: globals.css may not exist */ - logger.debug("No stylesheet found, using default"); - return DEFAULT_STYLESHEET; + logger.debug("No project stylesheet found; provider default will be used"); + return undefined; } } @@ -279,14 +286,15 @@ body::before { ctx: HandlerContext, rawCss: string, candidates: Set, + generationSession: CSSGenerationSession, ): Promise { const projectScope = ctx.projectSlug ?? ctx.projectDir; return getProjectCSS(projectScope, rawCss, candidates, { - minify: true, + minify: generationSession.minify, environment: "preview", buildMode: "production", - }); + }, { generationSession }); } private getContentContext(ctx: HandlerContext): ResolvedContentContext | null { @@ -314,6 +322,8 @@ body::before { private createPreparedCSSContext( projectScope: string | undefined, rawCss: string, + candidates: Set, + generationSession: CSSGenerationSession, styleProfileHash: string, contentContext: ResolvedContentContext | null, ctx: HandlerContext, @@ -330,7 +340,9 @@ body::before { rawCss, styleProfileHash, { - minify: true, + cssPipelineIdentity: generationSession.cacheIdentity, + candidatesHash: hashCandidates(candidates), + minify: generationSession.minify, environment: "preview", buildMode: "production", }, diff --git a/src/server/handlers/request/css.handler.test.ts b/src/server/handlers/request/css.handler.test.ts index 59c34cbd25..217374f016 100644 --- a/src/server/handlers/request/css.handler.test.ts +++ b/src/server/handlers/request/css.handler.test.ts @@ -4,6 +4,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import { CSSHandler } from "./css.handler.ts"; import type { HandlerContext } from "../types.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; +import { hashCSS } from "#veryfront/html/styles-builder/css-identity.ts"; function createMockAdapter(files: Record = {}): RuntimeAdapter { return { @@ -55,17 +56,19 @@ function makeCtx(files: Record = {}): HandlerContext { describe("server/handlers/request/css", () => { it("serves built CSS files from local dist when the JIT cache misses", async () => { const handler = new CSSHandler(); + const css = ".flex{display:flex}"; + const hash = hashCSS(css); const result = await handler.handle( - new Request("http://localhost/_vf/css/jecaqb.css"), + new Request(`http://localhost/_vf/css/${hash}.css`), makeCtx({ - "/project/dist/_vf/css/jecaqb.css": ".flex{display:flex}", + [`/project/dist/_vf/css/${hash}.css`]: css, }), ); const response = result.response!; assertEquals(response.status, 200); assertEquals(response.headers.get("content-type"), "text/css; charset=utf-8"); - assertEquals(await response.text(), ".flex{display:flex}"); + assertEquals(await response.text(), css); }); }); diff --git a/src/server/handlers/request/css.handler.ts b/src/server/handlers/request/css.handler.ts index 8f9b1a6669..8ad83c3f50 100644 --- a/src/server/handlers/request/css.handler.ts +++ b/src/server/handlers/request/css.handler.ts @@ -4,6 +4,11 @@ import { getCSSByHashAsync, regenerateCSSByHash, } from "#veryfront/html/styles-builder/tailwind-compiler.ts"; +import { + assertCSSContentIdentity, + createCSSAssetPathPattern, + extractCSSAssetHash, +} from "#veryfront/html/styles-builder/css-identity.ts"; import { HTTP_OK, PRIORITY_HIGH } from "#veryfront/utils/constants/index.ts"; import { extractCacheKeyContext, @@ -13,16 +18,17 @@ import { getHostEnv } from "#veryfront/platform/compat/process.ts"; import { runWithRequestContext } from "#veryfront/platform/adapters/fs/veryfront/multi-project-adapter.ts"; import { join } from "#veryfront/compat/path/index.ts"; -/** Pattern to match hashed CSS URLs: /_vf/css/[8-char-hash].css */ -const CSS_URL_PATTERN = /^\/_vf\/css\/([a-z0-9-]{1,16})\.css$/; +const CSS_URL_PATTERN = createCSSAssetPathPattern(); async function getCSSWithJITFallback( cssHash: string, projectSlug: string | undefined, + ctx: HandlerContext, ): Promise { const cached = await getCSSByHashAsync(cssHash); if (cached) return cached; - + const built = await getBuiltCSSFallback(cssHash, ctx); + if (built) return built; return regenerateCSSByHash(cssHash, projectSlug); } @@ -34,7 +40,9 @@ async function getBuiltCSSFallback( try { const exists = await ctx.adapter.fs.exists(builtCSSPath); if (!exists) return undefined; - return await ctx.adapter.fs.readFile(builtCSSPath); + const css = await ctx.adapter.fs.readFile(builtCSSPath); + assertCSSContentIdentity(css, cssHash); + return css; } catch { return undefined; } @@ -54,7 +62,7 @@ export class CSSHandler extends BaseHandler { const method = req.method.toUpperCase(); if (method !== "GET" && method !== "HEAD") return this.continue(); - const cssHash = new URL(req.url).pathname.match(CSS_URL_PATTERN)?.[1]; + const cssHash = extractCSSAssetHash(new URL(req.url).pathname); if (!cssHash) return this.continue(); const cacheCtx = extractCacheKeyContext(ctx); @@ -71,6 +79,7 @@ export class CSSHandler extends BaseHandler { getCSSWithJITFallback( cssHash, ctx.projectSlug ?? ctx.projectId, + ctx, ); // When no scoped cache context can be built (no project identity), fetch // without a cache-key context rather than crashing the request. @@ -89,9 +98,7 @@ export class CSSHandler extends BaseHandler { ) : await lookup(); - const resolvedCSS = css ?? await getBuiltCSSFallback(cssHash, ctx); - - if (!resolvedCSS) { + if (!css) { this.logInfo( `CSS not found and JIT regeneration failed: ${cssHash}. ` + `Server restart or cache expiry. Reload page to regenerate.`, @@ -113,7 +120,7 @@ export class CSSHandler extends BaseHandler { return this.respond(response); } - const body = method === "HEAD" ? null : resolvedCSS; + const body = method === "HEAD" ? null : css; const response = this.createResponseBuilder(ctx) .withCORS(req, ctx.securityConfig?.cors) diff --git a/src/server/handlers/request/project-run-execute.handler.test.ts b/src/server/handlers/request/project-run-execute.handler.test.ts index ec7714078d..8897092214 100644 --- a/src/server/handlers/request/project-run-execute.handler.test.ts +++ b/src/server/handlers/request/project-run-execute.handler.test.ts @@ -1,4 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; +import "#veryfront/html/styles-builder/__tests__/css-processor-setup.ts"; import { assertEquals, assertExists, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; import type { Agent } from "#veryfront/agent"; diff --git a/src/server/handlers/request/project-run-execute.handler.ts b/src/server/handlers/request/project-run-execute.handler.ts index 0bab56c8b7..7cadbefcb6 100644 --- a/src/server/handlers/request/project-run-execute.handler.ts +++ b/src/server/handlers/request/project-run-execute.handler.ts @@ -1107,8 +1107,8 @@ async function executeReleaseAssetBuildRun(input: { // Production CSS compiler: compiles the project's Tailwind CSS in-runtime // via the pure `generateTailwindCSS` primitive (no distributed-cache / - // candidate-contract machinery). Defensive — returns null on any failure, - // letting the executor keep its CSS gap. + // candidate-contract machinery). Missing providers and compile errors + // propagate to the executor, which records an explicit CSS gap. const compileProjectCss = createCompileProjectCss({ projectScope: projectReference, config: input.ctx.config, diff --git a/src/types/index.ts b/src/types/index.ts index 8777b5db43..bce3d5ae58 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -194,16 +194,12 @@ export interface RenderResult { } export type { - BrowserTargets, CriticalCSSResult, CSSBundle, CSSOptimizationOptions, CSSOptimizationStrategy, CSSOptimizerStats, CSSProcessingResult, - LightningCSSModule, - LightningCSSTransformOptions, - LightningCSSTransformResult, SelectorExtractionResult, } from "#veryfront/build/asset-pipeline/css-optimizer/types/index.ts"; diff --git a/src/utils/constants/build.ts b/src/utils/constants/build.ts index 4499976b14..c42982e9b6 100644 --- a/src/utils/constants/build.ts +++ b/src/utils/constants/build.ts @@ -13,8 +13,6 @@ export const IMAGE_OPTIMIZATION = { /** 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/css.ts b/src/utils/constants/css.ts new file mode 100644 index 0000000000..3331989f88 --- /dev/null +++ b/src/utils/constants/css.ts @@ -0,0 +1,19 @@ +/** + * Provider-neutral CSS resource admission limits. + * + * These values are shared by source discovery, request-time compilation, and + * build optimization. Keeping them below those layers prevents Server and HTML + * code from importing Build solely to agree on resource policy. + */ +export const MAX_CSS_FILES = 10_000; +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; +/** Shared compiler/purger ceiling for retained selector candidate evidence. */ +export const MAX_CSS_SELECTOR_TOKENS = 100_000; +/** Maximum characters in one class/selector candidate token. */ +export const MAX_CSS_SELECTOR_TOKEN_CHARACTERS = 1_024; +/** Aggregate UTF-8 evidence retained across normalized selector candidates. */ +export const MAX_CSS_SELECTOR_EVIDENCE_BYTES = MAX_CSS_TOTAL_BYTES; diff --git a/src/utils/css-artifact-identity.ts b/src/utils/css-artifact-identity.ts new file mode 100644 index 0000000000..e5d0e920d3 --- /dev/null +++ b/src/utils/css-artifact-identity.ts @@ -0,0 +1,98 @@ +/** + * Canonical identities shared by CSS generation, cache, and control-plane boundaries. + * + * This module deliberately has no vendor dependencies. Cache identities may cross + * process and service boundaries, so accepting two strings with the same encoded + * representation—or an unbounded string—is unsafe even when TypeScript calls the + * value a `string`. + * + * @module utils/css-artifact-identity + */ + +/** Maximum UTF-16 code units accepted for one complete CSS pipeline identity. */ +export const MAX_CSS_PIPELINE_IDENTITY_CODE_UNITS = 2_048; + +/** Maximum encoded UTF-8 bytes accepted for one complete CSS pipeline identity. */ +export const MAX_CSS_PIPELINE_IDENTITY_UTF8_BYTES = 2_048; + +const CSS_STYLE_PROFILE_HASH_PATTERN = /^[a-f0-9]{64}$/; +const ReflectApply = Reflect.apply; +const RegExpPrototypeTest = RegExp.prototype.test; +const StringPrototypeCharCodeAt = String.prototype.charCodeAt; +const StringPrototypeNormalize = String.prototype.normalize; +const StringPrototypeTrim = String.prototype.trim; +const TextEncoderPrototypeEncode = TextEncoder.prototype.encode; +const cssIdentityTextEncoder = new TextEncoder(); + +function charCodeAt(value: string, index: number): number { + return ReflectApply(StringPrototypeCharCodeAt, value, [index]) as number; +} + +function hasOnlyWellFormedNonControlCharacters(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = charCodeAt(value, index); + if (codeUnit <= 0x1f || (codeUnit >= 0x7f && codeUnit <= 0x9f)) { + return false; + } + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + if (index + 1 >= value.length) return false; + const trailing = charCodeAt(value, index + 1); + if (trailing < 0xdc00 || trailing > 0xdfff) return false; + index += 1; + continue; + } + if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) return false; + } + return true; +} + +/** Whether a value is safe to compare, encode, and persist as a CSS pipeline identity. */ +export function isCSSPipelineIdentity(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_CSS_PIPELINE_IDENTITY_CODE_UNITS || + ReflectApply(StringPrototypeTrim, value, []) !== value || + ReflectApply(StringPrototypeNormalize, value, ["NFC"]) !== value || + !hasOnlyWellFormedNonControlCharacters(value) + ) { + return false; + } + + const bytes = ReflectApply( + TextEncoderPrototypeEncode, + cssIdentityTextEncoder, + [value], + ) as Uint8Array; + return bytes.byteLength <= MAX_CSS_PIPELINE_IDENTITY_UTF8_BYTES; +} + +/** Validate and return an immutable string snapshot for cache or wire use. */ +export function assertCSSPipelineIdentity( + value: unknown, + label = "CSS pipeline identity", +): string { + if (!isCSSPipelineIdentity(value)) { + throw new TypeError( + `${label} must be a trimmed, NFC-normalized, well-formed string without control characters and no larger than ${MAX_CSS_PIPELINE_IDENTITY_CODE_UNITS} code units or ${MAX_CSS_PIPELINE_IDENTITY_UTF8_BYTES} UTF-8 bytes`, + ); + } + return value; +} + +/** Whether a value is the canonical style-scope profile SHA-256 identity. */ +export function isStyleProfileHash(value: unknown): value is string { + return typeof value === "string" && + ReflectApply(RegExpPrototypeTest, CSS_STYLE_PROFILE_HASH_PATTERN, [value]) === true; +} + +/** Validate and return the canonical style-scope profile SHA-256 identity. */ +export function assertStyleProfileHash( + value: unknown, + label = "Style profile hash", +): string { + if (!isStyleProfileHash(value)) { + throw new TypeError(`${label} must be a full lowercase SHA-256 digest`); + } + return value; +} diff --git a/src/utils/css-candidate-admission.test.ts b/src/utils/css-candidate-admission.test.ts new file mode 100644 index 0000000000..14314622eb --- /dev/null +++ b/src/utils/css-candidate-admission.test.ts @@ -0,0 +1,152 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { assertCSSCandidateToken, normalizeCSSCandidates } from "./css-candidate-admission.ts"; +import { MAX_CSS_SELECTOR_TOKEN_CHARACTERS, MAX_CSS_SELECTOR_TOKENS } from "./constants/css.ts"; + +describe("CSS candidate admission", () => { + it("snapshots dense arrays without invoking accessors or custom iteration", () => { + let reads = 0; + const accessor = ["alpha"]; + Object.defineProperty(accessor, "0", { + enumerable: true, + get() { + reads++; + return "hostile"; + }, + }); + assertThrows( + () => normalizeCSSCandidates(accessor), + TypeError, + "dense data-property array", + ); + assertEquals(reads, 0); + + const customIterator = ["alpha", "beta"] as string[] & { + [Symbol.iterator]: () => ArrayIterator; + }; + Object.defineProperty(customIterator, Symbol.iterator, { + value: () => { + reads++; + return ["hostile"].values(); + }, + }); + assertThrows( + () => normalizeCSSCandidates(customIterator), + TypeError, + "dense data-property array", + ); + assertEquals(reads, 0); + }); + + it("rejects sparse arrays and Proxies before executing their hooks", () => { + assertThrows( + () => normalizeCSSCandidates(new Array(1)), + TypeError, + "dense data-property array", + ); + + let proxyHooks = 0; + const proxy = new Proxy(["alpha"], { + getOwnPropertyDescriptor() { + proxyHooks++; + throw new Error("must not run"); + }, + ownKeys() { + proxyHooks++; + throw new Error("must not run"); + }, + }); + assertThrows(() => normalizeCSSCandidates(proxy), TypeError, "must not be a Proxy"); + assertEquals(proxyHooks, 0); + }); + + it("reads genuine Sets through native slots and ignores overridden iteration", () => { + let reads = 0; + const candidates = new Set(["beta", "alpha"]); + Object.defineProperty(candidates, "values", { + get() { + reads++; + return () => ["hostile"].values(); + }, + }); + Object.defineProperty(candidates, Symbol.iterator, { + get() { + reads++; + return () => ["hostile"].values(); + }, + }); + + assertEquals(normalizeCSSCandidates(candidates), ["beta", "alpha"]); + assertEquals(reads, 0); + }); + + it("rejects invalid tokens and bounded snapshots before retaining them", () => { + for (const candidate of ["", "two words", "line\nbreak", "nul\0byte"]) { + assertThrows(() => assertCSSCandidateToken(candidate), TypeError, "non-empty token"); + } + assertThrows( + () => assertCSSCandidateToken("x".repeat(MAX_CSS_SELECTOR_TOKEN_CHARACTERS + 1)), + TypeError, + "non-empty token", + ); + assertThrows( + () => normalizeCSSCandidates(new Array(MAX_CSS_SELECTOR_TOKENS + 1)), + TypeError, + "cannot exceed", + ); + }); + + it("uses captured admission intrinsics after global prototypes are poisoned", () => { + const arrayInput = ["alpha", "beta", "alpha"]; + const setInput = new Set(["gamma"]); + const originalApply = Reflect.apply; + const originalIsArray = Array.isArray; + const originalSafeInteger = Number.isSafeInteger; + const originalRegExpTest = RegExp.prototype.test; + const originalCharCodeAt = String.prototype.charCodeAt; + const originalSetAdd = Set.prototype.add; + const originalSetHas = Set.prototype.has; + const originalSetValues = Set.prototype.values; + let normalizedArray: string[] | undefined; + let normalizedSet: string[] | undefined; + let invalidError: unknown; + try { + Reflect.apply = () => { + throw new Error("poisoned Reflect.apply"); + }; + Array.isArray = (() => false) as unknown as typeof Array.isArray; + Number.isSafeInteger = () => false; + RegExp.prototype.test = () => false; + String.prototype.charCodeAt = () => 0; + Set.prototype.add = () => { + throw new Error("poisoned Set.add"); + }; + Set.prototype.has = () => false; + Set.prototype.values = () => { + throw new Error("poisoned Set.values"); + }; + + normalizedArray = normalizeCSSCandidates(arrayInput); + normalizedSet = normalizeCSSCandidates(setInput); + try { + assertCSSCandidateToken("two words"); + } catch (error) { + invalidError = error; + } + } finally { + Reflect.apply = originalApply; + Array.isArray = originalIsArray; + Number.isSafeInteger = originalSafeInteger; + RegExp.prototype.test = originalRegExpTest; + String.prototype.charCodeAt = originalCharCodeAt; + Set.prototype.add = originalSetAdd; + Set.prototype.has = originalSetHas; + Set.prototype.values = originalSetValues; + } + + assertEquals(normalizedArray, ["alpha", "beta"]); + assertEquals(normalizedSet, ["gamma"]); + assertEquals(invalidError instanceof TypeError, true); + }); +}); diff --git a/src/utils/css-candidate-admission.ts b/src/utils/css-candidate-admission.ts new file mode 100644 index 0000000000..7d5883e502 --- /dev/null +++ b/src/utils/css-candidate-admission.ts @@ -0,0 +1,209 @@ +import { + MAX_CSS_SELECTOR_EVIDENCE_BYTES, + MAX_CSS_SELECTOR_TOKEN_CHARACTERS, + MAX_CSS_SELECTOR_TOKENS, +} from "./constants/css.ts"; +import { isProxy as isProxyWithoutHooks } from "node:util/types"; +import { utf8ByteLength } from "./utf8-byte-length.ts"; + +const whitespacePattern = /\s/u; +const apply = Reflect.apply; +const ArrayConstructor = Array; +const SetConstructor = Set; +const NativeTypeError = TypeError; +const isArray = Array.isArray; +const numberIsSafeInteger = Number.isSafeInteger; +const NumberConstructor = Number; +const StringConstructor = String; +const arrayPush = Array.prototype.push; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const getPrototypeOf = Object.getPrototypeOf; +const hasOwn = Object.hasOwn; +const ownKeys = Reflect.ownKeys; +const regexpExec = RegExp.prototype.exec; +const stringCharCodeAt = String.prototype.charCodeAt; +const setAdd = Set.prototype.add; +const setHas = Set.prototype.has; +const setSizeGetter = (() => { + const getter = getOwnPropertyDescriptor(SetConstructor.prototype, "size")?.get; + if (typeof getter !== "function") { + throw new NativeTypeError("Required Set size intrinsic is unavailable"); + } + return getter; +})(); +const setValues = SetConstructor.prototype.values; +const setIteratorNext = getPrototypeOf( + apply(setValues, new SetConstructor(), []) as SetIterator, +).next as ( + this: SetIterator, +) => IteratorResult; + +function containsWhitespaceOrControl(value: string): boolean { + if (apply(regexpExec, whitespacePattern, [value]) !== null) return true; + for (let index = 0; index < value.length; index++) { + const code = apply(stringCharCodeAt, value, [index]) as number; + if ( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) + ) { + return true; + } + } + return false; +} + +/** Validate one provider-neutral class/selector candidate token. */ +export function assertCSSCandidateToken( + value: unknown, + label = "CSS candidate", +): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_CSS_SELECTOR_TOKEN_CHARACTERS || + containsWhitespaceOrControl(value) + ) { + throw new NativeTypeError( + `${label} must be a non-empty token of at most ${MAX_CSS_SELECTOR_TOKEN_CHARACTERS} characters without whitespace or control characters`, + ); + } + return value; +} + +function snapshotCandidateArray(value: unknown[], label: string): unknown[] { + const lengthDescriptor = getOwnPropertyDescriptor(value, "length"); + const length = lengthDescriptor && hasOwn(lengthDescriptor, "value") + ? lengthDescriptor.value + : undefined; + if ( + !numberIsSafeInteger(length) || + length < 0 || + length > MAX_CSS_SELECTOR_TOKENS + ) { + throw new NativeTypeError(`${label} cannot exceed ${MAX_CSS_SELECTOR_TOKENS} candidates`); + } + + const keys = ownKeys(value); + if (keys.length !== length + 1) { + throw new NativeTypeError(`${label} must be a dense data-property array`); + } + + const snapshot = new ArrayConstructor(length); + for (let index = 0; index < length; index++) { + const descriptor = getOwnPropertyDescriptor(value, StringConstructor(index)); + if ( + descriptor === undefined || + !hasOwn(descriptor, "value") || + descriptor.enumerable !== true + ) { + throw new NativeTypeError(`${label} must be a dense data-property array`); + } + snapshot[index] = descriptor.value; + } + + for (let keyIndex = 0; keyIndex < keys.length; keyIndex++) { + const key = keys[keyIndex]; + if (key === "length") continue; + if (typeof key !== "string") { + throw new NativeTypeError(`${label} must be a dense data-property array`); + } + const index = NumberConstructor(key); + if ( + !numberIsSafeInteger(index) || + index < 0 || + index >= length || + StringConstructor(index) !== key + ) { + throw new NativeTypeError(`${label} must be a dense data-property array`); + } + } + + return snapshot; +} + +function readIteratorResult( + result: IteratorResult, + expectedDone: boolean, + label: string, +): unknown { + const done = getOwnPropertyDescriptor(result, "done"); + const value = getOwnPropertyDescriptor(result, "value"); + if ( + done === undefined || + !hasOwn(done, "value") || + done.value !== expectedDone || + (expectedDone ? value !== undefined && !hasOwn(value, "value") : value === undefined) || + (value !== undefined && !hasOwn(value, "value")) + ) { + throw new NativeTypeError(`${label} Set iterator returned an invalid result`); + } + return value && hasOwn(value, "value") ? value.value : undefined; +} + +function snapshotCandidateSet(value: object, label: string): unknown[] { + let size: unknown; + let iterator: SetIterator; + try { + size = apply(setSizeGetter, value, []); + iterator = apply(setValues, value, []) as SetIterator; + } catch (cause) { + throw new NativeTypeError(`${label} must be an array or genuine Set`, { cause }); + } + if (!numberIsSafeInteger(size) || (size as number) < 0) { + throw new NativeTypeError(`${label} Set size is invalid`); + } + if ((size as number) > MAX_CSS_SELECTOR_TOKENS) { + throw new NativeTypeError(`${label} cannot exceed ${MAX_CSS_SELECTOR_TOKENS} candidates`); + } + + const snapshot = new ArrayConstructor(size as number); + for (let index = 0; index < snapshot.length; index++) { + const result = apply(setIteratorNext, iterator, []) as IteratorResult; + snapshot[index] = readIteratorResult(result, false, label); + } + readIteratorResult( + apply(setIteratorNext, iterator, []) as IteratorResult, + true, + label, + ); + return snapshot; +} + +/** Snapshot, validate, and deduplicate compiler candidate input. */ +export function normalizeCSSCandidates( + value: unknown, + label = "CSS candidates", +): string[] { + if (isProxyWithoutHooks(value)) { + throw new NativeTypeError(`${label} must not be a Proxy`); + } + if (typeof value !== "object" || value === null) { + throw new NativeTypeError(`${label} must be an array or Set`); + } + const input = isArray(value) + ? snapshotCandidateArray(value, label) + : snapshotCandidateSet(value, label); + + const seenCandidates = new SetConstructor(); + const candidates: string[] = []; + let evidenceBytes = 0; + for (let index = 0; index < input.length; index++) { + const rawCandidate = input[index]; + const candidate = assertCSSCandidateToken(rawCandidate, label); + if (apply(setHas, seenCandidates, [candidate])) continue; + if (candidates.length >= MAX_CSS_SELECTOR_TOKENS) { + throw new NativeTypeError(`${label} cannot exceed ${MAX_CSS_SELECTOR_TOKENS} candidates`); + } + const remainingBytes = MAX_CSS_SELECTOR_EVIDENCE_BYTES - evidenceBytes; + const candidateBytes = utf8ByteLength(candidate, remainingBytes); + if (candidateBytes > remainingBytes) { + throw new NativeTypeError( + `${label} cannot exceed ${MAX_CSS_SELECTOR_EVIDENCE_BYTES} UTF-8 bytes`, + ); + } + evidenceBytes += candidateBytes; + apply(setAdd, seenCandidates, [candidate]); + apply(arrayPush, candidates, [candidate]); + } + return candidates; +} diff --git a/src/utils/css-content-admission.ts b/src/utils/css-content-admission.ts new file mode 100644 index 0000000000..5af633b419 --- /dev/null +++ b/src/utils/css-content-admission.ts @@ -0,0 +1,32 @@ +import { MAX_CSS_FILE_BYTES, MAX_CSS_OUTPUT_FILE_BYTES } from "./constants/css.ts"; +import { utf8ByteLength } from "./utf8-byte-length.ts"; + +function assertBoundedCSSContent( + value: unknown, + maximumBytes: number, + label: string, +): number { + if (typeof value !== "string") { + throw new TypeError(`${label} must be a string`); + } + // Every UTF-16 code unit contributes at least one UTF-8 byte. Reject this + // common oversized case without walking an already-unacceptable string. + if (value.length > maximumBytes) { + throw new TypeError(`${label} exceeds ${maximumBytes} bytes`); + } + const bytes = utf8ByteLength(value, maximumBytes); + if (bytes > maximumBytes) { + throw new TypeError(`${label} exceeds ${maximumBytes} bytes`); + } + return bytes; +} + +/** Validate one authored CSS input and return its exact UTF-8 byte length. */ +export function assertCSSFileContent(value: unknown, label = "CSS input"): number { + return assertBoundedCSSContent(value, MAX_CSS_FILE_BYTES, label); +} + +/** Validate one emitted CSS asset and return its exact UTF-8 byte length. */ +export function assertCSSOutputContent(value: unknown, label = "CSS output"): number { + return assertBoundedCSSContent(value, MAX_CSS_OUTPUT_FILE_BYTES, label); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 7533ea259e..ab9db4503d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -146,3 +146,12 @@ export { endRequest, isEnabled, startRequest, startTimer, timeAsync } from "./pe export { parallelMap } from "./parallel.ts"; export { safeJsonParse, type SafeJsonParseResult } from "./json.ts"; + +export { + assertCSSPipelineIdentity, + assertStyleProfileHash, + isCSSPipelineIdentity, + isStyleProfileHash, + MAX_CSS_PIPELINE_IDENTITY_CODE_UNITS, + MAX_CSS_PIPELINE_IDENTITY_UTF8_BYTES, +} from "./css-artifact-identity.ts"; diff --git a/src/utils/is-well-formed-string.test.ts b/src/utils/is-well-formed-string.test.ts new file mode 100644 index 0000000000..e14a298a2d --- /dev/null +++ b/src/utils/is-well-formed-string.test.ts @@ -0,0 +1,19 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { isWellFormedString } from "./is-well-formed-string.ts"; + +describe("isWellFormedString", () => { + it("accepts ordinary strings and valid surrogate pairs", () => { + assertEquals(isWellFormedString(""), true); + assertEquals(isWellFormedString("styles/app.css"), true); + assertEquals(isWellFormedString("before\uD83D\uDE00after"), true); + }); + + it("rejects lone and mismatched surrogates", () => { + assertEquals(isWellFormedString("\uD800"), false); + assertEquals(isWellFormedString("\uDC00"), false); + assertEquals(isWellFormedString("\uD800x"), false); + assertEquals(isWellFormedString("\uD800\uD800"), false); + assertEquals(isWellFormedString("\uDC00\uDC00"), false); + }); +}); diff --git a/src/utils/is-well-formed-string.ts b/src/utils/is-well-formed-string.ts new file mode 100644 index 0000000000..0bd08e0760 --- /dev/null +++ b/src/utils/is-well-formed-string.ts @@ -0,0 +1,20 @@ +/** Dependency-free UTF-16 well-formedness check for runtimes before ES2024. */ + +const apply = Reflect.apply; +const stringCharCodeAt = String.prototype.charCodeAt; + +/** Return whether every UTF-16 surrogate in the string belongs to a valid pair. */ +export function isWellFormedString(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const codeUnit = apply(stringCharCodeAt, value, [index]) as number; + if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) { + index++; + if (index >= value.length) return false; + const trailingCodeUnit = apply(stringCharCodeAt, value, [index]) as number; + if (trailingCodeUnit < 0xDC00 || trailingCodeUnit > 0xDFFF) return false; + } else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) { + return false; + } + } + return true; +} diff --git a/src/utils/retained-string.test.ts b/src/utils/retained-string.test.ts new file mode 100644 index 0000000000..5bbb1476fc --- /dev/null +++ b/src/utils/retained-string.test.ts @@ -0,0 +1,42 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { detachRetainedString, estimateRetainedStringBytes } from "./retained-string.ts"; + +describe("retained string cache utilities", () => { + it("copies exact UTF-16 content across chunk and surrogate boundaries", () => { + const value = `${"a".repeat(8 * 1024 - 1)}😀${"z".repeat(8 * 1024 + 7)}`; + const detached = detachRetainedString(value); + assertEquals(detached, value); + assertEquals(detached.length, value.length); + }); + + it("accounts for the larger of UTF-8 and UTF-16 storage", () => { + assertEquals(estimateRetainedStringBytes("a") >= 2, true); + assertEquals(estimateRetainedStringBytes("😀") >= 4, true); + assertEquals( + estimateRetainedStringBytes("a".repeat(100)) >= 200, + true, + ); + }); + + it("uses captured copy intrinsics", () => { + const originalApply = Reflect.apply; + const originalCharCodeAt = String.prototype.charCodeAt; + const originalJoin = Array.prototype.join; + let copied: string | undefined; + try { + Reflect.apply = () => { + throw new Error("poisoned Reflect.apply"); + }; + String.prototype.charCodeAt = () => 0; + Array.prototype.join = () => "poisoned"; + copied = detachRetainedString("a".repeat(9 * 1024)); + } finally { + Reflect.apply = originalApply; + String.prototype.charCodeAt = originalCharCodeAt; + Array.prototype.join = originalJoin; + } + assertEquals(copied, "a".repeat(9 * 1024)); + }); +}); diff --git a/src/utils/retained-string.ts b/src/utils/retained-string.ts new file mode 100644 index 0000000000..76bcfb0f17 --- /dev/null +++ b/src/utils/retained-string.ts @@ -0,0 +1,53 @@ +import { utf8ByteLength } from "./utf8-byte-length.ts"; + +const COPY_CHUNK_CODE_UNITS = 8 * 1024; +const STORAGE_NODE_OVERHEAD_BYTES = 64; +const apply = Reflect.apply; +const ArrayConstructor = Array; +const NativeRangeError = RangeError; +const NativeTypeError = TypeError; +const Uint16ArrayConstructor = Uint16Array; +const arrayJoin = Array.prototype.join; +const mathCeil = Math.ceil; +const mathMax = Math.max; +const mathMin = Math.min; +const numberIsSafeInteger = Number.isSafeInteger; +const stringCharCodeAt = String.prototype.charCodeAt; +const stringFromCharCode = String.fromCharCode; + +/** Conservatively estimate a detached string's retained UTF-8/UTF-16 storage. */ +export function estimateRetainedStringBytes(value: string): number { + if (typeof value !== "string") { + throw new NativeTypeError("Retained cache value must be a string"); + } + const utf16Bytes = value.length * 2; + const storageNodes = mathMax(1, mathCeil(value.length / COPY_CHUNK_CODE_UNITS)); + const overheadBytes = storageNodes * STORAGE_NODE_OVERHEAD_BYTES; + if (!numberIsSafeInteger(utf16Bytes) || !numberIsSafeInteger(overheadBytes)) { + throw new NativeRangeError("Retained cache string exceeds the safe size range"); + } + return mathMax(utf16Bytes, utf8ByteLength(value)) + overheadBytes; +} + +/** + * Copy a string through its UTF-16 code units so a cached slice or rope cannot + * retain an unaccounted parent allocation. + */ +export function detachRetainedString(value: string): string { + if (typeof value !== "string") { + throw new NativeTypeError("Retained cache value must be a string"); + } + if (value.length === 0) return ""; + + const chunks = new ArrayConstructor(mathCeil(value.length / COPY_CHUNK_CODE_UNITS)); + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const start = chunkIndex * COPY_CHUNK_CODE_UNITS; + const end = mathMin(start + COPY_CHUNK_CODE_UNITS, value.length); + const codeUnits = new Uint16ArrayConstructor(end - start); + for (let index = start; index < end; index++) { + codeUnits[index - start] = apply(stringCharCodeAt, value, [index]) as number; + } + chunks[chunkIndex] = apply(stringFromCharCode, undefined, codeUnits) as string; + } + return chunks.length === 1 ? chunks[0]! : apply(arrayJoin, chunks, [""]) as string; +} diff --git a/src/utils/utf8-byte-length.ts b/src/utils/utf8-byte-length.ts new file mode 100644 index 0000000000..c0d933be52 --- /dev/null +++ b/src/utils/utf8-byte-length.ts @@ -0,0 +1,59 @@ +const apply = Reflect.apply; +const numberIsSafeInteger = Number.isSafeInteger; +const NativeRangeError = RangeError; +const NativeTypeError = TypeError; +const POSITIVE_INFINITY = Number.POSITIVE_INFINITY; +const stringCharCodeAt = String.prototype.charCodeAt; + +/** + * Count UTF-8 bytes without allocating an encoded copy of a potentially large + * string. Lone surrogates match TextEncoder's U+FFFD replacement behavior. + * + * When `stopAfter` is provided, the function returns `stopAfter + 1` as soon + * as the boundary is exceeded. + */ +export function utf8ByteLength( + value: string, + stopAfter = POSITIVE_INFINITY, +): number { + if (typeof value !== "string") { + throw new NativeTypeError("UTF-8 byte length input must be a string"); + } + if ( + stopAfter !== POSITIVE_INFINITY && + (!numberIsSafeInteger(stopAfter) || stopAfter < 0) + ) { + throw new NativeRangeError( + "UTF-8 byte length stopAfter must be a non-negative safe integer", + ); + } + + let bytes = 0; + for (let index = 0; index < value.length; index++) { + const codeUnit = apply(stringCharCodeAt, value, [index]) as number; + let width: number; + if (codeUnit <= 0x7f) { + width = 1; + } else if (codeUnit <= 0x7ff) { + width = 2; + } else if ( + codeUnit >= 0xd800 && + codeUnit <= 0xdbff && + index + 1 < value.length + ) { + const nextCodeUnit = apply(stringCharCodeAt, value, [index + 1]) as number; + if (nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff) { + width = 4; + index++; + } else { + width = 3; + } + } else { + width = 3; + } + + if (width > stopAfter - bytes) return stopAfter + 1; + bytes += width; + } + return bytes; +} diff --git a/tests/integration/build/asset-pipeline/integration.test.ts b/tests/integration/build/asset-pipeline/integration.test.ts index b965eb6ba8..bed8033723 100644 --- a/tests/integration/build/asset-pipeline/integration.test.ts +++ b/tests/integration/build/asset-pipeline/integration.test.ts @@ -11,6 +11,32 @@ import { getAssetPipelineStatus, runAssetPipeline, } from "../../../../src/build/asset-pipeline/index.ts"; +import { + createTestCSSOptimizationEngine, + withTestCSSOptimizationEngine, +} from "../../../_helpers/css-optimization-engine.ts"; + +const optimizationEngine = createTestCSSOptimizationEngine(); + +async function withCSSProject( + run: (projectDir: string) => Promise, + engine = optimizationEngine, +): Promise { + const projectDir = await Deno.makeTempDir(); + try { + await Deno.mkdir(`${projectDir}/styles`); + await Deno.writeTextFile( + `${projectDir}/styles/main.css`, + ".main { color: red; }", + ); + await withTestCSSOptimizationEngine( + engine, + () => run(projectDir), + ); + } finally { + await Deno.remove(projectDir, { recursive: true }); + } +} describe("Asset Pipeline", () => { describe("runAssetPipeline", () => { @@ -30,59 +56,118 @@ describe("Asset Pipeline", () => { const result = await runAssetPipeline(); assertExists(result); - assertEquals(typeof result.images.enabled, "boolean"); - assertEquals(typeof result.css.enabled, "boolean"); + assertEquals(result.images.enabled, false); + assertEquals(result.css.enabled, false); assertEquals(typeof result.duration, "number"); }); - it("images only", async () => { - const result = await runAssetPipeline({ - images: { - enabled: true, - inputDir: "./.veryfront/test-images-nonexistent", - }, - css: { enabled: false }, - }); - - assertExists(result); - assertEquals(typeof result.images.optimized, "number"); - assertEquals(result.css.enabled, false); + it("rejects a failing explicitly requested image stage", async () => { + await assertRejects(() => + runAssetPipeline({ + images: { + enabled: true, + inputDir: "./.veryfront/test-images-nonexistent", + }, + css: { enabled: false }, + }) + ); }); - it("CSS only", async () => { - const result = await runAssetPipeline({ - images: { enabled: false }, - css: { - enabled: true, - inputDir: "./.veryfront/test-css-nonexistent", - }, + it("runs an explicitly composed CSS stage", async () => { + await withCSSProject(async (projectDir) => { + const result = await runAssetPipeline({ + images: { enabled: false }, + css: { + enabled: true, + projectDir, + inputDir: "styles", + outputDir: ".veryfront/css", + }, + }); + + assertEquals(result.images.enabled, false); + assertEquals(result.css.enabled, true); + assertEquals(result.css.optimized, 1); }); - - assertExists(result); - 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`; + const outputDir = `${projectDir}/.veryfront/shared-css`; try { + await Deno.mkdir(`${projectDir}/styles`); + await Deno.writeTextFile( + `${projectDir}/styles/main.css`, + '@import "tailwindcss";\n.main { color: red; }', + ); + + await withTestCSSOptimizationEngine( + optimizationEngine, + () => + assertRejects( + () => + runAssetPipeline({ + images: { enabled: false }, + tailwind: { + enabled: true, + projectDir, + sourceDir: "styles", + outputDir: ".veryfront/shared-css", + }, + css: { + enabled: true, + projectDir, + inputDir: "styles", + outputDir: ".veryfront/shared-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 nested Tailwind and CSS output directories", async () => { + const projectDir = await Deno.makeTempDir(); + const outputDir = `${projectDir}/.veryfront/css`; + try { + await Deno.mkdir(`${projectDir}/styles`); + await Deno.writeTextFile( + `${projectDir}/styles/main.css`, + '@import "tailwindcss";\n.main { color: red; }', + ); + + await withTestCSSOptimizationEngine( + optimizationEngine, () => - 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", + assertRejects( + () => + runAssetPipeline({ + images: { enabled: false }, + tailwind: { + enabled: true, + projectDir, + sourceDir: "styles", + outputDir: ".veryfront/css/tailwind", + }, + css: { + enabled: true, + projectDir, + inputDir: "styles", + outputDir: ".veryfront/css", + }, + }), + TypeError, + "output directories for tailwind and css must not overlap physically", + ), ); await assertRejects( @@ -94,36 +179,52 @@ describe("Asset Pipeline", () => { } }); - it("rejects Tailwind output aliases of the CSS publication tree", async () => { + it("rejects physically aliased output directories without replacing existing files", async () => { const projectDir = await Deno.makeTempDir(); const physicalOutputDir = `${projectDir}/.veryfront/physical-css`; - const aliasOutputDir = `${projectDir}/.veryfront/alias-css`; + const aliasedOutputDir = `${projectDir}/.veryfront/aliased-css`; const sentinelPath = `${physicalOutputDir}/sentinel.txt`; + const generatedPath = `${physicalOutputDir}/main.css`; try { + await Deno.mkdir(`${projectDir}/styles`); await Deno.mkdir(physicalOutputDir, { recursive: true }); - await Deno.symlink(physicalOutputDir, aliasOutputDir, { type: "dir" }); + await Deno.symlink(physicalOutputDir, aliasedOutputDir, { type: "dir" }); await Deno.writeTextFile(sentinelPath, "preserve me"); + await Deno.writeTextFile( + `${projectDir}/styles/main.css`, + '@import "tailwindcss";\n.main { color: red; }', + ); - await assertRejects( + await withTestCSSOptimizationEngine( + optimizationEngine, () => - 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", + assertRejects( + () => + runAssetPipeline({ + images: { enabled: false }, + tailwind: { + enabled: true, + projectDir, + sourceDir: "styles", + outputDir: ".veryfront/aliased-css", + }, + css: { + enabled: true, + projectDir, + inputDir: "styles", + outputDir: ".veryfront/physical-css", + }, + }), + TypeError, + "output directories for tailwind and css must not overlap physically", + ), ); assertEquals(await Deno.readTextFile(sentinelPath), "preserve me"); + await assertRejects( + () => Deno.stat(generatedPath), + Deno.errors.NotFound, + ); } finally { await Deno.remove(projectDir, { recursive: true }); } @@ -135,8 +236,8 @@ describe("Asset Pipeline", () => { const deps = await checkAssetPipelineDependencies(); assertExists(deps); - assertEquals(typeof deps.sharp, "boolean"); - assertEquals(typeof deps.lightningCSS, "boolean"); + assertEquals(typeof deps.imageOptimization, "boolean"); + assertEquals(typeof deps.cssOptimization, "boolean"); }); }); @@ -175,8 +276,8 @@ describe("Asset Pipeline", () => { }); }); - describe("graceful degradation", () => { - it("does not throw when dependencies are missing", async () => { + describe("fail-closed stages", () => { + it("rejects instead of returning partial success", async () => { const options: AssetPipelineOptions = { images: { enabled: true, @@ -188,10 +289,34 @@ describe("Asset Pipeline", () => { }, }; - const result = await runAssetPipeline(options); + await assertRejects(() => runAssetPipeline(options)); + }); - assertExists(result); - assertEquals(typeof result.duration, "number"); + it("surfaces an explicitly composed CSS provider failure", async () => { + const providerFailure = new Error("CSS provider failed"); + const failingEngine = createTestCSSOptimizationEngine(() => { + throw providerFailure; + }); + + await withCSSProject( + async (projectDir) => { + await assertRejects( + () => + runAssetPipeline({ + images: { enabled: false }, + css: { + enabled: true, + projectDir, + inputDir: "styles", + outputDir: ".veryfront/css", + }, + }), + Error, + "CSS provider failed", + ); + }, + failingEngine, + ); }); }); @@ -250,24 +375,23 @@ describe("Asset Pipeline", () => { describe("configuration validation", () => { it("accepts valid configuration options", async () => { - const result = await runAssetPipeline({ - images: { - enabled: true, - formats: ["webp", "avif"], - sizes: [320, 640, 1024], - quality: 85, - }, - css: { - enabled: true, - minify: true, - autoprefixer: true, - purge: false, - }, + await withCSSProject(async (projectDir) => { + const result = await runAssetPipeline({ + images: { enabled: false }, + css: { + enabled: true, + projectDir, + inputDir: "styles", + outputDir: ".veryfront/css", + minify: true, + purge: false, + }, + }); + + assertExists(result); + assertEquals(result.images.enabled, false); + assertEquals(result.css.enabled, true); }); - - assertExists(result); - assertEquals(typeof result.images.enabled, "boolean"); - assertEquals(typeof result.css.enabled, "boolean"); }); }); }); diff --git a/tests/integration/compiled-binary-e2e.test-helpers.ts b/tests/integration/compiled-binary-e2e.test-helpers.ts index 0d4bf5e8b4..4fcc4f4a1b 100644 --- a/tests/integration/compiled-binary-e2e.test-helpers.ts +++ b/tests/integration/compiled-binary-e2e.test-helpers.ts @@ -151,6 +151,7 @@ async function startBinaryServer( nodeEnv = "development", extraEnv?: Record, ): Promise { + await installBinaryTestCSSProcessor(projectDir); const maxRetries = 3; for (let attempt = 0; attempt < maxRetries; attempt++) { @@ -219,6 +220,48 @@ async function startBinaryServer( throw new Error("Failed to start server after all retries"); } +const BINARY_TEST_CSS_PROCESSOR = ` +const compiler = Object.freeze({ + build() { + return ""; + }, +}); + +const processor = Object.freeze({ + cacheIdentity: "veryfront.binary-e2e.css-processor.v1", + defaultStylesheet: "", + async compile() { + return compiler; + }, +}); + +const optimizer = Object.freeze({ + cacheIdentity: "veryfront.binary-e2e.css-optimizer.v1", + optimize(request) { + return Object.freeze({ css: request.css }); + }, +}); + +export default function binaryE2ECSSProcessor() { + return { + name: "binary-e2e-css-processor", + version: "1.0.0", + capabilities: [], + contracts: { provides: ["CSSProcessor", "CSSOptimizationEngine"] }, + setup(context) { + context.provide("CSSProcessor", processor); + context.provide("CSSOptimizationEngine", optimizer); + }, + }; +} +`; + +async function installBinaryTestCSSProcessor(projectDir: string): Promise { + const extensionDir = join(projectDir, "extensions", "binary-e2e-css-processor"); + await Deno.mkdir(extensionDir, { recursive: true }); + await Deno.writeTextFile(join(extensionDir, "index.ts"), BINARY_TEST_CSS_PROCESSOR); +} + export async function createTestProject( name: string, pageContent: string, @@ -275,7 +318,15 @@ export async function withServer( } export async function fetchOkHtml(server: TestServer, path = "/"): Promise { - const response = await fetch(`http://127.0.0.1:${server.port}${path}`); + let response: Response; + try { + response = await fetch(`http://127.0.0.1:${server.port}${path}`); + } catch (cause) { + throw new Error( + `Request failed for ${path}\n\nRecent logs:\n${server.logs.join("").slice(-16000)}`, + { cause }, + ); + } const html = await response.text(); const recentLogs = server.logs.join("").slice(-16000); diff --git a/tests/integration/server/production-server.test.ts b/tests/integration/server/production-server.test.ts index c138636cb8..f4ee886cf9 100644 --- a/tests/integration/server/production-server.test.ts +++ b/tests/integration/server/production-server.test.ts @@ -27,6 +27,7 @@ import { TestDataFactory } from "../../fixtures/test-data-factory.ts"; import { withTestContext } from "../../_helpers/context.ts"; import { cleanupBundler } from "../../../src/rendering/cleanup.ts"; import { invalidateProjectMiddlewareCache } from "../../../src/server/runtime-handler/project-middleware.ts"; +import { registerTailwindExtension } from "../../../src/html/styles-builder/__tests__/css-processor-setup.ts"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -68,6 +69,7 @@ describe( "# Static Assets Fixture", ); + await registerTailwindExtension(); await buildProduction({ projectDir: context.projectDir, outputDir: join(context.projectDir, "dist"), @@ -172,6 +174,7 @@ describe( }`, ); + await registerTailwindExtension(); await buildProduction({ projectDir: context.projectDir, outputDir: join(context.projectDir, "dist"), @@ -204,6 +207,7 @@ describe( }`, ); + await registerTailwindExtension(); await buildProduction({ projectDir: context.projectDir, outputDir: join(context.projectDir, "dist"), diff --git a/tests/validation/002-global-state/002.8-tailwind-compiler-isolation.test.ts b/tests/validation/002-global-state/002.8-tailwind-compiler-isolation.test.ts index 6ad52ab0cb..c1ba8d51a9 100644 --- a/tests/validation/002-global-state/002.8-tailwind-compiler-isolation.test.ts +++ b/tests/validation/002-global-state/002.8-tailwind-compiler-isolation.test.ts @@ -14,7 +14,6 @@ import "../../_helpers/contract-init.ts"; import { assert, assertEquals } from "#veryfront/testing/assert"; import { beforeEach, describe, it } from "#veryfront/testing/bdd"; import { - clearPluginCache, generateTailwindCSS, getCompilerCacheStats, invalidateCompiler, @@ -26,14 +25,17 @@ describe("002.8 Tailwind Compiler Isolation", () => { }); describe("Compiler Cache", () => { - it("should cache compilers by stylesheet hash", async () => { + it("should cache compilations by stylesheet and candidate snapshot", async () => { const stylesheet = `@import "tailwindcss";`; await generateTailwindCSS(stylesheet, ["mt-4"]); - assertEquals(getCompilerCacheStats().size, 1, "Should have 1 cached compiler"); + assertEquals(getCompilerCacheStats().size, 1, "Should have 1 cached compilation"); + + await generateTailwindCSS(stylesheet, ["mt-4"]); + assertEquals(getCompilerCacheStats().size, 1, "Should reuse an identical compilation"); await generateTailwindCSS(stylesheet, ["mt-8"]); - assertEquals(getCompilerCacheStats().size, 1, "Should still have 1 cached compiler"); + assertEquals(getCompilerCacheStats().size, 2, "Distinct candidates need distinct output"); }); it("should create separate compilers for different stylesheets", async () => { @@ -63,29 +65,6 @@ describe("002.8 Tailwind Compiler Isolation", () => { }); }); - describe("Plugin Cache Isolation", () => { - it("should have separate plugin caches per stylesheet", async () => { - await generateTailwindCSS(`@import "tailwindcss"; /* A */`, ["mt-4"]); - await generateTailwindCSS(`@import "tailwindcss"; /* B */`, ["mt-4"]); - - const { entries } = getCompilerCacheStats(); - assertEquals(entries.length, 2); - - for (const { pluginCount } of entries) { - assert(pluginCount >= 0, "Should track plugin count"); - } - }); - - it("clearPluginCache should clear from all compilers", async () => { - await generateTailwindCSS(`@import "tailwindcss"; /* A */`, ["mt-4"]); - await generateTailwindCSS(`@import "tailwindcss"; /* B */`, ["mt-4"]); - - clearPluginCache(); - - assertEquals(getCompilerCacheStats().size, 2, "Compilers should remain cached"); - }); - }); - describe("Concurrent Safety", () => { it("should handle concurrent requests with different stylesheets", async () => { const stylesheets = [ @@ -100,8 +79,7 @@ describe("002.8 Tailwind Compiler Isolation", () => { ), ); - for (const { css, error } of results) { - assert(!error, "Should not have errors"); + for (const { css } of results) { assert(css.length > 0, "Should generate CSS"); } @@ -111,14 +89,10 @@ describe("002.8 Tailwind Compiler Isolation", () => { it("should handle concurrent requests with same stylesheet", async () => { const stylesheet = `@import "tailwindcss"; /* shared */`; - const results = await Promise.all( + await Promise.all( Array.from({ length: 10 }, () => generateTailwindCSS(stylesheet, ["mt-4"])), ); - for (const { error } of results) { - assert(!error, "Should not have errors"); - } - assertEquals(getCompilerCacheStats().size, 1, "Should reuse single compiler"); }); });