From 190edb8b72ef6293d60cf2470b1e0054c6912cfe Mon Sep 17 00:00:00 2001
From: Kevin van Zonneveld
Date: Sat, 12 Sep 2026 09:30:51 +0200
Subject: [PATCH 01/78] fix(img): preserve native attributes and enforce
loading contracts
---
packages/img/src/next/imageAttributes.ts | 124 +++++++++++++++++++++
packages/img/src/next/index.tsx | 107 +++++++-----------
packages/img/src/next/server.tsx | 46 +++-----
packages/img/test/next-server.test.tsx | 68 ++++++++++--
packages/img/test/next.test.tsx | 134 +++++++++++++++++++++--
packages/img/test/types.tsx | 52 ++++++++-
6 files changed, 406 insertions(+), 125 deletions(-)
create mode 100644 packages/img/src/next/imageAttributes.ts
diff --git a/packages/img/src/next/imageAttributes.ts b/packages/img/src/next/imageAttributes.ts
new file mode 100644
index 00000000..e70947e4
--- /dev/null
+++ b/packages/img/src/next/imageAttributes.ts
@@ -0,0 +1,124 @@
+import type { DOMAttributes, ImgHTMLAttributes } from 'react'
+
+/** Native attributes that can cross the server-rendering boundary, without caller-owned URLs. */
+export interface ImageAttributes
+ extends Omit<
+ ImgHTMLAttributes,
+ | keyof DOMAttributes
+ | 'defaultChecked'
+ | 'defaultValue'
+ | 'inlist'
+ | 'loading'
+ | 'src'
+ | 'srcSet'
+ | 'suppressContentEditableWarning'
+ | 'suppressHydrationWarning'
+ | 'tw'
+ > {
+ // React types this RDFa attribute as any; only serializable values belong in this API.
+ inlist?: string
+ [attribute: `data-${string}`]: string | number | boolean | null | undefined
+}
+
+// Exhaustive against React's native img attributes. New React attributes require an explicit
+// decision here; arbitrary JS props must never leak factory configuration or override URLs.
+const nativeAttributes: Record<
+ Exclude,
+ true
+> = {
+ about: true,
+ accessKey: true,
+ alt: true,
+ autoCapitalize: true,
+ autoCorrect: true,
+ autoFocus: true,
+ autoSave: true,
+ className: true,
+ color: true,
+ content: true,
+ contentEditable: true,
+ contextMenu: true,
+ crossOrigin: true,
+ datatype: true,
+ decoding: true,
+ dir: true,
+ draggable: true,
+ enterKeyHint: true,
+ exportparts: true,
+ fetchPriority: true,
+ height: true,
+ hidden: true,
+ id: true,
+ inert: true,
+ inlist: true,
+ inputMode: true,
+ is: true,
+ itemID: true,
+ itemProp: true,
+ itemRef: true,
+ itemScope: true,
+ itemType: true,
+ lang: true,
+ nonce: true,
+ part: true,
+ popover: true,
+ popoverTarget: true,
+ popoverTargetAction: true,
+ prefix: true,
+ property: true,
+ radioGroup: true,
+ referrerPolicy: true,
+ rel: true,
+ resource: true,
+ results: true,
+ rev: true,
+ role: true,
+ security: true,
+ sizes: true,
+ slot: true,
+ spellCheck: true,
+ tabIndex: true,
+ title: true,
+ translate: true,
+ typeof: true,
+ unselectable: true,
+ useMap: true,
+ vocab: true,
+ width: true,
+}
+
+/** Snapshots only native, serializable attributes before suspension or rendering. */
+export function snapshotImageAttributes(props: ImageAttributes): ImageAttributes {
+ const attributes = Object.fromEntries(
+ Object.entries(props).filter(
+ ([name, value]) =>
+ (Object.hasOwn(nativeAttributes, name) || /^(?:aria|data)-[\w.-]+$/.test(name)) &&
+ (value === undefined ||
+ value === null ||
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'),
+ ),
+ )
+ return { ...attributes, style: props.style === undefined ? undefined : { ...props.style } }
+}
+
+/** A preload is eager; explicitly lazy images must not issue preload requests. */
+export type ImageLoadingProps =
+ | { loading?: 'eager'; preload: true }
+ | { loading?: 'eager' | 'lazy'; preload?: false }
+
+/** Retains runtime validation for JavaScript callers as well as the discriminated public type. */
+export function snapshotImageLoading({
+ loading,
+ preload,
+}: {
+ loading?: 'eager' | 'lazy'
+ preload?: boolean
+}): ImageLoadingProps {
+ if (preload) {
+ if (loading === 'lazy') throw new Error('A preloaded Transloadit image cannot use lazy loading')
+ return { loading, preload }
+ }
+ return { loading, preload }
+}
diff --git a/packages/img/src/next/index.tsx b/packages/img/src/next/index.tsx
index bce31c63..89135f82 100644
--- a/packages/img/src/next/index.tsx
+++ b/packages/img/src/next/index.tsx
@@ -5,10 +5,12 @@ import type {
TransloaditImageModel,
TransloaditImageSourceSet,
} from '../index.ts'
+import type { ImageAttributes, ImageLoadingProps } from './imageAttributes.ts'
import { preload as preloadResource } from 'react-dom'
import { HydratedTransloaditPicture } from './HydratedTransloaditPicture.tsx'
+import { snapshotImageAttributes, snapshotImageLoading } from './imageAttributes.ts'
const transparentPixel =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
@@ -18,28 +20,25 @@ const mimeTypes = {
webp: 'image/webp',
} satisfies Record
-/** Presentation options shared by the signed Server Component and model-only renderer. */
-export interface TransloaditImagePresentationProps {
+interface ImagePresentationProps extends ImageAttributes {
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
}
+/** Serializable native image attributes and layout shared by both Next.js renderers. */
+export type TransloaditImagePresentationProps = ImagePresentationProps & ImageLoadingProps
+
/** Props for rendering an already-signed framework-neutral image model. */
-export interface TransloaditPictureProps extends TransloaditImagePresentationProps {
+export type TransloaditPictureProps = TransloaditImagePresentationProps & {
model: TransloaditImageModel
}
@@ -75,8 +74,8 @@ function escapeSourceSetUrl(url: string): string {
function preloadImage(
source: TransloaditImageSourceSet,
- sizes: string | undefined,
- fetchPriority?: 'auto' | 'high' | 'low',
+ sizes: string,
+ { crossOrigin, fetchPriority, referrerPolicy }: ImageAttributes,
): void {
const firstCandidate = source.candidates[0]
if (firstCandidate === undefined) {
@@ -85,94 +84,62 @@ function preloadImage(
preloadResource(firstCandidate.url, {
as: 'image',
+ crossOrigin,
fetchPriority,
imageSizes: sizes,
imageSrcSet: getSourceSet(source.candidates),
+ referrerPolicy,
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.
-
- )
-}
-
/**
* 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 {
+export function TransloaditPicture(props: TransloaditPictureProps): ReactNode {
+ const {
+ deferUntilHydrated = false,
+ loading,
+ media,
+ mediaPlaceholderSrc,
+ model,
+ objectFit,
+ preload = false,
+ sizes = '100vw',
+ } = props
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')
- }
+ snapshotImageLoading(props)
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')
+ const automaticSizes = /^auto(?:\s*,|\s*$)/i.test(sizes.trimStart())
+ if (automaticSizes && resolvedLoading !== 'lazy') {
+ throw new Error('Automatic image sizes require lazy loading')
+ }
if (model.sources.length === 0) {
throw new Error('Cannot render a Transloadit image without a source')
}
+ const attributes = snapshotImageAttributes(props)
const original = (
- takes precedence over either fallback.
src={media ? (mediaPlaceholderSrc ?? transparentPixel) : model.fallbackUrl}
- style={style}
- width={width}
+ style={objectFit === undefined ? attributes.style : { ...attributes.style, objectFit }}
/>
)
const fallback = media ? (
@@ -189,7 +156,7 @@ export function TransloaditPicture({
if (preferredSource === undefined) {
throw new Error('Cannot preload a Transloadit image without a source')
}
- preloadImage(preferredSource, sizes, fetchPriority)
+ preloadImage(preferredSource, sizes, attributes)
}
const picture = (
diff --git a/packages/img/src/next/server.tsx b/packages/img/src/next/server.tsx
index 35b27329..7fdd5128 100644
--- a/packages/img/src/next/server.tsx
+++ b/packages/img/src/next/server.tsx
@@ -19,6 +19,7 @@ import { Suspense } from 'react'
import { createTransloaditImageModel, transloaditStoragePreviewTemplate } from '../index.ts'
import { validateStoragePath, validateStoragePathPrefix } from '../storagePath.ts'
+import { snapshotImageAttributes, snapshotImageLoading } from './imageAttributes.ts'
import { TransloaditPicture } from './index.tsx'
const defaultStorageExpiresInMs = 60 * 60 * 1000
@@ -86,14 +87,8 @@ export interface TransloaditRedirectImageConfiguration extends TransloaditImageC
}
}
-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 {
+export type TransloaditImageProps = TransloaditImagePresentationProps & {
/** Encoding quality for the signed JPEG fallback. Defaults to 75. */
fallbackQuality?: number
formats?: StoragePreviewFormats
@@ -103,8 +98,13 @@ export interface TransloaditImageProps
src: string
/** Static shell used only while direct request-time signing is suspended. */
suspenseFallback?: ReactNode
+ /** Advanced candidate override. Defaults to a conservative ladder capped at `width`. */
+ widths?: readonly number[]
}
+/** Redirect images render synchronously and have no signing suspension to replace. */
+export type TransloaditRedirectImageProps = TransloaditImageProps & { suspenseFallback?: never }
+
/** One configured Next.js Server Component for Transloadit Storage objects. */
export type TransloaditImageComponent = (props: TransloaditImageProps) => ReactNode
@@ -117,7 +117,8 @@ export interface TransloaditImageIntegration {
}
/** Redirect-delivery integration with a route handler for private Storage images. */
-export interface TransloaditRedirectImageIntegration extends TransloaditImageIntegration {
+export interface TransloaditRedirectImageIntegration {
+ Image: (props: TransloaditRedirectImageProps) => ReactNode
storageRoute: TransloaditStorageRoute
}
@@ -319,19 +320,15 @@ function snapshotStorageImageProps(
path: string,
): TransloaditImageProps {
return {
+ ...snapshotImageAttributes(props),
+ ...snapshotImageLoading(props),
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,
@@ -346,27 +343,10 @@ function getStoragePath(src: unknown): string {
}
function renderPicture(
- props: CommonTransloaditImageProps,
+ props: TransloaditImagePresentationProps,
model: Parameters[0]['model'],
): ReactNode {
- return (
-
- )
+ return
}
function getStorageTransform(request: SmartCdnImageSignRequest): StorageImageTransform {
diff --git a/packages/img/test/next-server.test.tsx b/packages/img/test/next-server.test.tsx
index 29c3686c..bfa3da07 100644
--- a/packages/img/test/next-server.test.tsx
+++ b/packages/img/test/next-server.test.tsx
@@ -98,12 +98,56 @@ describe('createTransloaditImage', () => {
)
const source = document.querySelector('source')
- expect(source?.hasAttribute('sizes')).toBe(false)
+ expect(source?.getAttribute('sizes')).toBe('100vw')
expect(source?.getAttribute('srcset')).toContain('200w')
expect(source?.getAttribute('srcset')).toContain('400w')
expect(source?.getAttribute('srcset')).toContain('800w')
})
+ test.each([
+ 'direct',
+ 'redirect',
+ ])('preserves native attributes and descriptions in %s delivery', async (delivery) => {
+ const { Image } = createTransloaditImage({
+ ...baseConfiguration,
+ storage: {
+ ...baseConfiguration.storage,
+ delivery:
+ delivery === 'direct'
+ ? 'direct'
+ : { authorize: () => true, route: '/api/private-images' },
+ },
+ })
+ const document = parseMarkup(
+ await renderAsync(
+
+
+ The annual report
+ ,
+ ),
+ )
+ const image = document.getElementById('report-preview')
+ expect(image?.getAttribute('aria-describedby')).toBe('report-caption')
+ expect(
+ document.getElementById(image?.getAttribute('aria-describedby') ?? '')?.textContent,
+ ).toBe('The annual report')
+ expect(image?.getAttribute('title')).toBe('Annual report')
+ expect(image?.getAttribute('role')).toBe('img')
+ expect(image?.getAttribute('data-document')).toBe('report')
+ expect(image?.getAttribute('sizes')).toBe('auto')
+ })
+
test('rejects coercible Storage sources before signing', () => {
const { Image } = createTransloaditImage(baseConfiguration)
const stringConversion = vi.fn(() => 'https://assets.example/photo.jpg')
@@ -189,10 +233,12 @@ describe('createTransloaditImage', () => {
test('snapshots direct Storage props before crossing the request boundary', async () => {
const { Image } = createTransloaditImage(baseConfiguration)
let height = 300
+ let id = 'original-id'
let path = 'documents/report.pdf'
let width = 400
connection.mockImplementationOnce(() => {
height = 0
+ id = 'mutated-id'
path = 'private/secret.pdf'
width = 0
return Promise.resolve(undefined)
@@ -202,6 +248,9 @@ describe('createTransloaditImage', () => {
get height() {
return height
},
+ get id() {
+ return id
+ },
get src() {
return path
},
@@ -219,6 +268,7 @@ describe('createTransloaditImage', () => {
expect(candidate.input).toBe('documents/report.pdf')
expect(candidate.urlParams.h).toBe('300')
expect(candidate.urlParams.w).toBe('400')
+ expect(document.querySelector('img')?.id).toBe('original-id')
})
test('renders opaque authorized-route capabilities without request I/O or credentials', () => {
@@ -501,13 +551,15 @@ describe('createTransloaditImage', () => {
})
expect(() =>
- Image({
- alt: 'No suspension',
- height: 300,
- src: 'documents/report.pdf',
- suspenseFallback: 'Loading',
- width: 400,
- }),
+ Reflect.apply(Image, undefined, [
+ {
+ alt: 'No suspension',
+ height: 300,
+ src: 'documents/report.pdf',
+ suspenseFallback: 'Loading',
+ width: 400,
+ },
+ ]),
).toThrow('suspenseFallback is only used by direct Storage delivery')
})
diff --git a/packages/img/test/next.test.tsx b/packages/img/test/next.test.tsx
index 9a3e2c1d..6b8679b2 100644
--- a/packages/img/test/next.test.tsx
+++ b/packages/img/test/next.test.tsx
@@ -5,7 +5,7 @@ import type { Root } from 'react-dom/client'
import type { TransloaditImageModel } from '../src/index.ts'
-import { act } from 'react'
+import { act, createElement } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { renderToStaticMarkup, renderToString } from 'react-dom/server'
import { afterEach, describe, expect, test, vi } from 'vitest'
@@ -45,20 +45,25 @@ function renderPicture(
media: string
mediaPlaceholderSrc: string
preload: boolean
+ sizes: string
}> = {},
): Document {
const markup = renderToStaticMarkup(
- ,
+ // Intentionally allow invalid JS prop combinations to exercise runtime guards too.
+ Reflect.apply(createElement, undefined, [
+ TransloaditPicture,
+ {
+ alt: 'A canal house',
+ className: 'photo',
+ fetchPriority: 'high',
+ height: 300,
+ loading: 'lazy',
+ model,
+ sizes: '(min-width: 800px) 640px, 100vw',
+ width: 400,
+ ...overrides,
+ },
+ ]),
)
return new DOMParser().parseFromString(markup, 'text/html')
}
@@ -68,6 +73,86 @@ afterEach(() => {
})
describe('TransloaditPicture', () => {
+ test('preserves serializable image attributes without exposing renderer or signing inputs', () => {
+ const markup = renderToStaticMarkup(
+ Reflect.apply(TransloaditPicture, undefined, [
+ {
+ alt: 'A canal house',
+ 'aria-describedby': 'photo-caption',
+ authSecret: 'must-stay-private',
+ crossOrigin: 'anonymous',
+ 'data-photo': 'canal',
+ 'data-nonserializable': { privateValue: 'must-stay-private' },
+ decoding: 'sync',
+ height: 300,
+ id: 'canal-photo',
+ model,
+ onLoad: () => undefined,
+ referrerPolicy: 'no-referrer',
+ role: 'img',
+ src: '/untrusted-original.jpg',
+ srcSet: '/untrusted-candidate.jpg 320w',
+ title: 'Amsterdam',
+ urlParams: { sig: 'must-stay-private' },
+ width: 400,
+ },
+ ]),
+ )
+ const document = new DOMParser().parseFromString(markup, 'text/html')
+ const image = document.getElementById('canal-photo')
+
+ expect(image?.getAttribute('aria-describedby')).toBe('photo-caption')
+ expect(image?.getAttribute('title')).toBe('Amsterdam')
+ expect(image?.getAttribute('role')).toBe('img')
+ expect(image?.getAttribute('data-photo')).toBe('canal')
+ expect(image?.getAttribute('decoding')).toBe('sync')
+ expect(image?.getAttribute('crossorigin')).toBe('anonymous')
+ expect(image?.getAttribute('referrerpolicy')).toBe('no-referrer')
+ expect(image?.getAttribute('src')).toBe(model.fallbackUrl)
+ expect(image?.hasAttribute('srcset')).toBe(false)
+ expect(markup).not.toContain('must-stay-private')
+ expect(markup).not.toContain('untrusted')
+ expect(image?.hasAttribute('model')).toBe(false)
+ expect(image?.hasAttribute('onload')).toBe(false)
+ expect(image?.hasAttribute('data-nonserializable')).toBe(false)
+ })
+
+ test('emits the browser default size explicitly when sizes is omitted', () => {
+ const document = renderPicture({ sizes: undefined })
+ expect([...document.querySelectorAll('source')].map((source) => source.sizes)).toEqual([
+ '100vw',
+ '100vw',
+ ])
+ expect(document.querySelector('img')?.hasAttribute('sizes')).toBe(false)
+ })
+
+ test.each([
+ 'auto',
+ 'auto, 100vw',
+ 'AUTO, 400px',
+ ])('activates lazy automatic sizing for %s', (sizes) => {
+ const document = renderPicture({ sizes })
+ expect([...document.querySelectorAll('source')].map((source) => source.sizes)).toEqual([
+ sizes,
+ sizes,
+ ])
+ expect(document.querySelector('img')?.getAttribute('sizes')).toBe('auto')
+ expect(document.querySelector('img')?.getAttribute('loading')).toBe('lazy')
+ })
+
+ test.each([
+ 'auto',
+ 'auto, 100vw',
+ 'AUTO, 400px',
+ ])('rejects eager automatic sizing for %s', (sizes) => {
+ expect(() => renderPicture({ loading: 'eager', sizes })).toThrow(
+ 'Automatic image sizes require lazy loading',
+ )
+ expect(() => renderPicture({ loading: undefined, preload: true, sizes })).toThrow(
+ 'Automatic image sizes require lazy loading',
+ )
+ })
+
test('renders native picture sources and the supplied fallback', () => {
const document = renderPicture()
const sources = [...document.querySelectorAll('source')]
@@ -132,6 +217,31 @@ describe('TransloaditPicture', () => {
expect(image?.getAttribute('src')).toBe(model.fallbackUrl)
})
+ test('uses the same request policy on the preload and the image', () => {
+ const document = new DOMParser().parseFromString(
+ renderToStaticMarkup(
+ ,
+ ),
+ 'text/html',
+ )
+ const preload = document.querySelector('link[rel="preload"]')
+ const image = document.querySelector('img')
+
+ expect(preload?.getAttribute('crossorigin')).toBe('use-credentials')
+ expect(preload?.getAttribute('referrerpolicy')).toBe('no-referrer')
+ expect(preload?.getAttribute('imagesizes')).toBe('100vw')
+ expect(image?.getAttribute('crossorigin')).toBe('use-credentials')
+ expect(image?.getAttribute('referrerpolicy')).toBe('no-referrer')
+ })
+
test('rejects a media-gated preload instead of letting React deduplicate it incorrectly', () => {
expect(() =>
renderPicture({ loading: 'eager', media: '(min-width: 768px)', preload: true }),
diff --git a/packages/img/test/types.tsx b/packages/img/test/types.tsx
index 9f52ca55..14757209 100644
--- a/packages/img/test/types.tsx
+++ b/packages/img/test/types.tsx
@@ -7,6 +7,8 @@ import type {
} from '../src/next/server.tsx'
import { createTransloaditImageModel } from '../src/index.ts'
+import { TransloaditPicture } from '../src/next/index.tsx'
+import { createTransloaditImage } from '../src/next/server.tsx'
const modelOptions: TransloaditImageModelOptions = {
expiresAt: Date.UTC(2030, 0, 1),
@@ -16,12 +18,12 @@ const modelOptions: TransloaditImageModelOptions = {
width: 400,
}
-const imageProps: TransloaditImageProps = {
+const imageProps = {
alt: 'Preview of report.pdf',
height: 300,
src: 'documents/report.pdf',
width: 400,
-}
+} satisfies TransloaditImageProps
// @ts-expect-error Storage preview formats use format-specific quality values, not a tuple.
const modelWithTuple: TransloaditImageModelOptions = { ...modelOptions, formats: ['webp'] }
@@ -46,6 +48,42 @@ const image = Image(imageProps)
const directImage = direct.Image(imageProps)
const redirectedImage = redirect.Image(imageProps)
const routeResponse = redirect.storageRoute(new Request('https://app.example/images'))
+const attributedImage = (
+
+)
+const eagerImage =
+const lazyImage =
+// @ts-expect-error A preloaded image cannot be lazy.
+const lazyPreload =
+const lazyPicturePreload = (
+ // @ts-expect-error The model-only renderer also rejects a lazy preload.
+
+)
+// @ts-expect-error A redirect image never suspends for signing.
+const redirectFallback =
+// @ts-expect-error Event callbacks are not serializable image attributes.
+const callbackImage = undefined} />
+// @ts-expect-error Candidate URLs belong to the configured image model.
+const customSourceSet =
+// @ts-expect-error Signing policy belongs to the server-only factory.
+const perImageSecret =
+const configuredRedirect = createTransloaditImage({
+ authKey: 'key',
+ authSecret: 'secret',
+ workspace: 'app',
+ storage: { delivery: { route: '/images', authorize: () => true } },
+})
+const configuredRedirectFallback = (
+ // @ts-expect-error Factory overloads retain the redirect-specific component contract.
+
+)
// @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.
@@ -65,3 +103,13 @@ void model
void modelWithTuple
void redirectedImage
void routeResponse
+void attributedImage
+void eagerImage
+void lazyImage
+void lazyPreload
+void lazyPicturePreload
+void redirectFallback
+void callbackImage
+void customSourceSet
+void perImageSecret
+void configuredRedirectFallback
From 267278be39a7bf2bef55dcbd357eed168c53a224 Mon Sep 17 00:00:00 2001
From: Kevin van Zonneveld
Date: Sat, 12 Sep 2026 09:39:55 +0200
Subject: [PATCH 02/78] fix(img): reserve signing layout and document
responsive geometry
---
packages/img/README.md | 47 ++++++++--
packages/img/src/next/server.tsx | 25 +++++-
packages/img/test/next-server.test.tsx | 90 +++++++++++++++++++
.../app/benchmark/[delivery]/[count]/page.tsx | 1 +
.../img-next/app/storage-image/page.tsx | 32 +++++--
.../img-next/app/storage-redirect/page.tsx | 9 +-
scripts/test-img-next-fixture.ts | 19 +++-
7 files changed, 201 insertions(+), 22 deletions(-)
diff --git a/packages/img/README.md b/packages/img/README.md
index a01ad971..0b78f87f 100644
--- a/packages/img/README.md
+++ b/packages/img/README.md
@@ -49,14 +49,35 @@ export default function Page() {
)
}
```
+The 2400×1600 dimensions describe the source, not a 2400px display box. The CSS caps the hero at
+960px, preserves its 3:2 aspect ratio, and lets it shrink with its container. `sizes` describes
+that layout to the browser; it does not set CSS dimensions. Adjust it if your page has gutters or
+a narrower container.
+
+For a 400×400 avatar source displayed in a 48px box, limit the candidates to 1× and 2×:
+
+```tsx
+
+```
+
`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,
@@ -68,7 +89,9 @@ Direct delivery is the default and fits image-heavy views that already authorize
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.
+By default the shell reserves the image's dimensions and layout styles with an inert, invisible
+image that has no source and makes no request. `suspenseFallback` explicitly replaces that shell
+(including `null` to omit it); custom fallbacks must reserve their own space.
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
@@ -135,16 +158,18 @@ 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`.
+steps. `widths` is an advanced per-image override. Omitted `sizes` emits explicit `100vw` on the
+width-based sources. Supply the actual display width when it differs, or use `sizes="auto, 100vw"`
+for a lazy image whose size should come from its CSS box. Automatic sizes cannot be eager or preloaded.
```tsx
@@ -153,9 +178,8 @@ but strongly recommended whenever an image is not effectively `100vw`.
- 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.
+- Keep `width`/`height` in the source's proportions. Transforms use `r: 'pad'`; CSS `objectFit`
+ controls cropping in a display box but cannot undo padding already encoded in the image.
- `fallbackQuality` changes the signed JPEG fallback quality.
Private signature lifetimes default to at least one hour in stable five-minute rotation windows.
@@ -186,6 +210,13 @@ export const { Image } = createTransloaditImage({
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.
+### Opt-in hydration workaround
+
+`deferUntilHydrated` avoids WebKit parser-to-hydration request replay for non-critical images.
+Leave it off unless you have observed that problem: it delays candidate markup until hydration,
+and its initial `
}
+ width={400}
+ />
+ Following content
+ ,
+ )
+ const reader = stream.getReader()
+ const shell = new TextDecoder().decode((await reader.read()).value)
+ resolveConnection(undefined)
+ await stream.allReady
+ await reader.cancel()
+
+ expect(parseMarkup(shell).querySelector('[role="status"]')?.textContent).toBe('Custom preview')
+ expect(parseMarkup(shell).querySelector('img')).toBeNull()
+ })
+
test('allows explicit widths while making sizes optional', async () => {
const { Image } = createTransloaditImage(baseConfiguration)
const document = parseMarkup(
diff --git a/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx b/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx
index be01d040..0bb3b03f 100644
--- a/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx
+++ b/scripts/fixtures/img-next/app/benchmark/[delivery]/[count]/page.tsx
@@ -29,6 +29,7 @@ export default async function Page({ params }: PageProps): Promise {
key={index}
sizes="200px"
src={`documents/benchmark-${index + 1}.jpg`}
+ style={{ display: 'block', height: 'auto', width: 200 }}
width={400}
/>,
)
diff --git a/scripts/fixtures/img-next/app/storage-image/page.tsx b/scripts/fixtures/img-next/app/storage-image/page.tsx
index 992e2b5b..64dd0f28 100644
--- a/scripts/fixtures/img-next/app/storage-image/page.tsx
+++ b/scripts/fixtures/img-next/app/storage-image/page.tsx
@@ -4,13 +4,29 @@ import { TransloaditImage } from '../TransloaditImage.tsx'
export default function Page(): ReactNode {
return (
- }
- width={400}
- />
+
+
+ After the hero
+
+ After the avatar
+
)
}
diff --git a/scripts/fixtures/img-next/app/storage-redirect/page.tsx b/scripts/fixtures/img-next/app/storage-redirect/page.tsx
index 4e418674..61b7b4c9 100644
--- a/scripts/fixtures/img-next/app/storage-redirect/page.tsx
+++ b/scripts/fixtures/img-next/app/storage-redirect/page.tsx
@@ -7,11 +7,12 @@ export default function Page(): ReactNode {
)
}
diff --git a/scripts/test-img-next-fixture.ts b/scripts/test-img-next-fixture.ts
index 8e983827..35593f0a 100644
--- a/scripts/test-img-next-fixture.ts
+++ b/scripts/test-img-next-fixture.ts
@@ -267,7 +267,19 @@ async function main(): Promise {
'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('visibility:hidden'),
+ 'Storage shell does not reserve image layout',
+ )
+ assert(
+ storageShell.includes('width="2400"') && storageShell.includes('height="1600"'),
+ 'Hero source dimensions are absent',
+ )
+ assert(
+ storageShell.includes('height:auto;max-width:960px;width:100%'),
+ 'Hero responsive CSS is absent',
+ )
+ assert(storageShell.includes('height:48px;width:48px'), 'Avatar CSS box is absent')
assert(
!storageShell.includes('builtin%2Fstorage-preview%400.0.1'),
'A signed Storage URL leaked into the prerendered shell',
@@ -309,6 +321,11 @@ async function main(): Promise {
)
assert(!storageHtml.includes(fixtureSecret), 'Secret leaked into Storage output')
assert(!redirectHtml.includes(fixtureSecret), 'Secret leaked into redirect output')
+ assert(
+ storageHtml.includes(' 48w') && storageHtml.includes(' 96w'),
+ 'Avatar candidates are absent',
+ )
+ assert(storageHtml.includes('sizes="48px"'), 'Avatar sizes are absent')
const routeCandidate = getFirstPictureCandidates(redirectHtml)[0]
assert(routeCandidate !== undefined, 'Expected a redirect route candidate')
From ef5638e2d3473060197f09c7dfec89671bdc2578 Mon Sep 17 00:00:00 2001
From: Kevin van Zonneveld
Date: Sat, 12 Sep 2026 09:42:45 +0200
Subject: [PATCH 03/78] docs(img): explain durable redirects and bound existing
CDN grants
---
packages/img/README.md | 27 ++++++++++--
packages/img/test/next-server.test.tsx | 44 +++++++++++++++++++
.../img-next/app/TransloaditRedirectImage.tsx | 2 +
scripts/test-img-next-fixture.ts | 8 ++++
4 files changed, 77 insertions(+), 4 deletions(-)
diff --git a/packages/img/README.md b/packages/img/README.md
index 0b78f87f..954a696b 100644
--- a/packages/img/README.md
+++ b/packages/img/README.md
@@ -85,6 +85,10 @@ non-normalized Unicode, or more than 1024 UTF-8 bytes are rejected before signin
### Direct delivery
+Choose the delivery policy based on the page's lifetime: use authorized redirects for cached
+markup and pages that may outlive a CDN signature. Use direct delivery for request-authorized
+galleries that do not need a new application authorization check when each image loads.
+
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
@@ -95,8 +99,8 @@ image that has no source and makes no request. `suspenseFallback` explicitly rep
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.
+long-lived page. Prefer authorized redirects for that case; a longer direct signature only delays
+the boundary and also extends the lifetime of a URL that has already been issued.
### Authorized redirects
@@ -120,6 +124,8 @@ export const { Image, storageRoute } = createTransloaditImage({
basePath: '/app',
route: '/api/private-images',
},
+ expiresInMs: 5 * 60 * 1000,
+ rotationIntervalMs: 30 * 1000,
},
workspace,
})
@@ -137,18 +143,31 @@ The handler rejects changed, duplicate, unknown, oversized, or malformed capabil
calling application authorization. `authorize` must return the boolean `true` for the current
request.
+Use browser-attached credentials, normally your same-origin session cookie, in `authenticate`.
+Native image requests cannot attach an application-defined Bearer header.
+
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.
+The example issues CDN grants valid for at least five minutes and at most five minutes thirty
+seconds, including the rotation window. These are explicit example settings, not new defaults;
+allow enough time for a cold transformation. Cached capabilities can still request a new grant
+after an earlier CDN URL has expired, provided application authorization continues to allow access.
+
+Revoking application access denies **new redirect grants**. It does not invalidate signed CDN URLs
+already handed to a browser: those remain valid until their expiry. Downloaded bytes cannot be
+recalled. Shorter grants bound this remaining access window; they do not provide instant revocation.
+
| 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 |
+| Application checks for new image grants | During page rendering | On each redirect request |
+| Already-issued CDN URLs | Valid until expiry | Valid until expiry |
| Long-lived lazy pages | Signature can expire | Fresh CDN signature per load |
-| Typical fit | Large authorized galleries | Strict ACLs and revocation |
+| Typical fit | Request-authorized galleries | Cached markup and long-lived private pages |
## Responsive policy
diff --git a/packages/img/test/next-server.test.tsx b/packages/img/test/next-server.test.tsx
index 17db73fe..2caf572c 100644
--- a/packages/img/test/next-server.test.tsx
+++ b/packages/img/test/next-server.test.tsx
@@ -479,6 +479,50 @@ describe('createTransloaditImage', () => {
expect(connection).not.toHaveBeenCalled()
})
+ test('refreshes an expired target from cached markup, then denies new grants after revocation', async () => {
+ const authorize = vi.fn(() => true)
+ const { Image, storageRoute } = createTransloaditImage({
+ ...baseConfiguration,
+ storage: {
+ allowedPathPrefixes: ['documents/'],
+ delivery: { authorize, route: '/api/private-images' },
+ expiresInMs: 5 * 60 * 1000,
+ rotationIntervalMs: 30 * 1000,
+ },
+ })
+ const document = parseMarkup(
+ renderToStaticMarkup(
+ ,
+ ),
+ )
+ const originalCapability = new URL(getFirstCandidate(document), 'https://app.example')
+ const firstResponse = await storageRoute(new Request(originalCapability))
+ const firstLocation = firstResponse.headers.get('location')
+ if (firstLocation === null) throw new Error('Expected the first authorized target')
+ const originalExpiry = Number(new URL(firstLocation).searchParams.get('exp'))
+ expect(originalExpiry).toBe(Date.parse('2029-01-01T12:07:30Z'))
+
+ vi.setSystemTime(originalExpiry + 1)
+ const renewed = await storageRoute(new Request(originalCapability))
+ const renewedLocation = renewed.headers.get('location')
+ if (renewedLocation === null) throw new Error('Expected a renewed authorized target')
+ expect(renewed.status).toBe(307)
+ expect(renewedLocation).not.toBe(firstLocation)
+ expect(Number(new URL(renewedLocation).searchParams.get('exp'))).toBe(
+ Date.parse('2029-01-01T12:13:00Z'),
+ )
+ expect(renewed.headers.get('cache-control')).toBe('private, no-store')
+ expect(await renewed.text()).toBe('')
+
+ authorize.mockReturnValue(false)
+ const denied = await storageRoute(new Request(originalCapability))
+ expect(denied.status).toBe(404)
+ expect(denied.headers.get('location')).toBeNull()
+ expect(denied.headers.get('cache-control')).toBe('private, no-store')
+ expect(await denied.text()).toBe('')
+ expect(authorize).toHaveBeenCalledTimes(3)
+ })
+
test('binds capabilities to the secret, workspace, Template, route, and basePath', async () => {
const { url } = getStorageRouteCandidate()
const authorize = vi.fn(() => true)
diff --git a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
index bfbd58e1..64dddbb8 100644
--- a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
+++ b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
@@ -11,6 +11,8 @@ const { Image, storageRoute } = createTransloaditImage({
basePath: '/fixture',
route: '/api/private-images',
},
+ expiresInMs: 5 * 60 * 1000,
+ rotationIntervalMs: 30 * 1000,
},
})
diff --git a/scripts/test-img-next-fixture.ts b/scripts/test-img-next-fixture.ts
index 35593f0a..9248d29e 100644
--- a/scripts/test-img-next-fixture.ts
+++ b/scripts/test-img-next-fixture.ts
@@ -330,11 +330,19 @@ async function main(): Promise {
const routeCandidate = getFirstPictureCandidates(redirectHtml)[0]
assert(routeCandidate !== undefined, 'Expected a redirect route candidate')
const routeUrl = new URL(routeCandidate, baseUrl)
+ const beforeAuthorization = Date.now()
const allowed = await fetch(routeUrl, {
headers: { Authorization: 'Bearer fixture' },
redirect: 'manual',
})
assert(allowed.status === 307, 'Authorized Storage route did not redirect')
+ const location = allowed.headers.get('location')
+ assert(location !== null, 'Authorized Storage route has no target')
+ const expiresAt = Number(new URL(location).searchParams.get('exp'))
+ assert(
+ expiresAt >= beforeAuthorization + 5 * 60 * 1000 && expiresAt <= Date.now() + 330_000,
+ 'Redirect fixture did not use the documented five-minute plus 30-second grant',
+ )
assert(
allowed.headers.get('location')?.startsWith('https://cdn.example/') === true,
'Authorized Storage route did not target Smart CDN',
From 36fef92558f1f1c81f95efcdb51489e7374de04c Mon Sep 17 00:00:00 2001
From: Kevin van Zonneveld
Date: Sat, 12 Sep 2026 09:46:27 +0200
Subject: [PATCH 04/78] fix(img): retain decoding defaults and reject invalid
styles
---
packages/img/src/next/imageAttributes.ts | 6 +++++-
packages/img/src/next/index.tsx | 2 +-
packages/img/test/next.test.tsx | 23 +++++++++++++++++++++++
3 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/packages/img/src/next/imageAttributes.ts b/packages/img/src/next/imageAttributes.ts
index e70947e4..9d96e3d3 100644
--- a/packages/img/src/next/imageAttributes.ts
+++ b/packages/img/src/next/imageAttributes.ts
@@ -89,6 +89,10 @@ const nativeAttributes: Record<
/** Snapshots only native, serializable attributes before suspension or rendering. */
export function snapshotImageAttributes(props: ImageAttributes): ImageAttributes {
+ const style = props.style
+ if (style != null && (typeof style !== 'object' || Array.isArray(style))) {
+ throw new TypeError('Image style must be an object')
+ }
const attributes = Object.fromEntries(
Object.entries(props).filter(
([name, value]) =>
@@ -100,7 +104,7 @@ export function snapshotImageAttributes(props: ImageAttributes): ImageAttributes
typeof value === 'boolean'),
),
)
- return { ...attributes, style: props.style === undefined ? undefined : { ...props.style } }
+ return { ...attributes, style: style == null ? undefined : { ...style } }
}
/** A preload is eager; explicitly lazy images must not issue preload requests. */
diff --git a/packages/img/src/next/index.tsx b/packages/img/src/next/index.tsx
index 89135f82..299c06cd 100644
--- a/packages/img/src/next/index.tsx
+++ b/packages/img/src/next/index.tsx
@@ -130,9 +130,9 @@ export function TransloaditPicture(props: TransloaditPictureProps): ReactNode {
const original = (
// biome-ignore lint/performance/noImgElement: This package is the image optimizer.
= {},
): Document {
const markup = renderToStaticMarkup(
@@ -73,6 +74,28 @@ afterEach(() => {
})
describe('TransloaditPicture', () => {
+ test('retains asynchronous decoding when a wrapper forwards undefined', () => {
+ const markup = renderToStaticMarkup(
+ ,
+ )
+ const document = new DOMParser().parseFromString(markup, 'text/html')
+ expect(document.querySelector('img')?.getAttribute('decoding')).toBe('async')
+ })
+
+ test.each([
+ 'color:red',
+ ['color:red'],
+ 123,
+ ])('rejects a non-object style from JavaScript: %j', (style) => {
+ expect(() => renderPicture({ style })).toThrow('Image style must be an object')
+ })
+
test('preserves serializable image attributes without exposing renderer or signing inputs', () => {
const markup = renderToStaticMarkup(
Reflect.apply(TransloaditPicture, undefined, [
From 397bc3619fd3d0ac3254ca8a91ea6a384d0734f4 Mon Sep 17 00:00:00 2001
From: Kevin van Zonneveld
Date: Sat, 12 Sep 2026 09:59:58 +0200
Subject: [PATCH 05/78] fix(img): preserve picture layout selectors while
signing
---
packages/img/src/next/server.tsx | 19 ++++++++++---------
packages/img/test/next-server.test.tsx | 2 ++
scripts/fixtures/img-next/app/layout.tsx | 3 +++
.../img-next/app/storage-image/page.tsx | 2 +-
scripts/test-img-next-fixture.ts | 7 ++++++-
5 files changed, 22 insertions(+), 11 deletions(-)
diff --git a/packages/img/src/next/server.tsx b/packages/img/src/next/server.tsx
index 493768e4..16626b0b 100644
--- a/packages/img/src/next/server.tsx
+++ b/packages/img/src/next/server.tsx
@@ -150,15 +150,16 @@ interface TransloaditStorageImageRequestProps {
function StorageImagePlaceholder({ props }: TransloaditStorageImageRequestProps): ReactNode {
const attributes = snapshotImageAttributes(props)
return (
- // biome-ignore lint/performance/noImgElement: A source-free image retains native layout without fetching.
-
+
+
+
)
}
diff --git a/packages/img/test/next-server.test.tsx b/packages/img/test/next-server.test.tsx
index 17db73fe..421840bf 100644
--- a/packages/img/test/next-server.test.tsx
+++ b/packages/img/test/next-server.test.tsx
@@ -125,6 +125,8 @@ describe('createTransloaditImage', () => {
expect(placeholder?.getAttribute('width')).toBe('2400')
expect(placeholder?.getAttribute('height')).toBe('1600')
expect(placeholder?.getAttribute('class')).toBe('hero')
+ // Consumer selectors such as picture > img must apply before signing resolves too.
+ expect(placeholder?.parentElement?.tagName).toBe('PICTURE')
expect(placeholder?.getAttribute('style')).toBe(
'display:block;height:auto;max-width:960px;width:100%;visibility:hidden',
)
diff --git a/scripts/fixtures/img-next/app/layout.tsx b/scripts/fixtures/img-next/app/layout.tsx
index d50e935a..76377e58 100644
--- a/scripts/fixtures/img-next/app/layout.tsx
+++ b/scripts/fixtures/img-next/app/layout.tsx
@@ -7,6 +7,9 @@ interface LayoutProps {
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
index 64dd0f28..66ffcf63 100644
--- a/scripts/fixtures/img-next/app/storage-image/page.tsx
+++ b/scripts/fixtures/img-next/app/storage-image/page.tsx
@@ -7,11 +7,11 @@ export default function Page(): ReactNode {
After the hero
diff --git a/scripts/test-img-next-fixture.ts b/scripts/test-img-next-fixture.ts
index 35593f0a..79cb1caf 100644
--- a/scripts/test-img-next-fixture.ts
+++ b/scripts/test-img-next-fixture.ts
@@ -137,7 +137,8 @@ function getFirstPictureCandidates(html: string): string[] {
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')
+ // Streamed HTML also contains source-free Suspense placeholders; count only resolved images.
+ if (sourceSet === undefined) continue
const decoded = decodeHtmlAttribute(sourceSet)
const separator = decoded.indexOf(' ')
assert(separator > 0, 'Expected every benchmark candidate to have a width descriptor')
@@ -267,6 +268,10 @@ async function main(): Promise {
'Expected redirect-delivery markup to prerender',
)
const storageShell = await readFile(resolve(appOutput, 'storage-image.html'), 'utf8')
+ assert(
+ /
Date: Sat, 12 Sep 2026 10:19:02 +0200
Subject: [PATCH 06/78] feat(img): document and verify Storage image onboarding
---
.changeset/storage-image-seed.md | 10 +
.github/workflows/ci.yml | 17 +-
packages/img/README.md | 203 ++-
.../node/src/alphalib/types/assemblyStatus.ts | 1 +
.../node/src/alphalib/types/robots/_index.ts | 28 +
.../types/robots/_instructions-primitives.ts | 2 +
.../types/robots/transloadit-import.ts | 84 ++
.../types/robots/transloadit-store.ts | 96 ++
.../test/unit/assembly-status-helpers.test.ts | 9 +-
packages/node/test/unit/robots.test.ts | 19 +
scripts/fixtures/img-next/package-lock.json | 1271 +++++++++++++++--
scripts/fixtures/img-next/package.json | 3 +
scripts/fixtures/img-next/seed.test.ts | 85 ++
scripts/fixtures/img-next/seed.ts | 87 ++
scripts/img-next-fixture.test.ts | 65 +-
scripts/test-img-next-fixture.ts | 50 +-
16 files changed, 1892 insertions(+), 138 deletions(-)
create mode 100644 .changeset/storage-image-seed.md
create mode 100644 packages/node/src/alphalib/types/robots/transloadit-import.ts
create mode 100644 packages/node/src/alphalib/types/robots/transloadit-store.ts
create mode 100644 scripts/fixtures/img-next/seed.test.ts
create mode 100644 scripts/fixtures/img-next/seed.ts
diff --git a/.changeset/storage-image-seed.md b/.changeset/storage-image-seed.md
new file mode 100644
index 00000000..53c0fee2
--- /dev/null
+++ b/.changeset/storage-image-seed.md
@@ -0,0 +1,10 @@
+---
+'@transloadit/node': patch
+'@transloadit/types': patch
+'@transloadit/zod': patch
+'transloadit': patch
+'@transloadit/mcp-server': patch
+---
+
+Include the Transloadit Storage import and store Robots in the offline catalog and generated
+instructions, and type the optional `asset_id` in Assembly results.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 45090311..7ca436bf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- - name: Ensure yarn.lock matches dependency changes
+ - name: Ensure each package's lockfile matches dependency changes
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
@@ -59,13 +59,11 @@ jobs:
}
return !file.startsWith('docs/fingerprint/')
})
- const lockfileChanged = diffNames.includes('yarn.lock')
-
if (packageFiles.length === 0) {
process.exit(0)
}
- const hasDependencyChanges = packageFiles.some((file) => {
+ const changedPackages = packageFiles.filter((file) => {
let before = {}
let after = {}
try {
@@ -85,8 +83,15 @@ jobs:
})
})
- if (hasDependencyChanges && !lockfileChanged) {
- console.error('yarn.lock must be updated when dependency ranges change in package.json.')
+ // The packed Next app is a standalone npm consumer, outside the Yarn workspaces.
+ const missingLockfiles = new Set(changedPackages
+ .map((file) => file === 'scripts/fixtures/img-next/package.json'
+ ? 'scripts/fixtures/img-next/package-lock.json'
+ : 'yarn.lock')
+ .filter((lockfile) => !diffNames.includes(lockfile)))
+
+ if (missingLockfiles.size > 0) {
+ console.error(`${[...missingLockfiles].join(', ')} must be updated when dependency ranges change.`)
process.exit(1)
}
NODE
diff --git a/packages/img/README.md b/packages/img/README.md
index 954a696b..aba2de9b 100644
--- a/packages/img/README.md
+++ b/packages/img/README.md
@@ -10,6 +10,199 @@ 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.
+## Seed your first image
+
+This walkthrough uses Node.js 24.11 or newer and an existing Next.js 16 App Router app. The
+workspace must have Transloadit Storage writes enabled; package installation does not enable them.
+Start with an opaque JPEG or PNG. The current preview Built-in does not promise alpha preservation.
+
+### Install the local packages
+
+From this SDK checkout, install its locked dependencies and pack the local artifacts:
+
+```bash
+corepack yarn install --immutable
+corepack yarn workspace @transloadit/img pack --out /tmp/transloadit-img.tgz
+corepack yarn workspace @transloadit/node pack --out /tmp/transloadit-node.tgz
+corepack yarn workspace @transloadit/types pack --out /tmp/transloadit-types.tgz
+corepack yarn workspace @transloadit/utils pack --out /tmp/transloadit-utils.tgz
+```
+
+Then, from your Next.js app, install those tarballs. Include local `utils` so the app exercises the
+same signing code as the SDK checkout:
+
+```bash
+corepack yarn add @transloadit/img@file:/tmp/transloadit-img.tgz @transloadit/node@file:/tmp/transloadit-node.tgz @transloadit/utils@file:/tmp/transloadit-utils.tgz
+corepack yarn add -D @transloadit/types@file:/tmp/transloadit-types.tgz
+```
+
+The Assembly client and instruction types are only needed by the seed script; `@transloadit/img`
+does not add them to the browser or require an Assembly for each render.
+
+### Configure the two key purposes
+
+Use credentials from the **same workspace**, in server-only environment configuration such as an
+untracked `.env.local`:
+
+- `TRANSLOADIT_ASSEMBLY_KEY` and `TRANSLOADIT_ASSEMBLY_SECRET`: an **Assembly Auth Key** and its
+ secret, used to sign the one-time upload/store Assembly.
+- `TRANSLOADIT_SMART_CDN_KEY` and `TRANSLOADIT_SMART_CDN_SECRET`: a **Smart CDN Auth Key** and its
+ secret, used to sign delivery URLs. An Assembly-only key cannot replace this key.
+- `TRANSLOADIT_WORKSPACE`: that workspace's URL slug.
+
+Do not use a `NEXT_PUBLIC_` prefix or commit credentials. The rendering application only needs
+the Smart CDN credentials; keep the write-capable Assembly credentials in the seeding environment.
+
+### Store one image and keep its verified metadata
+
+Save this as `seed.ts` in the app. `createAssembly()` signs with the Assembly secret. The `stored`
+export step annotates its input: the receipt is in **`results[':original']`**, not `results.stored`.
+This recipe checks completion, a typed `asset_id`, the returned path, byte count, MD5, and positive
+image dimensions against the uploaded file. Those fields were verified in a real Storage canary.
+
+```ts
+import type { InterpolatableRobotTransloaditStoreInstructions } from '@transloadit/types/robots'
+
+import { createHash } from 'node:crypto'
+import { readFile } from 'node:fs/promises'
+
+import { Transloadit } from '@transloadit/node'
+
+/** An application-owned record saved once after upload, not fetched during rendering. */
+export interface StoredImageReceipt {
+ asset_id: string
+ height: number
+ md5hash: string
+ path: string
+ size: number
+ width: number
+}
+
+/** Seed one image with an Assembly key and verify its returned Storage receipt. */
+export async function seedStorageImage(
+ client: Transloadit,
+ filePath: string,
+): Promise {
+ const bytes = await readFile(filePath)
+ const expectedMd5 = createHash('md5').update(bytes).digest('hex')
+ const stored = {
+ conflict_strategy: 'error',
+ path: 'website/${file.url_name}',
+ robot: '/transloadit/store',
+ use: ':original',
+ } satisfies InterpolatableRobotTransloaditStoreInstructions
+ const assembly = await client.createAssembly({
+ files: { photo: filePath },
+ params: { steps: { stored } },
+ waitForCompletion: true,
+ })
+ const result = assembly.results?.[':original']?.[0]
+ const width = result?.meta?.width
+ const height = result?.meta?.height
+ if (
+ assembly.ok !== 'ASSEMBLY_COMPLETED' ||
+ typeof result?.asset_id !== 'string' ||
+ result.asset_id === '' ||
+ typeof result.path !== 'string' ||
+ !result.path.startsWith('website/') ||
+ result.size !== bytes.length ||
+ bytes.length === 0 ||
+ result.md5hash !== expectedMd5 ||
+ typeof width !== 'number' ||
+ !Number.isSafeInteger(width) ||
+ width <= 0 ||
+ typeof height !== 'number' ||
+ !Number.isSafeInteger(height) ||
+ height <= 0
+ ) {
+ throw new Error('The Assembly did not return a matching Storage image receipt')
+ }
+ return {
+ asset_id: result.asset_id,
+ height,
+ md5hash: result.md5hash,
+ path: result.path,
+ size: result.size,
+ width,
+ }
+}
+
+async function main(): Promise {
+ const authKey = process.env.TRANSLOADIT_ASSEMBLY_KEY
+ const authSecret = process.env.TRANSLOADIT_ASSEMBLY_SECRET
+ const filePath = process.argv[2]
+ if (!authKey || !authSecret || !filePath) {
+ throw new Error('Provide an Assembly key/secret and run: node seed.ts ./image.jpg')
+ }
+ const client = new Transloadit({
+ authKey,
+ authSecret,
+ endpoint: process.env.TRANSLOADIT_ASSEMBLY_ENDPOINT,
+ })
+ console.log(JSON.stringify(await seedStorageImage(client, filePath), null, 2))
+}
+
+if (import.meta.main) {
+ main().catch((error: unknown) => {
+ console.error(error)
+ process.exitCode = 1
+ })
+}
+```
+
+Run it once for an image you want to store, keeping the printed record as app data:
+
+```bash
+node --env-file=.env.local seed.ts ./canal-house.jpg > image.json
+```
+
+Proceed only when the command exits successfully. `conflict_strategy: 'error'` makes a repeated
+upload to the same path fail rather than silently replacing an asset. Choose a different filename
+or an intentional conflict policy for another upload. This small recipe reads the image into
+memory to verify its checksum; it is not a bulk-ingestion tool.
+
+The resulting JSON contains `asset_id`, `path`, `size`, `md5hash`, `width`, and `height`. Keep it
+alongside your content or in your application's database; rendering needs no metadata request.
+The `asset_id` identifies the stored asset, while the returned `path` is the component's `src`.
+Only the dimensions and path need to enter image markup.
+
+### Render the stored image
+
+Create the server-only `lib/transloaditImage.tsx` module shown below, then use the saved record
+(this example keeps `image.json` in the app root):
+
+```tsx
+import image from '../image.json'
+import { Image } from '../lib/transloaditImage.tsx'
+
+export default function Page() {
+ return (
+
+ )
+}
+```
+
+The dimensions come from the receipt, not the display box. The CSS preserves those proportions
+and caps the displayed width at 960px. This uses direct delivery; choose the authorized-redirect
+configuration below instead if the page may outlive a signature.
+
+### Direct devdock origin
+
+For a local devdock seed only, set `TRANSLOADIT_ASSEMBLY_ENDPOINT` to your trusted Assembly API
+endpoint. This is separate from the Smart CDN origin. For direct devdock image delivery, configure
+the image factory with the trusted URL Transform `baseUrl` (including its `{workspace}` placeholder)
+and `urlParams: { cdn: 'required' }`. This supplies API2's explicit `cdn: required` acknowledgment
+because native image requests cannot attach a custom header. It does **not** install a CDN or
+bypass signatures. Keep the Smart CDN key and secret, and never take either override from a request.
+Normal Smart CDN delivery needs neither local override.
+
## Next.js
The server entry point targets the Next.js 16 App Router with `cacheComponents: true` in
@@ -20,8 +213,8 @@ Create one server-only application module. The factory does not read environment
```tsx
import { createTransloaditImage } from '@transloadit/img/next/server'
-const authKey = process.env.TRANSLOADIT_KEY
-const authSecret = process.env.TRANSLOADIT_SECRET
+const authKey = process.env.TRANSLOADIT_SMART_CDN_KEY
+const authSecret = process.env.TRANSLOADIT_SMART_CDN_SECRET
const workspace = process.env.TRANSLOADIT_WORKSPACE
if (!authKey || !authSecret || !workspace) {
@@ -250,8 +443,10 @@ 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
+The fixture packs the image, SDK, instruction-type, and signing artifacts and installs them into a
+clean Next.js 16 App Router app. It executes this exact seed recipe against mocked Assembly receipts
+without network access, compiles it against the packed SDK/types, 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/node/src/alphalib/types/assemblyStatus.ts b/packages/node/src/alphalib/types/assemblyStatus.ts
index de740f06..0264f841 100644
--- a/packages/node/src/alphalib/types/assemblyStatus.ts
+++ b/packages/node/src/alphalib/types/assemblyStatus.ts
@@ -660,6 +660,7 @@ export type AssemblyStatusUploads = z.infer
export const assemblyStatusResultSchema = z
.object({
id: z.string().optional(),
+ asset_id: z.string().optional(),
basename: z.string().nullable().optional(),
field: z.string().nullable().optional(),
md5hash: z.string().nullable().optional(),
diff --git a/packages/node/src/alphalib/types/robots/_index.ts b/packages/node/src/alphalib/types/robots/_index.ts
index 809ae2a4..357fde53 100644
--- a/packages/node/src/alphalib/types/robots/_index.ts
+++ b/packages/node/src/alphalib/types/robots/_index.ts
@@ -391,6 +391,16 @@ import {
interpolatableRobotTlcdnDeliverInstructionsWithHiddenFieldsSchema,
meta as tlcdnDeliverMeta,
} from './tlcdn-deliver.ts'
+import {
+ interpolatableRobotTransloaditImportInstructionsSchema,
+ interpolatableRobotTransloaditImportInstructionsWithHiddenFieldsSchema,
+ meta as transloaditImportMeta,
+} from './transloadit-import.ts'
+import {
+ interpolatableRobotTransloaditStoreInstructionsSchema,
+ interpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsSchema,
+ meta as transloaditStoreMeta,
+} from './transloadit-store.ts'
import {
interpolatableRobotTusStoreInstructionsSchema,
interpolatableRobotTusStoreInstructionsWithHiddenFieldsSchema,
@@ -554,6 +564,8 @@ const robotStepsInstructions: RobotSchemaOptions = [
interpolatableRobotTigrisImportInstructionsSchema,
interpolatableRobotTigrisStoreInstructionsSchema,
interpolatableRobotTlcdnDeliverInstructionsSchema,
+ interpolatableRobotTransloaditImportInstructionsSchema,
+ interpolatableRobotTransloaditStoreInstructionsSchema,
interpolatableRobotTusStoreInstructionsSchema,
interpolatableRobotUploadHandleInstructionsSchema,
interpolatableRobotVideoAdaptiveInstructionsSchema,
@@ -651,6 +663,8 @@ const robotStepsInstructionsWithHiddenFields: RobotSchemaOptions = [
interpolatableRobotTigrisImportInstructionsWithHiddenFieldsSchema,
interpolatableRobotTigrisStoreInstructionsWithHiddenFieldsSchema,
interpolatableRobotTlcdnDeliverInstructionsWithHiddenFieldsSchema,
+ interpolatableRobotTransloaditImportInstructionsWithHiddenFieldsSchema,
+ interpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsSchema,
interpolatableRobotTusStoreInstructionsWithHiddenFieldsSchema,
interpolatableRobotUploadHandleInstructionsWithHiddenFieldsSchema,
interpolatableRobotVideoAdaptiveInstructionsWithHiddenFieldsSchema,
@@ -808,6 +822,8 @@ export const robotsMeta = {
tigrisImport,
tigrisStore,
tlcdnDeliverMeta,
+ transloaditImportMeta,
+ transloaditStoreMeta,
tusStoreMeta,
uploadHandleMeta,
videoAdaptiveMeta,
@@ -1302,6 +1318,18 @@ export type {
InterpolatableRobotTlcdnDeliverInstructionsWithHiddenFields,
InterpolatableRobotTlcdnDeliverInstructionsWithHiddenFieldsInput,
} from './tlcdn-deliver.ts'
+export type {
+ InterpolatableRobotTransloaditImportInstructions,
+ InterpolatableRobotTransloaditImportInstructionsInput,
+ InterpolatableRobotTransloaditImportInstructionsWithHiddenFields,
+ InterpolatableRobotTransloaditImportInstructionsWithHiddenFieldsInput,
+} from './transloadit-import.ts'
+export type {
+ InterpolatableRobotTransloaditStoreInstructions,
+ InterpolatableRobotTransloaditStoreInstructionsInput,
+ InterpolatableRobotTransloaditStoreInstructionsWithHiddenFields,
+ InterpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsInput,
+} from './transloadit-store.ts'
export type {
InterpolatableRobotTusStoreInstructions,
InterpolatableRobotTusStoreInstructionsInput,
diff --git a/packages/node/src/alphalib/types/robots/_instructions-primitives.ts b/packages/node/src/alphalib/types/robots/_instructions-primitives.ts
index 333757e3..1dee30b9 100644
--- a/packages/node/src/alphalib/types/robots/_instructions-primitives.ts
+++ b/packages/node/src/alphalib/types/robots/_instructions-primitives.ts
@@ -102,6 +102,8 @@ export const robotNames = z.enum([
'TextSpeakRobot',
'TextTranslateRobot',
'FilePreviewRobot',
+ 'TransloaditImportRobot',
+ 'TransloaditStoreRobot',
'TusStoreRobot',
'ProgressSimulateRobot',
])
diff --git a/packages/node/src/alphalib/types/robots/transloadit-import.ts b/packages/node/src/alphalib/types/robots/transloadit-import.ts
new file mode 100644
index 00000000..a2fb4693
--- /dev/null
+++ b/packages/node/src/alphalib/types/robots/transloadit-import.ts
@@ -0,0 +1,84 @@
+import type { RobotMetaInput } from './_instructions-primitives.ts'
+
+import { z } from 'zod'
+
+import { interpolateRobot, robotBase, robotImport } from './_instructions-primitives.ts'
+
+export const meta: RobotMetaInput = {
+ bytescount: 10,
+ discount_factor: 0.1,
+ discount_pct: 90,
+ example_code: {
+ steps: {
+ imported: {
+ robot: '/transloadit/import',
+ path: 'photos/cat.jpg',
+ },
+ },
+ },
+ example_code_description: 'Import a file from Transloadit Storage:',
+ has_small_icon: true,
+ isAllowedForUrlTransform: true,
+ isInternal: false,
+ minimum_charge: 0,
+ name: 'TransloaditImportRobot',
+ output_factor: 1,
+ override_lvl1: 'File Importing',
+ priceFactor: 10,
+ purpose_sentence: 'imports files from Transloadit Storage',
+ purpose_verb: 'import',
+ purpose_word: 'Transloadit Storage',
+ purpose_words: 'Import files from Transloadit Storage',
+ queueSlotCount: 10,
+ removeJobResultFilesFromDiskRightAfterStoringOnS3: true,
+ service_slug: 'file-importing',
+ slot_count: 10,
+ stage: 'beta',
+ title: 'Import files from Transloadit Storage',
+ typical_file_size_mb: 1.2,
+ typical_file_type: 'file',
+}
+
+export const robotTransloaditImportInstructionsSchema = robotBase
+ .merge(robotImport)
+ .extend({
+ robot: z.literal('/transloadit/import').describe(`
+Imports a file from your workspace's Transloadit Storage by its path.
+`),
+ path: z.string().describe(`
+The path of the file in Transloadit Storage, for example \`photos/cat.jpg\`.
+`),
+ })
+ .strict()
+
+export const robotTransloaditImportInstructionsWithHiddenFieldsSchema =
+ robotTransloaditImportInstructionsSchema.extend({
+ result: z
+ .union([z.literal('debug'), robotTransloaditImportInstructionsSchema.shape.result])
+ .optional(),
+ })
+
+export type RobotTransloaditImportInstructions = z.infer<
+ typeof robotTransloaditImportInstructionsSchema
+>
+export type RobotTransloaditImportInstructionsWithHiddenFields = z.infer<
+ typeof robotTransloaditImportInstructionsWithHiddenFieldsSchema
+>
+
+export const interpolatableRobotTransloaditImportInstructionsSchema = interpolateRobot(
+ robotTransloaditImportInstructionsSchema,
+)
+export type InterpolatableRobotTransloaditImportInstructions =
+ InterpolatableRobotTransloaditImportInstructionsInput
+
+export type InterpolatableRobotTransloaditImportInstructionsInput = z.input<
+ typeof interpolatableRobotTransloaditImportInstructionsSchema
+>
+
+export const interpolatableRobotTransloaditImportInstructionsWithHiddenFieldsSchema =
+ interpolateRobot(robotTransloaditImportInstructionsWithHiddenFieldsSchema)
+export type InterpolatableRobotTransloaditImportInstructionsWithHiddenFields =
+ InterpolatableRobotTransloaditImportInstructionsWithHiddenFieldsInput
+export type InterpolatableRobotTransloaditImportInstructionsWithHiddenFieldsInput = z.input<
+ typeof interpolatableRobotTransloaditImportInstructionsWithHiddenFieldsSchema
+>
diff --git a/packages/node/src/alphalib/types/robots/transloadit-store.ts b/packages/node/src/alphalib/types/robots/transloadit-store.ts
new file mode 100644
index 00000000..5b70f12c
--- /dev/null
+++ b/packages/node/src/alphalib/types/robots/transloadit-store.ts
@@ -0,0 +1,96 @@
+import type { RobotMetaInput } from './_instructions-primitives.ts'
+
+import { z } from 'zod'
+
+import { interpolateRobot, robotBase, robotUse } from './_instructions-primitives.ts'
+
+export const meta: RobotMetaInput = {
+ bytescount: 10,
+ discount_factor: 0.1,
+ discount_pct: 90,
+ example_code: {
+ steps: {
+ stored: {
+ robot: '/transloadit/store',
+ use: ':original',
+ },
+ },
+ },
+ example_code_description: 'Store uploaded files in Transloadit Storage:',
+ has_small_icon: true,
+ isAllowedForUrlTransform: false,
+ isInternal: false,
+ minimum_charge: 0,
+ name: 'TransloaditStoreRobot',
+ output_factor: 1,
+ override_lvl1: 'File Exporting',
+ priceFactor: 10,
+ purpose_sentence: 'stores files privately in Transloadit Storage',
+ purpose_verb: 'export',
+ purpose_word: 'Transloadit Storage',
+ purpose_words: 'Store files in Transloadit Storage',
+ queueSlotCount: 2,
+ removeJobResultFilesFromDiskRightAfterStoringOnS3: false,
+ service_slug: 'file-exporting',
+ slot_count: 2,
+ stage: 'beta',
+ title: 'Store files in Transloadit Storage',
+ trackOutputFileSize: true,
+ typical_file_size_mb: 1.2,
+ typical_file_type: 'file',
+}
+
+export const robotTransloaditStoreInstructionsSchema = robotBase
+ .merge(robotUse)
+ .extend({
+ robot: z.literal('/transloadit/store').describe(`
+Stores each input privately in Transloadit Storage.
+`),
+ conflict_strategy: z
+ .enum(['error', 'overwrite', 'rename'])
+ .default('rename')
+ .describe(`
+Chooses how to handle an existing destination.
+`),
+ path: z
+ .string()
+ .default('${file.url_name}')
+ .describe(`
+Sets the destination path inside your Transloadit Storage workspace, relative to its root: a
+filename, or folders and a filename such as \`website/hero.jpg\`. Folders that do not exist yet
+are created.
+`),
+ })
+ .strict()
+
+export const robotTransloaditStoreInstructionsWithHiddenFieldsSchema =
+ robotTransloaditStoreInstructionsSchema.extend({
+ result: z
+ .union([z.literal('debug'), robotTransloaditStoreInstructionsSchema.shape.result])
+ .optional(),
+ })
+
+export type RobotTransloaditStoreInstructions = z.infer<
+ typeof robotTransloaditStoreInstructionsSchema
+>
+export type RobotTransloaditStoreInstructionsWithHiddenFields = z.infer<
+ typeof robotTransloaditStoreInstructionsWithHiddenFieldsSchema
+>
+
+export const interpolatableRobotTransloaditStoreInstructionsSchema = interpolateRobot(
+ robotTransloaditStoreInstructionsSchema,
+)
+export type InterpolatableRobotTransloaditStoreInstructions =
+ InterpolatableRobotTransloaditStoreInstructionsInput
+
+export type InterpolatableRobotTransloaditStoreInstructionsInput = z.input<
+ typeof interpolatableRobotTransloaditStoreInstructionsSchema
+>
+
+export const interpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsSchema =
+ interpolateRobot(robotTransloaditStoreInstructionsWithHiddenFieldsSchema)
+export type InterpolatableRobotTransloaditStoreInstructionsWithHiddenFields =
+ InterpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsInput
+export type InterpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsInput = z.input<
+ typeof interpolatableRobotTransloaditStoreInstructionsWithHiddenFieldsSchema
+>
diff --git a/packages/node/test/unit/assembly-status-helpers.test.ts b/packages/node/test/unit/assembly-status-helpers.test.ts
index cc6d65a5..c98bd645 100644
--- a/packages/node/test/unit/assembly-status-helpers.test.ts
+++ b/packages/node/test/unit/assembly-status-helpers.test.ts
@@ -1,14 +1,21 @@
import type { AssemblyStatus } from '../../src/alphalib/types/assemblyStatus.ts'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, expectTypeOf, it } from 'vitest'
import {
+ assemblyStatusResultSchema,
isAssemblySysError,
isAssemblyTerminal,
isAssemblyTerminalError,
} from '../../src/alphalib/types/assemblyStatus.ts'
describe('assembly status helpers', () => {
+ it('types and validates Storage asset IDs instead of passing through arbitrary values', () => {
+ const result = assemblyStatusResultSchema.parse({ asset_id: 'JN6OawlqFmL419U23jUKcg' })
+ expect(result.asset_id).toBe('JN6OawlqFmL419U23jUKcg')
+ expectTypeOf(result.asset_id).toEqualTypeOf()
+ expect(assemblyStatusResultSchema.safeParse({ asset_id: 123 }).success).toBe(false)
+ })
it('treats system error shapes as terminal errors', () => {
const sysError = {
errno: -2,
diff --git a/packages/node/test/unit/robots.test.ts b/packages/node/test/unit/robots.test.ts
index 27936e6c..82673660 100644
--- a/packages/node/test/unit/robots.test.ts
+++ b/packages/node/test/unit/robots.test.ts
@@ -3,6 +3,25 @@ import { describe, expect, it } from 'vitest'
import { getRobotHelp, listRobots } from '../../src/Transloadit.ts'
describe('robot catalog helpers', () => {
+ it.each([
+ '/transloadit/store',
+ '/transloadit/import',
+ ])('documents the Storage robot %s offline', (robotName) => {
+ const help = getRobotHelp({ robotName, detailLevel: 'full' })
+ expect(help.name).toBe(robotName)
+ expect([...help.requiredParams, ...help.optionalParams].map((param) => param.name)).toContain(
+ 'path',
+ )
+ expect(help.examples?.length).toBeGreaterThan(0)
+ })
+
+ it('explains a complete destination path for Storage exports', () => {
+ const help = getRobotHelp({ robotName: '/transloadit/store', detailLevel: 'full' })
+ const path = help.optionalParams.find((param) => param.name === 'path')
+ expect(path?.description).toContain('folders and a filename')
+ expect(path?.description).toContain('website/hero.jpg')
+ expect(help.optionalParams.find((param) => param.name === 'conflict_strategy')).toBeDefined()
+ })
it('lists robots with searchable summaries', () => {
const { robots, nextCursor } = listRobots({ search: 'image', limit: 3 })
diff --git a/scripts/fixtures/img-next/package-lock.json b/scripts/fixtures/img-next/package-lock.json
index c148d2bd..438d05cf 100644
--- a/scripts/fixtures/img-next/package-lock.json
+++ b/scripts/fixtures/img-next/package-lock.json
@@ -13,6 +13,8 @@
"server-only": "0.0.1"
},
"devDependencies": {
+ "@transloadit/node": "4.11.1",
+ "@transloadit/types": "4.3.4",
"@types/node": "25.8.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
@@ -578,6 +580,13 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@next/env": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
@@ -700,136 +709,944 @@
"arm64"
],
"license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
+ "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/@sec-ant/readable-stream": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
+ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "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/@transloadit/abbr": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@transloadit/abbr/-/abbr-1.0.0.tgz",
+ "integrity": "sha512-Hg5xdbpsDfUiUc62fIAF6L86+o52pY37/eKCOKqvJCJfSJ0ET6AG5FBIg6o2tTiFF7si5lhGIHONs0/IAEwc2Q==",
+ "dev": true,
+ "license": "AGPL-3.0-only"
+ },
+ "node_modules/@transloadit/node": {
+ "version": "4.11.1",
+ "resolved": "https://registry.npmjs.org/@transloadit/node/-/node-4.11.1.tgz",
+ "integrity": "sha512-TS1O3G4fFj20ia3MwUfwTINgdlkmRWSVQr5+AeLaKJkSpVSml4/OTat3fqi4gbW6w9a+Fu0kq5bAJ5jZEnvR8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@transloadit/sev-logger": "^0.1.9",
+ "@transloadit/utils": "^4.4.1",
+ "cacheable-lookup": "^7.0.0",
+ "clipanion": "^4.0.0-rc.4",
+ "debug": "^4.4.3",
+ "dotenv": "^17.4.2",
+ "form-data": "^4.0.5",
+ "got": "14.6.6",
+ "into-stream": "^9.1.0",
+ "is-stream": "^4.0.1",
+ "json-to-ast": "^2.1.0",
+ "lodash-es": "^4.18.1",
+ "node-watch": "^0.7.4",
+ "p-map": "^7.0.4",
+ "p-queue": "^9.3.0",
+ "recursive-readdir": "^2.2.3",
+ "tus-js-client": "^4.3.1",
+ "typanion": "^3.14.0",
+ "type-fest": "^5.6.0",
+ "zod": "3.25.76"
+ },
+ "bin": {
+ "transloadit": "dist/cli.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@transloadit/sev-logger": {
+ "version": "0.1.9",
+ "resolved": "https://registry.npmjs.org/@transloadit/sev-logger/-/sev-logger-0.1.9.tgz",
+ "integrity": "sha512-TALqS5mOo+5TmwNdtRfsfOhtjhfCuXllVffNoiGEpewmbwsxBfrTdcE/9/Ayst9rfT0HST0KU1l71lxltLMEwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@transloadit/abbr": "^1.0.0"
+ }
+ },
+ "node_modules/@transloadit/types": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/@transloadit/types/-/types-4.3.4.tgz",
+ "integrity": "sha512-pmp3wo1bPIG+MwuZGatVMCsdvXUfy9nx+58dC8S30S8eb7+8H7WBCoBvuZUjajclx3uacj8TlhSXSZE660DBPA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@transloadit/utils": {
+ "version": "4.8.0",
+ "resolved": "https://registry.npmjs.org/@transloadit/utils/-/utils-4.8.0.tgz",
+ "integrity": "sha512-lNe9zKj51CeN1WpzSsGatCIPaQItudlbc6SgWzTc0jPnWUqf4hHJChAL2MfgOZ75zWz2st/S/jnKCtcZDImG6g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/brace-expansion": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/byte-counter": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/byte-counter/-/byte-counter-0.1.0.tgz",
+ "integrity": "sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cacheable-lookup": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
+ "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "13.0.19",
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-13.0.19.tgz",
+ "integrity": "sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-cache-semantics": "^4.2.0",
+ "get-stream": "^9.0.1",
+ "http-cache-semantics": "^4.2.0",
+ "keyv": "^5.6.0",
+ "mimic-response": "^4.0.0",
+ "normalize-url": "^8.1.1",
+ "responselike": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "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/clipanion": {
+ "version": "4.0.0-rc.4",
+ "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz",
+ "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "website"
+ ],
+ "dependencies": {
+ "typanion": "^3.8.0"
+ },
+ "peerDependencies": {
+ "typanion": "*"
+ }
+ },
+ "node_modules/code-error-fragment": {
+ "version": "0.0.230",
+ "resolved": "https://registry.npmjs.org/code-error-fragment/-/code-error-fragment-0.0.230.tgz",
+ "integrity": "sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/combine-errors": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz",
+ "integrity": "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==",
+ "dev": true,
+ "dependencies": {
+ "custom-error-instance": "2.1.1",
+ "lodash.uniqby": "4.5.0"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "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/custom-error-instance": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz",
+ "integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-10.0.0.tgz",
+ "integrity": "sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "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/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/form-data": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/form-data-encoder": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.1.0.tgz",
+ "integrity": "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
+ "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sec-ant/readable-stream": "^0.4.1",
+ "is-stream": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/got": {
+ "version": "14.6.6",
+ "resolved": "https://registry.npmjs.org/got/-/got-14.6.6.tgz",
+ "integrity": "sha512-QLV1qeYSo5l13mQzWgP/y0LbMr5Plr5fJilgAIwgnwseproEbtNym8xpLsDzeZ6MWXgNE6kdWGBjdh3zT/Qerg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/is": "^7.0.1",
+ "byte-counter": "^0.1.0",
+ "cacheable-lookup": "^7.0.0",
+ "cacheable-request": "^13.0.12",
+ "decompress-response": "^10.0.0",
+ "form-data-encoder": "^4.0.2",
+ "http2-wrapper": "^2.2.1",
+ "keyv": "^5.5.3",
+ "lowercase-keys": "^3.0.0",
+ "p-cancelable": "^4.0.1",
+ "responselike": "^4.0.2",
+ "type-fest": "^4.26.1"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
+ }
+ },
+ "node_modules/got/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/grapheme-splitter": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
+ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http2-wrapper": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
+ "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "quick-lru": "^5.1.1",
+ "resolve-alpn": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ }
+ },
+ "node_modules/into-stream": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-9.1.0.tgz",
+ "integrity": "sha512-DRsRnQrbzdFjaQ1oe4C6/EIUymIOEix1qROEJTF9dbMq+M4Zrm6VaLp6SD/B9IsiEjPZuBSnWWFN+udajugdWA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
+ "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/js-base64": {
+ "version": "3.9.3",
+ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.3.tgz",
+ "integrity": "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/json-to-ast": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/json-to-ast/-/json-to-ast-2.1.0.tgz",
+ "integrity": "sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "code-error-fragment": "0.0.230",
+ "grapheme-splitter": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash._baseiteratee": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz",
+ "integrity": "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash._stringtopath": "~4.8.0"
+ }
+ },
+ "node_modules/lodash._basetostring": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz",
+ "integrity": "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash._baseuniq": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz",
+ "integrity": "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash._createset": "~4.0.0",
+ "lodash._root": "~3.0.0"
+ }
+ },
+ "node_modules/lodash._createset": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz",
+ "integrity": "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash._root": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz",
+ "integrity": "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash._stringtopath": {
+ "version": "4.8.0",
+ "resolved": "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz",
+ "integrity": "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lodash._basetostring": "~4.12.0"
}
},
- "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"
- ],
+ "node_modules/lodash.throttle": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash.uniqby": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz",
+ "integrity": "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==",
+ "dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
+ "dependencies": {
+ "lodash._baseiteratee": "~4.7.0",
+ "lodash._baseuniq": "~4.6.0"
}
},
- "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==",
+ "node_modules/lowercase-keys": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
+ "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
+ "dev": true,
"license": "MIT",
"engines": {
- "node": "^14.21.3 || >=16"
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
- "url": "https://paulmillr.com/funding/"
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "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/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
}
},
- "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==",
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "undici-types": ">=7.24.0 <7.24.7"
+ "engines": {
+ "node": ">= 0.6"
}
},
- "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==",
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "csstype": "^3.2.2"
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
}
},
- "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==",
+ "node_modules/mimic-response": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
+ "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
"dev": true,
"license": "MIT",
- "peerDependencies": {
- "@types/react": "^19.2.0"
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
- "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"
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
},
"engines": {
- "node": ">=6.0.0"
+ "node": "*"
}
},
- "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==",
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"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",
@@ -901,6 +1718,82 @@
}
}
},
+ "node_modules/node-watch": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.7.4.tgz",
+ "integrity": "sha512-RinNxoz4W1cep1b928fuFhvAQ5ag/+1UlMDV7rbyGthBIgsiEouS4kvRayvvboxii4m8eolKOIBo3OjDqbc+uQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/normalize-url": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz",
+ "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
+ "integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.16"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "7.0.7",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz",
+ "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-queue": {
+ "version": "9.3.3",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz",
+ "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.4",
+ "p-timeout": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
+ "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -935,6 +1828,38 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/proper-lockfile": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
+ "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "retry": "^0.12.0",
+ "signal-exit": "^3.0.2"
+ }
+ },
+ "node_modules/querystringify": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/quick-lru": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -956,6 +1881,59 @@
"react": "^19.2.8"
}
},
+ "node_modules/recursive-readdir": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz",
+ "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/resolve-alpn": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/responselike": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/responselike/-/responselike-4.0.2.tgz",
+ "integrity": "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lowercase-keys": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1031,6 +2009,13 @@
}
}
},
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1063,12 +2048,83 @@
}
}
},
+ "node_modules/tagged-tag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
+ "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"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/tus-js-client": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz",
+ "integrity": "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.1.2",
+ "combine-errors": "^3.0.3",
+ "is-stream": "^2.0.0",
+ "js-base64": "^3.7.2",
+ "lodash.throttle": "^4.1.1",
+ "proper-lockfile": "^4.1.2",
+ "url-parse": "^1.5.7"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tus-js-client/node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typanion": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz",
+ "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "website"
+ ]
+ },
+ "node_modules/type-fest": {
+ "version": "5.9.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
+ "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "dependencies": {
+ "tagged-tag": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -1089,6 +2145,27 @@
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
+ },
+ "node_modules/url-parse": {
+ "version": "1.5.10",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+ "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/scripts/fixtures/img-next/package.json b/scripts/fixtures/img-next/package.json
index c113c37d..89384241 100644
--- a/scripts/fixtures/img-next/package.json
+++ b/scripts/fixtures/img-next/package.json
@@ -1,6 +1,7 @@
{
"name": "transloadit-img-next-fixture",
"private": true,
+ "type": "module",
"scripts": {
"build": "next build",
"start": "next start"
@@ -13,6 +14,8 @@
"server-only": "0.0.1"
},
"devDependencies": {
+ "@transloadit/node": "4.11.1",
+ "@transloadit/types": "4.3.4",
"@types/node": "25.8.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
diff --git a/scripts/fixtures/img-next/seed.test.ts b/scripts/fixtures/img-next/seed.test.ts
new file mode 100644
index 00000000..23f49088
--- /dev/null
+++ b/scripts/fixtures/img-next/seed.test.ts
@@ -0,0 +1,85 @@
+import type { AssemblyStatus } from '@transloadit/node'
+
+import assert from 'node:assert/strict'
+import { createHash } from 'node:crypto'
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { test } from 'node:test'
+
+import { Transloadit } from '@transloadit/node'
+
+import { seedStorageImage } from './seed.ts'
+
+// A local 1x1 PNG keeps the seed recipe tests offline; the separate devdock canary uses real API2.
+const bytes = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6nXcAAAAASUVORK5CYII=',
+ 'base64',
+)
+const receipt = {
+ asset_id: 'JN6OawlqFmL419U23jUKcg',
+ md5hash: createHash('md5').update(bytes).digest('hex'),
+ meta: { height: 1, width: 1 },
+ path: 'website/photo.png',
+ size: bytes.length,
+}
+
+test('seeds one original and returns verified metadata for rendering without another lookup', async (t) => {
+ const directory = await mkdtemp(join(tmpdir(), 'img-seed-test-'))
+ t.after(() => rm(directory, { recursive: true, force: true }))
+ const filePath = join(directory, 'photo.png')
+ await writeFile(filePath, bytes)
+ const client = new Transloadit({
+ authKey: 'assembly-key',
+ authSecret: 'assembly-secret',
+ endpoint: 'http://127.0.0.1:9',
+ })
+ const response: AssemblyStatus = { ok: 'ASSEMBLY_COMPLETED', results: { ':original': [receipt] } }
+ const create = t.mock.method(client, 'createAssembly', () =>
+ Object.assign(Promise.resolve(response), { assemblyId: 'offline-assembly' }),
+ )
+ assert.deepEqual(await seedStorageImage(client, filePath), {
+ asset_id: receipt.asset_id,
+ height: 1,
+ md5hash: receipt.md5hash,
+ path: receipt.path,
+ size: bytes.length,
+ width: 1,
+ })
+ assert.deepEqual(create.mock.calls[0]?.arguments[0], {
+ files: { photo: filePath },
+ params: {
+ steps: {
+ stored: {
+ robot: '/transloadit/store',
+ use: ':original',
+ path: 'website/${file.url_name}',
+ conflict_strategy: 'error',
+ },
+ },
+ },
+ waitForCompletion: true,
+ })
+})
+
+for (const missing of ['asset_id', 'path', 'size', 'md5hash', 'meta']) {
+ test(`rejects a seed receipt missing ${missing}`, async (t) => {
+ const directory = await mkdtemp(join(tmpdir(), 'img-seed-test-'))
+ t.after(() => rm(directory, { recursive: true, force: true }))
+ const filePath = join(directory, 'photo.png')
+ await writeFile(filePath, bytes)
+ const client = new Transloadit({
+ authKey: 'assembly-key',
+ authSecret: 'assembly-secret',
+ endpoint: 'http://127.0.0.1:9',
+ })
+ const response: AssemblyStatus = {
+ ok: 'ASSEMBLY_COMPLETED',
+ results: { ':original': [{ ...receipt, [missing]: undefined }] },
+ }
+ t.mock.method(client, 'createAssembly', () =>
+ Object.assign(Promise.resolve(response), { assemblyId: 'offline-assembly' }),
+ )
+ await assert.rejects(seedStorageImage(client, filePath), /matching Storage image receipt/)
+ })
+}
diff --git a/scripts/fixtures/img-next/seed.ts b/scripts/fixtures/img-next/seed.ts
new file mode 100644
index 00000000..1526dfca
--- /dev/null
+++ b/scripts/fixtures/img-next/seed.ts
@@ -0,0 +1,87 @@
+import type { InterpolatableRobotTransloaditStoreInstructions } from '@transloadit/types/robots'
+
+import { createHash } from 'node:crypto'
+import { readFile } from 'node:fs/promises'
+
+import { Transloadit } from '@transloadit/node'
+
+/** An application-owned record saved once after upload, not fetched during rendering. */
+export interface StoredImageReceipt {
+ asset_id: string
+ height: number
+ md5hash: string
+ path: string
+ size: number
+ width: number
+}
+
+/** Seed one image with an Assembly key and verify its returned Storage receipt. */
+export async function seedStorageImage(
+ client: Transloadit,
+ filePath: string,
+): Promise {
+ const bytes = await readFile(filePath)
+ const expectedMd5 = createHash('md5').update(bytes).digest('hex')
+ const stored = {
+ conflict_strategy: 'error',
+ path: 'website/${file.url_name}',
+ robot: '/transloadit/store',
+ use: ':original',
+ } satisfies InterpolatableRobotTransloaditStoreInstructions
+ const assembly = await client.createAssembly({
+ files: { photo: filePath },
+ params: { steps: { stored } },
+ waitForCompletion: true,
+ })
+ const result = assembly.results?.[':original']?.[0]
+ const width = result?.meta?.width
+ const height = result?.meta?.height
+ if (
+ assembly.ok !== 'ASSEMBLY_COMPLETED' ||
+ typeof result?.asset_id !== 'string' ||
+ result.asset_id === '' ||
+ typeof result.path !== 'string' ||
+ !result.path.startsWith('website/') ||
+ result.size !== bytes.length ||
+ bytes.length === 0 ||
+ result.md5hash !== expectedMd5 ||
+ typeof width !== 'number' ||
+ !Number.isSafeInteger(width) ||
+ width <= 0 ||
+ typeof height !== 'number' ||
+ !Number.isSafeInteger(height) ||
+ height <= 0
+ ) {
+ throw new Error('The Assembly did not return a matching Storage image receipt')
+ }
+ return {
+ asset_id: result.asset_id,
+ height,
+ md5hash: result.md5hash,
+ path: result.path,
+ size: result.size,
+ width,
+ }
+}
+
+async function main(): Promise {
+ const authKey = process.env.TRANSLOADIT_ASSEMBLY_KEY
+ const authSecret = process.env.TRANSLOADIT_ASSEMBLY_SECRET
+ const filePath = process.argv[2]
+ if (!authKey || !authSecret || !filePath) {
+ throw new Error('Provide an Assembly key/secret and run: node seed.ts ./image.jpg')
+ }
+ const client = new Transloadit({
+ authKey,
+ authSecret,
+ endpoint: process.env.TRANSLOADIT_ASSEMBLY_ENDPOINT,
+ })
+ console.log(JSON.stringify(await seedStorageImage(client, filePath), null, 2))
+}
+
+if (import.meta.main) {
+ main().catch((error: unknown) => {
+ console.error(error)
+ process.exitCode = 1
+ })
+}
diff --git a/scripts/img-next-fixture.test.ts b/scripts/img-next-fixture.test.ts
index 2ed06ae2..5758025a 100644
--- a/scripts/img-next-fixture.test.ts
+++ b/scripts/img-next-fixture.test.ts
@@ -1,7 +1,9 @@
-import { readFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
import { resolve } from 'node:path'
-import { expect, test } from 'vitest'
+import { execa } from 'execa'
+import { expect, onTestFinished, test } from 'vitest'
interface PackageManifest {
dependencies?: Record
@@ -28,3 +30,62 @@ test('locks every external runtime dependency of the packed image package', asyn
expect(fixtureDependencies[name], `${name} must be pinned in the fixture`).toMatch(/^\d/)
}
})
+
+test.each([
+ ['scripts/fixtures/img-next/package.json', 'scripts/fixtures/img-next/package-lock.json', 0],
+ ['scripts/fixtures/img-next/package.json', 'yarn.lock', 1],
+ ['package.json', 'scripts/fixtures/img-next/package-lock.json', 1],
+ ['package.json', 'yarn.lock', 0],
+])('guards dependency changes in %s with %s (exit %i)', async (manifest, lockfile, exitCode) => {
+ const directory = await mkdtemp(resolve(tmpdir(), 'img-lockfile-test-'))
+ onTestFinished(() => rm(directory, { recursive: true, force: true }))
+ const git = (...args: string[]) => execa('git', args, { cwd: directory })
+ await git('init', '--quiet')
+ await git('config', 'user.name', 'Fixture')
+ await git('config', 'user.email', 'fixture@example.invalid')
+ await mkdir(resolve(directory, 'scripts/fixtures/img-next'), { recursive: true })
+ const initial = `${JSON.stringify({ dependencies: { react: '19.2.0' } })}\n`
+ await writeFile(resolve(directory, 'package.json'), initial)
+ await writeFile(resolve(directory, 'scripts/fixtures/img-next/package.json'), initial)
+ await git('add', '.')
+ await git(
+ '-c',
+ 'core.hooksPath=/dev/null',
+ 'commit',
+ '--quiet',
+ '--no-gpg-sign',
+ '-m',
+ 'Baseline',
+ )
+ const { stdout: base } = await git('rev-parse', 'HEAD')
+ await writeFile(
+ resolve(directory, manifest),
+ `${JSON.stringify({ dependencies: { react: '19.2.1' } })}\n`,
+ )
+ await writeFile(resolve(directory, lockfile), 'Updated dependency lock\n')
+ await git('add', '.')
+ await git(
+ '-c',
+ 'core.hooksPath=/dev/null',
+ 'commit',
+ '--quiet',
+ '--no-gpg-sign',
+ '-m',
+ 'Dependency change',
+ )
+ const { stdout: head } = await git('rev-parse', 'HEAD')
+
+ // Exercise the actual legacy inline guard, not a duplicate implementation of its lockfile policy.
+ const workflow = await readFile(
+ resolve(import.meta.dirname, '../.github/workflows/ci.yml'),
+ 'utf8',
+ )
+ const guard = workflow.split("node <<'NODE'\n")[1]?.split('\n NODE')[0]
+ if (guard === undefined) throw new Error('CI lockfile guard was not found')
+ const result = await execa(process.execPath, ['--input-type=commonjs', '--eval', guard], {
+ cwd: directory,
+ env: { BASE_SHA: base, HEAD_SHA: head },
+ reject: false,
+ })
+ expect(result.exitCode, result.stderr).toBe(exitCode)
+})
diff --git a/scripts/test-img-next-fixture.ts b/scripts/test-img-next-fixture.ts
index f3927c6e..c5719066 100644
--- a/scripts/test-img-next-fixture.ts
+++ b/scripts/test-img-next-fixture.ts
@@ -200,6 +200,12 @@ async function runImageBenchmark(
async function main(): Promise {
const repoRoot = resolve(import.meta.dirname, '..')
+ const seed = await readFile(resolve(import.meta.dirname, 'fixtures/img-next/seed.ts'), 'utf8')
+ const readme = await readFile(resolve(repoRoot, 'packages/img/README.md'), 'utf8')
+ assert(
+ readme.includes(`\`\`\`ts\n${seed}\`\`\``),
+ 'The documented seed recipe differs from the tested fixture',
+ )
const temporaryRoot = await mkdtemp(resolve(tmpdir(), 'transloadit-img-next-'))
const fixtureDir = resolve(temporaryRoot, 'fixture')
const packDir = resolve(temporaryRoot, 'pack')
@@ -210,32 +216,20 @@ async function main(): Promise {
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')
+ const tarballs: string[] = []
+ // Package builds share dependencies, so pack sequentially to avoid racing their dist cleanup.
+ for (const name of ['img', 'node', 'types', 'utils']) {
+ const tarball = resolve(packDir, `transloadit-${name}.tgz`)
+ await execa(
+ 'corepack',
+ ['yarn', 'workspace', `@transloadit/${name}`, 'pack', '--out', tarball],
+ {
+ cwd: repoRoot,
+ stdio: 'inherit',
+ },
+ )
+ tarballs.push(tarball)
+ }
await execa('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], {
cwd: fixtureDir,
stdio: 'inherit',
@@ -250,11 +244,11 @@ async function main(): Promise {
'--no-save',
'--prefer-offline',
'--package-lock=false',
- resolve(packDir, utilsTarball),
- resolve(packDir, imageTarball),
+ ...tarballs,
],
{ cwd: fixtureDir, stdio: 'inherit' },
)
+ await execa(process.execPath, ['--test', 'seed.test.ts'], { cwd: fixtureDir, stdio: 'inherit' })
await execa('npm', ['run', 'build'], { cwd: fixtureDir, stdio: 'inherit' })
const appOutput = resolve(fixtureDir, '.next/server/app')
From 64a8e7b97073931b3c4b5a9734f976912b77104f Mon Sep 17 00:00:00 2001
From: Kevin van Zonneveld
Date: Sat, 12 Sep 2026 10:45:35 +0200
Subject: [PATCH 07/78] Validate image alternatives and keep placeholder
identity inert
---
docs/prompts/2026-09-12-img-review.md | 31 ++++++++++++++++++++++++
packages/img/README.md | 3 ++-
packages/img/src/next/imageAttributes.ts | 6 +++--
packages/img/src/next/index.tsx | 2 +-
packages/img/src/next/server.tsx | 8 ++++--
packages/img/test/next-server.test.tsx | 31 +++++++++++++++++++++++-
packages/img/test/next.test.tsx | 13 ++++++++++
7 files changed, 87 insertions(+), 7 deletions(-)
create mode 100644 docs/prompts/2026-09-12-img-review.md
diff --git a/docs/prompts/2026-09-12-img-review.md b/docs/prompts/2026-09-12-img-review.md
new file mode 100644
index 00000000..87eb2e6b
--- /dev/null
+++ b/docs/prompts/2026-09-12-img-review.md
@@ -0,0 +1,31 @@
+# Storage image onboarding review
+
+## Why
+
+Finish the complete Storage image DX in one reviewable PR, node-sdk #500 against `main`.
+The orchestrator supplied the whole-stack council review; do not duplicate that review.
+
+## Review checklist
+
+- [x] Reject non-string `alt` from JavaScript callers, including the renderer and factory paths.
+ Preserve strings, including the empty decorative alternative. Reproduce before fixing.
+- [x] Keep the pending Suspense shell free of the resolved image's `id` and ARIA relationships.
+ Preserve the real image's attributes and the placeholder's layout. Reproduce before fixing.
+- [ ] Run package checks, the packed Next fixture, and repository verification; monitor CI.
+- [ ] Retarget #500 to `main` and describe the whole A–D change, including schema provenance.
+ Close #497–#499 as superseded, without merging or publishing anything.
+- [ ] Dogfood one local Content consumer against the isolated canary devdock using the packed
+ README recipe, then run the fresh-reader user test. Record `/tmp/img-dogfood-round1.md`.
+- [ ] Add the approved native-browser proof in this same PR after dogfood and the user test.
+
+The two P3 fixes belong at the top of the existing `img-onboard` branch. No rebase or force push
+is needed. Transparency, package publication, API2 implementation, and production access remain
+outside this slice.
+
+The new focused run reproduced six failures before the fix and passed all 65 tests afterward.
+Logs: `/tmp/img-task2-p3-red.log` and `/tmp/img-task2-p3-green.log`. The shared attribute snapshot
+now validates `alt`; both rendering paths reuse the validated value. The fallback excludes all
+caller ARIA attributes because it is inert and decorative; its own `aria-hidden` remains.
+
+After both fixes, `yarn check`, `yarn verify:full`, img checks (87 tests plus type fixtures), and
+the packed production Next fixture passed. The package and fixture checks ran sequentially.
diff --git a/packages/img/README.md b/packages/img/README.md
index aba2de9b..b87bc2a1 100644
--- a/packages/img/README.md
+++ b/packages/img/README.md
@@ -287,7 +287,8 @@ rendering. The component calls Next.js `connection()` before creating short-live
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.
By default the shell reserves the image's dimensions and layout styles with an inert, invisible
-image that has no source and makes no request. `suspenseFallback` explicitly replaces that shell
+image that has no source and makes no request. IDs and ARIA relationships belong only to the
+resolved image, not its decorative shell. `suspenseFallback` explicitly replaces that shell
(including `null` to omit it); custom fallbacks must reserve their own space.
The browser requests the selected candidate directly from Smart CDN. Lazy loading remains the
diff --git a/packages/img/src/next/imageAttributes.ts b/packages/img/src/next/imageAttributes.ts
index 9d96e3d3..b3b8eb7d 100644
--- a/packages/img/src/next/imageAttributes.ts
+++ b/packages/img/src/next/imageAttributes.ts
@@ -88,7 +88,9 @@ const nativeAttributes: Record<
}
/** Snapshots only native, serializable attributes before suspension or rendering. */
-export function snapshotImageAttributes(props: ImageAttributes): ImageAttributes {
+export function snapshotImageAttributes(props: ImageAttributes): ImageAttributes & { alt: string } {
+ const alt = props.alt
+ if (typeof alt !== 'string') throw new TypeError('Image alt must be a string')
const style = props.style
if (style != null && (typeof style !== 'object' || Array.isArray(style))) {
throw new TypeError('Image style must be an object')
@@ -104,7 +106,7 @@ export function snapshotImageAttributes(props: ImageAttributes): ImageAttributes
typeof value === 'boolean'),
),
)
- return { ...attributes, style: style == null ? undefined : { ...style } }
+ return { ...attributes, alt, style: style == null ? undefined : { ...style } }
}
/** A preload is eager; explicitly lazy images must not issue preload requests. */
diff --git a/packages/img/src/next/index.tsx b/packages/img/src/next/index.tsx
index 299c06cd..8515fd49 100644
--- a/packages/img/src/next/index.tsx
+++ b/packages/img/src/next/index.tsx
@@ -131,7 +131,7 @@ export function TransloaditPicture(props: TransloaditPictureProps): ReactNode {
// biome-ignore lint/performance/noImgElement: This package is the image optimizer.
name !== 'id' && !name.startsWith('aria-')),
+ )
return (
{
{
)
const reader = stream.getReader()
const shell = new TextDecoder().decode((await reader.read()).value)
- const placeholder = parseMarkup(shell).getElementById('hero')
+ const placeholder = parseMarkup(shell).querySelector('img')
// Always resolve the request so a failed assertion cannot leak a suspended stream.
resolveConnection(undefined)
await stream.allReady
@@ -122,6 +124,12 @@ describe('createTransloaditImage', () => {
}
const image = parseMarkup(remaining).getElementById('hero')
+ expect(placeholder?.hasAttribute('id')).toBe(false)
+ expect(placeholder?.hasAttribute('aria-describedby')).toBe(false)
+ expect(placeholder?.hasAttribute('aria-labelledby')).toBe(false)
+ expect(image?.getAttribute('id')).toBe('hero')
+ expect(image?.getAttribute('aria-describedby')).toBe('hero-caption')
+ expect(image?.getAttribute('aria-labelledby')).toBe('hero hero-caption')
expect(placeholder?.getAttribute('width')).toBe('2400')
expect(placeholder?.getAttribute('height')).toBe('1600')
expect(placeholder?.getAttribute('class')).toBe('hero')
@@ -240,6 +248,27 @@ describe('createTransloaditImage', () => {
expect(image?.getAttribute('sizes')).toBe('auto')
})
+ test.each([
+ 'direct',
+ 'redirect',
+ ])('rejects non-string alt before rendering in %s delivery', (delivery) => {
+ const { Image } = createTransloaditImage({
+ ...baseConfiguration,
+ storage: {
+ ...baseConfiguration.storage,
+ delivery:
+ delivery === 'direct'
+ ? 'direct'
+ : { authorize: () => true, route: '/api/private-images' },
+ },
+ })
+ expect(() =>
+ Reflect.apply(Image, undefined, [
+ { alt: { text: 'Report' }, height: 600, src: 'documents/report.pdf', width: 800 },
+ ]),
+ ).toThrow('Image alt must be a string')
+ })
+
test('rejects coercible Storage sources before signing', () => {
const { Image } = createTransloaditImage(baseConfiguration)
const stringConversion = vi.fn(() => 'https://assets.example/photo.jpg')
diff --git a/packages/img/test/next.test.tsx b/packages/img/test/next.test.tsx
index da52c110..6afa8ece 100644
--- a/packages/img/test/next.test.tsx
+++ b/packages/img/test/next.test.tsx
@@ -40,6 +40,7 @@ const model: TransloaditImageModel = {
function renderPicture(
overrides: Partial<{
+ alt: unknown
deferUntilHydrated: boolean
loading: 'eager' | 'lazy'
media: string
@@ -74,6 +75,18 @@ afterEach(() => {
})
describe('TransloaditPicture', () => {
+ test.each([
+ { description: 'A canal house' },
+ undefined,
+ 123,
+ ])('rejects a non-string alt from JavaScript: %j', (alt) => {
+ expect(() => renderPicture({ alt })).toThrow('Image alt must be a string')
+ })
+
+ test.each(['A canal house', ''])('preserves the supplied alt text: %j', (alt) => {
+ expect(renderPicture({ alt }).querySelector('img')?.getAttribute('alt')).toBe(alt)
+ })
+
test('retains asynchronous decoding when a wrapper forwards undefined', () => {
const markup = renderToStaticMarkup(
Date: Sat, 12 Sep 2026 12:05:02 +0200
Subject: [PATCH 08/78] test(img): prove native cookie delivery in production
Next fixture
---
.github/workflows/ci.yml | 6 +
.gitignore | 1 +
docs/prompts/2026-09-12-img-review.md | 41 +-
.../fixtures/img-next/app/HydrationProbe.tsx | 22 +
.../img-next/app/TransloaditRedirectImage.tsx | 3 +-
.../img-next/app/api/browser-images/route.ts | 1 +
.../img-next/app/browser/BrowserImage.tsx | 19 +
.../fixtures/img-next/app/browser/page.tsx | 44 ++
.../img-next/app/imageConfiguration.ts | 2 +-
scripts/fixtures/img-next/app/layout.tsx | 7 +-
scripts/fixtures/img-next/browser-cdn.ts | 101 +++++
scripts/fixtures/img-next/browser-policy.ts | 14 +
scripts/fixtures/img-next/browser.spec.ts | 394 ++++++++++++++++++
scripts/fixtures/img-next/package-lock.json | 72 +++-
scripts/fixtures/img-next/package.json | 2 +
.../fixtures/img-next/playwright.config.ts | 21 +
scripts/test-img-next-fixture.ts | 51 ++-
17 files changed, 779 insertions(+), 22 deletions(-)
create mode 100644 scripts/fixtures/img-next/app/HydrationProbe.tsx
create mode 100644 scripts/fixtures/img-next/app/api/browser-images/route.ts
create mode 100644 scripts/fixtures/img-next/app/browser/BrowserImage.tsx
create mode 100644 scripts/fixtures/img-next/app/browser/page.tsx
create mode 100644 scripts/fixtures/img-next/browser-cdn.ts
create mode 100644 scripts/fixtures/img-next/browser-policy.ts
create mode 100644 scripts/fixtures/img-next/browser.spec.ts
create mode 100644 scripts/fixtures/img-next/playwright.config.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7ca436bf..668e68f8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -166,6 +166,12 @@ jobs:
node-version: 24
- run: corepack yarn install --immutable
- run: corepack yarn test:img:fixture
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: img-browser-evidence
+ path: test-results/img-next
+ if-no-files-found: ignore
unit:
name: Unit tests (Node ${{ matrix.node }})
diff --git a/.gitignore b/.gitignore
index 8ec81799..5f52b340 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ sample.js
npm-debug.log
env.sh
/coverage
+/test-results/
packages/node/coverage
.pnp.*
diff --git a/docs/prompts/2026-09-12-img-review.md b/docs/prompts/2026-09-12-img-review.md
index 87eb2e6b..3404d351 100644
--- a/docs/prompts/2026-09-12-img-review.md
+++ b/docs/prompts/2026-09-12-img-review.md
@@ -11,12 +11,13 @@ The orchestrator supplied the whole-stack council review; do not duplicate that
Preserve strings, including the empty decorative alternative. Reproduce before fixing.
- [x] Keep the pending Suspense shell free of the resolved image's `id` and ARIA relationships.
Preserve the real image's attributes and the placeholder's layout. Reproduce before fixing.
-- [ ] Run package checks, the packed Next fixture, and repository verification; monitor CI.
-- [ ] Retarget #500 to `main` and describe the whole A–D change, including schema provenance.
+- [x] Run package checks, the packed Next fixture, and repository verification; monitor CI.
+- [x] Retarget #500 to `main` and describe the whole A–D change, including schema provenance.
Close #497–#499 as superseded, without merging or publishing anything.
-- [ ] Dogfood one local Content consumer against the isolated canary devdock using the packed
+- [x] Dogfood one local Content consumer against the isolated canary devdock using the packed
README recipe, then run the fresh-reader user test. Record `/tmp/img-dogfood-round1.md`.
-- [ ] Add the approved native-browser proof in this same PR after dogfood and the user test.
+- [x] Add the approved native-browser proof in this same PR after dogfood and the user test.
+- [ ] Monitor the browser-proof commit's GitHub checks; keep README feedback for the next brief.
The two P3 fixes belong at the top of the existing `img-onboard` branch. No rebase or force push
is needed. Transparency, package publication, API2 implementation, and production access remain
@@ -29,3 +30,35 @@ caller ARIA attributes because it is inert and decorative; its own `aria-hidden`
After both fixes, `yarn check`, `yarn verify:full`, img checks (87 tests plus type fixtures), and
the packed production Next fixture passed. The package and fixture checks ran sequentially.
+
+The accepted local Content dogfood and independent user test are recorded in
+`/tmp/img-dogfood-round1.md`. Only #500 remains open; the README feedback is awaiting the
+orchestrator's round-2 brief and must not be changed in the browser-proof slice.
+
+The first real native-cookie regression returned 404 instead of 307 while the image request
+carried its HttpOnly session cookie and no Bearer header (`/tmp/img-task2-e-red.log`). The fixture
+now uses that cookie, and the browser suite follows redirects to an owned local origin that
+independently validates signatures/expiry and serves real encoded image bytes. The existing
+five-minute HTTP fixture policy remains; only the separate lifecycle page uses ten-second grants.
+
+The final local run passed all six Chromium cases in 24.3 seconds, after `yarn check`,
+`yarn verify:full`, and the img package's 87 tests plus type fixtures. The packed fixture builds
+production Next.js with `cacheComponents`, checks the six offline seed cases, and records the
+1/20/100-image delivery diagnostics before running the browser suite.
+
+The browser checks cover native HttpOnly-cookie authorization without Bearer headers, actual
+prerendered geometry and responsive candidates at 1200px/390px with application JavaScript held,
+subsequent hydration, renewal from the original lazy capability, revocation, expiration, and
+tampering. Every successful image response is decoded and checked against its requested dimensions
+and MIME type; unexpected requests, console errors, page errors, and HTTP failures fail the suite.
+The local CDN independently verifies HMAC signatures and expiry and receives no application cookie.
+
+The saved desktop/mobile screenshots were visually checked. Native candidate-height rounding
+can move following text by less than half a CSS pixel (640×427 versus 2400×1600); the geometry
+assertion documents that tolerance. LCP, image-ready and navigation timings are diagnostics, not
+performance thresholds. CI uploads the JSON report with successful screenshots/response evidence,
+plus failure screenshots and traces. This is Chromium with opaque generated fixtures, not proof of
+production CDN caching, transparency, other browsers, or improved production latency.
+
+Logs: `/tmp/img-task2-e-{check,verify,img-check,final-fixture}.log`.
+Local browser evidence: `test-results/img-next/results.json` (ignored build artifact).
diff --git a/scripts/fixtures/img-next/app/HydrationProbe.tsx b/scripts/fixtures/img-next/app/HydrationProbe.tsx
new file mode 100644
index 00000000..fce98538
--- /dev/null
+++ b/scripts/fixtures/img-next/app/HydrationProbe.tsx
@@ -0,0 +1,22 @@
+'use client'
+
+import type { ReactNode } from 'react'
+
+import { useState, useSyncExternalStore } from 'react'
+
+const subscribe = (): (() => void) => () => undefined
+
+/** A user-visible interaction distinguishes parsed HTML from hydrated application JavaScript. */
+export function HydrationProbe(): ReactNode {
+ const [count, setCount] = useState(0)
+ const hydrated = useSyncExternalStore(
+ subscribe,
+ () => true,
+ () => false,
+ )
+ return (
+
+ )
+}
diff --git a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
index 64dddbb8..09bd4d3c 100644
--- a/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
+++ b/scripts/fixtures/img-next/app/TransloaditRedirectImage.tsx
@@ -1,5 +1,6 @@
import { createTransloaditImage } from '@transloadit/img/next/server'
+import { authorizeFixtureImage } from '../browser-policy.ts'
import { imageConfiguration } from './imageConfiguration.ts'
const { Image, storageRoute } = createTransloaditImage({
@@ -7,7 +8,7 @@ const { Image, storageRoute } = createTransloaditImage({
storage: {
allowedPathPrefixes: ['documents/'],
delivery: {
- authorize: ({ request }) => request.headers.get('authorization') === 'Bearer fixture',
+ authorize: authorizeFixtureImage,
basePath: '/fixture',
route: '/api/private-images',
},
diff --git a/scripts/fixtures/img-next/app/api/browser-images/route.ts b/scripts/fixtures/img-next/app/api/browser-images/route.ts
new file mode 100644
index 00000000..9318cd06
--- /dev/null
+++ b/scripts/fixtures/img-next/app/api/browser-images/route.ts
@@ -0,0 +1 @@
+export { browserStorageRoute as GET } from '../../browser/BrowserImage.tsx'
diff --git a/scripts/fixtures/img-next/app/browser/BrowserImage.tsx b/scripts/fixtures/img-next/app/browser/BrowserImage.tsx
new file mode 100644
index 00000000..703f2248
--- /dev/null
+++ b/scripts/fixtures/img-next/app/browser/BrowserImage.tsx
@@ -0,0 +1,19 @@
+import { createTransloaditImage } from '@transloadit/img/next/server'
+
+import { authorizeFixtureImage } from '../../browser-policy.ts'
+import { imageConfiguration } from '../imageConfiguration.ts'
+
+/** Short grants make actual expiration testable without changing the package's clock/defaults. */
+export const { Image: BrowserImage, storageRoute: browserStorageRoute } = createTransloaditImage({
+ ...imageConfiguration,
+ storage: {
+ allowedPathPrefixes: ['documents/'],
+ delivery: {
+ authorize: authorizeFixtureImage,
+ basePath: '/fixture',
+ route: '/api/browser-images',
+ },
+ expiresInMs: 10_000,
+ rotationIntervalMs: 1_000,
+ },
+})
diff --git a/scripts/fixtures/img-next/app/browser/page.tsx b/scripts/fixtures/img-next/app/browser/page.tsx
new file mode 100644
index 00000000..aaed90ae
--- /dev/null
+++ b/scripts/fixtures/img-next/app/browser/page.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from 'react'
+
+import { BrowserImage } from './BrowserImage.tsx'
+
+export default function Page(): ReactNode {
+ return (
+
+ Private image lifecycle
+
+ After the hero
+
+ After the avatar
+
+
+ )
+}
diff --git a/scripts/fixtures/img-next/app/imageConfiguration.ts b/scripts/fixtures/img-next/app/imageConfiguration.ts
index 64aa4014..4dc0f263 100644
--- a/scripts/fixtures/img-next/app/imageConfiguration.ts
+++ b/scripts/fixtures/img-next/app/imageConfiguration.ts
@@ -1,6 +1,6 @@
export const imageConfiguration = {
authKey: 'fixture-auth-key',
authSecret: 'fixture-secret-must-never-reach-the-browser',
- baseUrl: 'https://cdn.example/file/{workspace}',
+ baseUrl: `${process.env.IMG_FIXTURE_CDN_ORIGIN ?? '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
index 76377e58..9d34c1f4 100644
--- a/scripts/fixtures/img-next/app/layout.tsx
+++ b/scripts/fixtures/img-next/app/layout.tsx
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react'
+import { HydrationProbe } from './HydrationProbe.tsx'
+
interface LayoutProps {
children: ReactNode
}
@@ -10,7 +12,10 @@ export default function Layout({ children }: LayoutProps): ReactNode {
- {children}
+
+ {children}
+
+