transloadit 4.12.0 → 4.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +232 -23
  2. package/dist/Transloadit.d.ts +55 -1
  3. package/dist/Transloadit.d.ts.map +1 -1
  4. package/dist/Transloadit.js +143 -2
  5. package/dist/Transloadit.js.map +1 -1
  6. package/dist/alphalib/types/assemblyStatus.d.ts +574 -198
  7. package/dist/alphalib/types/assemblyStatus.d.ts.map +1 -1
  8. package/dist/alphalib/types/assemblyStatus.js +17 -0
  9. package/dist/alphalib/types/assemblyStatus.js.map +1 -1
  10. package/dist/alphalib/types/robots/_index.d.ts +205 -93
  11. package/dist/alphalib/types/robots/_index.d.ts.map +1 -1
  12. package/dist/alphalib/types/robots/_index.js +8 -0
  13. package/dist/alphalib/types/robots/_index.js.map +1 -1
  14. package/dist/alphalib/types/robots/_instructions-primitives.d.ts +4 -4
  15. package/dist/alphalib/types/robots/_instructions-primitives.d.ts.map +1 -1
  16. package/dist/alphalib/types/robots/_instructions-primitives.js +2 -0
  17. package/dist/alphalib/types/robots/_instructions-primitives.js.map +1 -1
  18. package/dist/alphalib/types/robots/file-serve.d.ts +12 -0
  19. package/dist/alphalib/types/robots/file-serve.d.ts.map +1 -1
  20. package/dist/alphalib/types/robots/file-serve.js +9 -3
  21. package/dist/alphalib/types/robots/file-serve.js.map +1 -1
  22. package/dist/alphalib/types/robots/transloadit-import.d.ts +195 -0
  23. package/dist/alphalib/types/robots/transloadit-import.d.ts.map +1 -0
  24. package/dist/alphalib/types/robots/transloadit-import.js +108 -0
  25. package/dist/alphalib/types/robots/transloadit-import.js.map +1 -0
  26. package/dist/alphalib/types/robots/transloadit-store.d.ts +441 -0
  27. package/dist/alphalib/types/robots/transloadit-store.d.ts.map +1 -0
  28. package/dist/alphalib/types/robots/transloadit-store.js +69 -0
  29. package/dist/alphalib/types/robots/transloadit-store.js.map +1 -0
  30. package/dist/alphalib/types/robots/video-ondemand.d.ts +8 -8
  31. package/dist/alphalib/types/storageAsset.d.ts +226 -0
  32. package/dist/alphalib/types/storageAsset.d.ts.map +1 -0
  33. package/dist/alphalib/types/storageAsset.js +67 -0
  34. package/dist/alphalib/types/storageAsset.js.map +1 -0
  35. package/dist/alphalib/types/template.d.ts.map +1 -1
  36. package/dist/alphalib/types/template.js +5 -2
  37. package/dist/alphalib/types/template.js.map +1 -1
  38. package/dist/cli/commands/BaseCommand.d.ts +3 -1
  39. package/dist/cli/commands/BaseCommand.d.ts.map +1 -1
  40. package/dist/cli/commands/BaseCommand.js +3 -2
  41. package/dist/cli/commands/BaseCommand.js.map +1 -1
  42. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  43. package/dist/cli/commands/assemblies.js +9 -4
  44. package/dist/cli/commands/assemblies.js.map +1 -1
  45. package/dist/cli/commands/auth.d.ts +6 -0
  46. package/dist/cli/commands/auth.d.ts.map +1 -1
  47. package/dist/cli/commands/auth.js +21 -2
  48. package/dist/cli/commands/auth.js.map +1 -1
  49. package/dist/cli/commands/image.d.ts +15 -0
  50. package/dist/cli/commands/image.d.ts.map +1 -0
  51. package/dist/cli/commands/image.js +234 -0
  52. package/dist/cli/commands/image.js.map +1 -0
  53. package/dist/cli/commands/index.d.ts.map +1 -1
  54. package/dist/cli/commands/index.js +15 -1
  55. package/dist/cli/commands/index.js.map +1 -1
  56. package/dist/cli/commands/login.d.ts +24 -0
  57. package/dist/cli/commands/login.d.ts.map +1 -0
  58. package/dist/cli/commands/login.js +275 -0
  59. package/dist/cli/commands/login.js.map +1 -0
  60. package/dist/cli/commands/storage.d.ts +58 -0
  61. package/dist/cli/commands/storage.d.ts.map +1 -0
  62. package/dist/cli/commands/storage.js +589 -0
  63. package/dist/cli/commands/storage.js.map +1 -0
  64. package/dist/cli/commands/templates.d.ts.map +1 -1
  65. package/dist/cli/commands/templates.js +10 -6
  66. package/dist/cli/commands/templates.js.map +1 -1
  67. package/dist/cli/deviceLogin.d.ts +11 -0
  68. package/dist/cli/deviceLogin.d.ts.map +1 -0
  69. package/dist/cli/deviceLogin.js +187 -0
  70. package/dist/cli/deviceLogin.js.map +1 -0
  71. package/dist/cli/helpers.d.ts +23 -1
  72. package/dist/cli/helpers.d.ts.map +1 -1
  73. package/dist/cli/helpers.js +159 -25
  74. package/dist/cli/helpers.js.map +1 -1
  75. package/dist/cli/secretInput.d.ts +3 -0
  76. package/dist/cli/secretInput.d.ts.map +1 -0
  77. package/dist/cli/secretInput.js +11 -0
  78. package/dist/cli/secretInput.js.map +1 -0
  79. package/dist/cli/storageCatalog.d.ts +19 -0
  80. package/dist/cli/storageCatalog.d.ts.map +1 -0
  81. package/dist/cli/storageCatalog.js +129 -0
  82. package/dist/cli/storageCatalog.js.map +1 -0
  83. package/dist/cli/storagePublic.d.ts +3 -0
  84. package/dist/cli/storagePublic.d.ts.map +1 -0
  85. package/dist/cli/storagePublic.js +15 -0
  86. package/dist/cli/storagePublic.js.map +1 -0
  87. package/dist/cli/storageReceipts.d.ts +67 -0
  88. package/dist/cli/storageReceipts.d.ts.map +1 -0
  89. package/dist/cli/storageReceipts.js +242 -0
  90. package/dist/cli/storageReceipts.js.map +1 -0
  91. package/dist/cli/storageSnippets.d.ts +11 -0
  92. package/dist/cli/storageSnippets.d.ts.map +1 -0
  93. package/dist/cli/storageSnippets.js +89 -0
  94. package/dist/cli/storageSnippets.js.map +1 -0
  95. package/dist/storageImage.d.ts +40 -0
  96. package/dist/storageImage.d.ts.map +1 -0
  97. package/dist/storageImage.js +139 -0
  98. package/dist/storageImage.js.map +1 -0
  99. package/dist/storagePublicPrefixes.d.ts +62 -0
  100. package/dist/storagePublicPrefixes.d.ts.map +1 -0
  101. package/dist/storagePublicPrefixes.js +35 -0
  102. package/dist/storagePublicPrefixes.js.map +1 -0
  103. package/dist/storageResults.d.ts +31 -0
  104. package/dist/storageResults.d.ts.map +1 -0
  105. package/dist/storageResults.js +79 -0
  106. package/dist/storageResults.js.map +1 -0
  107. package/package.json +4 -3
  108. package/src/Transloadit.ts +251 -4
  109. package/src/alphalib/types/assemblyStatus.ts +17 -0
  110. package/src/alphalib/types/robots/_index.ts +28 -0
  111. package/src/alphalib/types/robots/_instructions-primitives.ts +2 -0
  112. package/src/alphalib/types/robots/file-serve.ts +11 -3
  113. package/src/alphalib/types/robots/transloadit-import.ts +154 -0
  114. package/src/alphalib/types/robots/transloadit-store.ts +98 -0
  115. package/src/alphalib/types/storageAsset.ts +88 -0
  116. package/src/alphalib/types/template.ts +3 -0
  117. package/src/cli/commands/BaseCommand.ts +4 -2
  118. package/src/cli/commands/assemblies.ts +13 -5
  119. package/src/cli/commands/auth.ts +26 -3
  120. package/src/cli/commands/image.ts +282 -0
  121. package/src/cli/commands/index.ts +27 -1
  122. package/src/cli/commands/login.ts +334 -0
  123. package/src/cli/commands/storage.ts +733 -0
  124. package/src/cli/commands/templates.ts +14 -7
  125. package/src/cli/deviceLogin.ts +226 -0
  126. package/src/cli/helpers.ts +204 -27
  127. package/src/cli/secretInput.ts +12 -0
  128. package/src/cli/storageCatalog.ts +192 -0
  129. package/src/cli/storagePublic.ts +15 -0
  130. package/src/cli/storageReceipts.ts +307 -0
  131. package/src/cli/storageSnippets.ts +96 -0
  132. package/src/storageImage.ts +222 -0
  133. package/src/storagePublicPrefixes.ts +39 -0
  134. package/src/storageResults.ts +119 -0
@@ -0,0 +1,192 @@
1
+ import type { StoredAsset } from '../alphalib/types/storageAsset.ts'
2
+ import type { ResolvedCliConfig } from './helpers.ts'
3
+ import type { IOutputCtl } from './OutputCtl.ts'
4
+
5
+ import { RequestError } from 'got'
6
+
7
+ import { ApiError } from '../ApiError.ts'
8
+ import { damPathMaxCodePoints } from '../alphalib/types/storageAsset.ts'
9
+ import { Transloadit } from '../Transloadit.ts'
10
+ import {
11
+ buildMissingCredentialsMessage,
12
+ noticeCliCredentialSource,
13
+ resolveCliConfig,
14
+ } from './helpers.ts'
15
+ import { assertStorageWorkspace } from './storageReceipts.ts'
16
+
17
+ function readDeadline(signal?: AbortSignal): AbortSignal {
18
+ return signal === undefined
19
+ ? AbortSignal.timeout(60_000)
20
+ : AbortSignal.any([signal, AbortSignal.timeout(60_000)])
21
+ }
22
+
23
+ /** Keeps Workspace discovery, credentials and bounded catalog reads on the same trusted origin. */
24
+ export async function withStorageCatalog<T>(
25
+ options: {
26
+ endpoint?: string
27
+ workspace?: string
28
+ projectWorkspace?: string
29
+ signal?: AbortSignal
30
+ },
31
+ operation: (client: Transloadit, workspace: string, endpoint: string) => Promise<T>,
32
+ failure: string,
33
+ output?: Pick<IOutputCtl, 'notice'>,
34
+ config: ResolvedCliConfig = resolveCliConfig(),
35
+ ): Promise<T> {
36
+ if (config.credentials === undefined)
37
+ throw new Error(config.loadError ?? buildMissingCredentialsMessage())
38
+ noticeCliCredentialSource(config, output, 'credentials')
39
+ const endpoint = new URL(
40
+ options.endpoint ?? config.credentialsEndpoint ?? 'https://api2.transloadit.com',
41
+ )
42
+ if (
43
+ !['http:', 'https:'].includes(endpoint.protocol) ||
44
+ endpoint.username ||
45
+ endpoint.password ||
46
+ endpoint.search ||
47
+ endpoint.hash ||
48
+ endpoint.pathname !== '/'
49
+ ) {
50
+ throw new Error(
51
+ 'Storage endpoint must be an HTTP(S) API origin without credentials, query or fragment',
52
+ )
53
+ }
54
+ const client = new Transloadit({
55
+ ...config.credentials,
56
+ endpoint: endpoint.origin,
57
+ maxRetries: 0,
58
+ })
59
+ try {
60
+ const { workspace } = await client.listStoredAssets({
61
+ limit: 1,
62
+ signal: readDeadline(options.signal),
63
+ })
64
+ assertStorageWorkspace(workspace, options.projectWorkspace, options.workspace)
65
+ options.signal?.throwIfAborted()
66
+ return await operation(client, workspace, endpoint.origin)
67
+ } catch (error) {
68
+ options.signal?.throwIfAborted()
69
+ if (error instanceof ApiError) {
70
+ const status = error.cause?.response?.statusCode
71
+ if (error.code === 'DAM_INVALID_REQUEST' || error.code === 'DAM_INVALID_INPUT')
72
+ throw new Error(
73
+ `${failure} received an invalid request. Check the Storage prefix and paging options.`,
74
+ { cause: error },
75
+ )
76
+ throw new Error(
77
+ `${failure} failed${status === undefined ? '' : ` (HTTP ${status})`}. Check the Storage API at ${endpoint.origin} and the Auth Key dam:read or dam:write scope.`,
78
+ { cause: error },
79
+ )
80
+ }
81
+ if (
82
+ error instanceof RequestError ||
83
+ (error instanceof Error && ['TimeoutError', 'AbortError'].includes(error.name))
84
+ ) {
85
+ throw new Error(
86
+ `${failure} timed out or lost its connection. Check the Storage endpoint and retry.`,
87
+ { cause: error },
88
+ )
89
+ }
90
+ throw error
91
+ }
92
+ }
93
+
94
+ /** Catalog prefixes allow ordinary filenames that the image renderer intentionally restricts. */
95
+ function validateCatalogPrefix(prefix: string): void {
96
+ const segments = prefix.replace(/\/$/, '').split('/')
97
+ if (
98
+ [...prefix].length > damPathMaxCodePoints ||
99
+ new TextEncoder().encode(prefix).length > 1024 ||
100
+ /[\\\p{Cc}\p{Cs}]/u.test(prefix) ||
101
+ (prefix !== '' &&
102
+ segments.some((segment) => segment.trim() === '' || segment === '.' || segment === '..'))
103
+ )
104
+ throw new Error(
105
+ 'Invalid Storage prefix: use at most 512 characters (1024 UTF-8 bytes) in a relative path without empty, dot, or parent-directory segments.',
106
+ )
107
+ }
108
+
109
+ /** Completes every bounded page, refusing wrong-Workspace, duplicate or out-of-prefix metadata. */
110
+ export async function listStorageAssets(
111
+ client: Transloadit,
112
+ workspace: string,
113
+ prefix: string,
114
+ signal?: AbortSignal,
115
+ ): Promise<StoredAsset[]> {
116
+ const normalizedPrefix = prefix.normalize('NFC')
117
+ validateCatalogPrefix(normalizedPrefix)
118
+ const assets: StoredAsset[] = []
119
+ const paths = new Set<string>()
120
+ const cursors = new Set<string>()
121
+ let cursor: string | undefined
122
+ do {
123
+ const page = await client.listStoredAssets({
124
+ prefix: normalizedPrefix,
125
+ cursor,
126
+ limit: 500,
127
+ signal: readDeadline(signal),
128
+ })
129
+ if (page.workspace !== workspace)
130
+ throw new Error('Storage changed Workspace while paging the catalog')
131
+ for (const asset of page.assets) {
132
+ try {
133
+ validateCatalogPrefix(asset.path)
134
+ if (asset.path.endsWith('/')) throw new Error('Expected a file path')
135
+ } catch (error) {
136
+ throw new Error(`Storage returned an invalid asset path: ${JSON.stringify(asset.path)}`, {
137
+ cause: error,
138
+ })
139
+ }
140
+ if (!asset.path.startsWith(normalizedPrefix) || paths.has(asset.path)) {
141
+ throw new Error(
142
+ `Storage returned a duplicate path or one outside the requested prefix: ${JSON.stringify(asset.path)}`,
143
+ )
144
+ }
145
+ paths.add(asset.path)
146
+ assets.push(asset)
147
+ }
148
+ if (page.next_cursor === null) break
149
+ cursor = page.next_cursor
150
+ if (!cursor || cursors.has(cursor) || page.assets.at(-1)?.path !== cursor) {
151
+ throw new Error('Storage omitted or repeated its listing cursor; results would be incomplete')
152
+ }
153
+ cursors.add(cursor)
154
+ } while (cursor !== undefined)
155
+ return assets
156
+ }
157
+
158
+ /** Verify writes against the selected key, not an unverified Workspace label in shell dotenv. */
159
+ export async function resolveStorageWorkspace(
160
+ options: { endpoint?: string; workspace?: string },
161
+ config: ResolvedCliConfig,
162
+ projectWorkspace?: string,
163
+ signal?: AbortSignal,
164
+ ): Promise<string> {
165
+ signal?.throwIfAborted()
166
+ if (config.auth === undefined || !('authKey' in config.auth)) {
167
+ throw new Error(
168
+ 'Storage project binding requires an Auth Key. Unset TRANSLOADIT_AUTH_TOKEN to use key credentials; run transloadit auth login if needed.',
169
+ )
170
+ }
171
+ const sameEndpoint =
172
+ options.endpoint === undefined ||
173
+ new URL(options.endpoint).origin ===
174
+ new URL(config.endpoint ?? 'https://api2.transloadit.com').origin
175
+ const workspace =
176
+ config.authWorkspaceVerified && sameEndpoint && config.authWorkspace !== undefined
177
+ ? config.authWorkspace
178
+ : await withStorageCatalog(
179
+ { endpoint: options.endpoint, signal },
180
+ async (_client, actual) => actual,
181
+ 'Workspace verification',
182
+ undefined,
183
+ {
184
+ credentials: config.auth,
185
+ credentialsEndpoint: config.endpoint,
186
+ credentialsSource: config.authSource,
187
+ },
188
+ )
189
+ assertStorageWorkspace(workspace, projectWorkspace, options.workspace)
190
+ signal?.throwIfAborted()
191
+ return workspace
192
+ }
@@ -0,0 +1,15 @@
1
+ import { ApiError } from '../ApiError.ts'
2
+
3
+ /** Actionable CLI advice without printing remote response bodies or credentials. */
4
+ export function storagePublicError(error: unknown, workspace?: string): string {
5
+ if (error instanceof ApiError && error.code === 'STORAGE_PUBLIC_PREFIX_NEEDS_SMART_CDN_KEY') {
6
+ const slug =
7
+ workspace !== undefined && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(workspace)
8
+ ? workspace
9
+ : '<workspace>'
10
+ return `Enable Smart CDN on an Auth Key at https://transloadit.com/c/${slug}/template-credentials/ and retry. The same key can serve Assemblies and Smart CDN.`
11
+ }
12
+ if (error instanceof TypeError) return error.message
13
+ // Keep unrecognized server errors out of CLI output; only known codes select specific advice.
14
+ return 'Could not update or list public Storage prefixes. Check the API endpoint and Auth Key dam:write scope, then retry.'
15
+ }
@@ -0,0 +1,307 @@
1
+ import type { FileHandle } from 'node:fs/promises'
2
+
3
+ import { randomUUID } from 'node:crypto'
4
+ import { chmod, lstat, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
5
+ import { basename, dirname, join } from 'node:path'
6
+
7
+ import { z } from 'zod'
8
+
9
+ import { storedAssetSchema } from '../alphalib/types/storageAsset.ts'
10
+ import { normalizeStoragePublicPrefix } from '../storagePublicPrefixes.ts'
11
+ import { ensureError, isErrnoException } from './types.ts'
12
+
13
+ // Keep every JSON key verbatim: a Storage filename may be "__proto__", which z.record strips.
14
+ const receiptsSchema = z.custom<Record<string, unknown>>(
15
+ (value: unknown) => typeof value === 'object' && value !== null && !Array.isArray(value),
16
+ )
17
+ const deliveryParameterSchema = z.union([z.string(), z.number(), z.boolean()])
18
+
19
+ /** Project identity and rendering metadata (including optional preview pixels), without credentials. */
20
+ export const storageCatalogSchema = z
21
+ .object({
22
+ workspace: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/),
23
+ apiOrigin: z.string().url().optional(),
24
+ public: z.array(
25
+ z.string().refine((prefix) => {
26
+ try {
27
+ return normalizeStoragePublicPrefix(prefix) === prefix
28
+ } catch {
29
+ return false
30
+ }
31
+ }, 'Expected a normalized public directory'),
32
+ ),
33
+ images: receiptsSchema,
34
+ delivery: z
35
+ .object({
36
+ baseUrl: z.string().url().optional(),
37
+ urlParams: z
38
+ .record(z.string(), z.union([deliveryParameterSchema, z.array(deliveryParameterSchema)]))
39
+ .optional(),
40
+ })
41
+ .passthrough()
42
+ .optional(),
43
+ })
44
+ .passthrough()
45
+ export type StorageProjectCatalog = z.infer<typeof storageCatalogSchema>
46
+
47
+ /** Default project catalog; --receipts can select a separate project explicitly. */
48
+ export const defaultStorageCatalog = 'transloadit.images.json'
49
+
50
+ /** Non-production login origins are explicit transport overrides, never rendering credentials. */
51
+ export function storageCatalogDelivery(endpoint?: string): StorageProjectCatalog['delivery'] {
52
+ if (!endpoint) return undefined
53
+ const { origin } = new URL(endpoint)
54
+ if (origin === 'https://api2.transloadit.com') return undefined
55
+ return { baseUrl: `${origin}/file/{workspace}`, urlParams: { cdn: 'required' } }
56
+ }
57
+
58
+ const imageReceiptSchema = storedAssetSchema.extend({
59
+ width: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
60
+ height: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
61
+ })
62
+
63
+ const typesHeader =
64
+ '// Generated by transloadit storage. Commit this file with the image catalog; do not edit.'
65
+
66
+ /** The single conventional type-registration file beside a CLI catalog. */
67
+ export function storageTypesPath(catalog: string): string {
68
+ return join(dirname(catalog), 'transloadit-images.d.ts')
69
+ }
70
+
71
+ function catalogTypes(catalog: StorageProjectCatalog, file?: string): string {
72
+ // Canonical LF keeps generated output deterministic; Git controls checkout-specific EOL conversion.
73
+ const properties = Object.entries(catalog.images)
74
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
75
+ .flatMap(([path, receipt]) => {
76
+ const dimensions = imageReceiptSchema.safeParse(receipt)
77
+ // Legacy entries remain on disk, but require native recovery before they are usable sources.
78
+ if (!dimensions.success) return []
79
+ const { width, height, asset_id, version_id, workspace } = dimensions.data
80
+ const name = JSON.stringify(path)
81
+ return ` ${name}: { path: ${name}; workspace: ${JSON.stringify(workspace)}; asset_id: ${JSON.stringify(asset_id)}; version_id: ${JSON.stringify(version_id)}; width: ${width}; height: ${height}; thumbhash?: string; hasAlpha?: boolean }`
82
+ })
83
+ return [
84
+ typesHeader,
85
+ ...(file === undefined ? [] : [`// Catalog: ${JSON.stringify(basename(file))}`]),
86
+ "import '@transloadit/viewer/next'",
87
+ '',
88
+ "declare module '@transloadit/viewer/next' {",
89
+ ' interface RegisteredStorageImages {',
90
+ ...properties,
91
+ ' }',
92
+ '}',
93
+ '',
94
+ ].join('\n')
95
+ }
96
+
97
+ async function readReceipts(
98
+ file: string,
99
+ ): Promise<{ catalog?: StorageProjectCatalog; mode?: number }> {
100
+ try {
101
+ const info = await lstat(file)
102
+ if (!info.isFile()) throw new Error('Expected a regular JSON file, not a symlink or directory')
103
+ return {
104
+ catalog: storageCatalogSchema.parse(JSON.parse(await readFile(file, 'utf8'))),
105
+ mode: info.mode & 0o777,
106
+ }
107
+ } catch (error) {
108
+ if (isErrnoException(error) && error.code === 'ENOENT') return {}
109
+ const reason =
110
+ error instanceof SyntaxError
111
+ ? 'invalid JSON'
112
+ : error instanceof z.ZodError
113
+ ? `invalid catalog field ${error.issues[0]?.path.join('.') || 'workspace/public/images'}`
114
+ : ensureError(error).message
115
+ throw new Error(`Cannot read receipts ${JSON.stringify(file)}: ${reason}`, { cause: error })
116
+ }
117
+ }
118
+
119
+ /** Read a project binding without creating a catalog or making a network request. */
120
+ export async function readStorageCatalog(file: string): Promise<StorageProjectCatalog | undefined> {
121
+ return (await readReceipts(file)).catalog
122
+ }
123
+
124
+ /** A workspace override selects another workspace, never another key or implicit project rebinding. */
125
+ export function assertStorageWorkspace(actual: string, project?: string, requested?: string): void {
126
+ if (!storageCatalogSchema.shape.workspace.safeParse(actual).success)
127
+ throw new Error(
128
+ `Invalid Storage Workspace ${JSON.stringify(actual)}: expected a URL-safe slug beginning with a letter or digit.`,
129
+ )
130
+ if (requested !== undefined && requested !== actual)
131
+ throw new Error(`Selected credentials belong to ${actual}, not ${requested}. Nothing uploaded.`)
132
+ if (project !== undefined && project !== actual && requested === undefined)
133
+ throw new Error(
134
+ `Project uses ${project}; the selected credentials belong to ${actual}. Nothing uploaded.`,
135
+ )
136
+ }
137
+
138
+ const receiptOriginSchema = z.object({ apiOrigin: z.string().url().optional() })
139
+
140
+ /** Delivery hosts do not prove provenance; a matching Workspace slug is not an API identity. */
141
+ export function assertStorageCatalogOrigin(
142
+ catalog: StorageProjectCatalog | undefined,
143
+ endpoint: string,
144
+ file: string,
145
+ ): void {
146
+ const mismatch = (): Error =>
147
+ new Error(
148
+ `Catalog ${JSON.stringify(file)} belongs to another API environment. Use --receipts for a separate catalog; the existing file was preserved.`,
149
+ )
150
+ if (catalog?.apiOrigin !== undefined && new URL(catalog.apiOrigin).origin !== endpoint)
151
+ throw mismatch()
152
+ if (
153
+ catalog !== undefined &&
154
+ catalog.apiOrigin === undefined &&
155
+ Object.keys(catalog.images).length === 0 &&
156
+ (catalog.public.length > 0 || catalog.delivery !== undefined)
157
+ )
158
+ throw new Error(
159
+ `Catalog ${JSON.stringify(file)} has no verified API origin. Run storage receipts sync into a new --receipts file using the original API --endpoint, then review and replace this catalog. The existing file was preserved.`,
160
+ )
161
+ for (const [path, value] of Object.entries(catalog?.images ?? {})) {
162
+ const previous = receiptOriginSchema.safeParse(value)
163
+ if (
164
+ !previous.success ||
165
+ (previous.data.apiOrigin === undefined && catalog?.apiOrigin === undefined)
166
+ )
167
+ throw new Error(
168
+ `Catalog ${JSON.stringify(file)} has no verified API origin for ${JSON.stringify(path)}. Run storage receipts sync into a new --receipts file using the original API --endpoint, then review and replace this catalog. The existing file was preserved.`,
169
+ )
170
+ if (
171
+ previous.data.apiOrigin !== undefined &&
172
+ new URL(previous.data.apiOrigin).origin !== endpoint
173
+ )
174
+ throw mismatch()
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Serializes CLI receipt writers and replaces a catalog only after the complete update succeeds.
180
+ * onCheckpoint observes a completed rename even if interruption or cleanup subsequently fails.
181
+ */
182
+ export async function updateStorageReceipts(
183
+ file: string,
184
+ update: (
185
+ catalog: StorageProjectCatalog | undefined,
186
+ signal: AbortSignal,
187
+ ) => Promise<StorageProjectCatalog | undefined>,
188
+ onCheckpoint?: () => void,
189
+ ): Promise<void> {
190
+ const lockPath = `${file}.lock`
191
+ const temporary = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`)
192
+ const typesFile = storageTypesPath(file)
193
+ if (basename(file) === 'transloadit-images.d.ts')
194
+ throw new Error(
195
+ 'The catalog cannot use the generated declarations filename transloadit-images.d.ts',
196
+ )
197
+ const typesTemporary = `${temporary}.d.ts`
198
+ const cancellation = new AbortController()
199
+ const cancel = (): void =>
200
+ cancellation.abort(
201
+ new Error('Storage command canceled. Check Storage before retrying a write.'),
202
+ )
203
+ // The first interrupt is cooperative; a forced exit/crash still leaves a lock for inspection.
204
+ process.once('SIGINT', cancel)
205
+ process.once('SIGTERM', cancel)
206
+ let lock: FileHandle | undefined
207
+ let typesLock: FileHandle | undefined
208
+ let retainTemporary = false
209
+ try {
210
+ lock = await open(lockPath, 'wx', 0o600).catch((error: unknown) => {
211
+ if (isErrnoException(error) && error.code === 'EEXIST') {
212
+ throw new Error(
213
+ `The receipts file is locked by another storage store or receipts sync. Remove ${lockPath} only after confirming no writer is running.`,
214
+ { cause: error },
215
+ )
216
+ }
217
+ throw error
218
+ })
219
+ cancellation.signal.throwIfAborted()
220
+ // Different catalog filenames still share one ambient type-registration file.
221
+ typesLock = await open(`${typesFile}.lock`, 'wx', 0o600).catch((error: unknown) => {
222
+ throw new Error(
223
+ `Generated declarations are locked: ${typesFile}. Wait for the other catalog writer.`,
224
+ { cause: error },
225
+ )
226
+ })
227
+ const { catalog, mode } = await readReceipts(file)
228
+ const typesInfo = await lstat(typesFile).catch((error: unknown) => {
229
+ if (isErrnoException(error) && error.code === 'ENOENT') return undefined
230
+ throw error
231
+ })
232
+ if (typesInfo !== undefined && !typesInfo.isFile())
233
+ throw new Error(`Expected a regular generated types file: ${typesFile}`)
234
+ if (
235
+ typesInfo !== undefined &&
236
+ (await readFile(typesFile, 'utf8')).split(/\r?\n/, 1)[0] !== typesHeader
237
+ )
238
+ throw new Error(
239
+ `Refusing to overwrite handwritten declarations in ${typesFile}. Move them to a separate file before retrying.`,
240
+ )
241
+ if (typesInfo !== undefined) {
242
+ const existing = (await readFile(typesFile, 'utf8')).replaceAll('\r\n', '\n')
243
+ const owner = existing.split('\n')[1]
244
+ const expectedOwner = `// Catalog: ${JSON.stringify(basename(file))}`
245
+ // Upgrade older generated files only when they exactly describe this existing catalog.
246
+ if (owner !== expectedOwner && !(catalog !== undefined && existing === catalogTypes(catalog)))
247
+ throw new Error(
248
+ `Generated declarations in ${typesFile} belong to ${owner?.startsWith('// Catalog: ') ? owner.slice(12) : 'another catalog'}. Use --receipts in a separate directory; the active catalog was preserved.`,
249
+ )
250
+ }
251
+ const updated = await update(catalog, cancellation.signal)
252
+ // An explicit one-off workspace override must not mix two workspaces in one catalog.
253
+ if (updated === undefined) {
254
+ cancellation.signal.throwIfAborted()
255
+ return
256
+ }
257
+ if (!storageCatalogSchema.safeParse(updated).success)
258
+ throw new Error(
259
+ 'Cannot write an invalid Storage catalog; check its Workspace, public prefixes and delivery configuration.',
260
+ )
261
+ // Once a remote write returned a receipt, finish its atomic checkpoint even if interrupted.
262
+ // New catalogs are ordinary source files: let the kernel apply umask, without reading it.
263
+ await writeFile(temporary, `${JSON.stringify(updated, null, 2)}\n`, {
264
+ flag: 'wx',
265
+ mode: mode === undefined ? 0o666 : 0o600,
266
+ })
267
+ retainTemporary = true
268
+ if (mode !== undefined) await chmod(temporary, mode)
269
+ await writeFile(typesTemporary, catalogTypes(updated, file), { flag: 'wx', mode: 0o666 })
270
+ if (typesInfo !== undefined) await chmod(typesTemporary, typesInfo.mode & 0o777)
271
+ await rename(temporary, file)
272
+ retainTemporary = false
273
+ onCheckpoint?.()
274
+ // The catalog is the authoritative checkpoint. A failure replacing derived types must
275
+ // never discard a verified upload; sync can regenerate declarations without another write.
276
+ await rename(typesTemporary, typesFile).catch((error: unknown) => {
277
+ throw new Error(
278
+ `Catalog saved, but cannot update ${typesFile}. Run storage receipts sync to regenerate types without re-uploading.`,
279
+ { cause: error },
280
+ )
281
+ })
282
+ cancellation.signal.throwIfAborted()
283
+ } catch (error) {
284
+ if (retainTemporary)
285
+ throw new Error(
286
+ `${ensureError(error).message}\nComplete catalog retained at ${JSON.stringify(temporary)}.`,
287
+ { cause: error },
288
+ )
289
+ throw error
290
+ } finally {
291
+ try {
292
+ if (typesLock !== undefined) {
293
+ await typesLock.close()
294
+ await rm(`${typesFile}.lock`, { force: true })
295
+ }
296
+ if (lock !== undefined) {
297
+ await lock.close()
298
+ await rm(lockPath, { force: true })
299
+ if (!retainTemporary) await rm(temporary, { force: true })
300
+ await rm(typesTemporary, { force: true })
301
+ }
302
+ } finally {
303
+ process.off('SIGINT', cancel)
304
+ process.off('SIGTERM', cancel)
305
+ }
306
+ }
307
+ }
@@ -0,0 +1,96 @@
1
+ import { statSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+
4
+ import { quoteCliArgument } from './helpers.ts'
5
+
6
+ /** Locate ordinary Next.js app directories without assuming the consumer's source layout. */
7
+ export function nextAppRoot(): '' | 'src/' | undefined {
8
+ if (statSync('app', { throwIfNoEntry: false })?.isDirectory()) return ''
9
+ if (statSync('src/app', { throwIfNoEntry: false })?.isDirectory()) return 'src/'
10
+ return undefined
11
+ }
12
+
13
+ /** Advice only: never execute or rewrite a consumer's Next config after a successful upload. */
14
+ export async function storageImageConfigAdvice(): Promise<string> {
15
+ for (const file of ['next.config.ts', 'next.config.mjs', 'next.config.js']) {
16
+ // An unavailable optional hint must not turn a completed Storage write into a CLI failure.
17
+ const source = await readFile(file, 'utf8').catch(() => undefined)
18
+ if (source === undefined) continue
19
+ if (source.includes('withTransloaditImages')) return ''
20
+ const commonJsAdvice = file.endsWith('.js')
21
+ ? '\nFor CommonJS, rename next.config.js to next.config.mjs and convert require/module.exports to import/export before using this ESM example.'
22
+ : ''
23
+ return `\n${file} is not wrapped yet. Keep your existing config in nextConfig and wrap its export:${commonJsAdvice}\nimport { withTransloaditImages } from '@transloadit/viewer/next/config'\nexport default withTransloaditImages(nextConfig)`
24
+ }
25
+ return ''
26
+ }
27
+
28
+ /** Private redirect delivery needs an application authorizer and key, not the CLI login key. */
29
+ export function storageImagePrivateAdvice(path: string, receipts?: string): string {
30
+ const prefix = path.slice(0, path.lastIndexOf('/') + 1)
31
+ if (prefix === '')
32
+ return '\nThis object is private. Configure per-object authorization explicitly, or store it under a directory to use image init --private. Set TRANSLOADIT_SMART_CDN_KEY/SECRET for rendering.'
33
+ const catalogOption = receipts === undefined ? '' : ` --receipts=${quoteCliArgument(receipts)}`
34
+ // Stop option parsing explicitly: Storage paths are data, including names starting with a dash.
35
+ return `\nThis directory is private. Rendering needs transloadit.authorize.ts and ${nextAppRoot() ?? ''}app/api/storage-images/route.ts (npx transloadit image init --private${catalogOption} -- ${quoteCliArgument(prefix)}) and TRANSLOADIT_SMART_CDN_KEY/SECRET. Restart next dev after adding them.`
36
+ }
37
+
38
+ function relativeImport(path: string): string {
39
+ return path.startsWith('./') || path.startsWith('../') ? path : `./${path}`
40
+ }
41
+
42
+ function sourceString(value: string): string {
43
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r').replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029')}'`
44
+ }
45
+
46
+ /** An empty-safe scaffold showing the first receipt in the initialized directory. */
47
+ export function storageImagePage(
48
+ receiptsImport: string,
49
+ prefix: string,
50
+ receipts?: string,
51
+ ): string {
52
+ const catalogOption = receipts === undefined ? '' : ` --receipts=${quoteCliArgument(receipts)}`
53
+ const command = `npx transloadit storage store${catalogOption}${prefix.startsWith('-') ? ' --' : ''} ./hero.jpg ${quoteCliArgument(`${prefix}hero.jpg`)}`
54
+ return [
55
+ "import { Image } from '@transloadit/viewer/next'",
56
+ `import catalog from ${sourceString(relativeImport(receiptsImport))}`,
57
+ '',
58
+ 'export default function Page() {',
59
+ ` const path = Object.keys(catalog.images).find((path) => path.startsWith(${sourceString(prefix)}))`,
60
+ ' if (path === undefined)',
61
+ ' return (',
62
+ ' <p>',
63
+ ` Run <code>{${sourceString(command)}}</code> to add your`,
64
+ ' first image.',
65
+ ' </p>',
66
+ ' )',
67
+ ' // Object.keys only returns own catalog keys, including when the catalog is still empty.',
68
+ ' const src = path as keyof typeof catalog.images',
69
+ ' // Replace the filename-derived alt with a description, or an empty string if decorative.',
70
+ ' const alt = path',
71
+ " .slice(path.lastIndexOf('/') + 1)",
72
+ " .replace(/\\.[^.]+$/, '')",
73
+ " .replaceAll(/[-_]+/g, ' ')",
74
+ ' return (',
75
+ ' <Image',
76
+ ' storage',
77
+ ' src={src}',
78
+ ' alt={alt}',
79
+ ' width={960}',
80
+ ' preload',
81
+ ' errorFallback={',
82
+ ' <p role="status">',
83
+ ' This image could not be loaded. Check the Storage path and delivery configuration.',
84
+ ' </p>',
85
+ ' }',
86
+ ' />',
87
+ ' )',
88
+ '}',
89
+ '',
90
+ ].join('\n')
91
+ }
92
+
93
+ /** Rendering-only variable names; values belong in the application's secret configuration. */
94
+ export function storageImageEnvBlock(publicOnly: boolean): string {
95
+ return publicOnly ? '' : 'TRANSLOADIT_KEY=\nTRANSLOADIT_SECRET=\n'
96
+ }