diff --git a/.changeset/share-smart-cdn-image-policy.md b/.changeset/share-smart-cdn-image-policy.md new file mode 100644 index 00000000..73e6855f --- /dev/null +++ b/.changeset/share-smart-cdn-image-policy.md @@ -0,0 +1,22 @@ +--- +"@transloadit/utils": patch +--- + +Add a framework-neutral `createSmartCdnImageCandidates` policy with an injected signer, and make +the existing Node candidate helper share it. Optional intrinsic source dimensions now prevent +upscaling and renditions whose derived height exceeds Smart CDN's image limit. Framework adapters +can reuse the exported format and width normalization instead of copying those limits. + +Candidate policy now distinguishes millisecond timestamps from accidentally seconds-based expiry +values and reports an invalid width by its index. Runtime `null` qualities are rejected consistently +with the exported TypeScript contract instead of being treated as an omitted format. + +Ignore a legacy caller-provided `sig` while signing instead of including a value that the generated +signature replaces, which could otherwise produce an unverifiable URL. +Unsigned URLs now omit caller-provided `auth_key`, `exp`, and `sig` fields so they remain +unambiguously unsigned and round-trip through the parser. + +Require callers to select a trusted workspace Template explicitly. The helper no longer defaults +to the arbitrary-origin `builtin/serve-image` Template. Require a separate browser `fallbackUrl` +because Template inputs are not necessarily browser-resolvable URLs. These intentional patch-level +replacements affect only the newly introduced, not-yet-adopted image-candidate API. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ef041ae..45090311 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,20 @@ jobs: - run: corepack yarn changeset:version:release - run: corepack yarn release:pack:dry-run + img-next-fixture: + name: Image package Next.js fixture + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + cache: npm + cache-dependency-path: scripts/fixtures/img-next/package-lock.json + node-version: 24 + - run: corepack yarn install --immutable + - run: corepack yarn test:img:fixture + unit: name: Unit tests (Node ${{ matrix.node }}) runs-on: ubuntu-latest diff --git a/README.md b/README.md index 581823fb..15fc8623 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Monorepo for Transloadit SDKs, shared packages, and the MCP server. ## Packages +- `@transloadit/img` — Private extraction candidate for responsive Smart CDN images. See + `packages/img/README.md`. - `@transloadit/node` — Node.js SDK + CLI. See `packages/node/README.md`. - `transloadit` — Stable unscoped package (built from `@transloadit/node`). - `@transloadit/mcp-server` — MCP server (Streamable HTTP + stdio). See `packages/mcp-server/README.md`. diff --git a/docs/prompts/2026-08-31-img-delivery-dx.md b/docs/prompts/2026-08-31-img-delivery-dx.md new file mode 100644 index 00000000..c6d63488 --- /dev/null +++ b/docs/prompts/2026-08-31-img-delivery-dx.md @@ -0,0 +1,199 @@ +# `@transloadit/img` delivery and DX completion + +## Why + +The first private `@transloadit/img` cut proves that responsive Smart CDN candidates can be +rendered safely from Next.js. Its component API still exposes signing lifecycle and source-model +details that application authors should not need to understand. Private Storage previews also need +two explicit delivery choices: direct signed CDN URLs for high-volume views, and request-authorized +redirects for stricter revocation and cache-stable HTML. + +An unsigned public `next/image` loader is intentionally out of scope until API2 can bind an +immutable delivery profile to allowed origins, source paths, transforms, dimensions, and budgets. +Disabling signature enforcement on the current `serve-image` Built-in would create an open +transformation and billing proxy. + +## Decisions + +- [x] Return a small integration object from `createTransloaditImage`: `{ Image }` for direct + delivery and `{ Image, storageRoute }` when an authorization route is configured. +- [x] Use one `Image` component with `src="https://…"` or `src={{ storage: 'path/file' }}`. +- [x] Treat `width` and `height` as intrinsic dimensions, like the platform image element; remove + duplicate source dimensions and the aspect-ratio exception API. +- [x] Derive a conservative responsive width ladder by default; keep `widths` as an advanced + optional override and make `sizes` optional but strongly recommended. +- [x] Keep the long-lived public URL expiry policy in factory configuration and default its minimum + lifetime to one year. Rotate expiry in coarse buckets of at most one day so a long-lived + server factory cannot emit expired URLs while repeated renders stay cache-friendly. +- [x] Keep public URLs and direct private Storage previews going straight from the browser to Smart + CDN. A Next server never proxies their bytes. +- [x] Keep direct Storage delivery as the default for gallery-scale use. Signed URLs are generated + per request and support normal lazy loading; document the expiry tradeoff. +- [x] Add an opt-in Storage redirect mode for request-time application authorization. Its + expiry-free local URLs carry a deterministic AES-256-GCM-SIV capability over the exact path + and transform, so private names stay out of markup and clients cannot mutate them into + arbitrary work. The route re-authorizes, issues a short-lived Smart CDN URL, and responds + with a non-cacheable redirect; image bytes never pass through Next. +- [x] Reject duplicate/unknown route parameters, altered tokens, paths outside configured prefixes, + unsupported transforms, and failed authorization without exposing private object details. +- [x] Keep explicit AVIF/WebP `` sources and a JPEG fallback. Do not use `Accept`-driven + `format:auto` until CDN cache keys normalize or vary on the selected format. +- [x] Extend the packed Next.js 16 fixture to prove static public and redirect markup, request-time + direct signing, route authorization, redirects, no secret leakage, and correct byte paths. +- [x] Add deterministic 1/20/100-image delivery benchmarks for HTML size and route invocation + behavior. Record numbers without flaky wall-clock CI thresholds. +- [x] Rewrite the README around the short happy path, then explain the two private delivery modes + and their security/performance tradeoff progressively. +- [x] Run focused tests, `yarn check`, full verification, package dry run, packed Next fixture, and + council review. + +## API2 follow-up (read-only in this slice) + +- [x] Inspect `~/code/api2-clone-3` without modifying it. +- [x] Specify an immutable public delivery-profile contract with origin, path, redirect, transform, + output, abuse, and billing limits enforced before imports or processing start. +- [x] Specify edge validation for an application-issued path-scoped token or cookie, including cache + key normalization for CloudFront and Bunny. +- [x] Identify concrete API2 code, tests, infrastructure, Node SDK, release, and rollout work needed + before exposing a public `next/image` loader. + +### Read-only findings + +- `builtin/serve-image@0.0.1` accepts an arbitrary HTTP(S) `fields.input`. It bounds the requested + dimensions, quality, strategy, and format, but does not require signatures. Disabling a + workspace's signature requirement would therefore expose an origin-fetching transformation and + billing proxy. +- `builtin/storage-preview@0.0.1` and `builtin/storage-serve@0.0.1` correctly set + `requireSignatureAuth: true`. The URL Transform gate rejects their unsigned requests before an + Assembly is reserved, but Built-in definitions have no delivery-profile concept yet. +- CloudFront's `NoCacheSigExp` policy intentionally removes signature and expiry aliases from the + cache key. Its viewer-request function validates known keys before cache lookup, but currently + passes missing signatures and KVS misses onward. That fail-open behavior must never be attached to + a cache behavior serving private shared entries. +- Bunny currently varies on hostname and every query parameter. This preserves authorization + isolation, but every newly signed URL creates another cache entry. +- Bunny's August 2026 public preview changes the earlier provider conclusion: pre-cache Edge Scripts + now offer an `onClientRequest` hook on every request. Native Advanced Token Authentication also + supports HMAC-SHA256 over exact paths or prefixes and signs query parameters, but its Pull Zone + security key is not a per-workspace application-key store. A Transloadit-wide multi-tenant path + still needs a custom validator or an API2 token mint. +- API2 issue #7998 already defines the right immutable DAM identity: + `/d/{assetId}/v{versionNumber}/{filename}`. Issue #8441 covers named transformation presets and + responsive helpers, while #8796 owns worker-side decoded/intermediate/output resource safety. + +### Recommended public delivery profile + +Add a dedicated admission layer, not another boolean on `BuiltinTemplateDefinition`: + +1. A versioned profile ID resolves server-side to one exact source origin (or a small named origin + set), an allowed relative path prefix, and one pinned certified Template version. The public URL + accepts only the profile ID plus a relative source path; it never accepts an arbitrary absolute + origin or caller-selected Template. +2. The immutable profile records an allowed width lattice, qualities, formats, resize strategies, + maximum source/output bytes, pixels, redirects, concurrency, miss rate, and billing budget. + Redirects should be disabled in v1; if added, every hop must be revalidated against the same + origin/path policy with DNS-rebinding and private-network protections. +3. Canonicalize and reject duplicates before profile lookup. Enforce the profile before Assembly + reservation or Robot work. The cache identity includes host/workspace, profile version, canonical + source path, source version, transform, and selected format. +4. Reuse `builtin/serve-image` as the execution primitive, but pin a new certified version or a + profile-owned entry point. Do not silently broaden `0.0.1`, and do not allow arbitrary customer + Template overrides in the public profile. Trusted signed integrations may keep their override. +5. Treat #8796's worker preflight/postflight as a dependency for production abuse resistance. API2 + request validation cannot by itself bound decompression or intermediate allocation. + +This produces the eventual secretless loader contract: + +```ts +createTransloaditLoader({ profile: 'marketing-v3', workspace: 'my-app' }) +``` + +The loader may select width and quality but cannot hold an Auth Secret. `format:auto` should only be +enabled after the edge converts browser capability into an explicit selected-format cache dimension. +Until then, `@transloadit/img`'s AVIF/WebP `` sources remain safer and more predictable. + +### Recommended private edge authorization + +Use a dedicated protected hostname or cache behavior; do not retrofit optional auth onto the current +mixed public path: + +1. The application issues a versioned HMAC capability containing `kid`, audience, workspace/profile, + exact immutable asset version or canonical path prefix, allowed preset/transform, `nbf`, and `exp`. + Prefer DAM asset/version identity from #7998 so internal Storage paths never become delivery IDs. +2. A viewer-request/pre-cache validator runs on every request, including hits. Missing tokens, + unknown or stale key IDs, unavailable key state, duplicate claims, non-canonical paths, and invalid + signatures fail closed before cache lookup. +3. Only after successful validation may auth token and expiry be excluded from the shared cache key. + Host/workspace, resource/version, profile/preset, canonical transform, and selected format remain. + Strip viewer auth before origin/logging where possible and add an origin-only edge attestation; + API2 must reject direct requests and spoofable client headers. +4. Start with a separate CloudFront canary by tightening the existing function and KVS flow. Bunny + now has equivalent pre-cache hooks in public preview, so build the validator around shared golden + vectors and test it there next. Do not change the live wildcard or query variation until provider + tests prove invalid/expired/unsigned requests cannot reuse a warm object. +5. Native CloudFront signed cookies are attractive for granting a directory of stable URLs, and + native Bunny Advanced Tokens cover exact paths/prefixes. Neither alone provides Transloadit's + desired per-workspace app-issued key contract on the current shared distribution, so keep the + capability grammar provider-neutral. + +### Required verification and rollout + +- Share golden canonicalization/HMAC vectors between API2, Node SDK, CloudFront Functions, and Bunny + Edge Script tests. Cover key rotation, exact/prefix scope, transform scope, cross-workspace replay, + expiry/not-before, duplicate aliases, encoding ambiguity, and every fail-closed KVS/database path. +- Add API2 system tests showing rejected profile requests create no Assembly/import/Robot work, plus + origin/path/redirect/SSRF and per-budget tests. Exercise #8796's real-backend resource boundaries. +- Add provider smoke tests that warm one object with token A, hit it with token B, then prove an + absent, altered, expired, and cross-tenant token never receives that object. Verify distinct valid + tokens share one cached representation and all content-changing fields split the cache. +- Land and deploy Storage PR #8844 separately after resolving its current merge conflict and review. + Then ship: API2 profile admission; CloudFront canary and Terraform; Bunny preview parity; a Node SDK + patch with the secretless loader; `@transloadit/img` publication and Content dogfood; finally docs, + marketing, Astro/framework adapters, and Uppy/DAM integration where relevant. + +## Progress and evidence + +### Storage-only cutover + +The final pre-publication source contract is narrower than the initial prototype: + +- `@transloadit/img` accepts only relative Transloadit Storage object paths. The Next component uses + the native-looking `src="website/photo.jpg"` shape; the redundant discriminated source object and + every arbitrary HTTP-origin policy were removed before publication. +- `builtin/storage-preview@0.0.1` remains the signed default, with one trusted factory-level + `template` override. Template selection is never controlled by an individual image. +- `@transloadit/utils` keeps the lower-level remote-image candidate primitive for workspace-owned + Templates, but no longer selects `builtin/serve-image` implicitly. Its Template is mandatory. +- Content can first move to a signed workspace Template with a literal Transloadit origin, then move + canonical originals to Transloadit Storage without changing the browser rendering model. +- API2 may remove `builtin/serve-image` only after the SDK patch and Content cutover are deployed. + Removing the Built-in first would break the currently published utils default and Content's live + signed candidates. + +- 2026-08-31: Papertrail evidence for `builtin/serve-image` showed only the internal `my-app` + workspace, and organization-wide GitHub code search found no external repository consumer. The + bounded production SQL audit was not bypassed after SSH reported a changed host key. +- 2026-08-31: the Storage-only refactor removed the public URL factory policy, origin allowlist, + long-lived public expiry cache, source discriminator, and public fixture route. Direct and + authorized-redirect Storage delivery remain independently covered. + +- 2026-08-31: PR #481 was green and based on current `origin/main`; no human or bot review comments + were open before this completion slice started. +- 2026-08-31: 78 package tests pass. The packed Next.js 16.3 fixture proves static redirect markup + under a configured `basePath`, dynamic direct signing, authorization, tamper rejection, empty + non-cacheable redirects, and absence of secrets/private paths in browser-visible build output. +- 2026-08-31: The 100-image diagnostic measured direct delivery at 391,054 raw / 24,573 Brotli + bytes with zero application image requests; redirect delivery measured 280,008 raw / 64,476 + Brotli bytes plus 100 authorization redirects. Direct therefore remains the gallery default. +- 2026-08-31: Local Claude Opus security review passed without merge blockers after independently + verifying cross-policy cryptographic isolation and 113 package tests. Its recommended explicit + replay regression now covers secret, workspace, Template, route, and `basePath` binding. +- 2026-08-31: Council review found that factory-fixed public expiry could eventually go stale and + that `baseUrl` accepted non-HTTP schemes. Regression tests failed first; the implementation now + uses a Next `use cache` expiry function and eagerly validates an HTTP(S)-only base URL. The packed + Next 16.3 fixture keeps `/public-image` static while reporting a six-hour revalidation and + twelve-hour cache expiry. This describes the superseded public-URL prototype; the Storage-only + cutover above removes that route and cache policy. +- 2026-08-31: The API2 repository and Storage PR #8844 were inspected read-only. Its existing + untracked files were left untouched. Current CloudFront and Bunny configurations, Built-ins, URL + Transform admission, and DAM/resource-limit issues informed the recommendation above. diff --git a/knip.ts b/knip.ts index 41d79938..c398c1c9 100644 --- a/knip.ts +++ b/knip.ts @@ -20,6 +20,17 @@ const config: KnipConfig = { interface: true, }, workspaces: { + 'packages/img': { + entry: ['src/index.ts', 'src/next/index.tsx', 'src/next/server.tsx', 'test/**/*.{ts,tsx}'], + project: ['{src,test}/**/*.{ts,tsx}'], + ignore: [...sharedIgnore], + ignoreDependencies: [ + // Knip cannot infer the Vitest environment dependency from its file pragma. + 'happy-dom', + // Tooling lives at the repo root in this monorepo. + 'vitest', + ], + }, 'packages/node': { entry: ['src/Transloadit.ts', 'src/cli.ts', 'test/**/*.{ts,tsx,js,jsx}', 'vitest.config.ts'], project: ['{src,test}/**/*.{ts,tsx,js,jsx}'], diff --git a/package.json b/package.json index bac19e6f..553944f7 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "changeset:version:release": "yarn changeset version && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install", "release:pack:dry-run": "node scripts/release-dry-run.ts", "lint:js": "biome check .", - "lint:ts": "yarn tsc:types && yarn tsc:node && yarn tsc:zod && yarn workspace @transloadit/notify-url-relay lint:ts", + "lint:ts": "yarn tsc:types && yarn tsc:node && yarn tsc:zod && yarn tsc:img && yarn workspace @transloadit/notify-url-relay lint:ts", "lint:transloadit-sync": "node scripts/check-transloadit-sync.ts", "lint:changesets": "node scripts/guard-changesets.ts", "lint": "yarn lint:js", @@ -26,10 +26,12 @@ "knip": "yarn run --binaries-only knip --exclude binaries --no-config-hints --no-progress", "pack": "node scripts/pack-transloadit.ts", "parity:transloadit": "node scripts/prepare-transloadit.ts && node scripts/fingerprint-pack.ts packages/transloadit --ignore-scripts --quiet --out /tmp/transloadit-after.json && node scripts/verify-fingerprint.ts --current /tmp/transloadit-after.json --diff", - "test:unit": "yarn workspace @transloadit/utils test:unit && yarn workspace @transloadit/node test:unit && yarn workspace @transloadit/mcp-server test:unit && yarn workspace @transloadit/types test:unit && yarn workspace @transloadit/zod test:unit && yarn workspace @transloadit/notify-url-relay test:unit", + "test:img:fixture": "node scripts/test-img-next-fixture.ts", + "test:unit": "vitest run ./scripts/withProcess.test.ts ./scripts/img-next-fixture.test.ts && yarn workspace @transloadit/utils test:unit && yarn workspace @transloadit/img test:unit && yarn workspace @transloadit/node test:unit && yarn workspace @transloadit/mcp-server test:unit && yarn workspace @transloadit/types test:unit && yarn workspace @transloadit/zod test:unit && yarn workspace @transloadit/notify-url-relay test:unit", "test:types": "yarn workspace @transloadit/zod test:types", "test:e2e": "yarn workspace @transloadit/node test:e2e", "test": "yarn workspace @transloadit/node test", + "tsc:img": "yarn workspace @transloadit/img lint:ts", "tsc:node": "yarn tsc:utils && node ./node_modules/typescript/bin/tsc -b packages/node/tsconfig.build.json && chmod +x packages/node/dist/cli.js", "tsc:types": "yarn workspace @transloadit/types generate && node ./node_modules/typescript/bin/tsc -b packages/types/tsconfig.build.json", "tsc:utils": "yarn workspace @transloadit/utils lint:ts", @@ -40,6 +42,7 @@ "@changesets/cli": "^2.31.0", "@types/node": "^25.8.0", "@vitest/coverage-v8": "^4.1.6", + "execa": "^9.6.1", "jest-diff": "^30.4.1", "knip": "^6.14.1", "npm-run-all": "^4.1.5", diff --git a/packages/img/README.md b/packages/img/README.md new file mode 100644 index 00000000..a01ad971 --- /dev/null +++ b/packages/img/README.md @@ -0,0 +1,207 @@ +# `@transloadit/img` + +Responsive previews of Transloadit Storage objects, delivered through Smart CDN. + +The package renders native ``, `srcset`, and `` elements. Image bytes travel directly +from Smart CDN to the browser; they are never optimized or proxied by the Next.js application. +Remote HTTP URLs are deliberately outside this package's source contract: an image must already +belong to the configured Transloadit Storage workspace. + +This workspace remains private at version `0.0.0` while the API and production dogfood soak. Do not +depend on it from npm yet. + +## Next.js + +The server entry point targets the Next.js 16 App Router with `cacheComponents: true` in +`next.config.ts`. + +Create one server-only application module. The factory does not read environment variables: + +```tsx +import { createTransloaditImage } from '@transloadit/img/next/server' + +const authKey = process.env.TRANSLOADIT_KEY +const authSecret = process.env.TRANSLOADIT_SECRET +const workspace = process.env.TRANSLOADIT_WORKSPACE + +if (!authKey || !authSecret || !workspace) { + throw new Error('Transloadit image credentials are required') +} + +export const { Image } = createTransloaditImage({ + authKey, + authSecret, + storage: { allowedPathPrefixes: ['website/'] }, + workspace, +}) +``` + +The Auth Secret stays in the server module and never enters rendered markup or a client bundle. +Signed browser URLs contain the public Auth Key identifier, as required by Smart CDN verification. + +Use a relative Storage object path as `src` and provide the source's intrinsic dimensions: + +```tsx +import { Image } from '../lib/transloaditImage.tsx' + +export default function Page() { + return ( + A canal house + ) +} +``` + +`storage.allowedPathPrefixes` is a hard workspace boundary, not object authorization. Prefixes must +be relative directories ending in `/`. The default is deny-all; `['']` deliberately allows the +workspace root. Paths with dot segments, backslashes, empty segments, control characters, +non-normalized Unicode, or more than 1024 UTF-8 bytes are rejected before signing. + +### Direct delivery + +Direct delivery is the default and fits image-heavy views that already authorize their data while +rendering. The component calls Next.js `connection()` before creating short-lived signed URLs. A +built-in Suspense boundary lets a Cache Components page prerender a shell, but the signed image +itself is request-rendered and must not be stored in a shared full-page cache. +`suspenseFallback` customizes that shell. + +The browser requests the selected candidate directly from Smart CDN. Lazy loading remains the +platform default. A candidate first requested after its signature expires can fail on an unusually +long-lived page; choose an appropriate bounded `expiresInMs`, eagerly load a measured critical +image, or use authorized redirect delivery. + +### Authorized redirects + +Redirect delivery keeps markup stable and rechecks application access when the browser loads an +image: + +```tsx +import { createTransloaditImage } from '@transloadit/img/next/server' + +export const { Image, storageRoute } = createTransloaditImage({ + authKey, + authSecret, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { + authorize: async ({ path, request }) => { + const user = await authenticate(request) + return user !== null && (await canReadStorageObject(user, path)) + }, + // Match next.config.ts when the application uses basePath. + basePath: '/app', + route: '/api/private-images', + }, + }, + workspace, +}) +``` + +Export the handler from that exact App Router path: + +```ts +export { storageRoute as GET } from '../../../lib/transloaditImage.tsx' +``` + +The component emits same-origin URLs containing an authenticated-encrypted capability for one +exact Storage path and transformation. Filenames and credentials stay out of prerendered HTML. +The handler rejects changed, duplicate, unknown, oversized, or malformed capabilities before +calling application authorization. `authorize` must return the boolean `true` for the current +request. + +After authorization, the handler returns a private, non-cacheable `307` to a fresh signed Smart CDN +URL. Image bytes still bypass Next.js. Rotating the Transloadit secret invalidates existing +capabilities, so redeploy cached static markup at the same time. + +| Property | Direct, the default | Authorized redirect | +| --- | --- | --- | +| Next.js work per loaded image | None | One authorization + redirect | +| Image bytes through Next.js | Never | Never | +| Shared/static image markup | No | Yes | +| Request-time revocation | No | Yes | +| Long-lived lazy pages | Signature can expire | Fresh CDN signature per load | +| Typical fit | Large authorized galleries | Strict ACLs and revocation | + +## Responsive policy + +Storage previews use signed-only `builtin/storage-preview@0.0.1`. AVIF quality 45 and WebP quality +75 are emitted in browser preference order, with a JPEG quality 75 fallback. Explicit formats keep +CDN objects independent from an unkeyed `Accept` header. + +The default candidate ladder is 320, 640, 960, 1280, 1920, 2560, and 3840 pixels, capped at the +declared intrinsic width and backend-safe height. The exact intrinsic width is included between +steps. `widths` is an advanced per-image override. `sizes` is optional because that is valid HTML, +but strongly recommended whenever an image is not effectively `100vw`. + +```tsx +Product photo +``` + +- Images are lazy and asynchronously decoded by default. +- `preload` implies eager loading. Combine it with `fetchPriority="high"` only for a measured LCP + image. Explicitly lazy preloads are rejected. +- `objectFit` is forwarded for deliberate crop or containment behavior. +- `deferUntilHydrated` avoids WebKit parser-to-hydration replay for non-critical images. It cannot be + eager or preloaded and is not a secrecy mechanism. +- `fallbackQuality` changes the signed JPEG fallback quality. + +Private signature lifetimes default to at least one hour in stable five-minute rotation windows. +Their sum cannot exceed 48 hours: + +```tsx +storage: { + allowedPathPrefixes: ['documents/'], + expiresInMs: 2 * 60 * 60 * 1000, + rotationIntervalMs: 5 * 60 * 1000, +} +``` + +## Template override + +A compatible workspace Template can replace the Built-in in trusted factory configuration: + +```tsx +export const { Image } = createTransloaditImage({ + authKey, + authSecret, + storage: { allowedPathPrefixes: ['website/'] }, + template: 'my-storage-preview', + workspace, +}) +``` + +Template selection is unavailable on individual images because the factory owns the signing +boundary. A replacement must accept the same trusted fields as the Storage preview Built-in. + +## Framework-neutral API + +`@transloadit/img` exports `createTransloaditImageModel` and serializable model types. +`@transloadit/img/next` renders an already-resolved model. These lower-level entry points let other +framework adapters inject a server-side URL resolver while credential and authorization policy stay +outside the renderer. + +## Verification + +```console +corepack yarn workspace @transloadit/img check +corepack yarn test:img:fixture +``` + +The fixture packs the published artifacts, installs them into a clean Next.js 16 App Router app, +builds partially prerendered and dynamic routes, starts the production server, probes route +authorization and capability tampering, checks for secret leakage, and reports direct-versus- +redirect HTML size and route work for 1, 20, and 100 images. Size measurements are deterministic; +wall-clock measurements are diagnostic and do not create flaky CI thresholds. diff --git a/packages/img/package.json b/packages/img/package.json new file mode 100644 index 00000000..8d2b31a7 --- /dev/null +++ b/packages/img/package.json @@ -0,0 +1,70 @@ +{ + "name": "@transloadit/img", + "version": "0.0.0", + "description": "Responsive Transloadit Storage previews powered by Smart CDN", + "private": true, + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/transloadit/node-sdk", + "directory": "packages/img" + }, + "files": [ + "dist", + "README.md" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./next": { + "types": "./dist/next/index.d.ts", + "default": "./dist/next/index.js" + }, + "./next/server": { + "types": "./dist/next/server.d.ts", + "default": "./dist/next/server.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "../../node_modules/.bin/tsc --build --clean tsconfig.build.json && ../../node_modules/.bin/tsc --build tsconfig.build.json", + "check": "yarn lint:ts && yarn test:unit", + "lint:ts": "../../node_modules/.bin/tsc --build tsconfig.build.json && ../../node_modules/.bin/tsc --noEmit --project tsconfig.json", + "prepack": "yarn build", + "test:unit": "yarn build && ../../node_modules/.bin/vitest run ./test" + }, + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@transloadit/utils": "workspace:^", + "server-only": "^0.0.1" + }, + "peerDependencies": { + "next": ">=16.0.0 <17.0.0", + "react": ">=19.0.0 <20.0.0", + "react-dom": ">=19.0.0 <20.0.0" + }, + "peerDependenciesMeta": { + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "happy-dom": "^20.9.0", + "next": "16.3.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + } +} diff --git a/packages/img/src/index.ts b/packages/img/src/index.ts new file mode 100644 index 00000000..86d78ad7 --- /dev/null +++ b/packages/img/src/index.ts @@ -0,0 +1,174 @@ +import type { SignSmartCdnImageRequest, SmartCdnImageFormat } from '@transloadit/utils' + +import { + resolveSmartCdnImageFormats, + resolveSmartCdnImageWidths, + smartCdnImageMaxDimension, +} from '@transloadit/utils' + +import { validateStoragePath } from './storagePath.ts' + +export type { SignSmartCdnImageRequest, SmartCdnImageSignRequest } from '@transloadit/utils' + +/** Signed Built-in used by default for Transloadit Storage previews. */ +export const transloaditStoragePreviewTemplate = 'builtin/storage-preview@0.0.1' +const defaultFallbackQuality = 75 +const defaultResponsiveImageWidths: readonly number[] = [320, 640, 960, 1280, 1920, 2560, 3840] +const minimumMillisecondTimestamp = 1_000_000_000_000 + +/** Image formats emitted as modern Transloadit Storage preview sources. */ +export type StoragePreviewFormat = SmartCdnImageFormat + +/** At least one Storage preview format with its format-specific quality. */ +export type StoragePreviewFormats = { + [Format in StoragePreviewFormat]: Readonly< + Record & Partial, number>> + > +}[StoragePreviewFormat] + +/** One signed responsive-image candidate. */ +export interface TransloaditImageCandidate { + url: string + width: number +} + +/** Ordered candidates for one browser-selectable image format. */ +export interface TransloaditImageSourceSet { + candidates: readonly TransloaditImageCandidate[] + format: StoragePreviewFormat +} + +/** Serializable data consumed by framework renderers. */ +export interface TransloaditImageModel { + /** Fixed URL expiry. Omitted when an adapter resolves fresh URLs after browser authorization. */ + expiresAt?: number + fallbackUrl: string + sources: readonly TransloaditImageSourceSet[] +} + +/** Framework-neutral options for a responsive Transloadit Storage preview. */ +export interface TransloaditImageModelOptions { + expiresAt: number + /** Encoding quality for the signed JPEG fallback. Defaults to 75. */ + fallbackQuality?: number + formats?: StoragePreviewFormats + /** Storage preview aspect-ratio numerator. */ + height: number + /** Relative object path inside the configured Transloadit Storage workspace. */ + src: string + /** Trusted compatible signed Template. Defaults to `builtin/storage-preview@0.0.1`. */ + template?: string + /** Storage preview aspect-ratio denominator and conservative JPEG fallback width. */ + width: number + /** Requested intrinsic candidate widths. Defaults to a conservative ladder up to the source. */ + widths?: readonly number[] +} + +function validateDimension(value: number, name: string): void { + if (!Number.isInteger(value) || value < 1 || value > smartCdnImageMaxDimension) { + throw new RangeError(`${name} must be an integer from 1 through ${smartCdnImageMaxDimension}`) + } +} + +function validatePositiveSafeInteger(value: unknown, name: string): asserts value is number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive safe integer`) + } +} + +function validateTemplate(template: string): void { + if (typeof template !== 'string' || template === '' || template.trim() !== template) { + throw new TypeError('template must be a non-empty string without surrounding whitespace') + } +} + +function validateQuality(quality: number, name: string): void { + if (!Number.isInteger(quality) || quality < 1 || quality > 100) { + throw new RangeError(`${name} must be an integer from 1 through 100`) + } +} + +function getStorageHeight(candidateWidth: number, width: number, height: number): number { + const candidateHeight = Math.max(1, Math.round((candidateWidth * height) / width)) + validateDimension(candidateHeight, 'candidate height') + return candidateHeight +} + +function getResponsiveImageWidths( + widths: readonly number[] | undefined, + maximumWidth: number, +): readonly number[] { + if (widths !== undefined) return widths + return [...defaultResponsiveImageWidths.filter((width) => width < maximumWidth), maximumWidth] +} + +/** Creates one signed, serializable responsive preview of a Transloadit Storage object. */ +export function createTransloaditImageModel( + options: TransloaditImageModelOptions, + sign: SignSmartCdnImageRequest, +): TransloaditImageModel { + const expiresAt = options.expiresAt + const fallbackQuality = options.fallbackQuality ?? defaultFallbackQuality + const formats = options.formats === undefined ? undefined : { ...options.formats } + const height = options.height + const src = options.src + const template = options.template ?? transloaditStoragePreviewTemplate + const width = options.width + const widthsSnapshot = Array.isArray(options.widths) ? [...options.widths] : options.widths + + validatePositiveSafeInteger(expiresAt, 'expiresAt') + if (expiresAt < minimumMillisecondTimestamp) { + throw new RangeError('expiresAt must be a millisecond timestamp') + } + if (typeof sign !== 'function') throw new TypeError('sign must be a function') + validatePositiveSafeInteger(width, 'width') + validatePositiveSafeInteger(height, 'height') + validateQuality(fallbackQuality, 'fallbackQuality') + validateStoragePath(src) + validateTemplate(template) + + const heightLimitedWidth = Number( + (BigInt(smartCdnImageMaxDimension) * BigInt(width)) / BigInt(height), + ) + if (heightLimitedWidth < 1) { + throw new RangeError('display aspect ratio cannot fit within backend dimensions') + } + const maximumWidth = Math.min(width, smartCdnImageMaxDimension, heightLimitedWidth) + const widths = resolveSmartCdnImageWidths( + getResponsiveImageWidths(widthsSnapshot, maximumWidth), + maximumWidth, + ) + const sources = resolveSmartCdnImageFormats(formats).map(({ format, quality }) => ({ + candidates: widths.map((candidateWidth) => ({ + url: sign({ + expiresAt, + input: src, + template, + urlParams: { + f: format, + h: getStorageHeight(candidateWidth, width, height), + q: quality, + r: 'pad', + w: candidateWidth, + }, + }), + width: candidateWidth, + })), + format, + })) + const fallbackWidth = Math.min(width, maximumWidth) + const fallbackUrl = sign({ + expiresAt, + input: src, + template, + urlParams: { + f: 'jpg', + h: getStorageHeight(fallbackWidth, width, height), + q: fallbackQuality, + r: 'pad', + w: fallbackWidth, + }, + }) + + return { expiresAt, fallbackUrl, sources } +} diff --git a/packages/img/src/next/HydratedTransloaditPicture.tsx b/packages/img/src/next/HydratedTransloaditPicture.tsx new file mode 100644 index 00000000..a80f4737 --- /dev/null +++ b/packages/img/src/next/HydratedTransloaditPicture.tsx @@ -0,0 +1,24 @@ +'use client' + +import type { ReactNode } from 'react' + +import { useSyncExternalStore } from 'react' + +interface HydratedTransloaditPictureProps { + children: ReactNode + fallback: ReactNode +} + +const subscribe = (): (() => void) => () => {} +const getClientSnapshot = (): true => true +const getServerSnapshot = (): false => false + +/** Mounts responsive source elements after hydration while retaining a no-script fallback. */ +export function HydratedTransloaditPicture({ + children, + fallback, +}: HydratedTransloaditPictureProps): ReactNode { + const hydrated = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot) + + return hydrated ? children : +} diff --git a/packages/img/src/next/index.tsx b/packages/img/src/next/index.tsx new file mode 100644 index 00000000..bce31c63 --- /dev/null +++ b/packages/img/src/next/index.tsx @@ -0,0 +1,216 @@ +import type { CSSProperties, ReactNode } from 'react' + +import type { + TransloaditImageCandidate, + TransloaditImageModel, + TransloaditImageSourceSet, +} from '../index.ts' + +import { preload as preloadResource } from 'react-dom' + +import { HydratedTransloaditPicture } from './HydratedTransloaditPicture.tsx' + +const transparentPixel = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' +const mimeTypes = { + avif: 'image/avif', + png: 'image/png', + webp: 'image/webp', +} satisfies Record + +/** Presentation options shared by the signed Server Component and model-only renderer. */ +export interface TransloaditImagePresentationProps { + alt: string + className?: string + deferUntilHydrated?: boolean + fetchPriority?: 'auto' | 'high' | 'low' + height: number + loading?: 'eager' | 'lazy' + media?: string + /** CSP-compatible placeholder used while `media` is unmatched. Defaults to an inline GIF. */ + mediaPlaceholderSrc?: string + /** Explicitly handles a display box whose aspect ratio differs from the source image. */ + objectFit?: CSSProperties['objectFit'] + preload?: boolean + /** Expected rendered widths. Browsers otherwise assume `100vw` for width-based source sets. */ + sizes?: string + style?: CSSProperties + width: number +} + +/** Props for rendering an already-signed framework-neutral image model. */ +export interface TransloaditPictureProps extends TransloaditImagePresentationProps { + model: TransloaditImageModel +} + +function getSourceSet(candidates: readonly TransloaditImageCandidate[]): string { + if (candidates.length === 0) { + throw new Error('Cannot render an empty Transloadit image source') + } + return candidates.map(({ url, width }) => `${escapeSourceSetUrl(url)} ${width}w`).join(', ') +} + +function getMimeType(format: TransloaditImageSourceSet['format']): string { + return mimeTypes[format] +} + +function escapeSourceSetUrl(url: string): string { + const sourceSet = url + .replaceAll('\t', '%09') + .replaceAll('\n', '%0A') + .replaceAll('\f', '%0C') + .replaceAll('\r', '%0D') + .replaceAll(' ', '%20') + let firstUrlCharacter = 0 + while (sourceSet[firstUrlCharacter] === ',') firstUrlCharacter += 1 + if (firstUrlCharacter === sourceSet.length) return '%2C'.repeat(sourceSet.length) + + let afterLastUrlCharacter = sourceSet.length + while (sourceSet[afterLastUrlCharacter - 1] === ',') afterLastUrlCharacter -= 1 + return `${'%2C'.repeat(firstUrlCharacter)}${sourceSet.slice( + firstUrlCharacter, + afterLastUrlCharacter, + )}${'%2C'.repeat(sourceSet.length - afterLastUrlCharacter)}` +} + +function preloadImage( + source: TransloaditImageSourceSet, + sizes: string | undefined, + fetchPriority?: 'auto' | 'high' | 'low', +): void { + const firstCandidate = source.candidates[0] + if (firstCandidate === undefined) { + throw new Error('Cannot preload an empty Transloadit image source') + } + + preloadResource(firstCandidate.url, { + as: 'image', + fetchPriority, + imageSizes: sizes, + imageSrcSet: getSourceSet(source.candidates), + type: getMimeType(source.format), + }) +} + +function OriginalImage({ + alt, + className, + fetchPriority, + height, + loading, + objectFit, + src, + style, + width, +}: Pick< + TransloaditImagePresentationProps, + 'alt' | 'className' | 'fetchPriority' | 'height' | 'loading' | 'objectFit' | 'style' | 'width' +> & { + src?: string +}): ReactNode { + return ( + // biome-ignore lint/performance/noImgElement: This package is the image optimizer. + {alt} + ) +} + +/** + * Renders browser-selected responsive candidates with one fallback. `media` keeps an unmatched + * viewport inert; the caller controls whether its layout still reserves space in that viewport. + * `deferUntilHydrated` avoids WebKit parser-to-hydration request replay. + */ +export function TransloaditPicture({ + alt, + className, + deferUntilHydrated = false, + fetchPriority, + height, + loading, + media, + mediaPlaceholderSrc, + model, + objectFit, + preload = false, + sizes, + style, + width, +}: TransloaditPictureProps): ReactNode { + if (deferUntilHydrated && (loading === 'eager' || preload)) { + throw new Error('An eager or preloaded Transloadit image cannot be deferred until hydration') + } + if (preload && loading === 'lazy') { + throw new Error('A preloaded Transloadit image cannot use lazy loading') + } + if (preload && media !== undefined) { + // React 19's responsive-preload identity omits media and can silently collapse art direction. + throw new Error('A media-gated Transloadit image cannot be preloaded') + } + const resolvedLoading = loading ?? (preload ? 'eager' : 'lazy') + if (model.sources.length === 0) { + throw new Error('Cannot render a Transloadit image without a source') + } + + const original = ( + takes precedence over either fallback. + src={media ? (mediaPlaceholderSrc ?? transparentPixel) : model.fallbackUrl} + style={style} + width={width} + /> + ) + const fallback = media ? ( + + + {original} + + ) : ( + original + ) + + if (preload) { + const preferredSource = model.sources[0] + if (preferredSource === undefined) { + throw new Error('Cannot preload a Transloadit image without a source') + } + preloadImage(preferredSource, sizes, fetchPriority) + } + + const picture = ( + + {model.sources.map((source) => ( + + ))} + {media ? : null} + {original} + + ) + + return deferUntilHydrated ? ( + {picture} + ) : ( + picture + ) +} diff --git a/packages/img/src/next/server.tsx b/packages/img/src/next/server.tsx new file mode 100644 index 00000000..35b27329 --- /dev/null +++ b/packages/img/src/next/server.tsx @@ -0,0 +1,681 @@ +import 'server-only' + +import type { SmartCdnUrlParams } from '@transloadit/utils/node' +import type { ReactNode } from 'react' + +import type { + SmartCdnImageSignRequest, + StoragePreviewFormats, + TransloaditImageModel, +} from '../index.ts' +import type { TransloaditImagePresentationProps } from './index.tsx' + +import { hkdfSync } from 'node:crypto' + +import { gcmsiv } from '@noble/ciphers/aes.js' +import { getSignedSmartCdnUrl } from '@transloadit/utils/node' +import { connection } from 'next/server.js' +import { Suspense } from 'react' + +import { createTransloaditImageModel, transloaditStoragePreviewTemplate } from '../index.ts' +import { validateStoragePath, validateStoragePathPrefix } from '../storagePath.ts' +import { TransloaditPicture } from './index.tsx' + +const defaultStorageExpiresInMs = 60 * 60 * 1000 +const defaultStorageRotationIntervalMs = 5 * 60 * 1000 +const imagePolicyParams = new Set(['auth_key', 'exp', 'f', 'h', 'q', 'r', 'sig', 'w']) +const maximumImageDimension = 8000 +const maximumStorageLifetimeMs = 48 * 60 * 60 * 1000 +const storageCapabilityAuthenticationBytes = 16 +const storageCapabilityMaximumLength = 4096 +const storageCapabilityMinimumBytes = storageCapabilityAuthenticationBytes + 1 +const storageCapabilityPattern = /^[A-Za-z0-9_-]+$/ +const storageCapabilityVersion = 1 +const storageRouteKeyDomain = '@transloadit/img/storage-route/v1' + +/** Values available to application authorization before a Storage redirect is issued. */ +export interface TransloaditStorageAuthorizationContext { + path: string + request: Request +} + +/** Application authorization for one exact private Storage object. */ +export type AuthorizeTransloaditStorageImage = ( + context: TransloaditStorageAuthorizationContext, +) => boolean | Promise + +/** Request-authorized, byte-pass-through-free Storage delivery through a local route. */ +export interface TransloaditStorageRedirectDelivery { + authorize: AuthorizeTransloaditStorageImage + /** Next.js `basePath` prepended only to browser-facing route URLs. */ + basePath?: string + /** Internal App Router path that exports `storageRoute`, for example `/api/private-images`. */ + route: string +} + +/** Bounded request-time policy for private Storage previews. */ +export interface TransloaditStorageImageConfiguration { + /** Authorized directory prefixes. Defaults to deny-all; an empty prefix explicitly allows all. */ + allowedPathPrefixes?: readonly string[] + /** Direct signed CDN URLs are the default; an object opts into authorized redirect delivery. */ + delivery?: 'direct' | TransloaditStorageRedirectDelivery + /** Minimum lifetime of each CDN signature. Defaults to one hour. */ + expiresInMs?: number + /** Stable CDN-signature rotation bucket. Defaults to five minutes. */ + rotationIntervalMs?: number +} + +/** Server-only credentials and trusted Smart CDN configuration. */ +export interface TransloaditImageConfiguration { + authKey: string + authSecret: string + /** Trusted development endpoint override; never derive this from request data. */ + baseUrl?: string + storage: TransloaditStorageImageConfiguration + /** Trusted compatible signed Template override for Storage previews. */ + template?: string + /** Trusted transport parameters appended to every signed URL, such as `cdn=required`. */ + urlParams?: SmartCdnUrlParams + workspace: string +} + +/** Configuration that opts into a request-authorized Storage route. */ +export interface TransloaditRedirectImageConfiguration extends TransloaditImageConfiguration { + storage: TransloaditStorageImageConfiguration & { + delivery: TransloaditStorageRedirectDelivery + } +} + +interface CommonTransloaditImageProps extends TransloaditImagePresentationProps { + /** Advanced candidate override. Defaults to a conservative ladder capped at `width`. */ + widths?: readonly number[] +} + +/** Props for a private Transloadit Storage preview. */ +export interface TransloaditImageProps + extends Omit { + /** Encoding quality for the signed JPEG fallback. Defaults to 75. */ + fallbackQuality?: number + formats?: StoragePreviewFormats + media?: never + mediaPlaceholderSrc?: never + /** Relative object path inside the configured Transloadit Storage workspace. */ + src: string + /** Static shell used only while direct request-time signing is suspended. */ + suspenseFallback?: ReactNode +} + +/** One configured Next.js Server Component for Transloadit Storage objects. */ +export type TransloaditImageComponent = (props: TransloaditImageProps) => ReactNode + +/** A Next.js route handler that authorizes and redirects one private image request. */ +export type TransloaditStorageRoute = (request: Request) => Promise + +/** Direct-delivery integration. Image bytes and requests bypass the Next.js server. */ +export interface TransloaditImageIntegration { + Image: TransloaditImageComponent +} + +/** Redirect-delivery integration with a route handler for private Storage images. */ +export interface TransloaditRedirectImageIntegration extends TransloaditImageIntegration { + storageRoute: TransloaditStorageRoute +} + +interface ResolvedStoragePolicy { + allowedPathPrefixes: readonly string[] + delivery: 'direct' | TransloaditStorageRedirectDelivery + expiresInMs: number + rotationIntervalMs: number +} + +interface ResolvedStorageCapabilityPolicy { + context: string + delivery: TransloaditStorageRedirectDelivery + key: Buffer +} + +interface StorageImageTransform { + format: 'avif' | 'jpg' | 'png' | 'webp' + height: number + path: string + quality: number + width: number +} + +interface TransloaditStorageImageRequestProps { + props: TransloaditImageProps +} + +function validateRequiredConfiguration(value: string, name: string): void { + if (typeof value !== 'string' || value === '' || value.trim() !== value) { + throw new TypeError(`${name} must be a non-empty string without surrounding whitespace`) + } +} + +function validateDuration(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive safe integer`) + } +} + +function validateBaseUrl(baseUrl: string | undefined): void { + if (baseUrl === undefined) return + const error = new TypeError( + 'baseUrl must be an absolute HTTP(S) URL without credentials, a query string, or a fragment', + ) + if (typeof baseUrl !== 'string' || baseUrl === '' || baseUrl.trim() !== baseUrl) throw error + let parsed: URL + try { + parsed = new URL(baseUrl.replaceAll('{workspace}', 'workspace')) + } catch { + throw error + } + if ( + (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || + parsed.username !== '' || + parsed.password !== '' || + parsed.search !== '' || + parsed.hash !== '' + ) { + throw error + } +} + +function validateTemplate(template: string | undefined, name: string): void { + if (template === undefined) return + if (typeof template !== 'string' || template === '' || template.trim() !== template) { + throw new TypeError(`${name} must be a non-empty string without surrounding whitespace`) + } +} + +function validateGlobalUrlParams(urlParams: SmartCdnUrlParams | undefined): void { + for (const parameter of Object.keys(urlParams ?? {})) { + if (imagePolicyParams.has(parameter)) { + throw new TypeError(`urlParams must not override image policy parameter: ${parameter}`) + } + } +} + +function validateStorageRoute(route: string): void { + const error = new TypeError('storage.delivery.route must be one absolute application path') + if ( + typeof route !== 'string' || + !route.startsWith('/') || + route.startsWith('//') || + route.length > 1024 + ) { + throw error + } + const parsed = new URL(route, 'https://transloadit.invalid') + if (parsed.origin !== 'https://transloadit.invalid' || parsed.pathname !== route) throw error +} + +function validateStorageBasePath(basePath: string | undefined): void { + if (basePath === undefined) return + const error = new TypeError( + 'storage.delivery.basePath must be one absolute path without a trailing slash', + ) + if ( + typeof basePath !== 'string' || + basePath === '' || + basePath === '/' || + !basePath.startsWith('/') || + basePath.startsWith('//') || + basePath.endsWith('/') || + basePath.length > 1024 + ) { + throw error + } + const parsed = new URL(basePath, 'https://transloadit.invalid') + if (parsed.origin !== 'https://transloadit.invalid' || parsed.pathname !== basePath) throw error +} + +function getBrowserStorageRoute(delivery: TransloaditStorageRedirectDelivery): string { + return `${delivery.basePath ?? ''}${delivery.route}` +} + +function removeTrailingSlash(path: string): string { + return path === '/' || !path.endsWith('/') ? path : path.slice(0, -1) +} + +function matchesStorageRoute(path: string, delivery: TransloaditStorageRedirectDelivery): boolean { + const normalized = removeTrailingSlash(path) + return ( + normalized === removeTrailingSlash(delivery.route) || + normalized === removeTrailingSlash(getBrowserStorageRoute(delivery)) + ) +} + +function getStoragePolicy( + configuration: TransloaditStorageImageConfiguration, +): ResolvedStoragePolicy { + const allowedPathPrefixes = configuration.allowedPathPrefixes ?? [] + const delivery = configuration.delivery ?? 'direct' + const expiresInMs = configuration.expiresInMs ?? defaultStorageExpiresInMs + const rotationIntervalMs = configuration.rotationIntervalMs ?? defaultStorageRotationIntervalMs + if (!Array.isArray(allowedPathPrefixes)) { + throw new TypeError('storage.allowedPathPrefixes must be an array') + } + const validatedPathPrefixes = new Set() + for (const [index, prefix] of allowedPathPrefixes.entries()) { + validateStoragePathPrefix(prefix, index) + validatedPathPrefixes.add(prefix) + } + validateDuration(expiresInMs, 'storage.expiresInMs') + validateDuration(rotationIntervalMs, 'storage.rotationIntervalMs') + if (expiresInMs + rotationIntervalMs > maximumStorageLifetimeMs) { + throw new RangeError('Storage image expiry plus its rotation interval must not exceed 48 hours') + } + if (delivery !== 'direct') { + if (typeof delivery !== 'object' || delivery === null || Array.isArray(delivery)) { + throw new TypeError('storage.delivery must be direct or a redirect configuration') + } + validateStorageRoute(delivery.route) + validateStorageBasePath(delivery.basePath) + if (typeof delivery.authorize !== 'function') { + throw new TypeError('storage.delivery.authorize must be a function') + } + } + return { + allowedPathPrefixes: [...validatedPathPrefixes], + delivery: + delivery === 'direct' + ? delivery + : { + authorize: delivery.authorize, + basePath: delivery.basePath, + route: delivery.route, + }, + expiresInMs, + rotationIntervalMs, + } +} + +function getStorageExpiresAt(now: number, policy: ResolvedStoragePolicy): number { + const nextRotation = (Math.floor(now / policy.rotationIntervalMs) + 1) * policy.rotationIntervalMs + return nextRotation + policy.expiresInMs +} + +function assertAllowedStoragePath(path: string, policy: ResolvedStoragePolicy): void { + validateStoragePath(path) + if (!policy.allowedPathPrefixes.some((prefix) => path.startsWith(prefix))) { + throw new TypeError('Storage image path is outside the configured allowed prefixes') + } +} + +function snapshotUrlParams( + urlParams: SmartCdnUrlParams | undefined, +): SmartCdnUrlParams | undefined { + if (urlParams === undefined) return undefined + const snapshot: SmartCdnUrlParams = {} + for (const [key, value] of Object.entries(urlParams)) { + snapshot[key] = Array.isArray(value) ? [...value] : value + } + return snapshot +} + +function snapshotStorageImageProps( + props: TransloaditImageProps, + path: string, +): TransloaditImageProps { + return { + alt: props.alt, + className: props.className, + deferUntilHydrated: props.deferUntilHydrated, + fallbackQuality: props.fallbackQuality, + fetchPriority: props.fetchPriority, + formats: props.formats === undefined ? undefined : { ...props.formats }, + height: props.height, + loading: props.loading, + objectFit: props.objectFit, + preload: props.preload, + sizes: props.sizes, + src: path, + style: props.style === undefined ? undefined : { ...props.style }, + suspenseFallback: props.suspenseFallback, + width: props.width, + widths: Array.isArray(props.widths) ? [...props.widths] : props.widths, + } +} + +function getStoragePath(src: unknown): string { + if (typeof src !== 'string') { + throw new TypeError('Storage image src must be one relative object path') + } + return src +} + +function renderPicture( + props: CommonTransloaditImageProps, + model: Parameters[0]['model'], +): ReactNode { + return ( + + ) +} + +function getStorageTransform(request: SmartCdnImageSignRequest): StorageImageTransform { + const { f: format, h: height, q: quality, r: strategy, w: width } = request.urlParams + if ( + (format !== 'avif' && format !== 'jpg' && format !== 'png' && format !== 'webp') || + typeof height !== 'number' || + typeof quality !== 'number' || + strategy !== 'pad' || + typeof width !== 'number' + ) { + throw new TypeError('Storage image model produced an unsupported transform') + } + return { format, height, path: request.input, quality, width } +} + +function createStorageRouteKey(authSecret: string, workspace: string): Buffer { + return Buffer.from(hkdfSync('sha256', authSecret, storageRouteKeyDomain, workspace, 32)) +} + +function getStorageCapabilityContext( + delivery: TransloaditStorageRedirectDelivery, + template: string, + workspace: string, +): string { + return JSON.stringify([ + storageRouteKeyDomain, + workspace, + template, + delivery.route, + getBrowserStorageRoute(delivery), + ]) +} + +function encryptStorageCapability( + context: string, + key: Buffer, + transform: StorageImageTransform, +): string { + // GCM-SIV safely tolerates nonce reuse. A fixed nonce keeps prerendered URLs deterministic while + // revealing only whether two capabilities protect the same path and transform under one policy. + const cipher = gcmsiv(key, new Uint8Array(12), Buffer.from(context)) + const plaintext = Buffer.from(JSON.stringify({ ...transform, version: storageCapabilityVersion })) + return Buffer.from(cipher.encrypt(plaintext)).toString('base64url') +} + +function getStorageRouteUrl( + context: string, + delivery: TransloaditStorageRedirectDelivery, + key: Buffer, + request: SmartCdnImageSignRequest, +): string { + const capability = encryptStorageCapability(context, key, getStorageTransform(request)) + return `${getBrowserStorageRoute(delivery)}?${new URLSearchParams({ cap: capability })}` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isStorageRouteFormat(value: unknown): value is StorageImageTransform['format'] { + return value === 'avif' || value === 'jpg' || value === 'png' || value === 'webp' +} + +function getStorageTransformFromPayload(payload: unknown): StorageImageTransform | undefined { + if (!isRecord(payload) || payload.version !== storageCapabilityVersion) return undefined + const { format, height, path, quality, width } = payload + if ( + !isStorageRouteFormat(format) || + typeof height !== 'number' || + !Number.isInteger(height) || + height < 1 || + height > maximumImageDimension || + typeof path !== 'string' || + typeof quality !== 'number' || + !Number.isInteger(quality) || + quality < 1 || + quality > 100 || + typeof width !== 'number' || + !Number.isInteger(width) || + width < 1 || + width > maximumImageDimension + ) { + return undefined + } + validateStoragePath(path) + return { format, height, path, quality, width } +} + +function decryptStorageCapability( + capability: string | null, + context: string, + key: Buffer, +): StorageImageTransform | undefined { + if ( + capability === null || + capability.length > storageCapabilityMaximumLength || + !storageCapabilityPattern.test(capability) + ) { + return undefined + } + const encoded = Buffer.from(capability, 'base64url') + if ( + encoded.byteLength < storageCapabilityMinimumBytes || + encoded.toString('base64url') !== capability + ) { + return undefined + } + try { + const cipher = gcmsiv(key, new Uint8Array(12), Buffer.from(context)) + const plaintext = Buffer.from(cipher.decrypt(encoded)).toString('utf8') + const payload: unknown = JSON.parse(plaintext) + return getStorageTransformFromPayload(payload) + } catch { + return undefined + } +} + +function parseStorageRouteTransform( + url: URL, + context: string, + key: Buffer, +): StorageImageTransform | undefined { + const parameters = [...url.searchParams.keys()] + if (parameters.length !== 1 || url.searchParams.getAll('cap').length !== 1) return undefined + return decryptStorageCapability(url.searchParams.get('cap'), context, key) +} + +function notFound(): Response { + return new Response(null, { + headers: { 'Cache-Control': 'private, no-store' }, + status: 404, + }) +} + +function createStorageRoute( + context: string, + delivery: TransloaditStorageRedirectDelivery, + key: Buffer, + policy: ResolvedStoragePolicy, + sign: (request: SmartCdnImageSignRequest) => string, + template: string, +): TransloaditStorageRoute { + return async function storageRoute(request: Request): Promise { + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response(null, { + headers: { Allow: 'GET, HEAD', 'Cache-Control': 'private, no-store' }, + status: 405, + }) + } + const url = new URL(request.url) + if (!matchesStorageRoute(url.pathname, delivery)) return notFound() + const transform = parseStorageRouteTransform(url, context, key) + if (transform === undefined) return notFound() + try { + assertAllowedStoragePath(transform.path, policy) + } catch { + return notFound() + } + if ((await delivery.authorize({ path: transform.path, request })) !== true) return notFound() + + const location = sign({ + expiresAt: getStorageExpiresAt(Date.now(), policy), + input: transform.path, + template, + urlParams: { + f: transform.format, + h: transform.height, + q: transform.quality, + r: 'pad', + w: transform.width, + }, + }) + return new Response(null, { + headers: { + 'Cache-Control': 'private, no-store', + Location: location, + 'Referrer-Policy': 'no-referrer', + }, + status: 307, + }) + } +} + +/** Creates one credentialed Next.js image integration without reading application environment. */ +export function createTransloaditImage( + configuration: TransloaditRedirectImageConfiguration, +): TransloaditRedirectImageIntegration +export function createTransloaditImage( + configuration: TransloaditImageConfiguration, +): TransloaditImageIntegration +export function createTransloaditImage( + configuration: TransloaditImageConfiguration, +): TransloaditImageIntegration | TransloaditRedirectImageIntegration { + const authKey = configuration.authKey + const authSecret = configuration.authSecret + const baseUrl = configuration.baseUrl + const storageTemplate = configuration.template ?? transloaditStoragePreviewTemplate + const urlParams = snapshotUrlParams(configuration.urlParams) + const workspace = configuration.workspace + validateRequiredConfiguration(authKey, 'authKey') + validateRequiredConfiguration(authSecret, 'authSecret') + validateRequiredConfiguration(workspace, 'workspace') + validateBaseUrl(baseUrl) + validateTemplate(storageTemplate, 'template') + validateGlobalUrlParams(urlParams) + + const storagePolicy = getStoragePolicy(configuration.storage) + // Redirect capabilities do not encode this value; keeping one factory snapshot makes their + // prerendered markup deterministic while request-time CDN signatures rotate independently. + const storageCapabilityModelExpiresAt = getStorageExpiresAt(Date.now(), storagePolicy) + const sign = (request: SmartCdnImageSignRequest): string => + getSignedSmartCdnUrl({ + authKey, + authSecret, + baseUrl, + expiresAt: request.expiresAt, + input: request.input, + template: request.template, + urlParams: { ...urlParams, ...request.urlParams }, + workspace, + }) + const storageCapability: ResolvedStorageCapabilityPolicy | undefined = + storagePolicy.delivery === 'direct' + ? undefined + : { + context: getStorageCapabilityContext(storagePolicy.delivery, storageTemplate, workspace), + delivery: storagePolicy.delivery, + key: createStorageRouteKey(authSecret, workspace), + } + const buildStorageUrl = + storageCapability === undefined + ? sign + : (request: SmartCdnImageSignRequest): string => + getStorageRouteUrl( + storageCapability.context, + storageCapability.delivery, + storageCapability.key, + request, + ) + + async function DirectStorageImage({ + props, + }: TransloaditStorageImageRequestProps): Promise { + await connection() + const model = createTransloaditImageModel( + { + expiresAt: getStorageExpiresAt(Date.now(), storagePolicy), + fallbackQuality: props.fallbackQuality, + formats: props.formats, + height: props.height, + src: props.src, + template: storageTemplate, + width: props.width, + widths: props.widths, + }, + sign, + ) + return renderPicture(props, model) + } + + function Image(props: TransloaditImageProps): ReactNode { + const storagePath = getStoragePath(props.src) + if (props.media !== undefined) { + throw new TypeError('Storage image previews do not support media conditions') + } + assertAllowedStoragePath(storagePath, storagePolicy) + const storageProps = snapshotStorageImageProps(props, storagePath) + if (storageCapability === undefined) { + return ( + + + + ) + } + if (props.suspenseFallback !== undefined) { + throw new TypeError('suspenseFallback is only used by direct Storage delivery') + } + const resolvedModel = createTransloaditImageModel( + { + expiresAt: storageCapabilityModelExpiresAt, + fallbackQuality: props.fallbackQuality, + formats: props.formats, + height: props.height, + src: storagePath, + template: storageTemplate, + width: props.width, + widths: props.widths, + }, + buildStorageUrl, + ) + const model: TransloaditImageModel = { + fallbackUrl: resolvedModel.fallbackUrl, + sources: resolvedModel.sources, + } + return renderPicture(storageProps, model) + } + + const integration: TransloaditImageIntegration = { Image } + if (storageCapability === undefined) return integration + return { + ...integration, + storageRoute: createStorageRoute( + storageCapability.context, + storageCapability.delivery, + storageCapability.key, + storagePolicy, + sign, + storageTemplate, + ), + } +} diff --git a/packages/img/src/storagePath.ts b/packages/img/src/storagePath.ts new file mode 100644 index 00000000..dea89c5c --- /dev/null +++ b/packages/img/src/storagePath.ts @@ -0,0 +1,73 @@ +const maximumStoragePathLength = 1024 +const invalidUnicodePattern = /[\p{Cc}\p{Cs}]/u + +function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength +} + +function exceedsMaximumStoragePathLength(value: string): boolean { + return value.length > maximumStoragePathLength || utf8ByteLength(value) > maximumStoragePathLength +} + +function hasAmbiguousSegments(path: string): boolean { + // A percent escape is literal object-key text here: signing encodes `%`, and API2 decodes the + // Smart CDN route exactly once before matching the same catalog path. + return ( + path.includes('|') || + path.includes('\\') || + path.split('/').some((segment) => segment === '.' || segment === '..') + ) +} + +function hasInvalidSegments(path: string): boolean { + return path.split('/').some((segment) => segment === '' || segment.trim() === '') +} + +/** Validates one object path before it reaches signing or an authorization-prefix comparison. */ +export function validateStoragePath(path: string): void { + if (typeof path !== 'string') { + throw new TypeError( + 'Storage image paths must be non-empty relative strings without surrounding whitespace', + ) + } + if (exceedsMaximumStoragePathLength(path)) { + throw new TypeError('Storage image paths must be at most 1024 UTF-8 bytes') + } + if (path === '' || path.trim() !== path || path.startsWith('/')) { + throw new TypeError( + 'Storage image paths must be non-empty relative strings without surrounding whitespace', + ) + } + if (hasAmbiguousSegments(path)) { + throw new TypeError( + 'Storage image paths must not contain delimiters, dot segments, or backslashes', + ) + } + if ( + path.normalize('NFC') !== path || + invalidUnicodePattern.test(path) || + hasInvalidSegments(path) + ) { + throw new TypeError('Storage image paths must use normalized, non-empty path segments') + } +} + +/** Validates one directory-boundary prefix; an empty prefix explicitly allows the workspace root. */ +export function validateStoragePathPrefix(prefix: string, index: number): void { + if (prefix === '') return + if ( + typeof prefix !== 'string' || + prefix.trim() !== prefix || + prefix.startsWith('/') || + !prefix.endsWith('/') || + exceedsMaximumStoragePathLength(prefix) || + prefix.normalize('NFC') !== prefix || + invalidUnicodePattern.test(prefix) || + hasAmbiguousSegments(prefix) || + hasInvalidSegments(prefix.slice(0, -1)) + ) { + throw new TypeError( + `storage.allowedPathPrefixes[${index}] must be empty or one safe relative prefix ending in /`, + ) + } +} diff --git a/packages/img/test/model.test.ts b/packages/img/test/model.test.ts new file mode 100644 index 00000000..292769eb --- /dev/null +++ b/packages/img/test/model.test.ts @@ -0,0 +1,281 @@ +import type { SmartCdnImageSignRequest, TransloaditImageModelOptions } from '../src/index.ts' + +import { describe, expect, test } from 'vitest' + +import { createTransloaditImageModel } from '../src/index.ts' + +const expiresAt = Date.UTC(2030, 0, 1) + +function collectSignedRequests(): { + requests: SmartCdnImageSignRequest[] + sign: (request: SmartCdnImageSignRequest) => string +} { + const requests: SmartCdnImageSignRequest[] = [] + return { + requests, + sign(request) { + requests.push(request) + return `https://cdn.example/${requests.length}` + }, + } +} + +describe('createTransloaditImageModel', () => { + test('builds responsive Storage previews and a signed JPEG fallback', () => { + const { requests, sign } = collectSignedRequests() + const model = createTransloaditImageModel( + { + expiresAt, + fallbackQuality: 68, + formats: { webp: 61 }, + height: 300, + src: 'documents/report.pdf', + width: 400, + widths: [400, 200], + }, + sign, + ) + + expect(model).toEqual({ + expiresAt, + fallbackUrl: 'https://cdn.example/3', + sources: [ + { + candidates: [ + { url: 'https://cdn.example/1', width: 200 }, + { url: 'https://cdn.example/2', width: 400 }, + ], + format: 'webp', + }, + ], + }) + expect(requests).toEqual([ + { + expiresAt, + input: 'documents/report.pdf', + template: 'builtin/storage-preview@0.0.1', + urlParams: { f: 'webp', h: 150, q: 61, r: 'pad', w: 200 }, + }, + { + expiresAt, + input: 'documents/report.pdf', + template: 'builtin/storage-preview@0.0.1', + urlParams: { f: 'webp', h: 300, q: 61, r: 'pad', w: 400 }, + }, + { + expiresAt, + input: 'documents/report.pdf', + template: 'builtin/storage-preview@0.0.1', + urlParams: { f: 'jpg', h: 300, q: 68, r: 'pad', w: 400 }, + }, + ]) + }) + + test('supports an explicit workspace Template', () => { + const { requests, sign } = collectSignedRequests() + + createTransloaditImageModel( + { + expiresAt, + formats: { webp: 75 }, + height: 300, + src: 'documents/report.pdf', + template: 'website/storage-preview', + width: 400, + widths: [400], + }, + sign, + ) + + expect(requests.every(({ template }) => template === 'website/storage-preview')).toBe(true) + }) + + test('caps the default ladder at the declared intrinsic width', () => { + const { sign } = collectSignedRequests() + const model = createTransloaditImageModel( + { + expiresAt, + formats: { webp: 75 }, + height: 300, + src: 'documents/report.pdf', + width: 400, + }, + sign, + ) + + expect(model.sources[0]?.candidates.map(({ width }) => width)).toEqual([320, 400]) + }) + + test('caps candidates and the JPEG fallback at the intrinsic width', () => { + const { requests, sign } = collectSignedRequests() + + const model = createTransloaditImageModel( + { + expiresAt, + formats: { webp: 61 }, + height: 300, + src: 'documents/report.pdf', + width: 400, + widths: [200, 800], + }, + sign, + ) + + expect(model.sources[0]?.candidates.map(({ width }) => width)).toEqual([200, 400]) + expect(requests.at(-1)?.urlParams).toEqual({ f: 'jpg', h: 300, q: 75, r: 'pad', w: 400 }) + }) + + test('rejects an invalid fallback quality before signing any candidate', () => { + const { requests, sign } = collectSignedRequests() + + expect(() => + createTransloaditImageModel( + { + expiresAt, + fallbackQuality: 0, + height: 300, + src: 'documents/report.pdf', + width: 400, + widths: [200, 400], + }, + sign, + ), + ).toThrow('fallbackQuality must be an integer from 1 through 100') + expect(requests).toEqual([]) + }) + + test('uses deterministic format preference and intrinsic dimensions', () => { + const { requests, sign } = collectSignedRequests() + const model = createTransloaditImageModel( + { + expiresAt, + formats: { webp: 70, avif: 40 }, + height: 1200, + src: 'portraits/report.pdf', + width: 400, + widths: [8000], + }, + sign, + ) + + expect(model.sources.map(({ format }) => format)).toEqual(['avif', 'webp']) + expect(model.sources.flatMap(({ candidates }) => candidates.map(({ width }) => width))).toEqual( + [400, 400], + ) + expect(requests.slice(0, -1).every(({ urlParams }) => urlParams.h === 1200)).toBe(true) + expect(requests.at(-1)?.urlParams).toEqual({ f: 'jpg', h: 1200, q: 75, r: 'pad', w: 400 }) + }) + + test.each([ + '', + '/documents/report.pdf', + ' documents/report.pdf', + ])('rejects an invalid Storage path: %s', (src) => { + const { sign } = collectSignedRequests() + + expect(() => + createTransloaditImageModel({ expiresAt, height: 300, src, width: 400, widths: [400] }, sign), + ).toThrow( + 'Storage image paths must be non-empty relative strings without surrounding whitespace', + ) + }) + + test.each([ + 'documents/../private/report.pdf', + 'documents/./report.pdf', + String.raw`documents\private\report.pdf`, + 'documents/cover.jpg|private/secret.pdf', + ])('rejects an ambiguous Storage path: %s', (src) => { + const { sign } = collectSignedRequests() + + expect(() => + createTransloaditImageModel({ expiresAt, height: 300, src, width: 400, widths: [400] }, sign), + ).toThrow('Storage image paths must not contain delimiters, dot segments, or backslashes') + }) + + test.each([ + 'documents//report.pdf', + 'documents/report.pdf/', + 'documents/ /report.pdf', + 'documents/\0report.pdf', + 'cafe\u0301/report.pdf', + ])('rejects a Storage path outside the API2 catalog grammar: %s', (src) => { + const { sign } = collectSignedRequests() + + expect(() => + createTransloaditImageModel({ expiresAt, height: 300, src, width: 400, widths: [400] }, sign), + ).toThrow('Storage image paths must use normalized, non-empty path segments') + }) + + test('measures the Storage path limit in UTF-8 bytes', () => { + const { sign } = collectSignedRequests() + + expect(() => + createTransloaditImageModel( + { + expiresAt, + height: 300, + src: `${'😀'.repeat(256)}.jpg`, + width: 400, + widths: [400], + }, + sign, + ), + ).toThrow('Storage image paths must be at most 1024 UTF-8 bytes') + }) + + test('snapshots caller-owned values before validation and signing', () => { + const { requests, sign } = collectSignedRequests() + let srcReads = 0 + const options = { + expiresAt, + height: 300, + get src() { + srcReads += 1 + return srcReads === 1 ? 'documents/report.pdf' : 'private/secret.pdf' + }, + width: 400, + widths: [400], + } satisfies TransloaditImageModelOptions + + createTransloaditImageModel(options, sign) + + expect(srcReads).toBe(1) + expect(requests.every(({ input }) => input === 'documents/report.pdf')).toBe(true) + }) + + test('treats percent escapes as literal catalog key bytes', () => { + const { requests, sign } = collectSignedRequests() + + createTransloaditImageModel( + { + expiresAt, + height: 300, + src: 'documents/%2e%2e/report.pdf', + width: 400, + widths: [400], + }, + sign, + ) + + expect(requests.every(({ input }) => input === 'documents/%2e%2e/report.pdf')).toBe(true) + }) + + test('rejects a seconds-based expiry before signing', () => { + const { requests, sign } = collectSignedRequests() + + expect(() => + createTransloaditImageModel( + { + expiresAt: 1_893_456_000, + height: 300, + src: 'documents/report.pdf', + width: 400, + widths: [400], + }, + sign, + ), + ).toThrow('expiresAt must be a millisecond timestamp') + expect(requests).toEqual([]) + }) +}) diff --git a/packages/img/test/next-server.test.tsx b/packages/img/test/next-server.test.tsx new file mode 100644 index 00000000..29c3686c --- /dev/null +++ b/packages/img/test/next-server.test.tsx @@ -0,0 +1,602 @@ +// @vitest-environment happy-dom + +import type { ReactNode } from 'react' + +import { parseSmartCdnUrl } from '@transloadit/utils/node' +import { renderToReadableStream, renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +const { connection } = vi.hoisted(() => ({ connection: vi.fn(async () => undefined) })) + +vi.mock('next/server.js', () => ({ connection })) +vi.mock('server-only', () => ({})) + +import { createTransloaditImage } from '../src/next/server.tsx' + +const authSecret = 'never-render-this-secret' +const baseConfiguration = { + authKey: 'auth-key', + authSecret, + baseUrl: 'https://cdn.example/file/{workspace}', + storage: { allowedPathPrefixes: ['documents/'] }, + workspace: 'my-app', +} + +async function renderAsync(node: ReactNode): Promise { + const stream = await renderToReadableStream(node) + await stream.allReady + return new Response(stream).text() +} + +function parseMarkup(markup: string): Document { + return new DOMParser().parseFromString(markup, 'text/html') +} + +function getFirstCandidate(document: Document): string { + const sourceSet = document.querySelector('source')?.getAttribute('srcset') + if (sourceSet === undefined || sourceSet === null) throw new Error('Expected an image source set') + const separator = sourceSet.indexOf(' ') + if (separator === -1) throw new Error('Expected a width descriptor') + return sourceSet.slice(0, separator) +} + +function getStorageRouteCandidate(): { + authorize: ReturnType + storageRoute: (request: Request) => Promise + url: URL +} { + const authorize = vi.fn( + ({ path, request }: { path: string; request: Request }): boolean => + path === 'documents/report.pdf' && request.headers.get('authorization') === 'Bearer allowed', + ) + const { Image, storageRoute } = createTransloaditImage({ + ...baseConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { authorize, route: '/api/private-images' }, + }, + }) + const markup = renderToStaticMarkup( + Report preview, + ) + return { + authorize, + storageRoute, + url: new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example'), + } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime('2029-01-01T12:02:00.000Z') + connection.mockClear() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createTransloaditImage', () => { + test('allows explicit widths while making sizes optional', async () => { + const { Image } = createTransloaditImage(baseConfiguration) + const document = parseMarkup( + await renderAsync( + Explicit widths, + ), + ) + const source = document.querySelector('source') + + expect(source?.hasAttribute('sizes')).toBe(false) + expect(source?.getAttribute('srcset')).toContain('200w') + expect(source?.getAttribute('srcset')).toContain('400w') + expect(source?.getAttribute('srcset')).toContain('800w') + }) + + test('rejects coercible Storage sources before signing', () => { + const { Image } = createTransloaditImage(baseConfiguration) + const stringConversion = vi.fn(() => 'https://assets.example/photo.jpg') + + expect(() => + Reflect.apply(Image, undefined, [ + { alt: 'Coercible', height: 600, src: { toString: stringConversion }, width: 800 }, + ]), + ).toThrow('Storage image src must be one relative object path') + expect(stringConversion).not.toHaveBeenCalled() + }) + + test('request-renders direct Storage previews with bounded stable signatures', async () => { + const { Image } = createTransloaditImage(baseConfiguration) + const render = async (): Promise => { + const markup = await renderAsync( + Preview of report.pdf, + ) + expect(markup).not.toContain(authSecret) + return parseMarkup(markup) + } + + const firstDocument = await render() + const firstSource = new URL(getFirstCandidate(firstDocument)) + const firstFallback = new URL(firstDocument.querySelector('img')?.getAttribute('src') ?? '') + + expect(connection).toHaveBeenCalledOnce() + expect(firstSource.pathname).toContain('/builtin%2Fstorage-preview%400.0.1/') + expect(firstSource.searchParams.get('f')).toBe('webp') + expect(firstSource.searchParams.get('h')).toBe('150') + expect(firstSource.searchParams.get('q')).toBe('61') + expect(firstFallback.searchParams.get('f')).toBe('jpg') + expect(firstDocument.querySelector('img')?.getAttribute('loading')).toBe('lazy') + expect(firstSource.searchParams.get('exp')).toBe(String(Date.parse('2029-01-01T13:05:00Z'))) + + vi.setSystemTime('2029-01-01T12:04:59.999Z') + const sameWindow = await render() + expect(sameWindow.querySelector('source')?.getAttribute('srcset')).toBe( + firstDocument.querySelector('source')?.getAttribute('srcset'), + ) + + vi.setSystemTime('2029-01-01T12:05:00.000Z') + const nextWindow = await render() + expect(nextWindow.querySelector('source')?.getAttribute('srcset')).not.toBe( + firstDocument.querySelector('source')?.getAttribute('srcset'), + ) + }) + + test('denies private paths by default and matches explicit directory boundaries', () => { + const { Image: denyAllImage } = createTransloaditImage({ + ...baseConfiguration, + storage: {}, + }) + const { Image } = createTransloaditImage(baseConfiguration) + + expect(() => + denyAllImage({ + alt: 'Denied', + height: 300, + src: 'documents/report.pdf', + width: 400, + }), + ).toThrow('outside the configured allowed prefixes') + expect(() => + Image({ + alt: 'Boundary mismatch', + height: 300, + src: 'documents-private/report.pdf', + width: 400, + }), + ).toThrow('outside the configured allowed prefixes') + expect(connection).not.toHaveBeenCalled() + }) + + test('snapshots direct Storage props before crossing the request boundary', async () => { + const { Image } = createTransloaditImage(baseConfiguration) + let height = 300 + let path = 'documents/report.pdf' + let width = 400 + connection.mockImplementationOnce(() => { + height = 0 + path = 'private/secret.pdf' + width = 0 + return Promise.resolve(undefined) + }) + const node = Image({ + alt: 'Snapshotted', + get height() { + return height + }, + get src() { + return path + }, + get width() { + return width + }, + widths: [400], + }) + const document = parseMarkup(await renderAsync(node)) + const candidate = parseSmartCdnUrl(getFirstCandidate(document), { + baseUrl: baseConfiguration.baseUrl, + workspace: baseConfiguration.workspace, + }) + + expect(candidate.input).toBe('documents/report.pdf') + expect(candidate.urlParams.h).toBe('300') + expect(candidate.urlParams.w).toBe('400') + }) + + test('renders opaque authorized-route capabilities without request I/O or credentials', () => { + const { authorize, url } = getStorageRouteCandidate() + + expect(connection).not.toHaveBeenCalled() + expect(authorize).not.toHaveBeenCalled() + expect(url.origin).toBe('https://app.example') + expect(url.pathname).toBe('/api/private-images') + expect([...url.searchParams.keys()]).toEqual(['cap']) + expect(url.href).not.toContain('documents') + expect(url.href).not.toContain('report.pdf') + expect(url.href).not.toContain('auth-key') + expect(url.href).not.toContain(authSecret) + }) + + test('prepends basePath while accepting Next.js stripped handler paths', async () => { + const { Image, storageRoute } = createTransloaditImage({ + ...baseConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { + authorize: () => true, + basePath: '/app', + route: '/api/private-images', + }, + }, + }) + const markup = renderToStaticMarkup( + Base path, + ) + const externalUrl = new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example') + const internalUrl = new URL(externalUrl) + internalUrl.pathname = '/api/private-images' + const internalResponse = await storageRoute(new Request(internalUrl)) + const externalResponse = await storageRoute(new Request(externalUrl)) + const trailingSlashUrl = new URL(externalUrl) + trailingSlashUrl.pathname = `${trailingSlashUrl.pathname}/` + const trailingSlashResponse = await storageRoute(new Request(trailingSlashUrl)) + + expect(externalUrl.pathname).toBe('/app/api/private-images') + expect(internalResponse.status).toBe(307) + expect(externalResponse.status).toBe(307) + expect(trailingSlashResponse.status).toBe(307) + }) + + test('requires authorize to return the boolean true', async () => { + const typedAuthorize = (): boolean => false + const malformedAuthorize = new Proxy(typedAuthorize, { + apply() { + return 'false' + }, + }) + const { Image, storageRoute } = createTransloaditImage({ + ...baseConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { authorize: malformedAuthorize, route: '/api/private-images' }, + }, + }) + const markup = renderToStaticMarkup( + Strict ACL, + ) + const routeUrl = new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example') + + expect(await storageRoute(new Request(routeUrl))).toMatchObject({ status: 404 }) + }) + + test('authorizes one exact route request and redirects without proxying image bytes', async () => { + const { authorize, storageRoute, url } = getStorageRouteCandidate() + const request = new Request(url, { headers: { Authorization: 'Bearer allowed' } }) + const response = await storageRoute(request) + const location = response.headers.get('location') + if (location === null) throw new Error('Expected a redirect location') + const target = parseSmartCdnUrl(location, { + baseUrl: baseConfiguration.baseUrl, + workspace: baseConfiguration.workspace, + }) + + expect(response.status).toBe(307) + expect(await response.text()).toBe('') + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(authorize).toHaveBeenCalledOnce() + expect(authorize).toHaveBeenCalledWith({ path: 'documents/report.pdf', request }) + expect(target.template).toBe('builtin/storage-preview@0.0.1') + expect(target.input).toBe('documents/report.pdf') + expect(target.urlParams).toMatchObject({ f: 'avif', h: '240', q: '45', r: 'pad', w: '320' }) + expect(target.auth?.expiresAt).toBe(Date.parse('2029-01-01T13:05:00Z')) + }) + + test('keeps cached capabilities valid while rotating only their redirect targets', async () => { + const { Image, storageRoute } = createTransloaditImage({ + ...baseConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { authorize: () => true, route: '/api/private-images' }, + }, + }) + const render = (): URL => { + const markup = renderToStaticMarkup( + Stable, + ) + return new URL(getFirstCandidate(parseMarkup(markup)), 'https://app.example') + } + const first = render() + const firstRedirect = await storageRoute(new Request(first)) + + vi.setSystemTime('2029-01-01T12:05:00Z') + const second = render() + const secondRedirect = await storageRoute(new Request(second)) + const cachedRedirect = await storageRoute(new Request(first)) + + expect(second.href).toBe(first.href) + expect(secondRedirect.headers.get('location')).not.toBe(firstRedirect.headers.get('location')) + expect(cachedRedirect.status).toBe(307) + expect(cachedRedirect.headers.get('location')).toBe(secondRedirect.headers.get('location')) + expect(connection).not.toHaveBeenCalled() + }) + + test('binds capabilities to the secret, workspace, Template, route, and basePath', async () => { + const { url } = getStorageRouteCandidate() + const authorize = vi.fn(() => true) + const createBoundRoute = ({ + authSecret: candidateSecret = authSecret, + basePath, + route = '/api/private-images', + storageTemplate, + workspace = baseConfiguration.workspace, + }: { + authSecret?: string + basePath?: string + route?: string + storageTemplate?: string + workspace?: string + } = {}) => + createTransloaditImage({ + ...baseConfiguration, + authSecret: candidateSecret, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { authorize, basePath, route }, + }, + template: storageTemplate, + workspace, + }).storageRoute + const otherRouteUrl = new URL(url) + otherRouteUrl.pathname = '/api/other-images' + const basePathUrl = new URL(url) + basePathUrl.pathname = '/app/api/private-images' + const attempts = [ + { + label: 'secret', + requestUrl: url, + storageRoute: createBoundRoute({ authSecret: 'another-secret' }), + }, + { + label: 'workspace', + requestUrl: url, + storageRoute: createBoundRoute({ workspace: 'another-app' }), + }, + { + label: 'Template', + requestUrl: url, + storageRoute: createBoundRoute({ storageTemplate: 'customer/storage-preview' }), + }, + { + label: 'route', + requestUrl: otherRouteUrl, + storageRoute: createBoundRoute({ route: '/api/other-images' }), + }, + { + label: 'basePath', + requestUrl: basePathUrl, + storageRoute: createBoundRoute({ basePath: '/app' }), + }, + ] + + for (const { label, requestUrl, storageRoute } of attempts) { + const response = await storageRoute( + new Request(requestUrl, { headers: { Authorization: 'Bearer allowed' } }), + ) + expect(response.status, label).toBe(404) + } + expect(authorize).not.toHaveBeenCalled() + }) + + test('returns the same empty 404 before authorization for every altered route capability', async () => { + const mutations: Array<{ label: string; mutate: (url: URL) => void }> = [ + { + label: 'authenticated bytes', + mutate(url): void { + const capability = url.searchParams.get('cap') + if (capability === null) throw new Error('Expected a capability') + const replacement = capability.startsWith('A') ? 'B' : 'A' + url.searchParams.set('cap', `${replacement}${capability.slice(1)}`) + }, + }, + { + label: 'truncated', + mutate(url): void { + const capability = url.searchParams.get('cap') + if (capability === null) throw new Error('Expected a capability') + url.searchParams.set('cap', capability.slice(0, -1)) + }, + }, + { + label: 'invalid alphabet', + mutate(url): void { + url.searchParams.set('cap', '%invalid') + }, + }, + { + label: 'oversized', + mutate(url): void { + url.searchParams.set('cap', 'A'.repeat(4097)) + }, + }, + { + label: 'duplicate', + mutate(url): void { + const capability = url.searchParams.get('cap') + if (capability === null) throw new Error('Expected a capability') + url.searchParams.append('cap', capability) + }, + }, + { + label: 'unknown', + mutate(url): void { + url.searchParams.set('download', '1') + }, + }, + { + label: 'route', + mutate(url): void { + url.pathname = '/api/other-images' + }, + }, + ] + + for (const { label, mutate } of mutations) { + const { authorize, storageRoute, url } = getStorageRouteCandidate() + mutate(url) + const response = await storageRoute( + new Request(url, { headers: { Authorization: 'Bearer allowed' } }), + ) + expect(response.status, label).toBe(404) + expect(await response.text(), label).toBe('') + expect(response.headers.get('cache-control'), label).toBe('private, no-store') + expect(authorize, label).not.toHaveBeenCalled() + } + }) + + test('conceals failed application authorization and disallows other methods', async () => { + const { authorize, storageRoute, url } = getStorageRouteCandidate() + const denied = await storageRoute(new Request(url)) + const post = await storageRoute( + new Request(url, { headers: { Authorization: 'Bearer allowed' }, method: 'POST' }), + ) + const head = await storageRoute( + new Request(url, { headers: { Authorization: 'Bearer allowed' }, method: 'HEAD' }), + ) + + expect(denied.status).toBe(404) + expect(await denied.text()).toBe('') + expect(head.status).toBe(307) + expect(await head.text()).toBe('') + expect(authorize).toHaveBeenCalledTimes(2) + expect(post.status).toBe(405) + expect(post.headers.get('allow')).toBe('GET, HEAD') + }) + + test('rejects direct-only suspense props in static redirect mode', () => { + const { Image } = createTransloaditImage({ + ...baseConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { authorize: () => true, route: '/api/private-images' }, + }, + }) + + expect(() => + Image({ + alt: 'No suspension', + height: 300, + src: 'documents/report.pdf', + suspenseFallback: 'Loading', + width: 400, + }), + ).toThrow('suspenseFallback is only used by direct Storage delivery') + }) + + test.each([ + 'auth_key', + 'exp', + 'f', + 'h', + 'q', + 'r', + 'sig', + 'w', + ])('reserves image-policy parameter %s from global URL parameters', (parameter) => { + expect(() => + createTransloaditImage({ + ...baseConfiguration, + urlParams: { [parameter]: 'caller-controlled' }, + }), + ).toThrow(`urlParams must not override image policy parameter: ${parameter}`) + }) + + test('validates credentials, route configuration, and bounded expiry', () => { + expect(() => createTransloaditImage({ ...baseConfiguration, authKey: '' })).toThrow( + 'authKey must be a non-empty string', + ) + expect(() => + createTransloaditImage({ ...baseConfiguration, baseUrl: 'ftp://cdn.example/file' }), + ).toThrow('baseUrl must be an absolute HTTP(S) URL') + expect(() => + createTransloaditImage({ + ...baseConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + expiresInMs: 48 * 60 * 60 * 1000, + rotationIntervalMs: 5 * 60 * 1000, + }, + }), + ).toThrow('must not exceed 48 hours') + expect(() => + createTransloaditImage({ + ...baseConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { authorize: () => true, route: 'api/private-images' }, + }, + }), + ).toThrow('storage.delivery.route must be one absolute application path') + expect(() => + createTransloaditImage({ + ...baseConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { + authorize: () => true, + basePath: '/app/', + route: '/api/private-images', + }, + }, + }), + ).toThrow('storage.delivery.basePath must be one absolute path without a trailing slash') + expect(() => + Reflect.apply(createTransloaditImage, undefined, [ + { + ...baseConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { authorize: 'yes', route: '/api/private-images' }, + }, + }, + ]), + ).toThrow('storage.delivery.authorize must be a function') + }) + + test('keeps template selection in trusted factory configuration', async () => { + const { Image } = createTransloaditImage({ + ...baseConfiguration, + template: 'my-storage-preview', + }) + const storageDocument = parseMarkup( + await renderAsync( + Storage, + ), + ) + + expect( + parseSmartCdnUrl(getFirstCandidate(storageDocument), { + baseUrl: baseConfiguration.baseUrl, + workspace: baseConfiguration.workspace, + }).template, + ).toBe('my-storage-preview') + }) +}) diff --git a/packages/img/test/next.test.tsx b/packages/img/test/next.test.tsx new file mode 100644 index 00000000..9a3e2c1d --- /dev/null +++ b/packages/img/test/next.test.tsx @@ -0,0 +1,318 @@ +// @vitest-environment happy-dom + +import type { ReactNode } from 'react' +import type { Root } from 'react-dom/client' + +import type { TransloaditImageModel } from '../src/index.ts' + +import { act } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { renderToStaticMarkup, renderToString } from 'react-dom/server' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { TransloaditPicture } from '../src/next/index.tsx' + +Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { + configurable: true, + value: true, +}) + +const model: TransloaditImageModel = { + expiresAt: Date.UTC(2030, 0, 1), + fallbackUrl: 'https://assets.example/original.jpg', + sources: [ + { + candidates: [ + { url: 'https://cdn.example/image-320.avif', width: 320 }, + { url: 'https://cdn.example/image-640.avif', width: 640 }, + ], + format: 'avif', + }, + { + candidates: [ + { url: 'https://cdn.example/image-320.webp', width: 320 }, + { url: 'https://cdn.example/image-640.webp', width: 640 }, + ], + format: 'webp', + }, + ], +} + +function renderPicture( + overrides: Partial<{ + deferUntilHydrated: boolean + loading: 'eager' | 'lazy' + media: string + mediaPlaceholderSrc: string + preload: boolean + }> = {}, +): Document { + const markup = renderToStaticMarkup( + , + ) + return new DOMParser().parseFromString(markup, 'text/html') +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('TransloaditPicture', () => { + test('renders native picture sources and the supplied fallback', () => { + const document = renderPicture() + const sources = [...document.querySelectorAll('source')] + const image = document.querySelector('img') + + expect(sources.map((source) => source.type)).toEqual(['image/avif', 'image/webp']) + expect(sources[0]?.sizes).toBe('(min-width: 800px) 640px, 100vw') + expect(sources[0]?.srcset).toBe( + 'https://cdn.example/image-320.avif 320w, https://cdn.example/image-640.avif 640w', + ) + expect(image?.getAttribute('alt')).toBe('A canal house') + expect(image?.getAttribute('class')).toBe('photo') + expect(image?.getAttribute('decoding')).toBe('async') + expect(image?.getAttribute('fetchpriority')).toBe('high') + expect(image?.getAttribute('height')).toBe('300') + expect(image?.getAttribute('loading')).toBe('lazy') + expect(image?.getAttribute('src')).toBe(model.fallbackUrl) + expect(image?.getAttribute('width')).toBe('400') + }) + + test('escapes candidate URL tokens before appending width descriptors', () => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + , + ), + 'text/html', + ) + + expect(document.querySelector('source')?.getAttribute('srcset')).toBe( + 'https://cdn.example/my%20photo%2C 320w', + ) + }) + + test('preloads only the preferred source', () => { + const document = renderPicture({ + loading: 'eager', + preload: true, + }) + const preload = document.querySelector('link[rel="preload"]') + const sources = [...document.querySelectorAll('picture source')] + const image = document.querySelector('img') + + expect(preload?.getAttribute('as')).toBe('image') + expect(preload?.getAttribute('imagesizes')).toBe('(min-width: 800px) 640px, 100vw') + expect(preload?.getAttribute('imagesrcset')).toBe(sources[0]?.getAttribute('srcset')) + expect(preload?.getAttribute('type')).toBe('image/avif') + expect(sources).toHaveLength(2) + expect(image?.getAttribute('src')).toBe(model.fallbackUrl) + }) + + test('rejects a media-gated preload instead of letting React deduplicate it incorrectly', () => { + expect(() => + renderPicture({ loading: 'eager', media: '(min-width: 768px)', preload: true }), + ).toThrow('A media-gated Transloadit image cannot be preloaded') + }) + + test('escapes whitespace in a media-gated fallback srcset URL', () => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + , + ), + 'text/html', + ) + + expect(document.querySelectorAll('source').item(2).getAttribute('srcset')).toBe( + '/images/my%20photo.jpg', + ) + }) + + test('encodes trailing commas in a media-gated fallback srcset URL', () => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + , + ), + 'text/html', + ) + + expect(document.querySelectorAll('source').item(2).getAttribute('srcset')).toBe( + '/images/photo%2C%2C', + ) + }) + + test('encodes leading commas in a media-gated fallback srcset URL', () => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + , + ), + 'text/html', + ) + + expect(document.querySelectorAll('source').item(2).getAttribute('srcset')).toBe( + '%2C%2C/images/photo.jpg', + ) + }) + + test('preserves the payload delimiter in a media-gated data URL fallback', () => { + const fallbackUrl = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=' + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + , + ), + 'text/html', + ) + + expect(document.querySelectorAll('source').item(2).getAttribute('srcset')).toBe(fallbackUrl) + }) + + test('uses a neutral inline fallback while a media condition is unmatched', () => { + const document = renderPicture({ media: '(min-width: 768px)' }) + + expect(document.querySelector('img')?.getAttribute('src')).toMatch(/^data:image\/gif;base64,/) + }) + + test('accepts a CSP-compatible media placeholder', () => { + const document = renderPicture({ + media: '(min-width: 768px)', + mediaPlaceholderSrc: '/images/transparent.gif', + }) + + expect(document.querySelector('img')?.getAttribute('src')).toBe('/images/transparent.gif') + }) + + test('makes preload eager by default and rejects an explicitly lazy preload', () => { + const preloaded = renderPicture({ loading: undefined, preload: true }) + + expect(preloaded.querySelector('img')?.getAttribute('loading')).toBe('eager') + expect(() => renderPicture({ loading: 'lazy', preload: true })).toThrow( + 'A preloaded Transloadit image cannot use lazy loading', + ) + }) + + test('keeps deferred candidate elements out of server markup', () => { + const document = renderPicture({ deferUntilHydrated: true }) + + expect(document.querySelector('noscript img')?.getAttribute('src')).toBe(model.fallbackUrl) + expect(document.querySelectorAll('source')).toHaveLength(0) + }) + + test('rejects a renderer model with an empty candidate set', () => { + expect(() => + renderToStaticMarkup( + , + ), + ).toThrow('Cannot render an empty Transloadit image source') + }) + + const deferredLoadingCases: Array<{ loading: 'eager' | 'lazy'; preload: boolean }> = [ + { loading: 'eager', preload: false }, + { loading: 'lazy', preload: true }, + ] + + test.each(deferredLoadingCases)('rejects deferring an $loading image with preload=$preload', ({ + loading, + preload, + }) => { + expect(() => renderPicture({ deferUntilHydrated: true, loading, preload })).toThrow( + 'An eager or preloaded Transloadit image cannot be deferred until hydration', + ) + }) + + test('hydrates one deferred picture without a recoverable error', async () => { + function DeferredPicture(): ReactNode { + return ( + + ) + } + + const container = document.createElement('div') + const recoverableErrors: unknown[] = [] + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + container.innerHTML = renderToString() + document.body.append(container) + let root: Root | undefined + + expect(container.querySelector('noscript')).not.toBeNull() + expect(container.querySelector('picture')).toBeNull() + + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError: (error) => recoverableErrors.push(error), + }) + await Promise.resolve() + }) + + expect(container.querySelector('noscript')).toBeNull() + expect(container.querySelector('picture')).not.toBeNull() + expect(recoverableErrors).toEqual([]) + expect(consoleError).not.toHaveBeenCalled() + + act(() => root?.unmount()) + container.remove() + }) +}) diff --git a/packages/img/test/types.tsx b/packages/img/test/types.tsx new file mode 100644 index 00000000..9f52ca55 --- /dev/null +++ b/packages/img/test/types.tsx @@ -0,0 +1,67 @@ +import type { TransloaditImageModelOptions } from '../src/index.ts' +import type { + TransloaditImageComponent, + TransloaditImageIntegration, + TransloaditImageProps, + TransloaditRedirectImageIntegration, +} from '../src/next/server.tsx' + +import { createTransloaditImageModel } from '../src/index.ts' + +const modelOptions: TransloaditImageModelOptions = { + expiresAt: Date.UTC(2030, 0, 1), + formats: { avif: 45, webp: 75 }, + height: 300, + src: 'documents/report.pdf', + width: 400, +} + +const imageProps: TransloaditImageProps = { + alt: 'Preview of report.pdf', + height: 300, + src: 'documents/report.pdf', + width: 400, +} + +// @ts-expect-error Storage preview formats use format-specific quality values, not a tuple. +const modelWithTuple: TransloaditImageModelOptions = { ...modelOptions, formats: ['webp'] } + +createTransloaditImageModel( + { + expiresAt: Date.UTC(2030, 0, 1), + // @ts-expect-error Storage previews always use a signed JPEG fallback. + fallbackUrl: '/public/report.jpg', + height: 300, + src: 'documents/report.pdf', + width: 400, + }, + () => '', +) + +declare const Image: TransloaditImageComponent +declare const direct: TransloaditImageIntegration +declare const redirect: TransloaditRedirectImageIntegration +const model = createTransloaditImageModel(modelOptions, () => '') +const image = Image(imageProps) +const directImage = direct.Image(imageProps) +const redirectedImage = redirect.Image(imageProps) +const routeResponse = redirect.storageRoute(new Request('https://app.example/images')) +// @ts-expect-error Direct integrations do not expose an authorization route. +const missingRoute = direct.storageRoute +// @ts-expect-error Storage previews always use their signed JPEG fallback. +const imageWithFallback = +// @ts-expect-error Storage previews do not support viewport-conditional activation. +const imageWithMedia = +// @ts-expect-error Storage-only sources are relative object paths, not discriminated objects. +const imageWithObjectSource = + +void directImage +void image +void imageWithFallback +void imageWithMedia +void imageWithObjectSource +void missingRoute +void model +void modelWithTuple +void redirectedImage +void routeResponse diff --git a/packages/img/tsconfig.build.json b/packages/img/tsconfig.build.json new file mode 100644 index 00000000..317e41bd --- /dev/null +++ b/packages/img/tsconfig.build.json @@ -0,0 +1,25 @@ +{ + "include": ["src"], + "exclude": ["dist", "test"], + "references": [{ "path": "../utils/tsconfig.build.json" }], + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "erasableSyntaxOnly": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["DOM", "ES2022"], + "module": "NodeNext", + "allowImportingTsExtensions": true, + "target": "ES2022", + "noImplicitOverride": true, + "rewriteRelativeImportExtensions": true, + "outDir": "dist", + "rootDir": "src", + // Next 16's declarations reference optional webpack and RSC types absent from consumers. + "skipLibCheck": true, + "strict": true, + "types": ["react", "react-dom"] + } +} diff --git a/packages/img/tsconfig.json b/packages/img/tsconfig.json new file mode 100644 index 00000000..2b512464 --- /dev/null +++ b/packages/img/tsconfig.json @@ -0,0 +1,18 @@ +{ + "exclude": ["coverage", "dist", "src"], + "references": [{ "path": "./tsconfig.build.json" }], + "compilerOptions": { + "erasableSyntaxOnly": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["DOM", "ES2022"], + "module": "NodeNext", + "allowImportingTsExtensions": true, + "noImplicitOverride": true, + "noEmit": true, + // Next 16's declarations reference optional webpack and RSC types absent from consumers. + "skipLibCheck": true, + "strict": true, + "types": ["react", "react-dom"] + } +} diff --git a/packages/utils/README.md b/packages/utils/README.md index 7f1f3a5a..8f452591 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -55,6 +55,9 @@ const { workspace, template, input, urlParams, auth } = parseSmartCdnUrl(url) const unsigned = stripSmartCdnAuth(url) ``` +`auth_key`, `exp`, and `sig` are reserved: signed builders replace them and the unsigned builder +omits them. Other fields, including `hsh`, round-trip through the builders and parser. + Both builders accept a `baseUrl` that replaces `https://{workspace}.tlcdn.com`, for example a local api2's URL Transform endpoint `https://api2-devdock.transloadit.dev/file/{workspace}` (a literal `{workspace}` is substituted). The signature does not cover the host, so treat `baseUrl` as trusted @@ -83,7 +86,12 @@ const imageCandidates = getSignedSmartCdnImageCandidates({ authSecret, // Reuse one absolute expiry across a build instead of recomputing it per request. expiresAt, - input: 'https://example.com/image.jpg', + // The browser fallback is independent from the Template's input grammar. + fallbackUrl: '/images/photo.jpg', + // This workspace Template pins https://example.com/ and accepts a relative path. + input: 'images/photo.jpg', + sourceDimensions: { height: 1600, width: 2400 }, + template: 'website-images', widths: [320, 640, 960], workspace, }) @@ -108,4 +116,12 @@ for (const source of imageCandidates.sources) { - `signParamsSync(paramsString, authSecret, algorithm?)`: Node-only sync signature helper. - `getSignedSmartCdnUrl(options)` from `@transloadit/utils/node`: synchronous Smart CDN URL signer. - `getSignedSmartCdnImageCandidates(options)`: deterministic structured, signed AVIF and WebP - candidates plus the original fallback URL. + candidates plus an explicit browser fallback. `template` is mandatory: use a trusted workspace + Template that owns its source policy. `fallbackUrl` is deliberately separate from `input`, which + may use a Template-specific grammar such as a relative origin-pinned path. Supply + `sourceDimensions` to prevent upscaling and keep both output dimensions within backend limits. +- `createSmartCdnImageCandidates(options, sign)` from `@transloadit/utils`: the same deterministic + image policy with an injected synchronous signer, for framework and package adapters that own + their credential boundary. +- `resolveSmartCdnImageFormats(formats)` and `resolveSmartCdnImageWidths(widths, maximumWidth?)`: + shared validation and normalization for adapters that use a different image Built-in. diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 800ee32a..179cfb43 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -11,9 +11,27 @@ export type { SmartCdnUrlOptions, SmartCdnUrlParams, } from './smartCdn.ts' +export type { + SignSmartCdnImageRequest, + SmartCdnImageCandidate, + SmartCdnImageCandidates, + SmartCdnImageFormat, + SmartCdnImageFormatQuality, + SmartCdnImageFormats, + SmartCdnImagePolicyOptions, + SmartCdnImageSignRequest, + SmartCdnImageSource, + SmartCdnImageSourceDimensions, +} from './smartCdnImage.ts' export * from './assemblyInstructionsCompiler.ts' export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts' +export { + createSmartCdnImageCandidates, + resolveSmartCdnImageFormats, + resolveSmartCdnImageWidths, + smartCdnImageMaxDimension, +} from './smartCdnImage.ts' const algorithmMap = { sha1: 'SHA-1', diff --git a/packages/utils/src/node.ts b/packages/utils/src/node.ts index b8e4186c..1ccb1126 100644 --- a/packages/utils/src/node.ts +++ b/packages/utils/src/node.ts @@ -1,9 +1,11 @@ import type { SignatureAlgorithm } from './index.ts' import type { SmartCdnUrlOptions } from './smartCdn.ts' +import type { SmartCdnImageCandidates, SmartCdnImagePolicyOptions } from './smartCdnImage.ts' import { createHmac } from 'node:crypto' import { finishSmartCdnUrl, prepareSmartCdnUrl } from './smartCdn.ts' +import { createSmartCdnImageCandidates } from './smartCdnImage.ts' export type { SignatureAlgorithm } from './index.ts' export type { @@ -13,117 +15,38 @@ export type { SmartCdnUrlOptions, SmartCdnUrlParams, } from './smartCdn.ts' +export type { + SignSmartCdnImageRequest, + SmartCdnImageCandidate, + SmartCdnImageCandidates, + SmartCdnImageFormat, + SmartCdnImageFormatQuality, + SmartCdnImageFormats, + SmartCdnImagePolicyOptions, + SmartCdnImageSignRequest, + SmartCdnImageSource, + SmartCdnImageSourceDimensions, +} from './smartCdnImage.ts' export { getSmartCdnUrl, parseSmartCdnUrl, stripSmartCdnAuth } from './smartCdn.ts' +export { + resolveSmartCdnImageFormats, + resolveSmartCdnImageWidths, + smartCdnImageMaxDimension, +} from './smartCdnImage.ts' export type SignatureAlgorithmInput = SignatureAlgorithm | (string & {}) -/** Image formats supported by the responsive-image Built-in. */ -export type SmartCdnImageFormat = 'avif' | 'png' | 'webp' - -/** One signed Smart CDN rendition at a specific intrinsic width. */ -export interface SmartCdnImageCandidate { - url: string - width: number -} - -/** Ordered candidates for one image format and quality. */ -export interface SmartCdnImageSource { - candidates: readonly SmartCdnImageCandidate[] - format: SmartCdnImageFormat - quality: number -} - -/** Structured data for rendering a responsive image. */ -export interface SmartCdnImageCandidates { - fallbackUrl: string - sources: readonly SmartCdnImageSource[] -} - /** Options for deterministic, server-generated Smart CDN image candidates. */ -export interface SmartCdnImageCandidatesOptions { +export interface SmartCdnImageCandidatesOptions extends SmartCdnImagePolicyOptions { /** Transloadit auth key used to sign every candidate URL. */ authKey: string /** Transloadit auth secret used to sign every candidate URL. */ authSecret: string - /** One absolute expiry in milliseconds since UNIX epoch, shared by every candidate. */ - expiresAt: number - /** Formats and their quality values. Defaults to AVIF 45 and WebP 75. */ - formats?: Readonly>> - /** Absolute HTTP(S) source URL accepted by the responsive-image Template. */ - input: string - /** Compatible Template override. Defaults to `builtin/serve-image@0.0.1`. */ - template?: string - /** Up to 32 intrinsic widths. Each value must be an integer from 1 through 8000. */ - widths: readonly number[] /** Workspace slug. */ workspace: string } -const defaultSmartCdnImageFormats: Readonly>> = { - avif: 45, - webp: 75, -} -const defaultSmartCdnImageTemplate = 'builtin/serve-image@0.0.1' -const smartCdnImageFormats: readonly SmartCdnImageFormat[] = ['avif', 'webp', 'png'] -const smartCdnImageMaxDimension = 8000 -const smartCdnImageMaxWidths = 32 - -function isSmartCdnImageFormat(value: string): value is SmartCdnImageFormat { - return value === 'avif' || value === 'png' || value === 'webp' -} - -function validateSmartCdnImageDimension(width: number): void { - if (!Number.isInteger(width) || width < 1 || width > smartCdnImageMaxDimension) { - throw new RangeError(`width must be an integer from 1 through ${smartCdnImageMaxDimension}`) - } -} - -function validateSmartCdnImageQuality(quality: number): void { - if (!Number.isInteger(quality) || quality < 1 || quality > 100) { - throw new RangeError('quality must be an integer from 1 through 100') - } -} - -function validateSmartCdnImageInput(input: string): void { - if (typeof input !== 'string' || input.trim() !== input || input.includes('|')) { - throw new TypeError('input must be a single HTTP or HTTPS URL string') - } - if (!URL.canParse(input)) { - throw new TypeError('input must be an HTTP or HTTPS URL') - } - - const protocol = new URL(input).protocol - if (protocol !== 'http:' && protocol !== 'https:') { - throw new TypeError('input must be an HTTP or HTTPS URL') - } -} - -function validateSmartCdnImageFormats( - formats: Readonly>>, -): void { - for (const format of Object.keys(formats)) { - if (!isSmartCdnImageFormat(format)) { - throw new TypeError(`Unsupported Smart CDN image format: ${format}`) - } - } - - let formatCount = 0 - for (const format of smartCdnImageFormats) { - const quality = formats[format] - if (quality == null) { - continue - } - - validateSmartCdnImageQuality(quality) - formatCount += 1 - } - - if (formatCount === 0) { - throw new TypeError('formats must contain at least one value') - } -} - export const signParamsSync = ( paramsString: string, authSecret: string, @@ -147,63 +70,31 @@ export const getSignedSmartCdnUrl = (opts: SmartCdnUrlOptions): string => { /** * Builds deterministic signed Smart CDN candidates for server-rendered `` elements. * - * Width descriptors are only accurate when callers do not request widths above the source image's - * intrinsic width. The helper deliberately keeps the Built-in in width-only `fit` mode. + * Pass `sourceDimensions` when known so width descriptors remain truthful without producing a + * rendition above the backend's width or derived-height limits. */ export function getSignedSmartCdnImageCandidates( opts: SmartCdnImageCandidatesOptions, ): SmartCdnImageCandidates { - if (typeof opts.authKey !== 'string' || opts.authKey === '') { + const authKey = opts.authKey + const authSecret = opts.authSecret + const workspace = opts.workspace + if (typeof authKey !== 'string' || authKey === '') { throw new TypeError('authKey is required') } - if (typeof opts.authSecret !== 'string' || opts.authSecret === '') { + if (typeof authSecret !== 'string' || authSecret === '') { throw new TypeError('authSecret is required') } - if (!Number.isSafeInteger(opts.expiresAt) || opts.expiresAt <= 0) { - throw new RangeError('expiresAt must be a positive safe integer') - } - if (!Array.isArray(opts.widths) || opts.widths.length === 0) { - throw new TypeError('widths must contain at least one value') - } - if (opts.widths.length > smartCdnImageMaxWidths) { - throw new RangeError(`widths must contain at most ${smartCdnImageMaxWidths} values`) - } - - validateSmartCdnImageInput(opts.input) - const widths = [...new Set(opts.widths)] - if (widths.length > smartCdnImageMaxWidths) { - throw new RangeError(`widths must contain at most ${smartCdnImageMaxWidths} unique values`) - } - for (const width of widths) { - validateSmartCdnImageDimension(width) - } - - const formats = opts.formats ?? defaultSmartCdnImageFormats - validateSmartCdnImageFormats(formats) - - widths.sort((left, right) => left - right) - const sources: SmartCdnImageSource[] = [] - for (const format of smartCdnImageFormats) { - const quality = formats[format] - if (quality == null) { - continue - } - - const candidates: SmartCdnImageCandidate[] = [] - for (const width of widths) { - const url = getSignedSmartCdnUrl({ - authKey: opts.authKey, - authSecret: opts.authSecret, - expiresAt: opts.expiresAt, - input: opts.input, - template: opts.template ?? defaultSmartCdnImageTemplate, - urlParams: { f: format, q: quality, r: 'fit', w: width }, - workspace: opts.workspace, - }) - candidates.push({ url, width }) - } - sources.push({ candidates, format, quality }) - } - return { fallbackUrl: opts.input, sources } + return createSmartCdnImageCandidates(opts, (request) => + getSignedSmartCdnUrl({ + authKey, + authSecret, + expiresAt: request.expiresAt, + input: request.input, + template: request.template, + urlParams: { ...request.urlParams }, + workspace, + }), + ) } diff --git a/packages/utils/src/smartCdn.ts b/packages/utils/src/smartCdn.ts index 87d1d24d..4aced615 100644 --- a/packages/utils/src/smartCdn.ts +++ b/packages/utils/src/smartCdn.ts @@ -30,7 +30,8 @@ export type SmartCdnUrlOptions = { */ input: string /** - * Additional parameters for the URL query string. + * Additional parameters for the URL query string. `auth_key`, `exp`, and `sig` are reserved: + * signed builders replace them and unsigned builders omit them. */ urlParams?: SmartCdnUrlParams /** @@ -84,7 +85,7 @@ export interface ParsedSmartCdnUrl { workspace: string template: string input: string - /** Every query parameter except the signature ones; repeated parameters become arrays. */ + /** Every query parameter except auth fields; repeated parameters become arrays. */ urlParams: Record /** Present when the URL carries `auth_key`, `exp` and `sig`. */ auth?: { @@ -157,6 +158,8 @@ export const prepareSmartCdnUrl = (opts: SmartCdnUrlOptions): PreparedSmartCdnUr const expiresAt = opts.expiresAt || Date.now() + 60 * 60 * 1000 const queryParams = buildQueryParams(opts.urlParams) + // Keep accepting legacy values: the signer safely replaces its own authentication fields. + queryParams.delete('sig') queryParams.set('auth_key', opts.authKey) queryParams.set('exp', `${expiresAt}`) queryParams.sort() @@ -190,6 +193,8 @@ export const getSmartCdnUrl = (opts: SmartCdnUnsignedUrlOptions): string => { const templateSlug = encodeURIComponent(opts.template) const inputField = encodeURIComponent(opts.input) const queryParams = buildQueryParams(opts.urlParams) + // An unsigned builder must not emit fields that make the URL look partially or fully signed. + for (const param of SIGNATURE_PARAMS) queryParams.delete(param) queryParams.sort() const query = queryParams.toString() return `${resolveBaseUrl(opts.baseUrl, workspaceSlug)}/${templateSlug}/${inputField}${ @@ -311,10 +316,20 @@ export const parseSmartCdnUrl = ( if (templateSlug === '') throw notSmartCdnUrl('missing the template segment') const urlParams: Record = {} - const signature: Record = {} + let authKey: string | undefined + let expiration: string | undefined + let signatureValue: string | undefined for (const [key, value] of new URLSearchParams(parsed.search)) { - if (SIGNATURE_PARAMS.has(key)) { - signature[key] = value + if (key === 'auth_key') { + authKey = value + continue + } + if (key === 'exp') { + expiration = value + continue + } + if (key === 'sig') { + signatureValue = value continue } const existing = urlParams[key] @@ -324,17 +339,18 @@ export const parseSmartCdnUrl = ( } let auth: ParsedSmartCdnUrl['auth'] - const present = Object.keys(signature).length + const present = [authKey, expiration, signatureValue].filter( + (value) => value !== undefined, + ).length if (present > 0) { - if (present !== SIGNATURE_PARAMS.size) { + if (authKey === undefined || expiration === undefined || signatureValue === undefined) { throw notSmartCdnUrl( 'incomplete signature parameters; expected auth_key, exp and sig together', ) } - const expiresAt = Number(signature.exp) - if (!Number.isInteger(expiresAt)) - throw notSmartCdnUrl(`exp '${signature.exp}' is not a timestamp`) - auth = { key: signature.auth_key as string, expiresAt, signature: signature.sig as string } + const expiresAt = Number(expiration) + if (!Number.isInteger(expiresAt)) throw notSmartCdnUrl(`exp '${expiration}' is not a timestamp`) + auth = { key: authKey, expiresAt, signature: signatureValue } } return { diff --git a/packages/utils/src/smartCdnImage.ts b/packages/utils/src/smartCdnImage.ts new file mode 100644 index 00000000..1f925bd4 --- /dev/null +++ b/packages/utils/src/smartCdnImage.ts @@ -0,0 +1,233 @@ +const defaultSmartCdnImageFormats: SmartCdnImageFormats = { avif: 45, webp: 75 } +const minimumMillisecondTimestamp = 1_000_000_000_000 +const smartCdnImageFormats: readonly SmartCdnImageFormat[] = ['avif', 'webp', 'png'] +const smartCdnImageMaxWidths = 32 + +/** Maximum requested width or height accepted by the responsive-image Built-ins. */ +export const smartCdnImageMaxDimension = 8000 + +/** Image formats supported by the responsive-image Built-in. */ +export type SmartCdnImageFormat = 'avif' | 'png' | 'webp' + +/** Formats and their format-specific quality values. */ +export type SmartCdnImageFormats = Readonly>> + +/** One responsive-image candidate at a specific intrinsic width. */ +export interface SmartCdnImageCandidate { + url: string + width: number +} + +/** Ordered candidates for one image format and quality. */ +export interface SmartCdnImageSource { + candidates: readonly SmartCdnImageCandidate[] + format: SmartCdnImageFormat + quality: number +} + +/** One validated format and its encoding quality, in browser preference order. */ +export interface SmartCdnImageFormatQuality { + format: SmartCdnImageFormat + quality: number +} + +/** Structured data for rendering a responsive image. */ +export interface SmartCdnImageCandidates { + fallbackUrl: string + sources: readonly SmartCdnImageSource[] +} + +/** Intrinsic dimensions used to prevent upscaling or an oversized derived height. */ +export interface SmartCdnImageSourceDimensions { + height: number + width: number +} + +/** One rendition request passed to an injected Smart CDN signer. */ +export interface SmartCdnImageSignRequest { + expiresAt: number + input: string + template: string + urlParams: Readonly> +} + +/** Injected signer that keeps responsive-image policy independent from credentials and runtimes. */ +export type SignSmartCdnImageRequest = (request: SmartCdnImageSignRequest) => string + +/** Framework-neutral options for deterministic Smart CDN image candidates. */ +export interface SmartCdnImagePolicyOptions { + /** One absolute expiry in milliseconds since UNIX epoch, shared by every candidate. */ + expiresAt: number + /** Browser-safe fallback URL, kept separate from the Template-specific input value. */ + fallbackUrl: string + /** Formats and their quality values. Defaults to AVIF 45 and WebP 75. */ + formats?: SmartCdnImageFormats + /** One source value accepted by the explicitly selected responsive-image Template. */ + input: string + /** Intrinsic dimensions, when known, used to keep generated output within backend limits. */ + sourceDimensions?: SmartCdnImageSourceDimensions + /** Trusted Template whose source policy is controlled by the caller's workspace. */ + template: string + /** Up to 32 intrinsic widths. Each value must be an integer from 1 through 8000. */ + widths: readonly number[] +} + +function isSmartCdnImageFormat(value: string): value is SmartCdnImageFormat { + return value === 'avif' || value === 'png' || value === 'webp' +} + +function validatePositiveSafeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive safe integer`) + } +} + +function validateSmartCdnImageDimension(value: number, name: string): void { + if (!Number.isInteger(value) || value < 1 || value > smartCdnImageMaxDimension) { + throw new RangeError(`${name} must be an integer from 1 through ${smartCdnImageMaxDimension}`) + } +} + +function validateSmartCdnImageQuality(quality: number): void { + if (!Number.isInteger(quality) || quality < 1 || quality > 100) { + throw new RangeError('quality must be an integer from 1 through 100') + } +} + +function validateSmartCdnImageInput(input: string): void { + if (typeof input !== 'string' || input === '' || input.trim() !== input || input.includes('|')) { + throw new TypeError('input must be one non-empty Template input string') + } +} + +function validateSmartCdnImageFallbackUrl(fallbackUrl: string): void { + if (typeof fallbackUrl !== 'string' || fallbackUrl === '' || fallbackUrl.trim() !== fallbackUrl) { + throw new TypeError('fallbackUrl must be a non-empty string without surrounding whitespace') + } +} + +function validateSmartCdnImageTemplate(template: string): void { + if (typeof template !== 'string' || template === '' || template.trim() !== template) { + throw new TypeError('template must be a non-empty string without surrounding whitespace') + } +} + +/** Resolves and validates format-specific qualities in deterministic browser preference order. */ +export function resolveSmartCdnImageFormats( + formats: SmartCdnImageFormats | undefined, +): SmartCdnImageFormatQuality[] { + const resolved = formats ?? defaultSmartCdnImageFormats + for (const format of Object.keys(resolved)) { + if (!isSmartCdnImageFormat(format)) { + throw new TypeError(`Unsupported Smart CDN image format: ${format}`) + } + } + + const selected: SmartCdnImageFormatQuality[] = [] + for (const format of smartCdnImageFormats) { + if (!Object.hasOwn(resolved, format)) continue + const quality = resolved[format] + if (quality === undefined) continue + validateSmartCdnImageQuality(quality) + selected.push({ format, quality }) + } + if (selected.length === 0) throw new TypeError('formats must contain at least one value') + return selected +} + +function getMaximumCandidateWidth( + sourceDimensions: SmartCdnImageSourceDimensions | undefined, +): number { + if (sourceDimensions === undefined) return smartCdnImageMaxDimension + + validatePositiveSafeInteger(sourceDimensions.width, 'sourceDimensions.width') + validatePositiveSafeInteger(sourceDimensions.height, 'sourceDimensions.height') + const heightLimitedWidth = Number( + (BigInt(smartCdnImageMaxDimension) * BigInt(sourceDimensions.width)) / + BigInt(sourceDimensions.height), + ) + if (heightLimitedWidth < 1) { + // Even a one-pixel-wide rendition would exceed the backend height limit; no truthful candidate + // can preserve this aspect ratio. + throw new RangeError('sourceDimensions aspect ratio cannot fit within backend dimensions') + } + return Math.min(smartCdnImageMaxDimension, sourceDimensions.width, heightLimitedWidth) +} + +/** Validates, caps, deduplicates, and sorts requested responsive-image widths. */ +export function resolveSmartCdnImageWidths( + widths: readonly number[], + maximumWidth = smartCdnImageMaxDimension, +): number[] { + if (!Array.isArray(widths) || widths.length === 0) { + throw new TypeError('widths must contain at least one value') + } + if (widths.length > smartCdnImageMaxWidths) { + throw new RangeError(`widths must contain at most ${smartCdnImageMaxWidths} values`) + } + + validateSmartCdnImageDimension(maximumWidth, 'maximumWidth') + const candidates = new Set() + for (const [index, width] of widths.entries()) { + validateSmartCdnImageDimension(width, `widths[${index}]`) + candidates.add(Math.min(width, maximumWidth)) + } + return [...candidates].sort((left, right) => left - right) +} + +/** + * Creates signed responsive-image candidates while leaving credential storage and HMAC choice to + * the injected signer. + */ +export function createSmartCdnImageCandidates( + options: SmartCdnImagePolicyOptions, + sign: SignSmartCdnImageRequest, +): SmartCdnImageCandidates { + const expiresAt = options.expiresAt + const fallbackUrl = options.fallbackUrl + const formatOptions = options.formats + const formatsSnapshot = formatOptions === undefined ? undefined : { ...formatOptions } + const input = options.input + const sourceDimensionOptions = options.sourceDimensions + const sourceDimensions = + sourceDimensionOptions === undefined + ? undefined + : { height: sourceDimensionOptions.height, width: sourceDimensionOptions.width } + const template = options.template + const widthOptions = options.widths + const widthsSnapshot = Array.isArray(widthOptions) ? [...widthOptions] : widthOptions + + validatePositiveSafeInteger(expiresAt, 'expiresAt') + if (expiresAt < minimumMillisecondTimestamp) { + throw new RangeError('expiresAt must be a millisecond timestamp') + } + validateSmartCdnImageFallbackUrl(fallbackUrl) + validateSmartCdnImageInput(input) + validateSmartCdnImageTemplate(template) + if (typeof sign !== 'function') throw new TypeError('sign must be a function') + + const formats = resolveSmartCdnImageFormats(formatsSnapshot) + const widths = resolveSmartCdnImageWidths( + widthsSnapshot, + getMaximumCandidateWidth(sourceDimensions), + ) + const sources: SmartCdnImageSource[] = [] + + for (const { format, quality } of formats) { + sources.push({ + candidates: widths.map((width) => ({ + url: sign({ + expiresAt, + input, + template, + urlParams: { f: format, q: quality, r: 'fit', w: width }, + }), + width, + })), + format, + quality, + }) + } + + return { fallbackUrl, sources } +} diff --git a/packages/utils/test/node.test.ts b/packages/utils/test/node.test.ts index 20d27108..4912f641 100644 --- a/packages/utils/test/node.test.ts +++ b/packages/utils/test/node.test.ts @@ -6,7 +6,9 @@ const baseOptions = { authKey: 'test-key', authSecret: 'test-secret', expiresAt: 1_900_000_000_000, + fallbackUrl: 'https://assets.example/image.jpg?version=1', input: 'https://assets.example/image.jpg?version=1', + template: 'website-images', widths: [640, 320, 640], workspace: 'test-workspace', } @@ -45,7 +47,7 @@ describe('getSignedSmartCdnImageCandidates', () => { const result = getSignedSmartCdnImageCandidates(baseOptions) expect(result).toEqual(getSignedSmartCdnImageCandidates(baseOptions)) - expect(result.fallbackUrl).toBe(baseOptions.input) + expect(result.fallbackUrl).toBe(baseOptions.fallbackUrl) expect(result.sources.map(({ format }) => format)).toEqual(['avif', 'webp']) const avifSource = requireSource(result.sources, 'avif') @@ -59,8 +61,7 @@ describe('getSignedSmartCdnImageCandidates', () => { for (const { url: rawUrl, width } of avifSource.candidates) { const url = new URL(rawUrl) expect(url.pathname).toBe( - '/builtin%2Fserve-image%400.0.1/' + - 'https%3A%2F%2Fassets.example%2Fimage.jpg%3Fversion%3D1', + '/website-images/' + 'https%3A%2F%2Fassets.example%2Fimage.jpg%3Fversion%3D1', ) expect(url.searchParams.get('auth_key')).toBe(baseOptions.authKey) expect(url.searchParams.get('exp')).toBe(`${baseOptions.expiresAt}`) @@ -99,18 +100,54 @@ describe('getSignedSmartCdnImageCandidates', () => { expect(new URL(pngCandidate.url).searchParams.get('q')).toBe('90') }) + it('keeps the Template input separate from the browser fallback URL', () => { + const result = getSignedSmartCdnImageCandidates({ + ...baseOptions, + fallbackUrl: '/images/photo.jpg', + input: 'images/photo.jpg', + }) + + expect(result.fallbackUrl).toBe('/images/photo.jpg') + }) + + it('snapshots credentials and workspace before signing candidates', () => { + let authKeyReads = 0 + let authSecretReads = 0 + let workspaceReads = 0 + const result = getSignedSmartCdnImageCandidates({ + ...baseOptions, + get authKey() { + authKeyReads += 1 + return authKeyReads === 1 ? baseOptions.authKey : 'changed-key' + }, + get authSecret() { + authSecretReads += 1 + return authSecretReads === 1 ? baseOptions.authSecret : 'changed-secret' + }, + get workspace() { + workspaceReads += 1 + return workspaceReads === 1 ? baseOptions.workspace : 'changed-workspace' + }, + }) + + expect(result).toEqual(getSignedSmartCdnImageCandidates(baseOptions)) + expect(authKeyReads).toBe(1) + expect(authSecretReads).toBe(1) + expect(workspaceReads).toBe(1) + }) + it('rejects values that the Built-in cannot execute safely', () => { expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, widths: [] })).toThrow( 'widths must contain at least one value', ) expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, widths: [0] })).toThrow( - 'width must be an integer from 1 through 8000', + 'widths[0] must be an integer from 1 through 8000', ) expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, widths: [8001] })).toThrow( - 'width must be an integer from 1 through 8000', + 'widths[0] must be an integer from 1 through 8000', ) expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, widths: [1.5] })).toThrow( - 'width must be an integer from 1 through 8000', + 'widths[0] must be an integer from 1 through 8000', ) expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, formats: { avif: 0 } }), @@ -134,37 +171,37 @@ describe('getSignedSmartCdnImageCandidates', () => { expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, expiresAt: Number.MAX_VALUE }), ).toThrow('expiresAt must be a positive safe integer') - expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, input: 'not-a-url' })).toThrow( - 'input must be an HTTP or HTTPS URL', - ) + expect(() => + getSignedSmartCdnImageCandidates({ ...baseOptions, fallbackUrl: ' /images/photo.jpg' }), + ).toThrow('fallbackUrl must be a non-empty string without surrounding whitespace') expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, // @ts-expect-error The runtime boundary must not coerce URL objects. input: new URL('https://assets.example/image.jpg'), }), - ).toThrow('input must be a single HTTP or HTTPS URL string') + ).toThrow('input must be one non-empty Template input string') expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, input: ' https://assets.example/image.jpg', }), - ).toThrow('input must be a single HTTP or HTTPS URL string') + ).toThrow('input must be one non-empty Template input string') expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, input: 'https://assets.example/one.jpg|https://assets.example/two.jpg', }), - ).toThrow('input must be a single HTTP or HTTPS URL string') - expect(() => - getSignedSmartCdnImageCandidates({ ...baseOptions, input: 'ftp://assets.example/image.jpg' }), - ).toThrow('input must be an HTTP or HTTPS URL') + ).toThrow('input must be one non-empty Template input string') expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, authKey: '' })).toThrow( 'authKey is required', ) expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, authSecret: '' })).toThrow( 'authSecret is required', ) + expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, template: '' })).toThrow( + 'template must be a non-empty string without surrounding whitespace', + ) expect(() => getSignedSmartCdnImageCandidates({ ...baseOptions, diff --git a/packages/utils/test/smartCdnGrammar.test.ts b/packages/utils/test/smartCdnGrammar.test.ts index 8aa3ec21..b801ebe8 100644 --- a/packages/utils/test/smartCdnGrammar.test.ts +++ b/packages/utils/test/smartCdnGrammar.test.ts @@ -78,6 +78,28 @@ describe('getSmartCdnUrl (unsigned)', () => { expect(() => getSmartCdnUrl({ ...storage, baseUrl: 'https://cdn.example/?x=1' })).toThrow( 'baseUrl must not contain a query string or fragment', ) + expect( + new URL( + getSmartCdnUrl({ ...storage, urlParams: { hsh: 'saved-template-hash' } }), + ).searchParams.get('hsh'), + ).toBe('saved-template-hash') + }) + + it('omits reserved authentication fields and remains parseable', () => { + const url = getSmartCdnUrl({ + ...storage, + urlParams: { + auth_key: 'caller-controlled', + exp: 1, + hsh: 'saved-template-hash', + sig: 'caller-controlled', + }, + }) + + expect(parseSmartCdnUrl(url).urlParams).toEqual({ hsh: 'saved-template-hash' }) + expect(new URL(url).searchParams.has('auth_key')).toBe(false) + expect(new URL(url).searchParams.has('exp')).toBe(false) + expect(new URL(url).searchParams.has('sig')).toBe(false) }) }) @@ -96,6 +118,33 @@ describe('getSignedSmartCdnUrl with baseUrl', () => { it('still matches the known answer without a baseUrl', async () => { await expect(getSignedSmartCdnUrl(signed)).resolves.toBe(knownAnswer) }) + + it('overwrites caller-provided signature parameters for backward compatibility', async () => { + const compatible = { + ...signed, + urlParams: { + ...signed.urlParams, + auth_key: 'caller-controlled', + exp: 1, + sig: 'caller-controlled', + }, + } + + await expect(getSignedSmartCdnUrl(compatible)).resolves.toBe(knownAnswer) + expect(getSignedSmartCdnUrlSync(compatible)).toBe(knownAnswer) + }) + + it('preserves signed template-cache metadata and parses it as a normal field', async () => { + const withTemplateHash = { + ...signed, + urlParams: { ...signed.urlParams, hsh: 'saved-template-hash' }, + } + const url = await getSignedSmartCdnUrl(withTemplateHash) + + expect(getSignedSmartCdnUrlSync(withTemplateHash)).toBe(url) + expect(new URL(url).searchParams.get('hsh')).toBe('saved-template-hash') + expect(parseSmartCdnUrl(url).urlParams.hsh).toBe('saved-template-hash') + }) }) describe('stripSmartCdnAuth', () => { @@ -122,6 +171,22 @@ describe('stripSmartCdnAuth', () => { }) describe('parseSmartCdnUrl', () => { + it('ignores signature fields inherited from Object.prototype', () => { + Object.defineProperties(Object.prototype, { + auth_key: { configurable: true, value: 'polluted-key' }, + exp: { configurable: true, value: '1900000000000' }, + sig: { configurable: true, value: 'sha256:polluted' }, + }) + + try { + expect(parseSmartCdnUrl(getSmartCdnUrl(storage)).auth).toBeUndefined() + } finally { + Reflect.deleteProperty(Object.prototype, 'auth_key') + Reflect.deleteProperty(Object.prototype, 'exp') + Reflect.deleteProperty(Object.prototype, 'sig') + } + }) + it('inverts the signed known answer', () => { const parsed = parseSmartCdnUrl(knownAnswer) expect(parsed).toEqual({ diff --git a/packages/utils/test/smartCdnImage.test.ts b/packages/utils/test/smartCdnImage.test.ts new file mode 100644 index 00000000..1c258b41 --- /dev/null +++ b/packages/utils/test/smartCdnImage.test.ts @@ -0,0 +1,126 @@ +import type { SmartCdnImageSignRequest } from '../src/smartCdnImage.ts' + +import { describe, expect, test, vi } from 'vitest' + +import { + createSmartCdnImageCandidates, + resolveSmartCdnImageFormats, + resolveSmartCdnImageWidths, +} from '../src/smartCdnImage.ts' + +describe('createSmartCdnImageCandidates', () => { + test('shares normalized format and width policy with framework packages', () => { + expect(resolveSmartCdnImageFormats({ webp: 61, avif: 43 })).toEqual([ + { format: 'avif', quality: 43 }, + { format: 'webp', quality: 61 }, + ]) + expect(resolveSmartCdnImageWidths([800, 400, 800], 600)).toEqual([400, 600]) + }) + + test('ignores inherited format qualities', () => { + Object.defineProperty(Object.prototype, 'png', { configurable: true, value: 90 }) + + try { + expect(resolveSmartCdnImageFormats({ webp: 75 })).toEqual([{ format: 'webp', quality: 75 }]) + expect(() => resolveSmartCdnImageFormats({})).toThrow( + 'formats must contain at least one value', + ) + } finally { + Reflect.deleteProperty(Object.prototype, 'png') + } + }) + + test('builds signer-agnostic candidates and caps width by both source dimensions', () => { + const requests: SmartCdnImageSignRequest[] = [] + const result = createSmartCdnImageCandidates( + { + expiresAt: 1_900_000_000_000, + fallbackUrl: '/images/portrait.jpg', + formats: { webp: 75 }, + input: 'https://assets.example/portrait.jpg', + sourceDimensions: { height: 10_000, width: 1_000 }, + template: 'website-images', + widths: [1_000, 400], + }, + (request) => { + requests.push(request) + return `https://cdn.example/${requests.length}` + }, + ) + + expect(result.sources[0]?.candidates).toEqual([ + { url: 'https://cdn.example/1', width: 400 }, + { url: 'https://cdn.example/2', width: 800 }, + ]) + expect(requests).toEqual([ + { + expiresAt: 1_900_000_000_000, + input: 'https://assets.example/portrait.jpg', + template: 'website-images', + urlParams: { f: 'webp', q: 75, r: 'fit', w: 400 }, + }, + { + expiresAt: 1_900_000_000_000, + input: 'https://assets.example/portrait.jpg', + template: 'website-images', + urlParams: { f: 'webp', q: 75, r: 'fit', w: 800 }, + }, + ]) + }) + + test('signs with the validated policy snapshot when option accessors later change', () => { + let expiresAtReads = 0 + let inputReads = 0 + const requests: SmartCdnImageSignRequest[] = [] + const result = createSmartCdnImageCandidates( + { + get expiresAt() { + expiresAtReads += 1 + return expiresAtReads === 1 ? 1_900_000_000_000 : 0 + }, + fallbackUrl: '/images/photo.jpg', + formats: { webp: 75 }, + get input() { + inputReads += 1 + return inputReads === 1 ? 'https://assets.example/photo.jpg' : 'file:///etc/passwd' + }, + template: 'website-images', + widths: [400], + }, + (request) => { + requests.push(request) + return 'https://cdn.example/image' + }, + ) + + expect(requests).toEqual([ + { + expiresAt: 1_900_000_000_000, + input: 'https://assets.example/photo.jpg', + template: 'website-images', + urlParams: { f: 'webp', q: 75, r: 'fit', w: 400 }, + }, + ]) + expect(result.fallbackUrl).toBe('/images/photo.jpg') + expect(expiresAtReads).toBe(1) + expect(inputReads).toBe(1) + }) + + test('rejects a seconds-based expiry before signing', () => { + const sign = vi.fn(() => 'https://cdn.example/image') + + expect(() => + createSmartCdnImageCandidates( + { + expiresAt: 1_893_456_000, + fallbackUrl: '/images/photo.jpg', + input: 'https://assets.example/photo.jpg', + template: 'website-images', + widths: [400], + }, + sign, + ), + ).toThrow('expiresAt must be a millisecond timestamp') + expect(sign).not.toHaveBeenCalled() + }) +}) diff --git a/scripts/fixtures/img-next/app/TransloaditImage.tsx b/scripts/fixtures/img-next/app/TransloaditImage.tsx new file mode 100644 index 00000000..0f53a4f6 --- /dev/null +++ b/scripts/fixtures/img-next/app/TransloaditImage.tsx @@ -0,0 +1,10 @@ +import { createTransloaditImage } from '@transloadit/img/next/server' + +import { imageConfiguration } from './imageConfiguration.ts' + +const { Image } = createTransloaditImage({ + ...imageConfiguration, + storage: { allowedPathPrefixes: ['documents/'] }, +}) + +export { Image as TransloaditImage } diff --git a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx new file mode 100644 index 00000000..bfbd58e1 --- /dev/null +++ b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx @@ -0,0 +1,17 @@ +import { createTransloaditImage } from '@transloadit/img/next/server' + +import { imageConfiguration } from './imageConfiguration.ts' + +const { Image, storageRoute } = createTransloaditImage({ + ...imageConfiguration, + storage: { + allowedPathPrefixes: ['documents/'], + delivery: { + authorize: ({ request }) => request.headers.get('authorization') === 'Bearer fixture', + basePath: '/fixture', + route: '/api/private-images', + }, + }, +}) + +export { Image as TransloaditRedirectImage, storageRoute } diff --git a/scripts/fixtures/img-next/app/api/private-images/route.ts b/scripts/fixtures/img-next/app/api/private-images/route.ts new file mode 100644 index 00000000..0b4e245a --- /dev/null +++ b/scripts/fixtures/img-next/app/api/private-images/route.ts @@ -0,0 +1 @@ +export { storageRoute as GET } from '../../TransloaditRedirectImage.tsx' diff --git a/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx b/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx new file mode 100644 index 00000000..be01d040 --- /dev/null +++ b/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react' + +import { notFound } from 'next/navigation.js' + +import { TransloaditImage } from '../../../TransloaditImage.tsx' +import { TransloaditRedirectImage } from '../../../TransloaditRedirectImage.tsx' + +interface PageProps { + params: Promise<{ count: string; delivery: string }> +} + +const benchmarkCounts = new Set([1, 20, 100]) + +export const instant = false + +export default async function Page({ params }: PageProps): Promise { + const { count: countValue, delivery } = await params + const count = Number(countValue) + if (!benchmarkCounts.has(count) || (delivery !== 'direct' && delivery !== 'redirect')) { + notFound() + } + const Image = delivery === 'direct' ? TransloaditImage : TransloaditRedirectImage + const images: ReactNode[] = [] + for (let index = 0; index < count; index += 1) { + images.push( + {`Benchmark, + ) + } + return
{images}
+} diff --git a/scripts/fixtures/img-next/app/imageConfiguration.ts b/scripts/fixtures/img-next/app/imageConfiguration.ts new file mode 100644 index 00000000..64aa4014 --- /dev/null +++ b/scripts/fixtures/img-next/app/imageConfiguration.ts @@ -0,0 +1,6 @@ +export const imageConfiguration = { + authKey: 'fixture-auth-key', + authSecret: 'fixture-secret-must-never-reach-the-browser', + baseUrl: 'https://cdn.example/file/{workspace}', + workspace: 'fixture', +} diff --git a/scripts/fixtures/img-next/app/layout.tsx b/scripts/fixtures/img-next/app/layout.tsx new file mode 100644 index 00000000..d50e935a --- /dev/null +++ b/scripts/fixtures/img-next/app/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react' + +interface LayoutProps { + children: ReactNode +} + +export default function Layout({ children }: LayoutProps): ReactNode { + return ( + + {children} + + ) +} diff --git a/scripts/fixtures/img-next/app/storage-image/page.tsx b/scripts/fixtures/img-next/app/storage-image/page.tsx new file mode 100644 index 00000000..992e2b5b --- /dev/null +++ b/scripts/fixtures/img-next/app/storage-image/page.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' + +import { TransloaditImage } from '../TransloaditImage.tsx' + +export default function Page(): ReactNode { + return ( + } + width={400} + /> + ) +} diff --git a/scripts/fixtures/img-next/app/storage-redirect/page.tsx b/scripts/fixtures/img-next/app/storage-redirect/page.tsx new file mode 100644 index 00000000..4e418674 --- /dev/null +++ b/scripts/fixtures/img-next/app/storage-redirect/page.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' + +import { TransloaditRedirectImage } from '../TransloaditRedirectImage.tsx' + +export default function Page(): ReactNode { + return ( + + ) +} diff --git a/scripts/fixtures/img-next/next.config.ts b/scripts/fixtures/img-next/next.config.ts new file mode 100644 index 00000000..70b9513e --- /dev/null +++ b/scripts/fixtures/img-next/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { basePath: '/fixture', cacheComponents: true } + +export default nextConfig diff --git a/scripts/fixtures/img-next/package-lock.json b/scripts/fixtures/img-next/package-lock.json new file mode 100644 index 00000000..c148d2bd --- /dev/null +++ b/scripts/fixtures/img-next/package-lock.json @@ -0,0 +1,1094 @@ +{ + "name": "transloadit-img-next-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "transloadit-img-next-fixture", + "dependencies": { + "@noble/ciphers": "1.3.0", + "next": "16.3.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "server-only": "0.0.1" + }, + "devDependencies": { + "@types/node": "25.8.0", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "typescript": "6.0.3" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@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" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.0", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.0", + "@next/swc-darwin-x64": "16.3.0", + "@next/swc-linux-arm64-gnu": "16.3.0", + "@next/swc-linux-arm64-musl": "16.3.0", + "@next/swc-linux-x64-gnu": "16.3.0", + "@next/swc-linux-x64-musl": "16.3.0", + "@next/swc-win32-arm64-msvc": "16.3.0", + "@next/swc-win32-x64-msvc": "16.3.0", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/scripts/fixtures/img-next/package.json b/scripts/fixtures/img-next/package.json new file mode 100644 index 00000000..c113c37d --- /dev/null +++ b/scripts/fixtures/img-next/package.json @@ -0,0 +1,21 @@ +{ + "name": "transloadit-img-next-fixture", + "private": true, + "scripts": { + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@noble/ciphers": "1.3.0", + "next": "16.3.0", + "react": "19.2.8", + "react-dom": "19.2.8", + "server-only": "0.0.1" + }, + "devDependencies": { + "@types/node": "25.8.0", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.5", + "typescript": "6.0.3" + } +} diff --git a/scripts/fixtures/img-next/tsconfig.json b/scripts/fixtures/img-next/tsconfig.json new file mode 100644 index 00000000..39cdfd0c --- /dev/null +++ b/scripts/fixtures/img-next/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "jsx": "preserve", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "target": "ES2017" + } +} diff --git a/scripts/img-next-fixture.test.ts b/scripts/img-next-fixture.test.ts new file mode 100644 index 00000000..2ed06ae2 --- /dev/null +++ b/scripts/img-next-fixture.test.ts @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { expect, test } from 'vitest' + +interface PackageManifest { + dependencies?: Record + devDependencies?: Record +} + +async function readManifest(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) +} + +test('locks every external runtime dependency of the packed image package', async () => { + const repoRoot = resolve(import.meta.dirname, '..') + const imageManifest = await readManifest(resolve(repoRoot, 'packages/img/package.json')) + const fixtureManifest = await readManifest( + resolve(repoRoot, 'scripts/fixtures/img-next/package.json'), + ) + const fixtureDependencies = { + ...fixtureManifest.dependencies, + ...fixtureManifest.devDependencies, + } + + for (const [name, range] of Object.entries(imageManifest.dependencies ?? {})) { + if (range.startsWith('workspace:')) continue + expect(fixtureDependencies[name], `${name} must be pinned in the fixture`).toMatch(/^\d/) + } +}) diff --git a/scripts/test-img-next-fixture.ts b/scripts/test-img-next-fixture.ts new file mode 100644 index 00000000..8e983827 --- /dev/null +++ b/scripts/test-img-next-fixture.ts @@ -0,0 +1,359 @@ +import { cp, mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { resolve } from 'node:path' +import { setTimeout } from 'node:timers/promises' +import { brotliCompressSync, gzipSync } from 'node:zlib' + +import { execa } from 'execa' + +import { withProcess } from './withProcess.ts' + +const fixtureSecret = 'fixture-secret-must-never-reach-the-browser' +const benchmarkCounts: readonly number[] = [1, 20, 100] + +interface ImageBenchmarkResult { + brotliBytes: number + count: number + delivery: 'direct' | 'redirect' + gzipBytes: number + htmlBytes: number + htmlMs: number + routeMs: number + routeRequests: number +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +function getFreePort(): Promise { + return new Promise((resolvePort, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (address === null || typeof address === 'string') { + server.close() + reject(new Error('Could not allocate a fixture port')) + return + } + server.close((error) => { + if (error) reject(error) + else resolvePort(address.port) + }) + }) + }) +} + +async function fetchWhenReady(url: string, signal?: AbortSignal): Promise { + for (let attempt = 0; attempt < 80; attempt += 1) { + const response = await fetch(url, { signal }).catch((error: unknown) => { + if (signal?.aborted) throw error + return undefined + }) + if (response?.ok) return response + if (response !== undefined) { + const body = (await response.text()).slice(0, 1_000) + throw new Error(`${url} returned HTTP ${response.status}: ${body}`) + } + await setTimeout(250, undefined, { signal }) + } + throw new Error(`Next.js fixture did not become ready at ${url}`) +} + +async function withFixtureServer( + fixtureDir: string, + verify: (baseUrl: string) => Promise, +): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + const port = await getFreePort() + const baseUrl = `http://127.0.0.1:${port}` + // Start Next directly so cleanup targets the server process instead of an npm wrapper. On + // Linux, killing the wrapper can leave Next holding Execa's output pipes open indefinitely. + const server = execa( + process.execPath, + [ + resolve(fixtureDir, 'node_modules/next/dist/bin/next'), + 'start', + '-H', + '127.0.0.1', + '-p', + `${port}`, + ], + { + cwd: fixtureDir, + reject: false, + }, + ) + server.stdout?.pipe(process.stdout) + server.stderr?.pipe(process.stderr) + const shouldRetry = await withProcess(server, async () => { + const abortController = new AbortController() + try { + const outcome = await Promise.race([ + fetchWhenReady(`${baseUrl}/fixture/storage-image`, abortController.signal).then( + () => undefined, + ), + server, + ]) + if (outcome === undefined) { + await verify(baseUrl) + return false + } + if (!outcome.stderr.includes('EADDRINUSE')) { + throw new Error(`Next.js fixture server exited before becoming ready: ${outcome.stderr}`) + } + return true + } finally { + abortController.abort() + } + }) + + if (!shouldRetry) return + } + + throw new Error('Next.js fixture could not reserve a port after five attempts') +} + +async function assertTreeExcludes(directory: string, forbidden: string): Promise { + const entries = await readdir(directory, { recursive: true, withFileTypes: true }) + for (const entry of entries) { + if (!entry.isFile()) continue + const contents = await readFile(resolve(entry.parentPath, entry.name)) + assert( + !contents.includes(forbidden), + `${forbidden} leaked into ${entry.parentPath}/${entry.name}`, + ) + } +} + +function decodeHtmlAttribute(value: string): string { + return value.replaceAll('&', '&').replaceAll('"', '"').replaceAll(''', "'") +} + +function getFirstPictureCandidates(html: string): string[] { + const pictures = html.match(//gi) ?? [] + const candidates: string[] = [] + for (const picture of pictures) { + const sourceSet = /]*\bsrcset="([^"]+)"/i.exec(picture)?.[1] + assert(sourceSet !== undefined, 'Expected every benchmark picture to contain a source set') + const decoded = decodeHtmlAttribute(sourceSet) + const separator = decoded.indexOf(' ') + assert(separator > 0, 'Expected every benchmark candidate to have a width descriptor') + candidates.push(decoded.slice(0, separator)) + } + return candidates +} + +async function runImageBenchmark( + baseUrl: string, + count: number, + delivery: 'direct' | 'redirect', +): Promise { + const htmlStartedAt = performance.now() + const response = await fetchWhenReady(`${baseUrl}/fixture/benchmark/${delivery}/${count}`) + const html = await response.text() + const htmlMs = performance.now() - htmlStartedAt + const candidates = getFirstPictureCandidates(html) + assert(candidates.length === count, `Expected ${count} ${delivery} benchmark pictures`) + + let routeMs = 0 + if (delivery === 'redirect') { + const routeStartedAt = performance.now() + const redirects = await Promise.all( + candidates.map((candidate) => + fetch(new URL(candidate, baseUrl), { + headers: { Authorization: 'Bearer fixture' }, + redirect: 'manual', + }), + ), + ) + routeMs = performance.now() - routeStartedAt + for (const redirect of redirects) { + assert(redirect.status === 307, 'Expected every authorized image route to redirect') + assert( + redirect.headers.get('location')?.startsWith('https://cdn.example/') === true, + 'Expected every image redirect to target Smart CDN', + ) + assert((await redirect.text()) === '', 'An image route must not proxy response bytes') + } + } else { + assert( + candidates.every((candidate) => candidate.startsWith('https://cdn.example/')), + 'Expected direct benchmark images to bypass the application route', + ) + } + + return { + brotliBytes: brotliCompressSync(html).byteLength, + count, + delivery, + gzipBytes: gzipSync(html).byteLength, + htmlBytes: Buffer.byteLength(html), + htmlMs: Math.round(htmlMs * 10) / 10, + routeMs: Math.round(routeMs * 10) / 10, + routeRequests: delivery === 'redirect' ? count : 0, + } +} + +async function main(): Promise { + const repoRoot = resolve(import.meta.dirname, '..') + const temporaryRoot = await mkdtemp(resolve(tmpdir(), 'transloadit-img-next-')) + const fixtureDir = resolve(temporaryRoot, 'fixture') + const packDir = resolve(temporaryRoot, 'pack') + + try { + // Keep the entire external execution graph reviewable and age-gated in the repository. + await Promise.all([ + cp(resolve(import.meta.dirname, 'fixtures/img-next'), fixtureDir, { recursive: true }), + mkdir(packDir), + ]) + await execa( + 'corepack', + [ + 'yarn', + 'workspace', + '@transloadit/img', + 'pack', + '--out', + resolve(packDir, 'transloadit-img-0.0.0.tgz'), + ], + { cwd: repoRoot, stdio: 'inherit' }, + ) + await execa( + 'npm', + ['pack', resolve(repoRoot, 'packages/utils'), '--pack-destination', packDir], + { + cwd: repoRoot, + stdio: 'inherit', + }, + ) + const tarballs = (await readdir(packDir)).filter((name) => name.endsWith('.tgz')) + assert(tarballs.length === 2, `Expected two package tarballs, found ${tarballs.length}`) + const imageTarball = tarballs.find((name) => name.startsWith('transloadit-img-')) + const utilsTarball = tarballs.find((name) => name.startsWith('transloadit-utils-')) + assert(imageTarball !== undefined, 'Expected an @transloadit/img package tarball') + assert(utilsTarball !== undefined, 'Expected an @transloadit/utils package tarball') + await execa('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], { + cwd: fixtureDir, + stdio: 'inherit', + }) + await execa( + 'npm', + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--no-save', + '--prefer-offline', + '--package-lock=false', + resolve(packDir, utilsTarball), + resolve(packDir, imageTarball), + ], + { cwd: fixtureDir, stdio: 'inherit' }, + ) + await execa('npm', ['run', 'build'], { cwd: fixtureDir, stdio: 'inherit' }) + + const appOutput = resolve(fixtureDir, '.next/server/app') + const outputNames = await readdir(appOutput, { recursive: true }) + assert( + outputNames.includes('storage-image.html'), + 'Expected a safe partial-prerender Storage shell', + ) + assert( + outputNames.includes('storage-redirect.html'), + 'Expected redirect-delivery markup to prerender', + ) + const storageShell = await readFile(resolve(appOutput, 'storage-image.html'), 'utf8') + assert(storageShell.includes('Loading preview'), 'Storage shell fallback is absent') + assert( + !storageShell.includes('builtin%2Fstorage-preview%400.0.1'), + 'A signed Storage URL leaked into the prerendered shell', + ) + await assertTreeExcludes(resolve(fixtureDir, '.next/static'), fixtureSecret) + await assertTreeExcludes(appOutput, fixtureSecret) + + await withFixtureServer(fixtureDir, async (baseUrl) => { + const storageHtml = await (await fetchWhenReady(`${baseUrl}/fixture/storage-image`)).text() + const redirectResponse = await fetchWhenReady(`${baseUrl}/fixture/storage-redirect`) + const redirectLinkHeader = redirectResponse.headers.get('link') ?? '' + const redirectHtml = await redirectResponse.text() + const imagePreloads = (redirectHtml.match(/]*>/g) ?? []).filter( + (tag) => tag.includes('rel="preload"') && tag.includes('as="image"'), + ) + assert( + imagePreloads.length === 1, + `Expected one responsive image preload; HTML=${JSON.stringify(imagePreloads)} Link=${redirectLinkHeader}`, + ) + const headEnd = redirectHtml.indexOf('') + assert( + headEnd > 0 && imagePreloads.every((tag) => redirectHtml.indexOf(tag) < headEnd), + 'Responsive image preloads were not hoisted into the document head', + ) + assert(imagePreloads[0]?.includes('imageSrcSet='), 'Responsive preload srcset is absent') + assert( + storageHtml.includes('builtin%2Fstorage-preview%400.0.1'), + 'Storage Built-in is absent', + ) + assert(storageHtml.includes('r=pad'), 'Storage preview does not preserve exact dimensions') + assert(storageHtml.includes('q=45'), 'Storage preview does not apply format-specific quality') + assert( + redirectHtml.includes('/fixture/api/private-images?'), + 'Authorized Storage route is absent', + ) + assert( + !redirectHtml.includes('builtin%2Fstorage-preview%400.0.1'), + 'Redirect markup contains a direct signed Storage URL', + ) + assert(!storageHtml.includes(fixtureSecret), 'Secret leaked into Storage output') + assert(!redirectHtml.includes(fixtureSecret), 'Secret leaked into redirect output') + + const routeCandidate = getFirstPictureCandidates(redirectHtml)[0] + assert(routeCandidate !== undefined, 'Expected a redirect route candidate') + const routeUrl = new URL(routeCandidate, baseUrl) + const allowed = await fetch(routeUrl, { + headers: { Authorization: 'Bearer fixture' }, + redirect: 'manual', + }) + assert(allowed.status === 307, 'Authorized Storage route did not redirect') + assert( + allowed.headers.get('location')?.startsWith('https://cdn.example/') === true, + 'Authorized Storage route did not target Smart CDN', + ) + const allowedHead = await fetch(routeUrl, { + headers: { Authorization: 'Bearer fixture' }, + method: 'HEAD', + redirect: 'manual', + }) + assert(allowedHead.status === 307, 'Authorized Storage route did not support HEAD') + assert((await allowedHead.text()) === '', 'Authorized HEAD response contained a body') + const denied = await fetch(routeUrl, { redirect: 'manual' }) + assert(denied.status === 404, 'Unauthorized Storage route did not conceal the object') + const capability = routeUrl.searchParams.get('cap') + assert(capability !== null, 'Authorized Storage route capability is absent') + const replacement = capability.endsWith('A') ? 'B' : 'A' + routeUrl.searchParams.set('cap', `${capability.slice(0, -1)}${replacement}`) + const altered = await fetch(routeUrl, { + headers: { Authorization: 'Bearer fixture' }, + redirect: 'manual', + }) + assert(altered.status === 404, 'Storage route accepted an altered transform capability') + + const benchmarks: ImageBenchmarkResult[] = [] + for (const count of benchmarkCounts) { + benchmarks.push(await runImageBenchmark(baseUrl, count, 'direct')) + benchmarks.push(await runImageBenchmark(baseUrl, count, 'redirect')) + } + console.table(benchmarks) + }) + } finally { + await rm(temporaryRoot, { force: true, recursive: true }) + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/withProcess.test.ts b/scripts/withProcess.test.ts new file mode 100644 index 00000000..b1f948ba --- /dev/null +++ b/scripts/withProcess.test.ts @@ -0,0 +1,17 @@ +import { execa } from 'execa' +import { expect, test } from 'vitest' + +import { withProcess } from './withProcess.ts' + +test('terminates and awaits the child when guarded work fails', async () => { + const child = execa(process.execPath, ['-e', 'setInterval(() => undefined, 1_000)']) + const readinessError = new Error('Fixture readiness failed') + + await expect( + withProcess(child, async () => { + await Promise.resolve() + throw readinessError + }), + ).rejects.toBe(readinessError) + expect(child.signalCode).toBe('SIGTERM') +}) diff --git a/scripts/withProcess.ts b/scripts/withProcess.ts new file mode 100644 index 00000000..5c57b06e --- /dev/null +++ b/scripts/withProcess.ts @@ -0,0 +1,15 @@ +import type { ResultPromise } from 'execa' + +/** Runs work while a subprocess lives, then terminates and awaits that process on every exit path. */ +export async function withProcess( + child: ResultPromise, + run: () => Promise, +): Promise { + try { + return await run() + } finally { + child.kill('SIGTERM') + // A successful cleanup often makes Execa reject; preserve the error from the guarded work. + await child.catch(() => undefined) + } +} diff --git a/tsconfig.json b/tsconfig.json index d5aedc09..9126144c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "allowImportingTsExtensions": true }, "references": [ + { "path": "./packages/img" }, { "path": "./packages/node" }, { "path": "./packages/notify-url-relay" }, { "path": "./packages/types" }, diff --git a/yarn.lock b/yarn.lock index 66166e8f..78284645 100644 --- a/yarn.lock +++ b/yarn.lock @@ -397,6 +397,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.11.3": + version: 1.11.3 + resolution: "@emnapi/runtime@npm:1.11.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.2.1": version: 1.2.1 resolution: "@emnapi/wasi-threads@npm:1.2.1" @@ -415,6 +424,251 @@ __metadata: languageName: node linkType: hard +"@img/colour@npm:^1.1.0": + version: 1.1.0 + resolution: "@img/colour@npm:1.1.0" + checksum: 10c0/2ebea2c0bbaee73b99badcefa04e1e71d83f36e5369337d3121dca841f4569533c4e2faddda6d62dd247f0d5cca143711f9446c59bcce81e427ba433a7a94a17 + languageName: node + linkType: hard + +"@img/sharp-darwin-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-arm64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-darwin-arm64": + optional: true + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-darwin-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-darwin-x64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-darwin-x64": + optional: true + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-freebsd-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-freebsd-wasm32@npm:0.35.4" + dependencies: + "@img/sharp-wasm32": "npm:0.35.4" + conditions: os=freebsd + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.3.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.3.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.3.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-arm@npm:1.3.3" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-ppc64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.3.3" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-riscv64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-riscv64@npm:1.3.3" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-s390x@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.3.3" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linux-x64@npm:1.3.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.3.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-x64@npm:1.3.3": + version: 1.3.3 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.3.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linux-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-arm@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-arm@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-arm": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-arm": + optional: true + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-ppc64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-ppc64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-ppc64": + optional: true + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-riscv64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-riscv64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-riscv64": + optional: true + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-s390x@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-s390x@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-s390x": + optional: true + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linux-x64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linux-x64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linux-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-linuxmusl-x64@npm:0.35.4" + dependencies: + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-wasm32@npm:0.35.4" + dependencies: + "@emnapi/runtime": "npm:^1.11.3" + checksum: 10c0/7f5e394f168eb2f038b8ae4949fb5434085d1db931ede2ae1389479d7a1dbe11e2fc9d58e8e9830bff744ba996256c9e411be1d466dfcbf6e1f4d153b84f25ff + languageName: node + linkType: hard + +"@img/sharp-webcontainers-wasm32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-webcontainers-wasm32@npm:0.35.4" + dependencies: + "@img/sharp-wasm32": "npm:0.35.4" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@img/sharp-win32-arm64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-arm64@npm:0.35.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-win32-ia32@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-ia32@npm:0.35.4" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@img/sharp-win32-x64@npm:0.35.4": + version: 0.35.4 + resolution: "@img/sharp-win32-x64@npm:0.35.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@inquirer/external-editor@npm:^1.0.2": version: 1.0.3 resolution: "@inquirer/external-editor@npm:1.0.3" @@ -578,6 +832,76 @@ __metadata: languageName: node linkType: hard +"@next/env@npm:16.3.0": + version: 16.3.0 + resolution: "@next/env@npm:16.3.0" + checksum: 10c0/a1e3fccc76b4e59f0c8a3106ec2dde750b85bb2f1b628caca2067968cd5b28a5d1c33ca268f67564d7374c86dd2d47f733ef105956833b263ad71d6ca44a139f + languageName: node + linkType: hard + +"@next/swc-darwin-arm64@npm:16.3.0": + version: 16.3.0 + resolution: "@next/swc-darwin-arm64@npm:16.3.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@next/swc-darwin-x64@npm:16.3.0": + version: 16.3.0 + resolution: "@next/swc-darwin-x64@npm:16.3.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@next/swc-linux-arm64-gnu@npm:16.3.0": + version: 16.3.0 + resolution: "@next/swc-linux-arm64-gnu@npm:16.3.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@next/swc-linux-arm64-musl@npm:16.3.0": + version: 16.3.0 + resolution: "@next/swc-linux-arm64-musl@npm:16.3.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@next/swc-linux-x64-gnu@npm:16.3.0": + version: 16.3.0 + resolution: "@next/swc-linux-x64-gnu@npm:16.3.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@next/swc-linux-x64-musl@npm:16.3.0": + version: 16.3.0 + resolution: "@next/swc-linux-x64-musl@npm:16.3.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@next/swc-win32-arm64-msvc@npm:16.3.0": + version: 16.3.0 + resolution: "@next/swc-win32-arm64-msvc@npm:16.3.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@next/swc-win32-x64-msvc@npm:16.3.0": + version: 16.3.0 + resolution: "@next/swc-win32-x64-msvc@npm:16.3.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@noble/ciphers@npm:^1.3.0": + version: 1.3.0 + resolution: "@noble/ciphers@npm:1.3.0" + checksum: 10c0/3ba6da645ce45e2f35e3b2e5c87ceba86b21dfa62b9466ede9edfb397f8116dae284f06652c0cd81d99445a2262b606632e868103d54ecc99fd946ae1af8cd37 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1080,6 +1404,15 @@ __metadata: languageName: node linkType: hard +"@swc/helpers@npm:0.5.15": + version: 0.5.15 + resolution: "@swc/helpers@npm:0.5.15" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10c0/33002f74f6f885f04c132960835fdfc474186983ea567606db62e86acd0680ca82f34647e8e610f4e1e422d1c16fce729dde22cd3b797ab1fd9061a825dabca4 + languageName: node + linkType: hard + "@transloadit/abbr@npm:^1.0.0": version: 1.0.0 resolution: "@transloadit/abbr@npm:1.0.0" @@ -1087,6 +1420,33 @@ __metadata: languageName: node linkType: hard +"@transloadit/img@workspace:packages/img": + version: 0.0.0-use.local + resolution: "@transloadit/img@workspace:packages/img" + dependencies: + "@noble/ciphers": "npm:^1.3.0" + "@transloadit/utils": "workspace:^" + "@types/react": "npm:^19.2.14" + "@types/react-dom": "npm:^19.2.3" + happy-dom: "npm:^20.9.0" + next: "npm:16.3.0" + react: "npm:^19.2.6" + react-dom: "npm:^19.2.6" + server-only: "npm:^0.0.1" + peerDependencies: + next: ">=16.0.0 <17.0.0" + react: ">=19.0.0 <20.0.0" + react-dom: ">=19.0.0 <20.0.0" + peerDependenciesMeta: + next: + optional: true + react: + optional: true + react-dom: + optional: true + languageName: unknown + linkType: soft + "@transloadit/mcp-server@workspace:packages/mcp-server": version: 0.0.0-use.local resolution: "@transloadit/mcp-server@workspace:packages/mcp-server" @@ -1174,7 +1534,7 @@ __metadata: languageName: unknown linkType: soft -"@transloadit/utils@npm:^4.3.0, @transloadit/utils@npm:^4.4.1, @transloadit/utils@workspace:packages/utils": +"@transloadit/utils@npm:^4.3.0, @transloadit/utils@npm:^4.4.1, @transloadit/utils@workspace:^, @transloadit/utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@transloadit/utils@workspace:packages/utils" dependencies: @@ -1314,12 +1674,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:^25.8.0": - version: 25.8.0 - resolution: "@types/node@npm:25.8.0" +"@types/node@npm:*, @types/node@npm:>=20.0.0": + version: 26.4.0 + resolution: "@types/node@npm:26.4.0" dependencies: - undici-types: "npm:>=7.24.0 <7.24.7" - checksum: 10c0/ff53e5428309d2e6060190ec5e02afd0e4a7369456b16130a7f5898f12a6ad0efd62d752830f2f7355d714ae429bc0acbb2dc0cbf761cadb03e88c4996cdf1dc + undici-types: "npm:~8.3.0" + checksum: 10c0/e6fc94ea3b58fb8040b38c465dec1c53c1fd9d2c092c81f699d28799726baf59f12e1600318e79e8e6f985bb919dab6ae820180b942b8e38c51acaad63e8aada languageName: node linkType: hard @@ -1330,6 +1690,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^25.8.0": + version: 25.8.0 + resolution: "@types/node@npm:25.8.0" + dependencies: + undici-types: "npm:>=7.24.0 <7.24.7" + checksum: 10c0/ff53e5428309d2e6060190ec5e02afd0e4a7369456b16130a7f5898f12a6ad0efd62d752830f2f7355d714ae429bc0acbb2dc0cbf761cadb03e88c4996cdf1dc + languageName: node + linkType: hard + "@types/qs@npm:*": version: 6.15.1 resolution: "@types/qs@npm:6.15.1" @@ -1344,6 +1713,24 @@ __metadata: languageName: node linkType: hard +"@types/react-dom@npm:^19.2.3": + version: 19.2.5 + resolution: "@types/react-dom@npm:19.2.5" + peerDependencies: + "@types/react": ^19.2.0 + checksum: 10c0/7a59467b043debf392d109daa1ba672e791f20ce6f24ae81fb54715f890f8119d24967e61e3644b6307428603b695b7d59e69eaf531bd1963a74a6cb462f5f49 + languageName: node + linkType: hard + +"@types/react@npm:^19.2.14": + version: 19.2.18 + resolution: "@types/react@npm:19.2.18" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/d04216172b4b4362b310017c210dfbe019fb4f4e7dffd0313e70b3acb3051d5c3e76e7e84e3cf4c6a3c824993ffadfe3949e589a6398a09d4200e7670e2962de + languageName: node + linkType: hard + "@types/recursive-readdir@npm:^2.2.4": version: 2.2.4 resolution: "@types/recursive-readdir@npm:2.2.4" @@ -1372,6 +1759,22 @@ __metadata: languageName: node linkType: hard +"@types/whatwg-mimetype@npm:^3.0.2": + version: 3.0.2 + resolution: "@types/whatwg-mimetype@npm:3.0.2" + checksum: 10c0/dad39d1e4abe760a0a963c84bbdbd26b1df0eb68aff83bdf6ecbb50ad781ead777f6906d19a87007790b750f7500a12e5624d31fc6a1529d14bd19b5c3a316d1 + languageName: node + linkType: hard + +"@types/ws@npm:^8.18.1": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/61aff1129143fcc4312f083bc9e9e168aa3026b7dd6e70796276dcfb2c8211c4292603f9c4864fae702f2ed86e4abd4d38aa421831c2fd7f856c931a481afbab + languageName: node + linkType: hard + "@vitest/coverage-v8@npm:^4.1.6": version: 4.1.6 resolution: "@vitest/coverage-v8@npm:4.1.6" @@ -1691,6 +2094,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.9.19": + version: 2.11.20 + resolution: "baseline-browser-mapping@npm:2.11.20" + bin: + baseline-browser-mapping: dist/cli.cjs + checksum: 10c0/67588b7edafa4e6c52996ec4b96b6e007312961cefc5a179cb49232f6681a38ba68d4d43990081e9d0e1dcadcbd28f13105ee7dc3e43b267385d0beb14824ecc + languageName: node + linkType: hard + "better-path-resolve@npm:1.0.0": version: 1.0.0 resolution: "better-path-resolve@npm:1.0.0" @@ -1766,6 +2178,15 @@ __metadata: languageName: node linkType: hard +"buffer-image-size@npm:^0.6.4": + version: 0.6.4 + resolution: "buffer-image-size@npm:0.6.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/7158205c8726caa521d3ae6ef7bc341eff211ff86f7278d122e45062e1720afef2f7ad8e845edc96c04f499b292c4ec8a74df9836a25074881260ba00b863448 + languageName: node + linkType: hard + "byte-counter@npm:^0.1.0": version: 0.1.0 resolution: "byte-counter@npm:0.1.0" @@ -1834,6 +2255,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001579": + version: 1.0.30001810 + resolution: "caniuse-lite@npm:1.0.30001810" + checksum: 10c0/d29bc73f33c888025d54c7ff2984372d3b350b15a6a9e174884fb40976175e9177f202e2a72c2d428dddfc032784bc8ba635a31d168fcab9fac65756d7b3d6b4 + languageName: node + linkType: hard + "chai@npm:^6.2.2": version: 6.2.2 resolution: "chai@npm:6.2.2" @@ -1885,6 +2313,13 @@ __metadata: languageName: node linkType: hard +"client-only@npm:0.0.1": + version: 0.0.1 + resolution: "client-only@npm:0.0.1" + checksum: 10c0/9d6cfd0c19e1c96a434605added99dff48482152af791ec4172fb912a71cff9027ff174efd8cdb2160cc7f377543e0537ffc462d4f279bc4701de3f2a3c4b358 + languageName: node + linkType: hard + "clipanion@npm:^4.0.0-rc.4": version: 4.0.0-rc.4 resolution: "clipanion@npm:4.0.0-rc.4" @@ -2062,6 +2497,13 @@ __metadata: languageName: node linkType: hard +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + "custom-error-instance@npm:2.1.1": version: 2.1.1 resolution: "custom-error-instance@npm:2.1.1" @@ -2166,7 +2608,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.3": +"detect-libc@npm:^2.0.3, detect-libc@npm:^2.1.2": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 @@ -2224,6 +2666,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^7.0.1": + version: 7.0.1 + resolution: "entities@npm:7.0.1" + checksum: 10c0/b4fb9937bb47ecb00aaaceb9db9cdd1cc0b0fb649c0e843d05cf5dbbd2e9d2df8f98721d8b1b286445689c72af7b54a7242fc2d63ef7c9739037a8c73363e7ca + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -2418,7 +2867,7 @@ __metadata: languageName: node linkType: hard -"execa@npm:9.6.1": +"execa@npm:9.6.1, execa@npm:^9.6.1": version: 9.6.1 resolution: "execa@npm:9.6.1" dependencies: @@ -2881,6 +3330,21 @@ __metadata: languageName: node linkType: hard +"happy-dom@npm:^20.9.0": + version: 20.11.13 + resolution: "happy-dom@npm:20.11.13" + dependencies: + "@types/node": "npm:>=20.0.0" + "@types/whatwg-mimetype": "npm:^3.0.2" + "@types/ws": "npm:^8.18.1" + buffer-image-size: "npm:^0.6.4" + entities: "npm:^7.0.1" + whatwg-mimetype: "npm:^3.0.0" + ws: "npm:^8.21.0" + checksum: 10c0/826ba12a7b7cac6ca72c38ea004606c5301d60fbb8bd6185da44193315d98356f2a21f1aadbd3b68eea66a6407f001941f0ddbe28d99789320e5ca8b6ffd9be6 + languageName: node + linkType: hard + "has-bigints@npm:^1.0.2": version: 1.1.0 resolution: "has-bigints@npm:1.1.0" @@ -3982,12 +4446,12 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.11": - version: 3.3.12 - resolution: "nanoid@npm:3.3.12" +"nanoid@npm:^3.3.16": + version: 3.3.18 + resolution: "nanoid@npm:3.3.18" bin: nanoid: bin/nanoid.cjs - checksum: 10c0/ba142b7b39e11e80c16dd74b0365d407880c87c1cf7e1480956981ae940ee36060fa5b6f092cd1e315184dd19244c657bd017d03327bd3c62247d691c5e8edfb + checksum: 10c0/b994b4e396730f8be2520923284e2040d61eaee55cc6d4935ef6d38d34bafdc46133eda4d3faea5073bda545aa6079d82b886caeac5c731cf9ac18bcc1301425 languageName: node linkType: hard @@ -3998,6 +4462,66 @@ __metadata: languageName: node linkType: hard +"next@npm:16.3.0": + version: 16.3.0 + resolution: "next@npm:16.3.0" + dependencies: + "@next/env": "npm:16.3.0" + "@next/swc-darwin-arm64": "npm:16.3.0" + "@next/swc-darwin-x64": "npm:16.3.0" + "@next/swc-linux-arm64-gnu": "npm:16.3.0" + "@next/swc-linux-arm64-musl": "npm:16.3.0" + "@next/swc-linux-x64-gnu": "npm:16.3.0" + "@next/swc-linux-x64-musl": "npm:16.3.0" + "@next/swc-win32-arm64-msvc": "npm:16.3.0" + "@next/swc-win32-x64-msvc": "npm:16.3.0" + "@swc/helpers": "npm:0.5.15" + baseline-browser-mapping: "npm:^2.9.19" + caniuse-lite: "npm:^1.0.30001579" + postcss: "npm:8.5.23" + sharp: "npm:^0.35.3" + styled-jsx: "npm:5.1.6" + peerDependencies: + "@opentelemetry/api": ^1.1.0 + "@playwright/test": ^1.51.1 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + dependenciesMeta: + "@next/swc-darwin-arm64": + optional: true + "@next/swc-darwin-x64": + optional: true + "@next/swc-linux-arm64-gnu": + optional: true + "@next/swc-linux-arm64-musl": + optional: true + "@next/swc-linux-x64-gnu": + optional: true + "@next/swc-linux-x64-musl": + optional: true + "@next/swc-win32-arm64-msvc": + optional: true + "@next/swc-win32-x64-msvc": + optional: true + sharp: + optional: true + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + "@playwright/test": + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + bin: + next: dist/bin/next + checksum: 10c0/f1bf9f608a4604348ea204cd0abd174bbe140813509168a8a4456ad9ce9f2515aff8564821dbca8234f919ba12cb295fad82c0649f73e437417cce146bb1eb39 + languageName: node + linkType: hard + "nice-try@npm:^1.0.4": version: 1.0.5 resolution: "nice-try@npm:1.0.5" @@ -4582,14 +5106,14 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.5.14": - version: 8.5.14 - resolution: "postcss@npm:8.5.14" +"postcss@npm:8.5.23, postcss@npm:^8.5.14": + version: 8.5.23 + resolution: "postcss@npm:8.5.23" dependencies: - nanoid: "npm:^3.3.11" + nanoid: "npm:^3.3.16" picocolors: "npm:^1.1.1" source-map-js: "npm:^1.2.1" - checksum: 10c0/48138207cf5ef5581be1bfe2cb65ccfe0ac75e43888ba045afc8ed6043d7b56aeb3b9a9fe5b353ff554be943cd0cc15d826ccb991525159175971e5ee8ab0237 + checksum: 10c0/ed714e635b330bb42666ecdd0c0b4f30224ded15b0b0052d6bc2b92832f2cf17d1c1141359453f96f0a84f8fbf4c405a74df187f559163b8f31131b2535455aa languageName: node linkType: hard @@ -4724,6 +5248,17 @@ __metadata: languageName: node linkType: hard +"react-dom@npm:^19.2.6": + version: 19.2.8 + resolution: "react-dom@npm:19.2.8" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.8 + checksum: 10c0/41ba2247b76f687fcfe5bbc99f514d6b851d8c8041c2f5ded36ed05bd7fdc5208cacbac9de51e3e6633e77f96f44cec9f0d4a5a55184dba4e00738f224439134 + languageName: node + linkType: hard + "react-is-18@npm:react-is@^18.3.1": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -4738,6 +5273,13 @@ __metadata: languageName: node linkType: hard +"react@npm:^19.2.6": + version: 19.2.8 + resolution: "react@npm:19.2.8" + checksum: 10c0/5f86bdb56426652fd6d989d30a6f2e603c057272c47c9ca3a3fbe190a3a39ee9ccce937d63cfc039717abed1b8891d6a499134bc35311acc07eafdacd86537cd + languageName: node + linkType: hard + "read-pkg@npm:^3.0.0": version: 3.0.0 resolution: "read-pkg@npm:3.0.0" @@ -5019,6 +5561,13 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 + languageName: node + linkType: hard + "semver@npm:2 || 3 || 4 || 5, semver@npm:^5.5.0": version: 5.7.2 resolution: "semver@npm:5.7.2" @@ -5028,12 +5577,12 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5, semver@npm:^7.5.3": - version: 7.8.0 - resolution: "semver@npm:7.8.0" +"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.8.5": + version: 7.8.5 + resolution: "semver@npm:7.8.5" bin: semver: bin/semver.js - checksum: 10c0/8f096ca9b80ffd47b308d03f9ce8c873e27e2983f36023c559cdc92c51e8433fc23ebbfe57ec9623fc155636a6961ee989501099841ae4bb1babc8d2b3f048cd + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c languageName: node linkType: hard @@ -5068,6 +5617,13 @@ __metadata: languageName: node linkType: hard +"server-only@npm:^0.0.1": + version: 0.0.1 + resolution: "server-only@npm:0.0.1" + checksum: 10c0/4704f0ef85da0be981af6d4ed8e739d39bcfd265b9c246a684060acda5642d0fdc6daffc2308e71e2682c5f508090978802eae0a77623c9b90a49f9ae68048d6 + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -5112,6 +5668,96 @@ __metadata: languageName: node linkType: hard +"sharp@npm:^0.35.3": + version: 0.35.4 + resolution: "sharp@npm:0.35.4" + dependencies: + "@img/colour": "npm:^1.1.0" + "@img/sharp-darwin-arm64": "npm:0.35.4" + "@img/sharp-darwin-x64": "npm:0.35.4" + "@img/sharp-freebsd-wasm32": "npm:0.35.4" + "@img/sharp-libvips-darwin-arm64": "npm:1.3.3" + "@img/sharp-libvips-darwin-x64": "npm:1.3.3" + "@img/sharp-libvips-linux-arm": "npm:1.3.3" + "@img/sharp-libvips-linux-arm64": "npm:1.3.3" + "@img/sharp-libvips-linux-ppc64": "npm:1.3.3" + "@img/sharp-libvips-linux-riscv64": "npm:1.3.3" + "@img/sharp-libvips-linux-s390x": "npm:1.3.3" + "@img/sharp-libvips-linux-x64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.3" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.3" + "@img/sharp-linux-arm": "npm:0.35.4" + "@img/sharp-linux-arm64": "npm:0.35.4" + "@img/sharp-linux-ppc64": "npm:0.35.4" + "@img/sharp-linux-riscv64": "npm:0.35.4" + "@img/sharp-linux-s390x": "npm:0.35.4" + "@img/sharp-linux-x64": "npm:0.35.4" + "@img/sharp-linuxmusl-arm64": "npm:0.35.4" + "@img/sharp-linuxmusl-x64": "npm:0.35.4" + "@img/sharp-webcontainers-wasm32": "npm:0.35.4" + "@img/sharp-win32-arm64": "npm:0.35.4" + "@img/sharp-win32-ia32": "npm:0.35.4" + "@img/sharp-win32-x64": "npm:0.35.4" + detect-libc: "npm:^2.1.2" + semver: "npm:^7.8.5" + dependenciesMeta: + "@img/sharp-darwin-arm64": + optional: true + "@img/sharp-darwin-x64": + optional: true + "@img/sharp-freebsd-wasm32": + optional: true + "@img/sharp-libvips-darwin-arm64": + optional: true + "@img/sharp-libvips-darwin-x64": + optional: true + "@img/sharp-libvips-linux-arm": + optional: true + "@img/sharp-libvips-linux-arm64": + optional: true + "@img/sharp-libvips-linux-ppc64": + optional: true + "@img/sharp-libvips-linux-riscv64": + optional: true + "@img/sharp-libvips-linux-s390x": + optional: true + "@img/sharp-libvips-linux-x64": + optional: true + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + "@img/sharp-libvips-linuxmusl-x64": + optional: true + "@img/sharp-linux-arm": + optional: true + "@img/sharp-linux-arm64": + optional: true + "@img/sharp-linux-ppc64": + optional: true + "@img/sharp-linux-riscv64": + optional: true + "@img/sharp-linux-s390x": + optional: true + "@img/sharp-linux-x64": + optional: true + "@img/sharp-linuxmusl-arm64": + optional: true + "@img/sharp-linuxmusl-x64": + optional: true + "@img/sharp-webcontainers-wasm32": + optional: true + "@img/sharp-win32-arm64": + optional: true + "@img/sharp-win32-ia32": + optional: true + "@img/sharp-win32-x64": + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/dbcc0909200cbff98e82805c86085b94afbd45f373897a04b21e52e9bbc1671dc64970350dd3cb6b2d0970f3444be7100d728005dc6620e39b599269ed7e15eb + languageName: node + linkType: hard + "shebang-command@npm:^1.2.0": version: 1.2.0 resolution: "shebang-command@npm:1.2.0" @@ -5410,6 +6056,22 @@ __metadata: languageName: node linkType: hard +"styled-jsx@npm:5.1.6": + version: 5.1.6 + resolution: "styled-jsx@npm:5.1.6" + dependencies: + client-only: "npm:0.0.1" + peerDependencies: + react: ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + peerDependenciesMeta: + "@babel/core": + optional: true + babel-plugin-macros: + optional: true + checksum: 10c0/ace50e7ea5ae5ae6a3b65a50994c51fca6ae7df9c7ecfd0104c36be0b4b3a9c5c1a2374d16e2a11e256d0b20be6d47256d768ecb4f91ab390f60752a075780f5 + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -5526,6 +6188,7 @@ __metadata: "@changesets/cli": "npm:^2.31.0" "@types/node": "npm:^25.8.0" "@vitest/coverage-v8": "npm:^4.1.6" + execa: "npm:^9.6.1" jest-diff: "npm:^30.4.1" knip: "npm:^6.14.1" npm-run-all: "npm:^4.1.5" @@ -5566,7 +6229,7 @@ __metadata: languageName: unknown linkType: soft -"tslib@npm:^2.4.0": +"tslib@npm:^2.4.0, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -5721,6 +6384,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~8.3.0": + version: 8.3.0 + resolution: "undici-types@npm:8.3.0" + checksum: 10c0/c8aa7e2fbebfce519654dafadc0ece59be888d2ccaf180fb4495da875e7b536d2456345c384069c7e6f3e9c9ab7435f074957da306f142343eee86ff8048855a + languageName: node + linkType: hard + "undici@npm:^6.25.0": version: 6.25.0 resolution: "undici@npm:6.25.0" @@ -5908,6 +6578,13 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 10c0/323895a1cda29a5fb0b9ca82831d2c316309fede0365047c4c323073e3239067a304a09a1f4b123b9532641ab604203f33a1403b5ca6a62ef405bcd7a204080f + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" @@ -6021,6 +6698,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.21.0": + version: 8.21.3 + resolution: "ws@npm:8.21.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/7b28dc2863ea0e2cece68d142a3eee90361021b73f750431e6d8076bb7dede5fdfdb75b3d29534b62f411261147b76b1bc80fb5cc63ab0aabd467280e85b22e0 + languageName: node + linkType: hard + "yallist@npm:^5.0.0": version: 5.0.0 resolution: "yallist@npm:5.0.0"