@palettelab/sdk 0.1.14 → 0.1.15

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.
package/README.md CHANGED
@@ -166,7 +166,7 @@ Public frontend helpers exported by `@palettelab/sdk`:
166
166
  - API: `apiFetch(path, init?)`, `apiUpload(path, file, fieldName?, extraFields?)`, `setBaseUrl(url)`, `getBaseUrl()`.
167
167
  - Errors: `PaletteApiError`, `errorFromResponse(response)`, `isPaletteApiError(error)`.
168
168
  - Data Rooms: `DataRoomClient`, `dataRooms`, plus `list`, `create`, `get`, `folder`, `ensureRoom`, `requireRoomByName`, `findRoomByName`, `createFolder`, `ensureFolder`, `findFolderByName`, `resolveFolderPath`, `findFileByName`, `requestUpload`, `confirmUpload`, and `uploadFile`.
169
- - Storage: `StorageClient`, `uploadToSignedUrl(uploadUrl, file, contentType?)`.
169
+ - Storage: `StorageClient`, `upload(file, options)`, `resume(file, options)`, and `uploadToSignedUrl(uploadUrl, file, contentType?)`.
170
170
  - Install config: `getInstallConfig(pluginId)`, `updateInstallConfig(pluginId, values)`.
171
171
  - Organization/user: `UserClient`, `OrganizationClient`, including `current`, `updateProfile`, `listMine`, `listMembers`, `getMember`, `getMemberByEmail`, `inviteMember`, and `updateMemberRole`.
172
172
  - Permissions: `hasPermission(ctx, permission)`, `hasAnyPermission(ctx, permissions)`, `hasAllPermissions(ctx, permissions)`.
@@ -286,12 +286,30 @@ Included clients:
286
286
  - `palette.dataRooms.list()`, `create()`, `get()`, `folder()`, `createFolder()`, `ensureFolder()`, `findRoomByName()`, `findFolderByName()`, `resolveFolderPath()`, `findFileByName()`, and `uploadFile()`
287
287
  - `palette.config.get()` and `palette.config.update(values)`, with optional plugin ID override
288
288
  - `palette.permissions.has()`, `hasAny()`, and `hasAll()`
289
- - `palette.storage.uploadToSignedUrl()`
289
+ - `palette.storage.upload(file, options)`, `resume(file, options)`, and `uploadToSignedUrl()`
290
290
  - `palette.toast.success()`, `error()`, and `info()`
291
291
 
292
292
  These helpers are intentionally thin wrappers over platform APIs. Apps can still
293
293
  use `apiFetch()` directly for custom backend routes.
294
294
 
295
+ App storage uploads are scoped by Palette to:
296
+
297
+ ```text
298
+ uploads/apps/{app_name}_{plugin_id}/{organisation_slug}_{organisation_id}/{file}
299
+ ```
300
+
301
+ Declare `"storage"` in `platform_services`, then upload directly from the
302
+ browser with resumable chunking and progress:
303
+
304
+ ```tsx
305
+ const palette = createPaletteClient(usePlatform())
306
+
307
+ await palette.storage.upload(file, {
308
+ key: `receipts/${file.name}`,
309
+ onProgress: (p) => setPercent(p.percentage),
310
+ })
311
+ ```
312
+
295
313
  Member helpers operate on the active organisation. Use `members:read` for
296
314
  listing or looking up members, and `members:write` for invitations or role
297
315
  updates. When the runtime provides declared app permissions, these helpers check
package/dist/index.d.mts CHANGED
@@ -107,8 +107,41 @@ declare class DataRoomClient {
107
107
  }
108
108
  declare const dataRooms: DataRoomClient;
109
109
 
110
+ type StorageUploadState = "starting" | "uploading" | "paused" | "complete";
111
+ type StorageUploadProgress = {
112
+ loaded: number;
113
+ total: number;
114
+ percentage: number;
115
+ chunkIndex: number;
116
+ chunkCount: number;
117
+ state: StorageUploadState;
118
+ };
119
+ type StorageUploadOptions = {
120
+ pluginId?: string;
121
+ key?: string;
122
+ contentType?: string;
123
+ chunkSize?: number;
124
+ resumable?: boolean;
125
+ onProgress?: (progress: StorageUploadProgress) => void;
126
+ signal?: AbortSignal;
127
+ };
128
+ type StorageUploadResult = {
129
+ uploadId: string;
130
+ mode: "gcs_resumable" | "local_resumable" | string;
131
+ bucket: string;
132
+ objectPath: string;
133
+ fileUrl: string;
134
+ contentType: string;
135
+ size: number;
136
+ };
110
137
  declare function uploadToSignedUrl(uploadUrl: string, file: Blob, contentType?: string): Promise<void>;
111
138
  declare class StorageClient {
139
+ private readonly ctx?;
140
+ constructor(ctx?: PlatformContext | undefined);
141
+ private pluginId;
142
+ private createSession;
143
+ upload(file: File, options?: StorageUploadOptions): Promise<StorageUploadResult>;
144
+ resume(file: File, options?: StorageUploadOptions): Promise<StorageUploadResult>;
112
145
  uploadToSignedUrl: typeof uploadToSignedUrl;
113
146
  }
114
147
 
@@ -179,4 +212,4 @@ declare function hasPermission(ctx: PlatformContext, permission: string): boolea
179
212
  declare function hasAnyPermission(ctx: PlatformContext, permissions: string[]): boolean;
180
213
  declare function hasAllPermissions(ctx: PlatformContext, permissions: string[]): boolean;
181
214
 
182
- export { DataRoom, DataRoomClient, type DataRoomContents, DataRoomFile, DataRoomFolder, type DataRoomUploadOptions, type InstallConfig, type OrgInviteMemberInput, type OrgInviteMemberResponse, type OrgMember, type OrgMemberRole, OrgSummary, OrganizationClient, PaletteApiError, type PaletteClient, PlatformContext, type SandboxBridge, StorageClient, User, UserClient, apiFetch, apiUpload, createMockPlatformContext, createPaletteClient, createSandboxBridge, dataRooms, errorFromResponse, getBaseUrl, getInstallConfig, hasAllPermissions, hasAnyPermission, hasPermission, isPaletteApiError, isSandboxRuntime, setBaseUrl, updateInstallConfig, uploadToSignedUrl, withPluginProvider };
215
+ export { DataRoom, DataRoomClient, type DataRoomContents, DataRoomFile, DataRoomFolder, type DataRoomUploadOptions, type InstallConfig, type OrgInviteMemberInput, type OrgInviteMemberResponse, type OrgMember, type OrgMemberRole, OrgSummary, OrganizationClient, PaletteApiError, type PaletteClient, PlatformContext, type SandboxBridge, StorageClient, type StorageUploadOptions, type StorageUploadProgress, type StorageUploadResult, type StorageUploadState, User, UserClient, apiFetch, apiUpload, createMockPlatformContext, createPaletteClient, createSandboxBridge, dataRooms, errorFromResponse, getBaseUrl, getInstallConfig, hasAllPermissions, hasAnyPermission, hasPermission, isPaletteApiError, isSandboxRuntime, setBaseUrl, updateInstallConfig, uploadToSignedUrl, withPluginProvider };
package/dist/index.d.ts CHANGED
@@ -107,8 +107,41 @@ declare class DataRoomClient {
107
107
  }
108
108
  declare const dataRooms: DataRoomClient;
109
109
 
110
+ type StorageUploadState = "starting" | "uploading" | "paused" | "complete";
111
+ type StorageUploadProgress = {
112
+ loaded: number;
113
+ total: number;
114
+ percentage: number;
115
+ chunkIndex: number;
116
+ chunkCount: number;
117
+ state: StorageUploadState;
118
+ };
119
+ type StorageUploadOptions = {
120
+ pluginId?: string;
121
+ key?: string;
122
+ contentType?: string;
123
+ chunkSize?: number;
124
+ resumable?: boolean;
125
+ onProgress?: (progress: StorageUploadProgress) => void;
126
+ signal?: AbortSignal;
127
+ };
128
+ type StorageUploadResult = {
129
+ uploadId: string;
130
+ mode: "gcs_resumable" | "local_resumable" | string;
131
+ bucket: string;
132
+ objectPath: string;
133
+ fileUrl: string;
134
+ contentType: string;
135
+ size: number;
136
+ };
110
137
  declare function uploadToSignedUrl(uploadUrl: string, file: Blob, contentType?: string): Promise<void>;
111
138
  declare class StorageClient {
139
+ private readonly ctx?;
140
+ constructor(ctx?: PlatformContext | undefined);
141
+ private pluginId;
142
+ private createSession;
143
+ upload(file: File, options?: StorageUploadOptions): Promise<StorageUploadResult>;
144
+ resume(file: File, options?: StorageUploadOptions): Promise<StorageUploadResult>;
112
145
  uploadToSignedUrl: typeof uploadToSignedUrl;
113
146
  }
114
147
 
@@ -179,4 +212,4 @@ declare function hasPermission(ctx: PlatformContext, permission: string): boolea
179
212
  declare function hasAnyPermission(ctx: PlatformContext, permissions: string[]): boolean;
180
213
  declare function hasAllPermissions(ctx: PlatformContext, permissions: string[]): boolean;
181
214
 
182
- export { DataRoom, DataRoomClient, type DataRoomContents, DataRoomFile, DataRoomFolder, type DataRoomUploadOptions, type InstallConfig, type OrgInviteMemberInput, type OrgInviteMemberResponse, type OrgMember, type OrgMemberRole, OrgSummary, OrganizationClient, PaletteApiError, type PaletteClient, PlatformContext, type SandboxBridge, StorageClient, User, UserClient, apiFetch, apiUpload, createMockPlatformContext, createPaletteClient, createSandboxBridge, dataRooms, errorFromResponse, getBaseUrl, getInstallConfig, hasAllPermissions, hasAnyPermission, hasPermission, isPaletteApiError, isSandboxRuntime, setBaseUrl, updateInstallConfig, uploadToSignedUrl, withPluginProvider };
215
+ export { DataRoom, DataRoomClient, type DataRoomContents, DataRoomFile, DataRoomFolder, type DataRoomUploadOptions, type InstallConfig, type OrgInviteMemberInput, type OrgInviteMemberResponse, type OrgMember, type OrgMemberRole, OrgSummary, OrganizationClient, PaletteApiError, type PaletteClient, PlatformContext, type SandboxBridge, StorageClient, type StorageUploadOptions, type StorageUploadProgress, type StorageUploadResult, type StorageUploadState, User, UserClient, apiFetch, apiUpload, createMockPlatformContext, createPaletteClient, createSandboxBridge, dataRooms, errorFromResponse, getBaseUrl, getInstallConfig, hasAllPermissions, hasAnyPermission, hasPermission, isPaletteApiError, isSandboxRuntime, setBaseUrl, updateInstallConfig, uploadToSignedUrl, withPluginProvider };
package/dist/index.js CHANGED
@@ -359,6 +359,8 @@ function hasAllPermissions(ctx, permissions) {
359
359
  }
360
360
 
361
361
  // src/storage.ts
362
+ var DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024;
363
+ var MIN_CHUNK_SIZE = 256 * 1024;
362
364
  async function uploadToSignedUrl(uploadUrl, file, contentType = "application/octet-stream") {
363
365
  const res = await fetch(uploadUrl, {
364
366
  method: "PUT",
@@ -367,10 +369,178 @@ async function uploadToSignedUrl(uploadUrl, file, contentType = "application/oct
367
369
  });
368
370
  if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`);
369
371
  }
372
+ function normalizeChunkSize(value) {
373
+ const raw = Math.max(value || DEFAULT_CHUNK_SIZE, MIN_CHUNK_SIZE);
374
+ return Math.ceil(raw / MIN_CHUNK_SIZE) * MIN_CHUNK_SIZE;
375
+ }
376
+ function defaultPluginId() {
377
+ if (typeof window === "undefined") return "";
378
+ const match = window.location.pathname.match(/\/apps\/([^/?#]+)/);
379
+ return match ? decodeURIComponent(match[1]) : "";
380
+ }
381
+ function fileFingerprint(pluginId, file, key) {
382
+ return [pluginId, key || file.name, file.size, file.lastModified].join(":");
383
+ }
384
+ function sessionStorageKey(pluginId, fingerprint) {
385
+ return `palette:storage-upload:${pluginId}:${fingerprint}`;
386
+ }
387
+ function readStoredSession(pluginId, fingerprint) {
388
+ if (typeof window === "undefined") return null;
389
+ try {
390
+ const raw = window.localStorage.getItem(sessionStorageKey(pluginId, fingerprint));
391
+ if (!raw) return null;
392
+ const session = JSON.parse(raw);
393
+ return session.plugin_id === pluginId && session.fingerprint === fingerprint ? session : null;
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+ function writeStoredSession(pluginId, fingerprint, session) {
399
+ if (typeof window === "undefined") return;
400
+ const stored = { ...session, plugin_id: pluginId, fingerprint, updated_at: Date.now() };
401
+ window.localStorage.setItem(sessionStorageKey(pluginId, fingerprint), JSON.stringify(stored));
402
+ }
403
+ function clearStoredSession(pluginId, fingerprint) {
404
+ if (typeof window === "undefined") return;
405
+ window.localStorage.removeItem(sessionStorageKey(pluginId, fingerprint));
406
+ }
407
+ function absoluteApiUrl(pathOrUrl) {
408
+ return /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${getBaseUrl()}${pathOrUrl}`;
409
+ }
410
+ function isPaletteApiUpload(url) {
411
+ return url.startsWith(getBaseUrl()) || url.startsWith("/");
412
+ }
413
+ function report(loaded, total, chunkIndex, chunkCount, state, onProgress) {
414
+ onProgress?.({
415
+ loaded,
416
+ total,
417
+ percentage: total > 0 ? Math.min(100, Math.round(loaded / total * 1e4) / 100) : 100,
418
+ chunkIndex,
419
+ chunkCount,
420
+ state
421
+ });
422
+ }
423
+ function xhrPut(url, body, headers, signal, onUploadProgress) {
424
+ return new Promise((resolve, reject) => {
425
+ const xhr = new XMLHttpRequest();
426
+ xhr.open("PUT", absoluteApiUrl(url), true);
427
+ xhr.withCredentials = isPaletteApiUpload(url);
428
+ for (const [key, value] of Object.entries(headers)) xhr.setRequestHeader(key, value);
429
+ xhr.upload.onprogress = (event) => {
430
+ if (event.lengthComputable) onUploadProgress?.(event.loaded);
431
+ };
432
+ xhr.onload = () => {
433
+ const ok = [200, 201, 204, 308].includes(xhr.status);
434
+ if (!ok) reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
435
+ else resolve({ status: xhr.status, range: xhr.getResponseHeader("Range") });
436
+ };
437
+ xhr.onerror = () => reject(new Error("Upload failed"));
438
+ xhr.onabort = () => reject(new DOMException("Upload aborted", "AbortError"));
439
+ if (signal) {
440
+ if (signal.aborted) {
441
+ xhr.abort();
442
+ return;
443
+ }
444
+ signal.addEventListener("abort", () => xhr.abort(), { once: true });
445
+ }
446
+ xhr.send(body);
447
+ });
448
+ }
449
+ function uploadedFromRange(range) {
450
+ if (!range) return 0;
451
+ const match = range.match(/bytes=0-(\d+)/);
452
+ return match ? Number(match[1]) + 1 : 0;
453
+ }
454
+ async function queryGcsOffset(session) {
455
+ try {
456
+ const result = await xhrPut(session.upload_url, new Blob([]), {
457
+ "Content-Range": `bytes */${session.size}`
458
+ });
459
+ if ([200, 201, 204].includes(result.status)) return session.size;
460
+ return uploadedFromRange(result.range);
461
+ } catch {
462
+ return 0;
463
+ }
464
+ }
465
+ async function queryLocalOffset(session) {
466
+ const res = await fetch(absoluteApiUrl(session.status_url), { credentials: "include" });
467
+ if (!res.ok) return 0;
468
+ const body = await res.json();
469
+ return Number(body.uploaded_bytes || 0);
470
+ }
471
+ async function queryOffset(session) {
472
+ if (session.mode === "local_resumable") return queryLocalOffset(session);
473
+ return queryGcsOffset(session);
474
+ }
370
475
  var StorageClient = class {
371
- constructor() {
476
+ constructor(ctx) {
477
+ this.ctx = ctx;
372
478
  this.uploadToSignedUrl = uploadToSignedUrl;
373
479
  }
480
+ pluginId(explicit) {
481
+ const resolved = explicit || this.ctx?.pluginId || defaultPluginId();
482
+ if (!resolved) throw new Error("pluginId is required for app storage uploads");
483
+ return resolved;
484
+ }
485
+ async createSession(pluginId, file, options) {
486
+ const res = await (this.ctx?.apiFetch || apiFetch)(`/api/v1/app-storage/${encodeURIComponent(pluginId)}/uploads`, {
487
+ method: "POST",
488
+ body: JSON.stringify({
489
+ filename: file.name,
490
+ content_type: options.contentType || file.type || "application/octet-stream",
491
+ size: file.size,
492
+ key: options.key || null,
493
+ resumable: options.resumable ?? true,
494
+ chunk_size: normalizeChunkSize(options.chunkSize)
495
+ })
496
+ });
497
+ return res.json();
498
+ }
499
+ async upload(file, options = {}) {
500
+ const pluginId = this.pluginId(options.pluginId);
501
+ const fingerprint = fileFingerprint(pluginId, file, options.key);
502
+ const stored = options.resumable === false ? null : readStoredSession(pluginId, fingerprint);
503
+ const session = stored || await this.createSession(pluginId, file, options);
504
+ writeStoredSession(pluginId, fingerprint, session);
505
+ const chunkSize = normalizeChunkSize(options.chunkSize || session.chunk_size);
506
+ const chunkCount = Math.max(1, Math.ceil(file.size / chunkSize));
507
+ let offset = stored ? await queryOffset(session) : 0;
508
+ report(offset, file.size, Math.floor(offset / chunkSize), chunkCount, "starting", options.onProgress);
509
+ while (offset < file.size) {
510
+ if (options.signal?.aborted) throw new DOMException("Upload aborted", "AbortError");
511
+ const start = offset;
512
+ const end = Math.min(file.size, start + chunkSize) - 1;
513
+ const chunk = file.slice(start, end + 1);
514
+ const chunkIndex = Math.floor(start / chunkSize) + 1;
515
+ const lastReportedBase = start;
516
+ await xhrPut(
517
+ session.upload_url,
518
+ chunk,
519
+ {
520
+ "Content-Type": session.content_type,
521
+ "Content-Range": `bytes ${start}-${end}/${file.size}`
522
+ },
523
+ options.signal,
524
+ (loaded) => report(lastReportedBase + loaded, file.size, chunkIndex, chunkCount, "uploading", options.onProgress)
525
+ );
526
+ offset = end + 1;
527
+ report(offset, file.size, chunkIndex, chunkCount, offset >= file.size ? "complete" : "uploading", options.onProgress);
528
+ writeStoredSession(pluginId, fingerprint, session);
529
+ }
530
+ clearStoredSession(pluginId, fingerprint);
531
+ return {
532
+ uploadId: session.upload_id,
533
+ mode: session.mode,
534
+ bucket: session.bucket,
535
+ objectPath: session.object_path,
536
+ fileUrl: session.file_url,
537
+ contentType: session.content_type,
538
+ size: session.size
539
+ };
540
+ }
541
+ async resume(file, options = {}) {
542
+ return this.upload(file, { ...options, resumable: true });
543
+ }
374
544
  };
375
545
 
376
546
  // src/user-org.ts
@@ -448,7 +618,7 @@ var OrganizationClient = class {
448
618
 
449
619
  // src/palette-client.ts
450
620
  function createPaletteClient(ctx) {
451
- const defaultPluginId = () => {
621
+ const defaultPluginId2 = () => {
452
622
  if (ctx?.pluginId) return ctx.pluginId;
453
623
  if (typeof window === "undefined") return "";
454
624
  const match = window.location.pathname.match(/\/apps\/([^/?#]+)/);
@@ -458,15 +628,15 @@ function createPaletteClient(ctx) {
458
628
  user: new UserClient(ctx),
459
629
  organization: new OrganizationClient(ctx),
460
630
  dataRooms: new DataRoomClient(),
461
- storage: new StorageClient(),
631
+ storage: new StorageClient(ctx),
462
632
  config: {
463
633
  get: (pluginId = ctx?.pluginId ?? "") => {
464
- const resolved = pluginId || defaultPluginId();
634
+ const resolved = pluginId || defaultPluginId2();
465
635
  if (!resolved) throw new Error("pluginId is required to read install config");
466
636
  return getInstallConfig(resolved);
467
637
  },
468
638
  update: (values, pluginId = ctx?.pluginId ?? "") => {
469
- const resolved = pluginId || defaultPluginId();
639
+ const resolved = pluginId || defaultPluginId2();
470
640
  if (!resolved) throw new Error("pluginId is required to update install config");
471
641
  return updateInstallConfig(resolved, values);
472
642
  }
package/dist/index.mjs CHANGED
@@ -302,6 +302,8 @@ function hasAllPermissions(ctx, permissions) {
302
302
  }
303
303
 
304
304
  // src/storage.ts
305
+ var DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024;
306
+ var MIN_CHUNK_SIZE = 256 * 1024;
305
307
  async function uploadToSignedUrl(uploadUrl, file, contentType = "application/octet-stream") {
306
308
  const res = await fetch(uploadUrl, {
307
309
  method: "PUT",
@@ -310,10 +312,178 @@ async function uploadToSignedUrl(uploadUrl, file, contentType = "application/oct
310
312
  });
311
313
  if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`);
312
314
  }
315
+ function normalizeChunkSize(value) {
316
+ const raw = Math.max(value || DEFAULT_CHUNK_SIZE, MIN_CHUNK_SIZE);
317
+ return Math.ceil(raw / MIN_CHUNK_SIZE) * MIN_CHUNK_SIZE;
318
+ }
319
+ function defaultPluginId() {
320
+ if (typeof window === "undefined") return "";
321
+ const match = window.location.pathname.match(/\/apps\/([^/?#]+)/);
322
+ return match ? decodeURIComponent(match[1]) : "";
323
+ }
324
+ function fileFingerprint(pluginId, file, key) {
325
+ return [pluginId, key || file.name, file.size, file.lastModified].join(":");
326
+ }
327
+ function sessionStorageKey(pluginId, fingerprint) {
328
+ return `palette:storage-upload:${pluginId}:${fingerprint}`;
329
+ }
330
+ function readStoredSession(pluginId, fingerprint) {
331
+ if (typeof window === "undefined") return null;
332
+ try {
333
+ const raw = window.localStorage.getItem(sessionStorageKey(pluginId, fingerprint));
334
+ if (!raw) return null;
335
+ const session = JSON.parse(raw);
336
+ return session.plugin_id === pluginId && session.fingerprint === fingerprint ? session : null;
337
+ } catch {
338
+ return null;
339
+ }
340
+ }
341
+ function writeStoredSession(pluginId, fingerprint, session) {
342
+ if (typeof window === "undefined") return;
343
+ const stored = { ...session, plugin_id: pluginId, fingerprint, updated_at: Date.now() };
344
+ window.localStorage.setItem(sessionStorageKey(pluginId, fingerprint), JSON.stringify(stored));
345
+ }
346
+ function clearStoredSession(pluginId, fingerprint) {
347
+ if (typeof window === "undefined") return;
348
+ window.localStorage.removeItem(sessionStorageKey(pluginId, fingerprint));
349
+ }
350
+ function absoluteApiUrl(pathOrUrl) {
351
+ return /^https?:\/\//i.test(pathOrUrl) ? pathOrUrl : `${getBaseUrl()}${pathOrUrl}`;
352
+ }
353
+ function isPaletteApiUpload(url) {
354
+ return url.startsWith(getBaseUrl()) || url.startsWith("/");
355
+ }
356
+ function report(loaded, total, chunkIndex, chunkCount, state, onProgress) {
357
+ onProgress?.({
358
+ loaded,
359
+ total,
360
+ percentage: total > 0 ? Math.min(100, Math.round(loaded / total * 1e4) / 100) : 100,
361
+ chunkIndex,
362
+ chunkCount,
363
+ state
364
+ });
365
+ }
366
+ function xhrPut(url, body, headers, signal, onUploadProgress) {
367
+ return new Promise((resolve, reject) => {
368
+ const xhr = new XMLHttpRequest();
369
+ xhr.open("PUT", absoluteApiUrl(url), true);
370
+ xhr.withCredentials = isPaletteApiUpload(url);
371
+ for (const [key, value] of Object.entries(headers)) xhr.setRequestHeader(key, value);
372
+ xhr.upload.onprogress = (event) => {
373
+ if (event.lengthComputable) onUploadProgress?.(event.loaded);
374
+ };
375
+ xhr.onload = () => {
376
+ const ok = [200, 201, 204, 308].includes(xhr.status);
377
+ if (!ok) reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
378
+ else resolve({ status: xhr.status, range: xhr.getResponseHeader("Range") });
379
+ };
380
+ xhr.onerror = () => reject(new Error("Upload failed"));
381
+ xhr.onabort = () => reject(new DOMException("Upload aborted", "AbortError"));
382
+ if (signal) {
383
+ if (signal.aborted) {
384
+ xhr.abort();
385
+ return;
386
+ }
387
+ signal.addEventListener("abort", () => xhr.abort(), { once: true });
388
+ }
389
+ xhr.send(body);
390
+ });
391
+ }
392
+ function uploadedFromRange(range) {
393
+ if (!range) return 0;
394
+ const match = range.match(/bytes=0-(\d+)/);
395
+ return match ? Number(match[1]) + 1 : 0;
396
+ }
397
+ async function queryGcsOffset(session) {
398
+ try {
399
+ const result = await xhrPut(session.upload_url, new Blob([]), {
400
+ "Content-Range": `bytes */${session.size}`
401
+ });
402
+ if ([200, 201, 204].includes(result.status)) return session.size;
403
+ return uploadedFromRange(result.range);
404
+ } catch {
405
+ return 0;
406
+ }
407
+ }
408
+ async function queryLocalOffset(session) {
409
+ const res = await fetch(absoluteApiUrl(session.status_url), { credentials: "include" });
410
+ if (!res.ok) return 0;
411
+ const body = await res.json();
412
+ return Number(body.uploaded_bytes || 0);
413
+ }
414
+ async function queryOffset(session) {
415
+ if (session.mode === "local_resumable") return queryLocalOffset(session);
416
+ return queryGcsOffset(session);
417
+ }
313
418
  var StorageClient = class {
314
- constructor() {
419
+ constructor(ctx) {
420
+ this.ctx = ctx;
315
421
  this.uploadToSignedUrl = uploadToSignedUrl;
316
422
  }
423
+ pluginId(explicit) {
424
+ const resolved = explicit || this.ctx?.pluginId || defaultPluginId();
425
+ if (!resolved) throw new Error("pluginId is required for app storage uploads");
426
+ return resolved;
427
+ }
428
+ async createSession(pluginId, file, options) {
429
+ const res = await (this.ctx?.apiFetch || apiFetch)(`/api/v1/app-storage/${encodeURIComponent(pluginId)}/uploads`, {
430
+ method: "POST",
431
+ body: JSON.stringify({
432
+ filename: file.name,
433
+ content_type: options.contentType || file.type || "application/octet-stream",
434
+ size: file.size,
435
+ key: options.key || null,
436
+ resumable: options.resumable ?? true,
437
+ chunk_size: normalizeChunkSize(options.chunkSize)
438
+ })
439
+ });
440
+ return res.json();
441
+ }
442
+ async upload(file, options = {}) {
443
+ const pluginId = this.pluginId(options.pluginId);
444
+ const fingerprint = fileFingerprint(pluginId, file, options.key);
445
+ const stored = options.resumable === false ? null : readStoredSession(pluginId, fingerprint);
446
+ const session = stored || await this.createSession(pluginId, file, options);
447
+ writeStoredSession(pluginId, fingerprint, session);
448
+ const chunkSize = normalizeChunkSize(options.chunkSize || session.chunk_size);
449
+ const chunkCount = Math.max(1, Math.ceil(file.size / chunkSize));
450
+ let offset = stored ? await queryOffset(session) : 0;
451
+ report(offset, file.size, Math.floor(offset / chunkSize), chunkCount, "starting", options.onProgress);
452
+ while (offset < file.size) {
453
+ if (options.signal?.aborted) throw new DOMException("Upload aborted", "AbortError");
454
+ const start = offset;
455
+ const end = Math.min(file.size, start + chunkSize) - 1;
456
+ const chunk = file.slice(start, end + 1);
457
+ const chunkIndex = Math.floor(start / chunkSize) + 1;
458
+ const lastReportedBase = start;
459
+ await xhrPut(
460
+ session.upload_url,
461
+ chunk,
462
+ {
463
+ "Content-Type": session.content_type,
464
+ "Content-Range": `bytes ${start}-${end}/${file.size}`
465
+ },
466
+ options.signal,
467
+ (loaded) => report(lastReportedBase + loaded, file.size, chunkIndex, chunkCount, "uploading", options.onProgress)
468
+ );
469
+ offset = end + 1;
470
+ report(offset, file.size, chunkIndex, chunkCount, offset >= file.size ? "complete" : "uploading", options.onProgress);
471
+ writeStoredSession(pluginId, fingerprint, session);
472
+ }
473
+ clearStoredSession(pluginId, fingerprint);
474
+ return {
475
+ uploadId: session.upload_id,
476
+ mode: session.mode,
477
+ bucket: session.bucket,
478
+ objectPath: session.object_path,
479
+ fileUrl: session.file_url,
480
+ contentType: session.content_type,
481
+ size: session.size
482
+ };
483
+ }
484
+ async resume(file, options = {}) {
485
+ return this.upload(file, { ...options, resumable: true });
486
+ }
317
487
  };
318
488
 
319
489
  // src/user-org.ts
@@ -391,7 +561,7 @@ var OrganizationClient = class {
391
561
 
392
562
  // src/palette-client.ts
393
563
  function createPaletteClient(ctx) {
394
- const defaultPluginId = () => {
564
+ const defaultPluginId2 = () => {
395
565
  if (ctx?.pluginId) return ctx.pluginId;
396
566
  if (typeof window === "undefined") return "";
397
567
  const match = window.location.pathname.match(/\/apps\/([^/?#]+)/);
@@ -401,15 +571,15 @@ function createPaletteClient(ctx) {
401
571
  user: new UserClient(ctx),
402
572
  organization: new OrganizationClient(ctx),
403
573
  dataRooms: new DataRoomClient(),
404
- storage: new StorageClient(),
574
+ storage: new StorageClient(ctx),
405
575
  config: {
406
576
  get: (pluginId = ctx?.pluginId ?? "") => {
407
- const resolved = pluginId || defaultPluginId();
577
+ const resolved = pluginId || defaultPluginId2();
408
578
  if (!resolved) throw new Error("pluginId is required to read install config");
409
579
  return getInstallConfig(resolved);
410
580
  },
411
581
  update: (values, pluginId = ctx?.pluginId ?? "") => {
412
- const resolved = pluginId || defaultPluginId();
582
+ const resolved = pluginId || defaultPluginId2();
413
583
  if (!resolved) throw new Error("pluginId is required to update install config");
414
584
  return updateInstallConfig(resolved, values);
415
585
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/sdk",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Palette Platform SDK for building plugins and apps",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",