From 0655bf0e3519f40c826141377f6948ba2e04177f Mon Sep 17 00:00:00 2001 From: Kevin van Zonneveld Date: Mon, 31 Aug 2026 07:35:21 +0200 Subject: [PATCH 1/5] Extract responsive Smart CDN image package --- .changeset/share-smart-cdn-image-policy.md | 13 + .github/workflows/ci.yml | 11 + README.md | 2 + knip.ts | 11 + package.json | 7 +- packages/img/README.md | 203 ++++ packages/img/package.json | 69 ++ packages/img/src/imageSource.ts | 59 + packages/img/src/index.ts | 295 +++++ .../src/next/HydratedTransloaditPicture.tsx | 24 + packages/img/src/next/index.tsx | 215 ++++ packages/img/src/next/server.tsx | 402 ++++++ packages/img/src/storagePath.ts | 69 ++ packages/img/test/model.test.ts | 530 ++++++++ packages/img/test/next-server.test.tsx | 712 +++++++++++ packages/img/test/next.test.tsx | 318 +++++ packages/img/test/types.tsx | 103 ++ packages/img/tsconfig.build.json | 25 + packages/img/tsconfig.json | 18 + packages/utils/README.md | 12 +- packages/utils/src/index.ts | 18 + packages/utils/src/node.ts | 183 +-- packages/utils/src/smartCdn.ts | 38 +- packages/utils/src/smartCdnImage.ts | 225 ++++ packages/utils/test/node.test.ts | 32 +- packages/utils/test/smartCdnGrammar.test.ts | 65 + packages/utils/test/smartCdnImage.test.ts | 120 ++ .../img-next/app/TransloaditImage.tsx | 10 + scripts/fixtures/img-next/app/layout.tsx | 13 + .../img-next/app/public-image/page.tsx | 25 + .../img-next/app/storage-image/page.tsx | 17 + scripts/fixtures/img-next/next.config.ts | 5 + scripts/fixtures/img-next/package-lock.json | 1081 +++++++++++++++++ scripts/fixtures/img-next/package.json | 20 + scripts/fixtures/img-next/tsconfig.json | 12 + scripts/img-next-fixture.test.ts | 30 + scripts/test-img-next-fixture.ts | 215 ++++ scripts/withProcess.test.ts | 17 + scripts/withProcess.ts | 15 + tsconfig.json | 1 + yarn.lock | 728 ++++++++++- 41 files changed, 5782 insertions(+), 186 deletions(-) create mode 100644 .changeset/share-smart-cdn-image-policy.md create mode 100644 packages/img/README.md create mode 100644 packages/img/package.json create mode 100644 packages/img/src/imageSource.ts create mode 100644 packages/img/src/index.ts create mode 100644 packages/img/src/next/HydratedTransloaditPicture.tsx create mode 100644 packages/img/src/next/index.tsx create mode 100644 packages/img/src/next/server.tsx create mode 100644 packages/img/src/storagePath.ts create mode 100644 packages/img/test/model.test.ts create mode 100644 packages/img/test/next-server.test.tsx create mode 100644 packages/img/test/next.test.tsx create mode 100644 packages/img/test/types.tsx create mode 100644 packages/img/tsconfig.build.json create mode 100644 packages/img/tsconfig.json create mode 100644 packages/utils/src/smartCdnImage.ts create mode 100644 packages/utils/test/smartCdnImage.test.ts create mode 100644 scripts/fixtures/img-next/app/TransloaditImage.tsx create mode 100644 scripts/fixtures/img-next/app/layout.tsx create mode 100644 scripts/fixtures/img-next/app/public-image/page.tsx create mode 100644 scripts/fixtures/img-next/app/storage-image/page.tsx create mode 100644 scripts/fixtures/img-next/next.config.ts create mode 100644 scripts/fixtures/img-next/package-lock.json create mode 100644 scripts/fixtures/img-next/package.json create mode 100644 scripts/fixtures/img-next/tsconfig.json create mode 100644 scripts/img-next-fixture.test.ts create mode 100644 scripts/test-img-next-fixture.ts create mode 100644 scripts/withProcess.test.ts create mode 100644 scripts/withProcess.ts diff --git a/.changeset/share-smart-cdn-image-policy.md b/.changeset/share-smart-cdn-image-policy.md new file mode 100644 index 00000000..9fe3d910 --- /dev/null +++ b/.changeset/share-smart-cdn-image-policy.md @@ -0,0 +1,13 @@ +--- +"@transloadit/utils": minor +--- + +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. + +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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ef041ae..53ce6df4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,6 +148,17 @@ 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 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + 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/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..9e6443ca --- /dev/null +++ b/packages/img/README.md @@ -0,0 +1,203 @@ +# `@transloadit/img` + +Responsive images powered by Transloadit Smart CDN. The package signs deterministic AVIF and WebP +candidate sets on the server and lets the browser choose the best candidate through native +`` and `srcset` behavior. + +This workspace is private while the API and production dogfood soak. Do not depend on version +`0.0.0` from npm. + +## Next.js + +Configure one Server Component in a server-only application module: + +```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 TransloaditImage = createTransloaditImage({ + allowedSourceOrigins: ['https://assets.example'], + authKey, + authSecret, + storage: { allowedPathPrefixes: ['documents/'] }, + workspace, +}) +``` + +The factory never reads environment variables itself. Its auth secret signs URLs on the server and +is not placed in rendered markup or client bundles. + +### Public URL image + +Public URL inputs are suitable for static rendering. Supply intrinsic source dimensions so +candidate widths can be capped truthfully, plus one fixed expiry. Rebuild or revalidate the page +before that expiry. The static render deliberately does not read the current clock or reject an +elapsed timestamp, because doing so would opt the render into synchronous request-time I/O. + +```tsx +import { TransloaditImage } from '../lib/TransloaditImage' + +export default function Page() { + return ( + + ) +} +``` + +URL sources must be public HTTP(S) URLs without user information, query strings, or fragments. +Their exact origin (scheme, host, and any non-default port) must occur in the factory's +`allowedSourceOrigins`; the default is deny-all. An HTTPS origin does not authorize HTTP. Use +Transloadit Storage for private inputs rather than copying source credentials into browser HTML. +`fallbackSrc` is rendered directly and may be relative or a data URL; keep it application-owned and +never derive it from an untrusted request. + +### Transloadit Storage preview + +A Storage source uses signed-only `builtin/storage-preview@0.0.1`. It can produce an image preview +for an image, document, video, audio file, or unknown file type. The component uses Next.js +`connection()` before signing and derives a stable, short-lived expiry. An internal Suspense boundary +lets Cache Components prerender a safe shell, but the signed image is rendered per request and must +not be placed in a shared full-page cache. + +Storage signing is deny-all until `storage.allowedPathPrefixes` explicitly authorizes a directory. +Prefixes must be relative and end in `/`; `['']` deliberately grants the entire workspace. Keep +request-derived paths behind your application authorization as well: a prefix is a hard workspace +boundary, not a replacement for checking whether the current user may read a particular object. +Dot segments and backslashes are rejected before prefix matching. +Paths also follow API2's catalog grammar: NFC-normalized Unicode, no control characters or empty +segments, and at most 1024 UTF-8 bytes. + +```tsx +import { TransloaditImage } from '../lib/TransloaditImage' + +export default function Page() { + return ( + } + width={400} + widths={[200, 400, 800]} + /> + ) +} +``` + +Storage previews default to AVIF quality 45 and WebP quality 75 sources plus a signed JPEG quality +75 fallback. They use the Built-in's `pad` strategy so every `w` descriptor matches the actual +output width without cropping or distorting an input whose dimensions are not known ahead of time. +`builtin/storage-serve` is intentionally not an image source: it delivers the original private +object for raw viewing or download. + +Storage support requires `builtin/storage-preview@0.0.1` to be deployed in the target Transloadit +environment. The default signed URL lifetime is at least one hour, rounded into stable five-minute +windows. Both values can be configured, but their sum may not exceed 48 hours: + +```tsx +export const TransloaditImage = createTransloaditImage({ + authKey, + authSecret, + storage: { + allowedPathPrefixes: ['documents/'], + expiresInMs: 30 * 60 * 1000, + rotationIntervalMs: 5 * 60 * 1000, + }, + workspace, +}) +``` + +### Loading behavior + +- Public URL images are lazy by default. Storage previews are eager by default so a candidate that + was never requested cannot first enter the viewport after its signature expires. Set + `loading="lazy"` explicitly when the page's expected lifetime and bandwidth tradeoff make that + appropriate. +- `preload` implies eager loading. Combine it with `fetchPriority="high"` for a measured LCP image; + an explicitly lazy preload is rejected. +- `preload` cannot be combined with `media`: React 19 does not include `media` in responsive + preload identity and can silently collapse complementary hints. Use `fetchPriority="high"` for a + media-gated image instead. +- For public URL images, `media` applies the viewport condition to every source and leaves the + fallback `` inert when it does not match. Apply the same condition to the surrounding layout + if the unmatched slot must not reserve its declared width and height. The neutral default + placeholder is an inline GIF; set `mediaPlaceholderSrc` to a same-origin transparent asset when + CSP excludes `data:` from `img-src`. Storage previews reject `media`, because a candidate first + requested after the condition changes could already have an expired signature. +- Set `objectFit="cover"` (or another explicit CSS `object-fit` value) when display dimensions use a + materially different aspect ratio from the public source. The component tolerates normal + sub-pixel metadata rounding, but rejects an unhandled mismatch rather than silently stretching + the image. +- `deferUntilHydrated` withholds non-critical candidate elements until hydration to avoid WebKit's + parser-to-hydration replay. It cannot be combined with eager loading or preloading and retains a + `