diff --git a/Dockerfile b/Dockerfile index 0314fb4..719e15a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,33 @@ # syntax=docker/dockerfile:1.7 -FROM node:22-alpine AS dependencies +FROM node:22-alpine AS image-runtime + +# Distribution libraries support baseline CPUs and dispatch optional SIMD at runtime. +RUN apk add --no-cache glib expat libjpeg-turbo libpng libwebp tiff lcms2 librsvg libstdc++ + +FROM image-runtime AS image-build +RUN apk add --no-cache python3 make g++ meson ninja pkgconf xz \ + glib-dev expat-dev libjpeg-turbo-dev libpng-dev libwebp-dev tiff-dev lcms2-dev librsvg-dev + +ADD --checksum=sha256:3c41e1d5458081bfa4a5bc54e116c46259c75c6760a18027764555632b9dda3e https://github.com/libvips/libvips/releases/download/v8.18.6/vips-8.18.6.tar.xz /tmp/vips.tar.xz +RUN if [ "$(uname -m)" = x86_64 ]; then export CFLAGS='-O2 -march=x86-64 -mtune=generic' CXXFLAGS='-O2 -march=x86-64 -mtune=generic'; fi \ + && mkdir /tmp/vips && tar -xJf /tmp/vips.tar.xz -C /tmp/vips --strip-components=1 \ + && meson setup /tmp/vips/build /tmp/vips --prefix=/opt/vips --libdir=lib \ + --buildtype=release --auto-features=disabled \ + -Dhighway=disabled -Dorc=disabled -Dcplusplus=true -Dexamples=false \ + -Djpeg=enabled -Dpng=enabled -Dwebp=enabled -Dtiff=enabled -Dlcms=enabled -Drsvg=enabled -Dzlib=enabled \ + && meson compile -C /tmp/vips/build -j 2 \ + && meson install -C /tmp/vips/build + +FROM image-build AS dependencies WORKDIR /app -# npm may build the native SQLite binding when no matching prebuild is available. -RUN apk add --no-cache python3 make g++ - ARG NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ -ENV NEXT_TELEMETRY_DISABLED=1 +ENV NEXT_TELEMETRY_DISABLED=1 \ + PKG_CONFIG_PATH=/opt/vips/lib/pkgconfig \ + LD_LIBRARY_PATH=/opt/vips/lib \ + npm_config_nodedir=/usr/local COPY package.json package-lock.json ./ RUN --mount=type=cache,id=portfolio-npm-node22,target=/root/.npm,sharing=locked \ @@ -17,8 +36,12 @@ RUN --mount=type=cache,id=portfolio-npm-node22,target=/root/.npm,sharing=locked --replace-registry-host=registry.npmjs.org \ --include=optional +# Compile Sharp against our baseline libvips, not its x86-64-v2 prebuilt binary. +RUN if [ "$(uname -m)" = x86_64 ]; then export CFLAGS='-O2 -march=x86-64 -mtune=generic' CXXFLAGS='-O2 -march=x86-64 -mtune=generic'; fi \ + && npm explore sharp -- npm run build + COPY scripts/check-image-runtime.mjs ./scripts/check-image-runtime.mjs -RUN node scripts/check-image-runtime.mjs +RUN node scripts/check-image-runtime.mjs --require-source FROM dependencies AS verification @@ -29,14 +52,15 @@ RUN --mount=type=cache,id=portfolio-next-node22,target=/app/.next/cache,sharing= npm run build RUN npm test -FROM node:22-alpine AS runtime +FROM image-runtime AS runtime WORKDIR /app ENV NODE_ENV=production \ NEXT_TELEMETRY_DISABLED=1 \ HOSTNAME=0.0.0.0 \ PORT=3000 \ - DATA_DIR=/data + DATA_DIR=/data \ + LD_LIBRARY_PATH=/opt/vips/lib RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs @@ -44,6 +68,11 @@ RUN addgroup --system --gid 1001 nodejs \ COPY --from=verification --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=verification --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=verification --chown=nextjs:nodejs /app/public ./public +COPY --from=image-build /opt/vips/lib /opt/vips/lib +# Explicitly retain the custom binding even if Next's file tracer misses it. +COPY --from=dependencies --chown=nextjs:nodejs /app/node_modules/sharp/src/build/Release/*.node ./node_modules/sharp/src/build/Release/ +COPY scripts/check-image-runtime.mjs ./scripts/check-image-runtime.mjs +RUN node scripts/check-image-runtime.mjs --require-source RUN mkdir -p .next/cache /data && chown -R nextjs:nodejs .next/cache /data USER nextjs diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 3145bbb..9c04028 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -57,10 +57,12 @@ Drizzle Kit, npm installs, or a compiler at runtime. Startup applies the committ SQL migrations and seeds samples only when the database is empty. `/api/health` checks database readiness without rendering the portfolio. -Sharp uses native image processing where supported and includes a WebAssembly -fallback for servers without the required x86-64 CPU features. The fallback -preserves uploads and image optimization, but can use more CPU time. Keep -`@img/sharp-wasm32` at the same version as `sharp` when upgrading. +The Docker build compiles libvips and Sharp from source for baseline CPUs. This +supports VPS hosts that lack the features required by Sharp's prebuilt native +and WebAssembly binaries. The first build takes longer; Docker caches the image +libraries for later application changes. Image processing is checked before +Next.js compilation and again in the final runtime image. When upgrading Sharp, +keep the libvips source version in the Dockerfile at or above Sharp's minimum. Use one application instance with a local filesystem. Do not mount the SQLite database over NFS/SMB, use an ephemeral serverless disk, or run multiple replicas diff --git a/next.config.mjs b/next.config.mjs index c8bca10..559be18 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -5,9 +5,7 @@ const nextConfig = { outputFileTracingIncludes: { '/*': [ './migrations/**/*', - './node_modules/@img/sharp-wasm32/**/*', - './node_modules/@emnapi/runtime/**/*', - './node_modules/tslib/**/*', + './node_modules/sharp/src/build/Release/*.node', ], }, outputFileTracingExcludes: { diff --git a/package-lock.json b/package-lock.json index 0a72c96..9989522 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "portfolio-v2-antigravity", "version": "0.1.0", "dependencies": { - "@img/sharp-wasm32": "0.35.4", "better-auth": "^1.7.5", "better-sqlite3": "^13.0.3", "drizzle-orm": "^0.45.2", @@ -33,6 +32,8 @@ "esbuild": "^0.28.2", "eslint": "^9.39.5", "eslint-config-next": "^16.3.4", + "node-addon-api": "8.9.2", + "node-gyp": "12.4.0", "tsx": "^4.23.13", "typescript": "^5.7.3" } @@ -457,6 +458,7 @@ "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@emnapi/runtime/-/runtime-1.11.3.tgz", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", + "optional": true, "dependencies": { "tslib": "^2.4.0" } @@ -1610,6 +1612,7 @@ "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, "dependencies": { "@emnapi/runtime": "^1.11.3" }, @@ -1696,6 +1699,19 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2734,6 +2750,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -3348,6 +3374,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/client-only/-/client-only-0.0.1.tgz", @@ -3730,6 +3766,16 @@ "dev": true, "license": "MIT" }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/es-abstract/-/es-abstract-1.24.2.tgz", @@ -4486,6 +4532,13 @@ "node": ">=0.10.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4838,6 +4891,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/has-bigints/-/has-bigints-1.1.0.tgz", @@ -5728,6 +5788,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/ms/-/ms-2.1.3.tgz", @@ -5882,6 +5965,57 @@ "semver": "bin/semver.js" } }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-releases": { "version": "2.0.54", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/node-releases/-/node-releases-2.0.54.tgz", @@ -5892,6 +6026,22 @@ "node": ">=18" } }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/object-assign/-/object-assign-4.1.1.tgz", @@ -6220,6 +6370,16 @@ "node": ">= 0.8.0" } }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/prop-types/-/prop-types-15.8.1.tgz", @@ -6948,6 +7108,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/three": { "version": "0.180.0", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/three/-/three-0.180.0.tgz", @@ -7227,6 +7414,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "6.28.1", + "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 4533c0a..93a42b0 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "maintenance": "tsx --env-file-if-exists=.env scripts/maintenance.ts" }, "dependencies": { - "@img/sharp-wasm32": "0.35.4", "better-auth": "^1.7.5", "better-sqlite3": "^13.0.3", "drizzle-orm": "^0.45.2", @@ -41,6 +40,8 @@ "esbuild": "^0.28.2", "eslint": "^9.39.5", "eslint-config-next": "^16.3.4", + "node-addon-api": "8.9.2", + "node-gyp": "12.4.0", "tsx": "^4.23.13", "typescript": "^5.7.3" }, diff --git a/scripts/check-image-runtime.mjs b/scripts/check-image-runtime.mjs index 896e3b7..5fbfe96 100644 --- a/scripts/check-image-runtime.mjs +++ b/scripts/check-image-runtime.mjs @@ -2,24 +2,19 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -// Check the fallback directly: Sharp 0.35.4 can hide its load error behind -// an unrelated `err.code.endsWith` exception on unsupported native CPUs. -try { - require('@img/sharp-wasm32/sharp.node'); - console.log('Sharp WebAssembly binding loaded.'); -} catch (error) { - console.error('Sharp WebAssembly binding failed to load:', error); - process.exit(1); -} - try { const { default: sharp } = await import('sharp'); - const input = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#336699' } }).webp().toBuffer(); - const metadata = await sharp(input).metadata(); - if (metadata.format !== 'webp' || metadata.width !== 8 || metadata.height !== 8) { - throw new Error('Image encode/decode verification failed'); + if (process.argv.includes('--require-source') && !Object.keys(require.cache).some(file => /sharp[/\\]src[/\\]build[/\\]Release[/\\].+\.node$/.test(file))) { + throw new Error('Expected the source-built Sharp binding; refusing to use a prebuilt fallback'); } - console.log('Sharp ESM import and image processing passed.'); + for (const format of ['jpeg', 'png', 'webp']) { + const input = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#336699' } }).toFormat(format).toBuffer(); + const metadata = await sharp(input).metadata(); + if (metadata.format !== format || metadata.width !== 8 || metadata.height !== 8) { + throw new Error(`${format} encode/decode verification failed`); + } + } + console.log(`Sharp ${sharp.versions.sharp}, libvips ${sharp.versions.vips}: JPEG, PNG and WebP passed.`); } catch (error) { console.error('Sharp image runtime failed:', error); process.exit(1); diff --git a/tests/sharp-fallback.test.mjs b/tests/sharp-fallback.test.mjs deleted file mode 100644 index 1c99ada..0000000 --- a/tests/sharp-fallback.test.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import test from 'node:test'; - -test('image processing falls back to WebAssembly without native CPU support', () => { - // Isolate the native-binding simulation from the rest of the test process. - const result = spawnSync(process.execPath, ['-e', ` - const Module = require('node:module'); - const original = Module._load; - Module._load = function (id, ...args) { - if (id.endsWith('.node') && !id.includes('wasm32')) { - const error = new Error('Simulated unavailable native binding'); - error.code = 'MODULE_NOT_FOUND'; - throw error; - } - return original.call(this, id, ...args); - }; - const sharp = require('sharp'); - (async () => { - const image = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#336699' } }).webp().toBuffer(); - const metadata = await sharp(image).metadata(); - if (metadata.format !== 'webp' || metadata.width !== 8 || metadata.height !== 8) throw new Error('Image round trip failed'); - })().catch(error => { console.error(error); process.exitCode = 1; }); - `], { encoding: 'utf8', timeout: 30000 }); - assert.equal(result.status, 0, result.stderr || result.error?.message); -});