@palettelab/sdk 0.1.13 → 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
@@ -122,15 +122,30 @@ import {
122
122
  PluginProvider,
123
123
  usePlatform,
124
124
  createPaletteClient,
125
+ DataRoomClient,
126
+ StorageClient,
125
127
  usePluginTranslations,
128
+ translate,
129
+ normalizePaletteLanguage,
126
130
  usePluginTasks,
127
131
  usePluginDataRooms,
128
132
  usePluginChat,
129
133
  apiFetch,
130
134
  apiUpload,
135
+ setBaseUrl,
136
+ getBaseUrl,
131
137
  createSandboxBridge,
138
+ isSandboxRuntime,
132
139
  getInstallConfig,
133
140
  updateInstallConfig,
141
+ hasPermission,
142
+ hasAnyPermission,
143
+ hasAllPermissions,
144
+ PaletteApiError,
145
+ errorFromResponse,
146
+ isPaletteApiError,
147
+ createMockPlatformContext,
148
+ withPluginProvider,
134
149
  } from "@palettelab/sdk"
135
150
  ```
136
151
 
@@ -142,6 +157,25 @@ import type { PluginManifest } from "@palettelab/sdk/types"
142
157
  import { PluginProvider } from "@palettelab/sdk/components"
143
158
  ```
144
159
 
160
+ ## Helper Reference
161
+
162
+ Public frontend helpers exported by `@palettelab/sdk`:
163
+
164
+ - Provider/context: `PluginProvider`, `usePlatform`, `PlatformCtx`.
165
+ - Client facade: `createPaletteClient(platform?)`.
166
+ - API: `apiFetch(path, init?)`, `apiUpload(path, file, fieldName?, extraFields?)`, `setBaseUrl(url)`, `getBaseUrl()`.
167
+ - Errors: `PaletteApiError`, `errorFromResponse(response)`, `isPaletteApiError(error)`.
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`, `upload(file, options)`, `resume(file, options)`, and `uploadToSignedUrl(uploadUrl, file, contentType?)`.
170
+ - Install config: `getInstallConfig(pluginId)`, `updateInstallConfig(pluginId, values)`.
171
+ - Organization/user: `UserClient`, `OrganizationClient`, including `current`, `updateProfile`, `listMine`, `listMembers`, `getMember`, `getMemberByEmail`, `inviteMember`, and `updateMemberRole`.
172
+ - Permissions: `hasPermission(ctx, permission)`, `hasAnyPermission(ctx, permissions)`, `hasAllPermissions(ctx, permissions)`.
173
+ - Translations: `normalizePaletteLanguage`, `translate`, `usePluginTranslations`.
174
+ - Hooks: `usePluginTasks`, `usePluginDataRooms`, `usePluginChat`.
175
+ - Sandbox: `createSandboxBridge`, `isSandboxRuntime`.
176
+ - Testing: `createMockPlatformContext`, `withPluginProvider`.
177
+ - Types: `PluginManifest`, `PluginComponentProps`, `PlatformContext`, `PaletteClient`, resource, task, chat, data-room, user, organization, translation, and sandbox bridge types.
178
+
145
179
  ## Plugin Root
146
180
 
147
181
  The platform passes plugin runtime context into your root component. Wrap your UI with `PluginProvider` so hooks can read it.
@@ -252,12 +286,30 @@ Included clients:
252
286
  - `palette.dataRooms.list()`, `create()`, `get()`, `folder()`, `createFolder()`, `ensureFolder()`, `findRoomByName()`, `findFolderByName()`, `resolveFolderPath()`, `findFileByName()`, and `uploadFile()`
253
287
  - `palette.config.get()` and `palette.config.update(values)`, with optional plugin ID override
254
288
  - `palette.permissions.has()`, `hasAny()`, and `hasAll()`
255
- - `palette.storage.uploadToSignedUrl()`
289
+ - `palette.storage.upload(file, options)`, `resume(file, options)`, and `uploadToSignedUrl()`
256
290
  - `palette.toast.success()`, `error()`, and `info()`
257
291
 
258
292
  These helpers are intentionally thin wrappers over platform APIs. Apps can still
259
293
  use `apiFetch()` directly for custom backend routes.
260
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
+
261
313
  Member helpers operate on the active organisation. Use `members:read` for
262
314
  listing or looking up members, and `members:write` for invitations or role
263
315
  updates. When the runtime provides declared app permissions, these helpers check
@@ -364,8 +416,8 @@ Plugins can read and update their installation configuration through helper APIs
364
416
  ```ts
365
417
  import { getInstallConfig, updateInstallConfig } from "@palettelab/sdk"
366
418
 
367
- const config = await getInstallConfig()
368
- await updateInstallConfig({ ...config, enabled: true })
419
+ const config = await getInstallConfig("my-plugin")
420
+ await updateInstallConfig("my-plugin", { ...config, enabled: true })
369
421
  ```
370
422
 
371
423
  ## Sandbox Bridge
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.13",
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",