@promptbook/cli 0.112.0-102 → 0.112.0-103

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 (32) hide show
  1. package/apps/agents-server/README.md +0 -6
  2. package/apps/agents-server/src/app/admin/image-generator-test/ImageAttachmentsEditor.tsx +11 -6
  3. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +13 -15
  4. package/apps/agents-server/src/app/admin/servers/useCreateServerWizard.ts +13 -14
  5. package/apps/agents-server/src/app/api/upload/route.ts +110 -383
  6. package/apps/agents-server/src/tools/$provideCdnForServer.ts +52 -55
  7. package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +17 -49
  8. package/apps/agents-server/src/utils/cdn/classes/TrackedFilesStorage.ts +5 -2
  9. package/apps/agents-server/src/utils/cdn/interfaces/IFilesStorage.ts +5 -0
  10. package/apps/agents-server/src/utils/shareTargetPayloads.ts +15 -63
  11. package/apps/agents-server/src/utils/upload/createBookEditorUploadHandler.ts +23 -150
  12. package/apps/agents-server/src/utils/upload/uploadFileToServer.ts +113 -0
  13. package/esm/index.es.js +194 -35
  14. package/esm/index.es.js.map +1 -1
  15. package/esm/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
  16. package/esm/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
  17. package/esm/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
  18. package/esm/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
  19. package/esm/src/version.d.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +1 -3
  22. package/src/other/templates/getTemplatesPipelineCollection.ts +737 -815
  23. package/src/version.ts +2 -2
  24. package/src/versions.txt +1 -0
  25. package/umd/index.umd.js +194 -35
  26. package/umd/index.umd.js.map +1 -1
  27. package/umd/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
  28. package/umd/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
  29. package/umd/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
  30. package/umd/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
  31. package/umd/src/version.d.ts +1 -1
  32. package/apps/agents-server/src/utils/cdn/resolveCdnStorageProvider.ts +0 -40
@@ -3,87 +3,84 @@ import { DigitalOceanSpaces } from '../utils/cdn/classes/DigitalOceanSpaces';
3
3
  import { TrackedFilesStorage } from '../utils/cdn/classes/TrackedFilesStorage';
4
4
  import { VercelBlobStorage } from '../utils/cdn/classes/VercelBlobStorage';
5
5
  import { IIFilesStorageWithCdn } from '../utils/cdn/interfaces/IFilesStorage';
6
- import { resolveCdnStorageProvider } from '../utils/cdn/resolveCdnStorageProvider';
7
6
 
8
7
  /**
9
- * Environment value that enables path-style S3 requests.
8
+ * Cache of CDN instance
10
9
  *
11
10
  * @private internal cache for `$provideCdnForServer`
12
11
  */
13
- const TRUE_ENV_VALUE = 'true';
12
+ let cdn: IIFilesStorageWithCdn | null = null;
14
13
 
15
14
  /**
16
- * Cache of raw CDN instance.
17
- *
18
- * @private internal cache for `$provideCdnForServer`
15
+ * Provides a CDN storage interface for server-side file operations, with caching to reuse instances.
19
16
  */
20
- let rawCdn: IIFilesStorageWithCdn | null = null;
17
+ export function $provideCdnForServer(): IIFilesStorageWithCdn {
18
+ if (!cdn) {
19
+ const inner = createCdnStorageForServer();
20
+ const supabase = $provideSupabaseForServer();
21
+ cdn = new TrackedFilesStorage(inner, supabase);
22
+ }
21
23
 
22
- /**
23
- * Cache of tracked CDN instance.
24
- *
25
- * @private internal cache for `$provideCdnForServer`
26
- */
27
- let trackedCdn: IIFilesStorageWithCdn | null = null;
24
+ return cdn;
25
+ }
28
26
 
29
27
  /**
30
- * Creates a CDN storage instance from environment variables.
28
+ * Creates the configured CDN storage implementation for server-side file operations.
31
29
  *
32
- * @private internal factory for `$provideCdnForServer`
30
+ * @private helper of `$provideCdnForServer`
33
31
  */
34
- function createCdnForServer(): IIFilesStorageWithCdn {
35
- const provider = resolveCdnStorageProvider();
36
-
37
- switch (provider) {
38
- case 's3':
39
- return new DigitalOceanSpaces({
40
- bucket: process.env.CDN_BUCKET!,
41
- pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX || '',
42
- endpoint: process.env.CDN_ENDPOINT!,
43
- region: process.env.CDN_REGION || 'auto',
44
- accessKeyId: process.env.CDN_ACCESS_KEY_ID!,
45
- secretAccessKey: process.env.CDN_SECRET_ACCESS_KEY!,
46
- cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
47
- gzip: process.env.CDN_GZIP !== 'false',
48
- forcePathStyle: process.env.CDN_FORCE_PATH_STYLE === TRUE_ENV_VALUE,
49
- isPublicReadAclEnabled: process.env.CDN_ENABLE_PUBLIC_READ_ACL !== 'false',
50
- });
51
-
52
- case 'vercel':
53
- return new VercelBlobStorage({
54
- token: process.env.VERCEL_BLOB_READ_WRITE_TOKEN!,
55
- pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX!,
56
- cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
57
- });
32
+ function createCdnStorageForServer(): IIFilesStorageWithCdn {
33
+ if (isS3CompatibleStorageSelected()) {
34
+ return new DigitalOceanSpaces({
35
+ bucket: process.env.CDN_BUCKET!,
36
+ pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX || '',
37
+ endpoint: process.env.CDN_ENDPOINT!,
38
+ accessKeyId: process.env.CDN_ACCESS_KEY_ID!,
39
+ secretAccessKey: process.env.CDN_SECRET_ACCESS_KEY!,
40
+ cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
41
+ gzip: true,
42
+ forcePathStyle: process.env.CDN_FORCE_PATH_STYLE === 'true',
43
+ region: process.env.CDN_REGION || 'auto',
44
+ });
58
45
  }
46
+
47
+ return new VercelBlobStorage({
48
+ token: process.env.VERCEL_BLOB_READ_WRITE_TOKEN!,
49
+ pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX || '',
50
+ cdnPublicUrl: new URL(process.env.NEXT_PUBLIC_CDN_PUBLIC_URL!),
51
+ });
59
52
  }
60
53
 
61
54
  /**
62
- * Provides an untracked CDN storage interface for code paths that manage `File` rows themselves.
55
+ * Checks whether the current environment should use the S3-compatible storage implementation.
63
56
  *
64
- * @private internal cache for `$provideCdnForServer`
57
+ * @private helper of `$provideCdnForServer`
65
58
  */
66
- export function $provideUntrackedCdnForServer(): IIFilesStorageWithCdn {
67
- if (!rawCdn) {
68
- rawCdn = createCdnForServer();
59
+ function isS3CompatibleStorageSelected(): boolean {
60
+ const storageMode = (process.env.PTBK_FILE_STORAGE_MODE || process.env.CDN_PROVIDER || '').toLowerCase();
61
+ const isS3StorageMode =
62
+ storageMode === 's3' || storageMode === 'external-s3' || storageMode === 'self-contained-s3';
63
+
64
+ if (isS3StorageMode) {
65
+ return hasS3CompatibleStorageConfiguration();
69
66
  }
70
67
 
71
- return rawCdn;
68
+ return !process.env.VERCEL_BLOB_READ_WRITE_TOKEN && hasS3CompatibleStorageConfiguration();
72
69
  }
73
70
 
74
71
  /**
75
- * Provides a CDN storage interface for server-side file operations, with caching to reuse instances.
72
+ * Checks whether all S3-compatible storage environment variables are present.
76
73
  *
77
- * @private internal cache for `$provideCdnForServer`
74
+ * @private helper of `$provideCdnForServer`
78
75
  */
79
- export function $provideCdnForServer(): IIFilesStorageWithCdn {
80
- if (!trackedCdn) {
81
- const inner = $provideUntrackedCdnForServer();
82
- const supabase = $provideSupabaseForServer();
83
- trackedCdn = new TrackedFilesStorage(inner, supabase);
84
- }
85
-
86
- return trackedCdn;
76
+ function hasS3CompatibleStorageConfiguration(): boolean {
77
+ return Boolean(
78
+ process.env.CDN_BUCKET &&
79
+ process.env.CDN_ENDPOINT &&
80
+ process.env.CDN_ACCESS_KEY_ID &&
81
+ process.env.CDN_SECRET_ACCESS_KEY &&
82
+ process.env.NEXT_PUBLIC_CDN_PUBLIC_URL,
83
+ );
87
84
  }
88
85
 
89
86
  // TODO: [🏓] Unite `xxxForServer` and `xxxForNode` naming
@@ -12,45 +12,16 @@ type IDigitalOceanSpacesConfig = {
12
12
  readonly bucket: string;
13
13
  readonly pathPrefix: string;
14
14
  readonly endpoint: string;
15
- readonly region?: string;
16
15
  readonly accessKeyId: string;
17
16
  readonly secretAccessKey: string;
18
17
  readonly cdnPublicUrl: URL;
19
18
  readonly gzip: boolean;
20
19
  readonly forcePathStyle?: boolean;
21
- readonly isPublicReadAclEnabled?: boolean;
20
+ readonly region?: string;
22
21
 
23
22
  // TODO: [⛳️] Probbably prefix should be in this config not on the consumer side
24
23
  };
25
24
 
26
- /**
27
- * Resolves the S3 endpoint URL, preserving explicit `http` endpoints for local MinIO.
28
- *
29
- * @private internal helper for DigitalOceanSpaces.
30
- */
31
- function resolveS3Endpoint(endpoint: string): string {
32
- if (/^https?:\/\//i.test(endpoint)) {
33
- return endpoint;
34
- }
35
-
36
- return `https://${endpoint}`;
37
- }
38
-
39
- /**
40
- * Returns a URL object whose pathname ends with `/`.
41
- *
42
- * @private internal helper for DigitalOceanSpaces.
43
- */
44
- function ensureTrailingSlashUrl(url: URL): URL {
45
- const normalizedUrl = new URL(url.href);
46
-
47
- if (!normalizedUrl.pathname.endsWith('/')) {
48
- normalizedUrl.pathname = `${normalizedUrl.pathname}/`;
49
- }
50
-
51
- return normalizedUrl;
52
- }
53
-
54
25
  /**
55
26
  * Class implementing digital ocean spaces.
56
27
  */
@@ -64,7 +35,7 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
64
35
  public constructor(private readonly config: IDigitalOceanSpacesConfig) {
65
36
  this.s3 = new S3Client({
66
37
  region: config.region || 'auto',
67
- endpoint: resolveS3Endpoint(config.endpoint),
38
+ endpoint: normalizeS3Endpoint(config.endpoint),
68
39
  forcePathStyle: config.forcePathStyle,
69
40
  credentials: {
70
41
  accessKeyId: config.accessKeyId,
@@ -74,13 +45,13 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
74
45
  }
75
46
 
76
47
  public getItemUrl(key: string): URL {
77
- return new URL(this.getStorageKey(key), ensureTrailingSlashUrl(this.cdnPublicUrl));
48
+ return new URL(this.config.pathPrefix + '/' + key, this.cdnPublicUrl);
78
49
  }
79
50
 
80
51
  public async getItem(key: string): Promise<IFile | null> {
81
52
  const parameters = {
82
53
  Bucket: this.config.bucket,
83
- Key: this.getStorageKey(key),
54
+ Key: this.config.pathPrefix + '/' + key,
84
55
  };
85
56
 
86
57
  try {
@@ -136,12 +107,12 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
136
107
  const uploadResult = await this.s3.send(
137
108
  new PutObjectCommand({
138
109
  Bucket: this.config.bucket,
139
- Key: this.getStorageKey(key),
110
+ Key: this.config.pathPrefix + '/' + key,
140
111
  ContentType: processedFile.type,
141
112
  ...putObjectRequestAdditional,
142
113
  Body: processedFile.data,
143
114
  // TODO: Public read access / just private to extending class
144
- ...(this.config.isPublicReadAclEnabled === false ? {} : { ACL: 'public-read' }),
115
+ ACL: 'public-read',
145
116
  }),
146
117
  );
147
118
 
@@ -149,22 +120,19 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
149
120
  throw new Error(`Upload result does not contain ETag`);
150
121
  }
151
122
  }
123
+ }
152
124
 
153
- /**
154
- * Builds the final object key used in the S3 bucket.
155
- *
156
- * @private internal helper for DigitalOceanSpaces.
157
- */
158
- private getStorageKey(key: string): string {
159
- const normalizedKey = key.replace(/^\/+/g, '');
160
- const normalizedPathPrefix = this.config.pathPrefix.replace(/^\/+|\/+$/g, '');
161
-
162
- if (!normalizedPathPrefix) {
163
- return normalizedKey;
164
- }
165
-
166
- return `${normalizedPathPrefix}/${normalizedKey}`;
125
+ /**
126
+ * Normalizes endpoint values from legacy host-only configuration and full S3 URLs.
127
+ *
128
+ * @private helper of `DigitalOceanSpaces`
129
+ */
130
+ function normalizeS3Endpoint(endpoint: string): string {
131
+ if (/^https?:\/\//i.test(endpoint)) {
132
+ return endpoint;
167
133
  }
134
+
135
+ return `https://${endpoint}`;
168
136
  }
169
137
 
170
138
  // TODO: Implement Read-only mode
@@ -37,17 +37,20 @@ export class TrackedFilesStorage implements IIFilesStorageWithCdn {
37
37
 
38
38
  try {
39
39
  const { userId, purpose } = file;
40
- const storageUrl = this.getItemUrl(key).href;
40
+ const cdnUrl = this.getItemUrl(key).href;
41
+ const securityResult =
42
+ file.securityResult as AgentsServerDatabase['public']['Tables']['File']['Insert']['securityResult'];
41
43
 
42
44
  await this.supabase.from(await $getTableName('File')).insert({
43
45
  userId: userId || null,
44
46
  fileName: key,
45
47
  fileSize: file.fileSize ?? file.data.length,
46
48
  fileType: file.type,
47
- storageUrl,
49
+ storageUrl: cdnUrl,
48
50
  shortUrl: null,
49
51
  purpose: purpose || 'UNKNOWN',
50
52
  status: 'COMPLETED',
53
+ securityResult: securityResult || null,
51
54
  });
52
55
  } catch (error) {
53
56
  console.error('Failed to track upload:', error);
@@ -25,6 +25,11 @@ export type IFile = {
25
25
  * Note: This is optional, if not provided, the size of the buffer is used
26
26
  */
27
27
  fileSize?: number;
28
+
29
+ /**
30
+ * Optional file security result collected before the file is tracked.
31
+ */
32
+ securityResult?: Record<string, unknown>;
28
33
  };
29
34
 
30
35
  /**
@@ -2,7 +2,7 @@ import { $getTableName } from '@/src/database/$getTableName';
2
2
  import { $provideSupabaseForServer } from '@/src/database/$provideSupabaseForServer';
3
3
  import type { Json } from '@/src/database/schema';
4
4
  import { FILE_SECURITY_CHECKERS } from '@/src/file-security-checkers';
5
- import { $provideUntrackedCdnForServer } from '@/src/tools/$provideCdnForServer';
5
+ import { $provideCdnForServer } from '@/src/tools/$provideCdnForServer';
6
6
  import { $provideServer } from '@/src/tools/$provideServer';
7
7
  import { getUserFileCdnKey } from '@/src/utils/cdn/utils/getUserFileCdnKey';
8
8
  import { validateMimeType } from '@/src/utils/validators/validateMimeType';
@@ -231,12 +231,14 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
231
231
 
232
232
  const mimeType = resolveShareTargetMimeType(file.type);
233
233
  const blobPath = getUserFileCdnKey(buffer, normalizedFilename);
234
- const cdn = $provideUntrackedCdnForServer();
234
+ const cdn = $provideCdnForServer();
235
+ const storageUrl = cdn.getItemUrl(blobPath).href;
236
+
235
237
  await cdn.setItem(blobPath, {
236
- data: buffer,
237
238
  type: mimeType,
238
- fileSize: buffer.byteLength,
239
+ data: buffer,
239
240
  purpose: SHARE_TARGET_FILE_PURPOSE,
241
+ fileSize: buffer.byteLength,
240
242
  }).catch((error) => {
241
243
  throw new DatabaseError(
242
244
  spaceTrim(`
@@ -246,24 +248,14 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
246
248
  `),
247
249
  );
248
250
  });
249
- const storageUrl = cdn.getItemUrl(blobPath).href;
250
- const fileRecordId = await insertShareTargetFileRecord({
251
- fileName: normalizedFilename,
252
- fileSize: buffer.byteLength,
253
- fileType: mimeType,
254
- storageUrl,
255
- });
256
251
 
257
- if (fileRecordId !== null) {
258
- after(() =>
259
- populateShareTargetFileSecurityResult({
260
- fileId: fileRecordId,
261
- storageUrl,
262
- }).catch((error) => {
263
- console.error('[share-target] Failed to finalize file security result', error);
264
- }),
265
- );
266
- }
252
+ after(() =>
253
+ populateShareTargetFileSecurityResult({
254
+ storageUrl,
255
+ }).catch((error) => {
256
+ console.error('[share-target] Failed to finalize file security result', error);
257
+ }),
258
+ );
267
259
 
268
260
  return {
269
261
  name: normalizedFilename,
@@ -272,50 +264,10 @@ async function createShareTargetAttachment(file: File, maxFileUploadBytes: numbe
272
264
  };
273
265
  }
274
266
 
275
- /**
276
- * Inserts one admin-visible file row for a shared attachment.
277
- */
278
- async function insertShareTargetFileRecord(options: {
279
- fileName: string;
280
- fileSize: number;
281
- fileType: string;
282
- storageUrl: string;
283
- }): Promise<number | null> {
284
- try {
285
- const supabase = $provideSupabaseForServer() as TODO_any;
286
- const fileTableName = await $getTableName('File');
287
- const { data, error } = await supabase
288
- .from(fileTableName)
289
- .insert({
290
- fileName: options.fileName,
291
- fileSize: options.fileSize,
292
- fileType: options.fileType,
293
- storageUrl: options.storageUrl,
294
- shortUrl: null,
295
- purpose: SHARE_TARGET_FILE_PURPOSE,
296
- status: 'COMPLETED',
297
- agentId: null,
298
- securityResult: null,
299
- })
300
- .select('id')
301
- .maybeSingle();
302
-
303
- if (error) {
304
- console.error('[share-target] Failed to store uploaded file metadata', error);
305
- return null;
306
- }
307
-
308
- return typeof data?.id === 'number' ? data.id : null;
309
- } catch (error) {
310
- console.error('[share-target] Failed to insert shared file metadata', error);
311
- return null;
312
- }
313
- }
314
-
315
267
  /**
316
268
  * Populates best-effort file-security results after the share redirect has already returned.
317
269
  */
318
- async function populateShareTargetFileSecurityResult(options: { fileId: number; storageUrl: string }): Promise<void> {
270
+ async function populateShareTargetFileSecurityResult(options: { storageUrl: string }): Promise<void> {
319
271
  const securityResult: Record<string, unknown> = {};
320
272
 
321
273
  for (const checkerId of Object.keys(FILE_SECURITY_CHECKERS)) {
@@ -343,7 +295,7 @@ async function populateShareTargetFileSecurityResult(options: { fileId: number;
343
295
  .update({
344
296
  securityResult,
345
297
  })
346
- .eq('id', options.fileId);
298
+ .eq('storageUrl', options.storageUrl);
347
299
 
348
300
  if (error) {
349
301
  throw new DatabaseError(
@@ -1,23 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { upload } from '@vercel/blob/client';
4
- import type { number_percent, string_knowledge_source_content } from '@promptbook-local/types';
5
- import { isServerRoutedCdnUploadProvider, resolveCdnStorageProvider } from '../cdn/resolveCdnStorageProvider';
3
+ import type { string_knowledge_source_content } from '@promptbook-local/types';
6
4
  import { getSafeCdnPath } from '../cdn/utils/getSafeCdnPath';
7
5
  import { normalizeUploadFilename } from '../normalization/normalizeUploadFilename';
6
+ import {
7
+ buildDefaultUserFileUploadPath,
8
+ uploadFileToServer,
9
+ type FileUploadProgressCallback,
10
+ } from './uploadFileToServer';
8
11
 
9
12
  /**
10
13
  * Progress callback invoked during file uploads.
11
14
  *
12
15
  * @private used by chat and book editors.
13
16
  */
14
- export type FileUploadProgressCallback = (
15
- progress: number_percent,
16
- stats?: {
17
- loadedBytes: number;
18
- totalBytes: number;
19
- },
20
- ) => void;
17
+ export type { FileUploadProgressCallback };
21
18
 
22
19
  /**
23
20
  * Optional upload configuration such as progress reporting or cancellation.
@@ -56,8 +53,8 @@ type UploadPathBuilder = (normalizedFilename: string, pathPrefix: string) => str
56
53
  /**
57
54
  * Constant for build default user file path.
58
55
  */
59
- const buildDefaultUserFilePath: UploadPathBuilder = (normalizedFilename, pathPrefix) =>
60
- pathPrefix ? `${pathPrefix}/user/files/${normalizedFilename}` : `user/files/${normalizedFilename}`;
56
+ const buildDefaultUserFilePath: UploadPathBuilder = (normalizedFilename) =>
57
+ buildDefaultUserFileUploadPath(normalizedFilename);
61
58
 
62
59
  /**
63
60
  * Configuration of the shared upload handler.
@@ -76,109 +73,6 @@ type SharedUploadHandlerConfig = {
76
73
  */
77
74
  const DEFAULT_SHORT_URL_PREFIX = 'https://ptbk.io/k/';
78
75
 
79
- /**
80
- * Response returned by the server-routed upload API.
81
- *
82
- * @private used by chat and book editors.
83
- */
84
- type ServerRoutedUploadResponse = {
85
- url: string;
86
- };
87
-
88
- /**
89
- * Uploads a file through `/api/upload` for S3-compatible storage providers.
90
- *
91
- * @private used by chat and book editors.
92
- */
93
- function uploadFileThroughServer(options: {
94
- safeUploadPath: string;
95
- file: File;
96
- purpose: string;
97
- abortSignal?: AbortSignal;
98
- onProgress?: FileUploadProgressCallback;
99
- }): Promise<ServerRoutedUploadResponse> {
100
- const { safeUploadPath, file, purpose, abortSignal, onProgress } = options;
101
- const formData = new FormData();
102
- formData.set('file', file);
103
- formData.set('pathname', safeUploadPath);
104
- formData.set('purpose', purpose);
105
- formData.set('contentType', file.type || 'application/octet-stream');
106
-
107
- return new Promise((resolve, reject) => {
108
- const request = new XMLHttpRequest();
109
-
110
- /**
111
- * Removes abort listeners once the upload finishes.
112
- */
113
- const cleanup = () => {
114
- abortSignal?.removeEventListener('abort', handleAbort);
115
- };
116
-
117
- /**
118
- * Aborts the active XHR upload when the caller aborts.
119
- */
120
- const handleAbort = () => {
121
- request.abort();
122
- cleanup();
123
- reject(new DOMException('Upload aborted.', 'AbortError'));
124
- };
125
-
126
- request.upload.addEventListener('progress', (event) => {
127
- if (!event.lengthComputable) {
128
- return;
129
- }
130
-
131
- onProgress?.((event.loaded / event.total) as number_percent, {
132
- loadedBytes: event.loaded,
133
- totalBytes: event.total,
134
- });
135
- });
136
-
137
- request.addEventListener('load', () => {
138
- cleanup();
139
-
140
- if (request.status < 200 || request.status >= 300) {
141
- reject(new Error(`Upload failed with HTTP ${request.status}: ${request.responseText}`));
142
- return;
143
- }
144
-
145
- try {
146
- const parsedResponse = JSON.parse(request.responseText) as Partial<ServerRoutedUploadResponse>;
147
- if (typeof parsedResponse.url !== 'string' || parsedResponse.url.trim() === '') {
148
- reject(new Error('Upload response did not contain a file URL.'));
149
- return;
150
- }
151
-
152
- onProgress?.(1 as number_percent, {
153
- loadedBytes: file.size,
154
- totalBytes: file.size,
155
- });
156
- resolve({ url: parsedResponse.url });
157
- } catch (error) {
158
- reject(error);
159
- }
160
- });
161
-
162
- request.addEventListener('error', () => {
163
- cleanup();
164
- reject(new Error('Upload failed before the server responded.'));
165
- });
166
-
167
- request.addEventListener('abort', () => {
168
- cleanup();
169
- });
170
-
171
- if (abortSignal?.aborted) {
172
- handleAbort();
173
- return;
174
- }
175
-
176
- abortSignal?.addEventListener('abort', handleAbort);
177
- request.open('POST', '/api/upload');
178
- request.send(formData);
179
- });
180
- }
181
-
182
76
  /**
183
77
  * Upload handler that normalizes the filename, uploads via `/api/upload`, and optionally returns a short URL.
184
78
  *
@@ -196,50 +90,29 @@ export function createFileUploadHandler<ReturnType extends string = string>(
196
90
 
197
91
  return async (file, optionsOrOnProgress) => {
198
92
  const { onProgress, abortSignal } = normalizeUploadOptions(optionsOrOnProgress);
199
- const provider = resolveCdnStorageProvider();
200
- const isServerRoutedUploadEnabled = isServerRoutedCdnUploadProvider(provider);
201
93
  const pathPrefix = process.env.NEXT_PUBLIC_CDN_PATH_PREFIX || '';
202
94
  const normalizedFilename = normalizeUploadFilename(file.name);
203
- const publicUploadPath = pathBuilder(normalizedFilename, pathPrefix);
204
- const storageUploadPath = pathBuilder(normalizedFilename, isServerRoutedUploadEnabled ? '' : pathPrefix);
205
- const safeUploadPath = getSafeCdnPath({
206
- pathname: storageUploadPath,
207
- pathPrefix: isServerRoutedUploadEnabled ? pathPrefix : undefined,
95
+ const uploadPath = pathBuilder(normalizedFilename, pathPrefix);
96
+ const safeUploadPath = getSafeCdnPath({ pathname: uploadPath, pathPrefix });
97
+
98
+ const uploadResult = await uploadFileToServer({
99
+ file,
100
+ pathname: safeUploadPath,
101
+ purpose,
102
+ contentType: file.type || 'application/octet-stream',
103
+ abortSignal,
104
+ onProgress,
208
105
  });
209
106
 
210
- const blob = isServerRoutedUploadEnabled
211
- ? await uploadFileThroughServer({
212
- safeUploadPath,
213
- file,
214
- purpose,
215
- abortSignal,
216
- onProgress,
217
- })
218
- : await upload(safeUploadPath, file, {
219
- access: 'public',
220
- handleUploadUrl: '/api/upload',
221
- clientPayload: JSON.stringify({
222
- purpose,
223
- contentType: file.type || 'application/octet-stream',
224
- }),
225
- abortSignal,
226
- onUploadProgress: (progressEvent) => {
227
- onProgress?.(progressEvent.percentage / 100, {
228
- loadedBytes: progressEvent.loaded,
229
- totalBytes: progressEvent.total,
230
- });
231
- },
232
- });
233
-
234
107
  if (returnShortUrl && process.env.NEXT_PUBLIC_CDN_PUBLIC_URL) {
235
- const slashIndex = publicUploadPath.lastIndexOf('/');
236
- const directoryPath = slashIndex === -1 ? '' : `${publicUploadPath.slice(0, slashIndex + 1)}`;
108
+ const slashIndex = uploadPath.lastIndexOf('/');
109
+ const directoryPath = slashIndex === -1 ? '' : `${uploadPath.slice(0, slashIndex + 1)}`;
237
110
  const longUrlPrefix = `${process.env.NEXT_PUBLIC_CDN_PUBLIC_URL}/${directoryPath}`;
238
- const shortUrl = blob.url.split(longUrlPrefix).join(shortUrlPrefix);
111
+ const shortUrl = uploadResult.url.split(longUrlPrefix).join(shortUrlPrefix);
239
112
  return shortUrl as ReturnType;
240
113
  }
241
114
 
242
- return blob.url as ReturnType;
115
+ return uploadResult.url as ReturnType;
243
116
  };
244
117
  }
245
118