diffio 0.1.111 → 0.2.0

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
@@ -1,6 +1,6 @@
1
1
  # Diffio JS SDK
2
2
 
3
- The Diffio JS SDK helps you call the Diffio API from Node. This version covers project creation, upload, generation, progress checks, and download URLs.
3
+ The Diffio JS SDK helps you call the Diffio API from Node. This version covers project creation, edge upload, Diffio 4.5 generations, progress checks, and download URLs.
4
4
 
5
5
  ## Install
6
6
 
@@ -42,9 +42,34 @@ const projects = await client.listProjects({
42
42
  });
43
43
  ```
44
44
 
45
+ ## Models
46
+
47
+ | Model | `model` value | Endpoint | Notes |
48
+ |---|---|---|---|
49
+ | Diffio 4.5 Flash | `diffio-4.5-flash` | `/v1/diffio-4.5-flash-generation` | Default. Fast, high quality speech restoration. |
50
+ | Diffio 4.5 Pro | `diffio-4.5-pro` | `/v1/diffio-4.5-pro-generation` | Best quality. Paid accounts only. |
51
+
52
+ Omitting `model` uses `diffio-4.5-flash`. Earlier models (`diffio-2`, `diffio-2-flash`, `diffio-3.2`,
53
+ `diffio-3.4`, `diffio-3.5`, `diffio-4.0-flash`, `diffio-4.0-pro`) are retired: the SDK refuses them
54
+ before sending a request, and the API answers their endpoints with HTTP 410 `model_retired`.
55
+ Generations created before a model was retired keep their original `modelKey`, so response
56
+ `modelKey` fields are typed as `string`.
57
+
45
58
  ## Create a project and generation
46
59
 
47
- `createProject` uploads the file and returns the project metadata.
60
+ `createProject` creates the project, uploads the file through Diffio's upload edge, and confirms
61
+ the upload, so the project is ready for a generation when it returns.
62
+
63
+ The upload follows the session `create_project` returns: the SDK starts a multipart upload at
64
+ `{edgeBaseUrl}/v1/uploads/start`, sends the file in parts of `partSizeBytes` (32 MiB, three at a time)
65
+ to `/v1/uploads/parts/{partNumber}`, completes it with `/v1/uploads/complete`, and then calls
66
+ `/v1/complete_project_upload`. Each part is tried up to four times on network errors, timeouts,
67
+ `408`, `429`, and `5xx` answers. A failed upload is aborted and raises `DiffioUploadError` (a
68
+ `DiffioApiError`) with `uploadErrorCode` (`upload/too-large`, `upload/unauthorized`,
69
+ `upload/rejected`, `upload/network`, `upload/server`, `upload/invalid-response`, or
70
+ `upload/canceled`), the edge's `edgeErrorCode` when it sent one, and the `apiProjectId`. Files
71
+ larger than the session's `maxBytes` (2 GiB) are refused before any bytes are sent. The upload token
72
+ is used only inside the SDK and is not part of the returned project.
48
73
 
49
74
  ```ts
50
75
  import { DiffioClient } from "diffio";
@@ -58,12 +83,19 @@ const project = await client.createProject({
58
83
 
59
84
  const generation = await client.createGeneration({
60
85
  apiProjectId: project.apiProjectId,
61
- model: "diffio-4.0-flash",
62
- sampling: { steps: 12, guidance: 1.5 },
86
+ model: "diffio-4.5-flash",
63
87
  idempotencyKey: "restore-sample-001"
64
88
  });
65
89
 
66
90
  console.log(generation.generationId, generation.idempotentReplay ?? false);
91
+ console.log(project.upload.objectKey, project.uploadCompletion.sizeBytes);
92
+ ```
93
+
94
+ If `createProject` uploaded the file but the confirmation call failed, confirm it yourself. The call
95
+ is idempotent, and Diffio also records the upload on its own shortly after the edge completes it.
96
+
97
+ ```ts
98
+ await client.projects.completeUpload({ apiProjectId: "proj_123" });
67
99
  ```
68
100
 
69
101
  Reuse the same `idempotencyKey` when retrying generation creation for a project. The API then
@@ -77,11 +109,12 @@ retrying after an uncertain response.
77
109
 
78
110
  `waitForGeneration` and `generations.waitForComplete` wait for the overall `status` to become
79
111
  `complete`. Individual stages reaching 100% or `complete` do not end polling while video publication
80
- or usage settlement is still pending. For Diffio 2.0, `complete` means restored media is ready;
81
- transcription can still be `pending`, become `available` later, or finish as `unavailable`.
82
- Read `progress.transcription?.status` independently. Older responses omit `transcription`;
83
- absence does not establish availability. Unavailable transcription does not fail completed
84
- Diffio 2.0 media. Diffio 3.5 requires its transcript before restoration can complete.
112
+ or usage settlement is still pending. They poll for up to 600 seconds unless you pass `timeout`
113
+ or `timeoutInSeconds`. `complete` means restored media is ready. Diffio 4.5 transcribes the recording before
114
+ restoration starts, so a completed generation has its transcript unless transcription finished as
115
+ `unavailable`; while a generation runs, transcription can be `pending`, `available`, or `unavailable`. Read
116
+ `progress.transcription?.status` independently. Older responses omit `transcription`; absence does
117
+ not establish availability. Unavailable transcription does not fail completed media.
85
118
 
86
119
  ## Audio isolation helper
87
120
 
@@ -91,8 +124,7 @@ import { DiffioClient } from "diffio";
91
124
  const client = new DiffioClient({ apiKey: "diffio_live_..." });
92
125
  const result = await client.audioIsolation.isolate({
93
126
  filePath: "sample.wav",
94
- model: "diffio-4.0-flash",
95
- sampling: { steps: 12, guidance: 1.5 },
127
+ model: "diffio-4.5-flash",
96
128
  idempotencyKey: "restore-sample-001"
97
129
  });
98
130
 
@@ -113,8 +145,7 @@ import { DiffioClient } from "diffio";
113
145
  const client = new DiffioClient({ apiKey: "diffio_live_..." });
114
146
  const [audioBytes, info] = await client.restoreAudio({
115
147
  filePath: "sample.wav",
116
- model: "diffio-4.0-flash",
117
- sampling: { steps: 12, guidance: 1.5 },
148
+ model: "diffio-4.5-flash",
118
149
  idempotencyKey: "restore-sample-001",
119
150
  onProgress: (progress) => console.log(progress.status)
120
151
  });
@@ -139,10 +170,17 @@ const progress = await client.generations.getProgress({
139
170
  apiProjectId: "proj_123"
140
171
  });
141
172
 
142
- console.log(progress.status);
173
+ console.log(progress.status, progress.stage);
174
+ console.log(progress.queue?.message ?? "not queued");
175
+ console.log(progress.stageProgress?.overallPercent);
143
176
  console.log(progress.transcription?.status ?? "not reported");
144
177
  ```
145
178
 
179
+ `stage` names the one step the generation is in (`pending`, `preparing`, `transcribing`, `queued`,
180
+ `starting`, `downloading`, `decoding`, `restoring`, `finalizing`, `uploading`, `complete`, or
181
+ `failed`). While it waits for a processing worker, `queue` reports its position and why it waits;
182
+ while a worker runs it, `stageProgress` reports percentages and byte counts when known.
183
+
146
184
  ## Generation download
147
185
 
148
186
  ```ts
@@ -158,6 +196,10 @@ const download = await client.generations.getDownload({
158
196
  console.log(download.downloadUrl);
159
197
  ```
160
198
 
199
+ `downloadUrl` is a signed, time-limited media URL that needs no `Authorization` header. The
200
+ response has `generationId`, `apiProjectId`, `downloadType`, `downloadUrl`, `fileName`,
201
+ `storagePath`, and `mimeType`.
202
+
161
203
  Set `downloadType` to `"transcript"` to fetch the transcript JSON artifact when available.
162
204
  Pending transcripts raise `DiffioApiError` with `statusCode === 409` and error code
163
205
  `TRANSCRIPT_PENDING`. Unavailable transcripts return `404` with `TRANSCRIPT_UNAVAILABLE`.
@@ -260,9 +302,9 @@ console.log(event.svixMessageId);
260
302
  Use the raw request body (not parsed JSON) plus the `svix-*` headers and your webhook signing secret.
261
303
 
262
304
  Verified events expose the same optional `event.transcription` object as generation progress.
263
- A Diffio 2.0 `generation.completed` event can report `pending` or `unavailable` transcription.
264
- Later transcript publication does not emit another completion event; poll progress when you need
265
- to follow a pending transcript. Older events can omit `transcription`.
305
+ A `generation.completed` event reports `available` transcription, or `unavailable` when no
306
+ transcript could be produced; completion does not wait for a later transcript. Older events can omit
307
+ `transcription`.
266
308
 
267
309
  ```ts
268
310
  import express from "express";
@@ -303,4 +345,5 @@ Examples use ES modules. Save files with a `.mjs` extension or set `"type": "mod
303
345
  ```bash
304
346
  cd diffio-js
305
347
  npm run build
348
+ npm test
306
349
  ```
package/dist/Client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BaseClientOptions, BaseRequestOptions, NormalizedClientOptions } from "./BaseClient";
2
- import type { AccountSettingsResponse, ApiKeyResponse, ApiKeysListResponse, AudioIsolationResult, CreateGenerationResponse, CreateProjectResponse, GenerationDownloadResponse, GenerationProgressResponse, ListProjectGenerationsResponse, ListProjectsResponse, ModelKey, RestoreMetadata, UsageSummaryResponse, WebhookConfigureResponse, WebhookTestEventResponse } from "./api/types";
2
+ import type { AccountSettingsResponse, ApiKeyResponse, ApiKeysListResponse, AudioIsolationResult, CompleteProjectUploadResponse, CreateGenerationResponse, CreateProjectResponse, GenerationDownloadResponse, GenerationProgressResponse, ListProjectGenerationsResponse, ListProjectsResponse, ModelKey, RestoreMetadata, UsageSummaryResponse, WebhookConfigureResponse, WebhookTestEventResponse } from "./api/types";
3
3
  import { AccountClient, ApiKeysClient, AudioIsolationClient, GenerationsClient, ProjectsClient, UsageClient, WebhooksClient } from "./api/resources";
4
4
  export declare namespace DiffioClient {
5
5
  type Options = BaseClientOptions;
@@ -25,7 +25,16 @@ export declare class DiffioClient {
25
25
  fileFormat?: string;
26
26
  requestOptions?: DiffioClient.RequestOptions;
27
27
  }): Promise<CreateProjectResponse>;
28
- private _uploadFile;
28
+ /**
29
+ * Confirms that a project's edge upload landed and starts preprocessing. createProject calls it;
30
+ * call it yourself only to finish an upload whose confirmation failed. It is idempotent.
31
+ */
32
+ completeProjectUpload(options: {
33
+ apiProjectId: string;
34
+ requestOptions?: DiffioClient.RequestOptions;
35
+ }): Promise<CompleteProjectUploadResponse>;
36
+ /** Sends one edge upload call with the session's upload token; never the API key or SDK default headers. */
37
+ private _sendEdgeUploadRequest;
29
38
  /** Retries generation admission only when the caller supplies a nonblank idempotency key. */
30
39
  createGeneration(options: {
31
40
  apiProjectId: string;
@@ -47,7 +56,7 @@ export declare class DiffioClient {
47
56
  apiProjectId?: string;
48
57
  requestOptions?: DiffioClient.RequestOptions;
49
58
  }): Promise<GenerationProgressResponse>;
50
- /** Waits for media completion and settlement. Diffio 2.0 transcription may still be pending or unavailable. */
59
+ /** Waits for media completion and settlement; transcription may still be pending or unavailable. */
51
60
  waitForGeneration(options: {
52
61
  generationId: string;
53
62
  apiProjectId?: string;
package/dist/Client.js CHANGED
@@ -40,22 +40,29 @@ const supplier_1 = require("./core/supplier");
40
40
  const url_1 = require("./core/url");
41
41
  const retry_1 = require("./core/retry");
42
42
  const errors_1 = require("./errors");
43
+ const edgeUpload_1 = require("./core/edgeUpload");
43
44
  const serialization_1 = require("./api/serialization");
44
45
  const resources_1 = require("./api/resources");
45
46
  const mime_types_1 = require("mime-types");
46
47
  const DEFAULT_BASE_URL = "https://api.diffio.ai";
47
48
  const API_PREFIX = "v1";
49
+ /** Generation endpoint for each supported model, as api/model_registry.json in diffio-ui lists them. */
48
50
  const MODEL_ENDPOINTS = {
49
- "diffio-2": "diffio-2.0-generation",
50
- "diffio-2-flash": "diffio-2.0-flash-generation",
51
- "diffio-3.4": "diffio-3.4-generation",
52
- "diffio-3.5": "diffio-3.5-generation",
53
- "diffio-4.0-flash": "diffio-4.0-flash-generation",
54
- "diffio-4.0-pro": "diffio-4.0-pro-generation"
51
+ "diffio-4.5-flash": "diffio-4.5-flash-generation",
52
+ "diffio-4.5-pro": "diffio-4.5-pro-generation"
55
53
  };
54
+ /** The registry's `freeDefault` model; Pro is paid-only, so it cannot be the default for every key. */
55
+ const DEFAULT_MODEL_KEY = "diffio-4.5-flash";
56
+ const SUPPORTED_MODEL_KEYS = Object.keys(MODEL_ENDPOINTS);
57
+ /** Per-request timeout for one edge upload call; a 32 MiB part needs about 1 Mbit/s to finish in time. */
58
+ const DEFAULT_EDGE_UPLOAD_TIMEOUT_SECONDS = 300;
59
+ /** complete_project_upload is idempotent, so it is retried even when the client disables retries by default. */
60
+ const DEFAULT_COMPLETE_UPLOAD_MAX_RETRIES = 3;
56
61
  const DEFAULT_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504];
57
62
  const DEFAULT_RETRY_BACKOFF = 0.5;
58
63
  const DEFAULT_TIMEOUT_SECONDS = 60;
64
+ /** How long waitForGeneration polls by default; fleet generations can queue and run for minutes (matches the Python SDK). */
65
+ const DEFAULT_GENERATION_WAIT_TIMEOUT_SECONDS = 600;
59
66
  const WEBHOOK_EVENT_TYPES = [
60
67
  "generation.queued",
61
68
  "generation.processing",
@@ -107,45 +114,75 @@ class DiffioClient {
107
114
  payload.fileFormat = fileFormat;
108
115
  }
109
116
  const response = await this._requestJson("POST", "create_project", payload, requestOptions);
110
- const project = (0, serialization_1.parseCreateProjectResponse)(response);
111
- await this._uploadFile({
112
- uploadUrl: project.uploadUrl,
113
- uploadMethod: project.uploadMethod,
114
- filePath,
115
- contentType: resolvedContentType,
116
- requestOptions
117
- });
118
- return project;
119
- }
120
- async _uploadFile(options) {
121
- const { uploadUrl, uploadMethod, filePath, data, contentType, requestOptions } = options;
122
- if ((filePath == null) === (data == null)) {
123
- throw new errors_1.DiffioApiError("Provide filePath or data");
117
+ const apiProjectId = typeof response?.apiProjectId === "string" ? response.apiProjectId : undefined;
118
+ const session = (0, edgeUpload_1.parseEdgeUploadSession)(response?.upload);
119
+ if (!apiProjectId || !session) {
120
+ throw new errors_1.DiffioUploadError("upload/invalid-response", "create_project did not return an upload session (apiProjectId and upload are required).", { responseBody: response, apiProjectId });
121
+ }
122
+ try {
123
+ const sizeBytes = getFileSize(filePath);
124
+ const fileHandle = await openFileForReading(filePath);
125
+ try {
126
+ await (0, edgeUpload_1.uploadProjectMediaToEdge)({
127
+ session,
128
+ sizeBytes,
129
+ readPartBytes: (startByte, endByte) => readFileBytes(fileHandle, startByte, endByte),
130
+ sendEdgeRequest: (request) => this._sendEdgeUploadRequest(request, requestOptions)
131
+ });
132
+ }
133
+ finally {
134
+ await fileHandle.close();
135
+ }
124
136
  }
125
- let resolvedContentType = contentType;
126
- if (!resolvedContentType && filePath) {
127
- const guessed = (0, mime_types_1.lookup)(filePath);
128
- resolvedContentType = typeof guessed === "string" ? guessed : undefined;
137
+ catch (error) {
138
+ if (error instanceof errors_1.DiffioUploadError) {
139
+ error.apiProjectId = error.apiProjectId ?? apiProjectId;
140
+ }
141
+ throw error;
129
142
  }
130
- if (!resolvedContentType) {
131
- resolvedContentType = "application/octet-stream";
143
+ const uploadCompletion = await this.completeProjectUpload({ apiProjectId, requestOptions });
144
+ return (0, serialization_1.createProjectUploadResult)(response, session, uploadCompletion);
145
+ }
146
+ /**
147
+ * Confirms that a project's edge upload landed and starts preprocessing. createProject calls it;
148
+ * call it yourself only to finish an upload whose confirmation failed. It is idempotent.
149
+ */
150
+ async completeProjectUpload(options) {
151
+ const { apiProjectId, requestOptions } = options;
152
+ if (!apiProjectId) {
153
+ throw new errors_1.DiffioApiError("apiProjectId is required");
132
154
  }
133
- const method = (uploadMethod || "PUT").toUpperCase();
134
- const extraHeaders = {
135
- "Content-Type": resolvedContentType
155
+ const completeRequestOptions = {
156
+ ...requestOptions,
157
+ maxRetries: requestOptions?.maxRetries ?? this._options.maxRetries ?? DEFAULT_COMPLETE_UPLOAD_MAX_RETRIES
136
158
  };
137
- if (isStorageEmulatorUrl(uploadUrl)) {
138
- extraHeaders.Authorization = "Bearer owner";
159
+ const response = await this._requestJson("POST", "complete_project_upload", { apiProjectId }, completeRequestOptions);
160
+ return (0, serialization_1.parseCompleteProjectUploadResponse)(response);
161
+ }
162
+ /** Sends one edge upload call with the session's upload token; never the API key or SDK default headers. */
163
+ async _sendEdgeUploadRequest(request, requestOptions) {
164
+ const fetchFn = this._options.fetch ?? globalThis.fetch;
165
+ if (!fetchFn) {
166
+ throw new errors_1.DiffioApiError("fetch is not available in this runtime");
139
167
  }
140
- const bodyFactory = filePath ? () => createFileReadStream(filePath) : () => data;
141
- await this._requestBinary(method, uploadUrl, bodyFactory, requestOptions, extraHeaders, true);
168
+ const timeoutSeconds = requestOptions?.timeoutInSeconds ?? requestOptions?.timeout ?? DEFAULT_EDGE_UPLOAD_TIMEOUT_SECONDS;
169
+ // A Uint8Array or string body has a known length, so fetch sends the Content-Length the edge requires.
170
+ const response = await fetchWithTimeout(fetchFn, request.url, {
171
+ method: request.method,
172
+ headers: {
173
+ Authorization: `Bearer ${request.bearerToken}`,
174
+ "Content-Type": request.contentType
175
+ },
176
+ body: request.body
177
+ }, timeoutSeconds * 1000, requestOptions?.abortSignal);
178
+ return { status: response.status, bodyText: await response.text() };
142
179
  }
143
180
  /** Retries generation admission only when the caller supplies a nonblank idempotency key. */
144
181
  async createGeneration(options) {
145
- const { apiProjectId, model = "diffio-4.0-flash", sampling, params, idempotencyKey, requestOptions } = options;
146
- const endpoint = MODEL_ENDPOINTS[model];
182
+ const { apiProjectId, model = DEFAULT_MODEL_KEY, sampling, params, idempotencyKey, requestOptions } = options;
183
+ const endpoint = Object.prototype.hasOwnProperty.call(MODEL_ENDPOINTS, model) ? MODEL_ENDPOINTS[model] : undefined;
147
184
  if (!endpoint) {
148
- throw new errors_1.DiffioApiError(`Unsupported model: ${model}`);
185
+ throw new errors_1.DiffioApiError(`Unsupported model: ${model}. Use ${SUPPORTED_MODEL_KEYS.join(" or ")}.`);
149
186
  }
150
187
  const payload = { apiProjectId };
151
188
  if (sampling != null) {
@@ -184,10 +221,10 @@ class DiffioClient {
184
221
  const response = await this._requestJson("POST", "get_generation_progress", payload, requestOptions);
185
222
  return (0, serialization_1.parseGenerationProgressResponse)(response);
186
223
  }
187
- /** Waits for media completion and settlement. Diffio 2.0 transcription may still be pending or unavailable. */
224
+ /** Waits for media completion and settlement; transcription may still be pending or unavailable. */
188
225
  async waitForGeneration(options) {
189
226
  const { generationId, apiProjectId, pollInterval = 2, timeout, timeoutInSeconds, onProgress, showProgress, requestOptions } = options;
190
- const timeoutSeconds = timeoutInSeconds ?? timeout ?? DEFAULT_TIMEOUT_SECONDS;
227
+ const timeoutSeconds = timeoutInSeconds ?? timeout ?? DEFAULT_GENERATION_WAIT_TIMEOUT_SECONDS;
191
228
  const deadline = Date.now() + timeoutSeconds * 1000;
192
229
  let lastProgress = null;
193
230
  while (Date.now() < deadline) {
@@ -450,11 +487,8 @@ class DiffioClient {
450
487
  return (0, serialization_1.createAudioIsolationResult)(project, generation);
451
488
  }
452
489
  async _downloadBinary(downloadUrl, requestOptions) {
453
- const extraHeaders = {};
454
- if (isStorageEmulatorUrl(downloadUrl)) {
455
- extraHeaders.Authorization = "Bearer owner";
456
- }
457
- const response = await this._requestBinary("GET", downloadUrl, () => undefined, requestOptions, extraHeaders, true);
490
+ // The download URL is a signed edge media URL; it needs no Authorization header.
491
+ const response = await this._requestBinary("GET", downloadUrl, () => undefined, requestOptions, {}, true);
458
492
  return response;
459
493
  }
460
494
  async _requestJson(method, path, payload, requestOptions) {
@@ -636,9 +670,12 @@ async function parseErrorResponse(response) {
636
670
  }
637
671
  function getErrorMessage(body, status) {
638
672
  if (body && typeof body === "object" && "error" in body) {
639
- const message = body.error;
640
- if (message) {
641
- return String(message);
673
+ const error = body.error;
674
+ if (error && typeof error === "object" && typeof error.message === "string") {
675
+ return error.message;
676
+ }
677
+ if (error) {
678
+ return String(error);
642
679
  }
643
680
  }
644
681
  return `Request failed with status ${status}`;
@@ -657,36 +694,6 @@ function guessContentType(filePath) {
657
694
  }
658
695
  return undefined;
659
696
  }
660
- function isStorageEmulatorUrl(url) {
661
- try {
662
- const parsed = new URL(url);
663
- const host = parsed.hostname.toLowerCase();
664
- const port = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
665
- if (["127.0.0.1", "localhost", "0.0.0.0", "::1"].includes(host)) {
666
- if (!parsed.port || port === 9199) {
667
- return true;
668
- }
669
- }
670
- const envHost = typeof process !== "undefined"
671
- ? process.env.STORAGE_EMULATOR_HOST || process.env.FIREBASE_STORAGE_EMULATOR_HOST
672
- : undefined;
673
- if (!envHost) {
674
- return false;
675
- }
676
- const normalized = envHost.startsWith("http://") || envHost.startsWith("https://") ? envHost : `http://${envHost}`;
677
- const emulatorParsed = new URL(normalized);
678
- const emulatorHost = emulatorParsed.hostname.toLowerCase();
679
- const emulatorPort = emulatorParsed.port
680
- ? Number(emulatorParsed.port)
681
- : emulatorParsed.protocol === "https:"
682
- ? 443
683
- : 80;
684
- return host === emulatorHost && port === emulatorPort;
685
- }
686
- catch {
687
- return false;
688
- }
689
- }
690
697
  function isNodeReadable(value) {
691
698
  return Boolean(value) && typeof value === "object" && typeof value.pipe === "function";
692
699
  }
@@ -698,14 +705,23 @@ function destroyNodeReadable(value) {
698
705
  destroy.call(value);
699
706
  }
700
707
  }
701
- async function createFileReadStream(filePath) {
702
- const fs = await Promise.resolve().then(() => __importStar(require("node:fs")));
703
- const stream = fs.createReadStream(filePath);
704
- await new Promise((resolve, reject) => {
705
- stream.once("open", () => resolve());
706
- stream.once("error", reject);
707
- });
708
- return stream;
708
+ async function openFileForReading(filePath) {
709
+ const fs = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
710
+ return fs.open(filePath, "r");
711
+ }
712
+ /** Reads bytes [startByte, endByte) of an open file into one buffer, looping over short reads. */
713
+ async function readFileBytes(fileHandle, startByte, endByte) {
714
+ const length = endByte - startByte;
715
+ const buffer = Buffer.alloc(length);
716
+ let offset = 0;
717
+ while (offset < length) {
718
+ const { bytesRead } = await fileHandle.read(buffer, offset, length - offset, startByte + offset);
719
+ if (bytesRead === 0) {
720
+ throw new errors_1.DiffioUploadError("upload/invalid-response", "The file became shorter while it was uploading.");
721
+ }
722
+ offset += bytesRead;
723
+ }
724
+ return buffer;
709
725
  }
710
726
  function getFileSize(filePath) {
711
727
  const fs = require("node:fs");
@@ -1,5 +1,5 @@
1
1
  import type { DiffioClient } from "../../../../Client";
2
- import type { ListProjectGenerationsResponse, ListProjectsResponse } from "../../../types";
2
+ import type { CompleteProjectUploadResponse, ListProjectGenerationsResponse, ListProjectsResponse } from "../../../types";
3
3
  export interface ProjectsListOptions {
4
4
  requestOptions?: DiffioClient.RequestOptions;
5
5
  }
@@ -7,9 +7,15 @@ export interface ProjectsListGenerationsOptions {
7
7
  apiProjectId: string;
8
8
  requestOptions?: DiffioClient.RequestOptions;
9
9
  }
10
+ export interface ProjectsCompleteUploadOptions {
11
+ apiProjectId: string;
12
+ requestOptions?: DiffioClient.RequestOptions;
13
+ }
10
14
  export declare class ProjectsClient {
11
15
  private _parent;
12
16
  constructor(parent: DiffioClient);
13
17
  list(options?: ProjectsListOptions): Promise<ListProjectsResponse>;
18
+ /** Confirms a finished edge upload and starts preprocessing; createProject already does this. */
19
+ completeUpload(options: ProjectsCompleteUploadOptions): Promise<CompleteProjectUploadResponse>;
14
20
  listGenerations(options: ProjectsListGenerationsOptions): Promise<ListProjectGenerationsResponse>;
15
21
  }
@@ -8,6 +8,10 @@ class ProjectsClient {
8
8
  async list(options = {}) {
9
9
  return this._parent.listProjects(options);
10
10
  }
11
+ /** Confirms a finished edge upload and starts preprocessing; createProject already does this. */
12
+ async completeUpload(options) {
13
+ return this._parent.completeProjectUpload(options);
14
+ }
11
15
  async listGenerations(options) {
12
16
  return this._parent.listProjectGenerations(options);
13
17
  }
@@ -1,5 +1,8 @@
1
- import type { AccountSettingsResponse, ApiKeyResponse, ApiKeysListResponse, AudioIsolationResult, CreateGenerationResponse, CreateProjectResponse, GenerationWebhookEvent, GenerationDownloadResponse, GenerationProgressResponse, GenerationProgressStage, ListProjectGenerationsResponse, ListProjectsResponse, ProjectGenerationSummary, ProjectSummary, UsageSummaryResponse, WebhookConfigureResponse, WebhookTestEventResponse } from "./types";
2
- export declare function parseCreateProjectResponse(data: any): CreateProjectResponse;
1
+ import type { EdgeUploadSession } from "../core/edgeUpload";
2
+ import type { AccountSettingsResponse, ApiKeyResponse, ApiKeysListResponse, AudioIsolationResult, CompleteProjectUploadResponse, CreateGenerationResponse, CreateProjectResponse, GenerationWebhookEvent, GenerationDownloadResponse, GenerationProgressResponse, GenerationProgressStage, ListProjectGenerationsResponse, ListProjectsResponse, ProjectGenerationSummary, ProjectSummary, UsageSummaryResponse, WebhookConfigureResponse, WebhookTestEventResponse } from "./types";
3
+ /** Builds the public createProject result; it omits the upload token, which can still overwrite the upload. */
4
+ export declare function createProjectUploadResult(data: any, session: EdgeUploadSession, uploadCompletion: CompleteProjectUploadResponse): CreateProjectResponse;
5
+ export declare function parseCompleteProjectUploadResponse(data: any): CompleteProjectUploadResponse;
3
6
  export declare function parseProjectSummary(data: any): ProjectSummary;
4
7
  export declare function parseListProjectsResponse(data: any): ListProjectsResponse;
5
8
  export declare function parseCreateGenerationResponse(data: any): CreateGenerationResponse;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.parseCreateProjectResponse = parseCreateProjectResponse;
3
+ exports.createProjectUploadResult = createProjectUploadResult;
4
+ exports.parseCompleteProjectUploadResponse = parseCompleteProjectUploadResponse;
4
5
  exports.parseProjectSummary = parseProjectSummary;
5
6
  exports.parseListProjectsResponse = parseListProjectsResponse;
6
7
  exports.parseCreateGenerationResponse = parseCreateGenerationResponse;
@@ -17,14 +18,30 @@ exports.parseUsageSummaryResponse = parseUsageSummaryResponse;
17
18
  exports.parseWebhookConfigureResponse = parseWebhookConfigureResponse;
18
19
  exports.parseGenerationWebhookEvent = parseGenerationWebhookEvent;
19
20
  exports.createAudioIsolationResult = createAudioIsolationResult;
20
- function parseCreateProjectResponse(data) {
21
+ /** Builds the public createProject result; it omits the upload token, which can still overwrite the upload. */
22
+ function createProjectUploadResult(data, session, uploadCompletion) {
23
+ const upload = {
24
+ uploadSessionId: session.uploadSessionId,
25
+ edgeBaseUrl: session.edgeBaseUrl,
26
+ objectKey: session.objectKey,
27
+ partSizeBytes: session.partSizeBytes,
28
+ maxBytes: session.maxBytes,
29
+ expiresAt: session.expiresAt
30
+ };
21
31
  return {
22
32
  apiProjectId: data.apiProjectId,
23
- uploadUrl: data.uploadUrl,
24
- uploadMethod: data.uploadMethod || "PUT",
25
- objectPath: data.objectPath,
26
- bucket: data.bucket,
27
- expiresAt: data.expiresAt
33
+ upload,
34
+ objectPath: data.objectPath ?? session.objectKey,
35
+ expiresAt: data.expiresAt ?? session.expiresAt,
36
+ uploadCompletion
37
+ };
38
+ }
39
+ function parseCompleteProjectUploadResponse(data) {
40
+ const sizeBytes = data?.sizeBytes;
41
+ return {
42
+ apiProjectId: data?.apiProjectId,
43
+ status: data?.status ?? "uploaded",
44
+ sizeBytes: typeof sizeBytes === "number" ? sizeBytes : null
28
45
  };
29
46
  }
30
47
  function parseProjectSummary(data) {
@@ -86,6 +103,8 @@ function parseGenerationProgressStage(data) {
86
103
  function parseGenerationProgressResponse(data) {
87
104
  const restoredVideo = data?.restoredVideo;
88
105
  const transcription = parseGenerationTranscription(data?.transcription);
106
+ const stageProgress = parseGenerationStageProgress(data?.stageProgress);
107
+ const queue = parseGenerationQueueStatus(data?.queue);
89
108
  return {
90
109
  generationId: data.generationId,
91
110
  apiProjectId: data.apiProjectId,
@@ -94,6 +113,9 @@ function parseGenerationProgressResponse(data) {
94
113
  preProcessing: parseGenerationProgressStage(data.preProcessing),
95
114
  inference: parseGenerationProgressStage(data.inference),
96
115
  restoredVideo: restoredVideo ? parseGenerationProgressStage(restoredVideo) : null,
116
+ ...(typeof data?.stage === "string" ? { stage: data.stage } : {}),
117
+ ...(stageProgress ? { stageProgress } : {}),
118
+ ...(queue ? { queue } : {}),
97
119
  ...(transcription ? { transcription } : {}),
98
120
  error: data.error ?? null,
99
121
  errorDetails: data.errorDetails ?? null
@@ -107,7 +129,6 @@ function parseGenerationDownloadResponse(data) {
107
129
  downloadUrl: data.downloadUrl,
108
130
  fileName: data.fileName,
109
131
  storagePath: data.storagePath,
110
- bucket: data.bucket,
111
132
  mimeType: data.mimeType
112
133
  };
113
134
  }
@@ -175,6 +196,42 @@ function parseGenerationWebhookEvent(data) {
175
196
  errorDetails: data.errorDetails ?? null
176
197
  };
177
198
  }
199
+ const optionalNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
200
+ function parseGenerationStageProgress(data) {
201
+ if (data == null || typeof data !== "object" || Array.isArray(data)) {
202
+ return undefined;
203
+ }
204
+ const source = data;
205
+ const progress = {};
206
+ for (const key of [
207
+ "overallPercent",
208
+ "stagePercent",
209
+ "bytesDone",
210
+ "bytesTotal",
211
+ "availableThroughSeconds",
212
+ "durationSeconds"
213
+ ]) {
214
+ const value = optionalNumber(source[key]);
215
+ if (value != null) {
216
+ progress[key] = value;
217
+ }
218
+ }
219
+ return progress;
220
+ }
221
+ function parseGenerationQueueStatus(data) {
222
+ if (data == null || typeof data !== "object" || Array.isArray(data)) {
223
+ return undefined;
224
+ }
225
+ const source = data;
226
+ return {
227
+ position: optionalNumber(source.position),
228
+ connectedWorkers: optionalNumber(source.connectedWorkers),
229
+ idleWorkers: optionalNumber(source.idleWorkers),
230
+ busyWorkers: optionalNumber(source.busyWorkers),
231
+ waitReason: typeof source.waitReason === "string" ? source.waitReason : null,
232
+ message: typeof source.message === "string" ? source.message : ""
233
+ };
234
+ }
178
235
  function parseGenerationTranscription(data) {
179
236
  if (data == null || typeof data !== "object" || !("status" in data)) {
180
237
  return undefined;
@@ -1,4 +1,5 @@
1
- export type ModelKey = "diffio-2" | "diffio-2-flash" | "diffio-3.4" | "diffio-3.5" | "diffio-4.0-flash" | "diffio-4.0-pro";
1
+ /** Models new generations can use (api/model_registry.json in diffio-ui); each has its own endpoint. */
2
+ export type ModelKey = "diffio-4.5-flash" | "diffio-4.5-pro";
2
3
  export type DownloadType = "audio" | "video" | "transcript";
3
4
  export type WebhookMode = "test" | "live";
4
5
  export type WebhookEventType = "generation.queued" | "generation.processing" | "generation.failed" | "generation.completed";
@@ -7,13 +8,28 @@ export type TranscriptionStatus = "pending" | "available" | "unavailable";
7
8
  export interface GenerationTranscription {
8
9
  status: TranscriptionStatus;
9
10
  }
11
+ /** The edge upload session create_project opened; the upload token stays inside the SDK. */
12
+ export interface ProjectUploadSession {
13
+ uploadSessionId: string;
14
+ edgeBaseUrl: string;
15
+ objectKey: string;
16
+ partSizeBytes: number;
17
+ maxBytes: number;
18
+ expiresAt: string;
19
+ }
20
+ /** Response of `/v1/complete_project_upload`; repeated calls return the same answer. */
21
+ export interface CompleteProjectUploadResponse {
22
+ apiProjectId: string;
23
+ status: "uploaded" | string;
24
+ sizeBytes: number | null;
25
+ }
26
+ /** A created project whose media `createProject` already uploaded through the edge and confirmed. */
10
27
  export interface CreateProjectResponse {
11
28
  apiProjectId: string;
12
- uploadUrl: string;
13
- uploadMethod: string;
29
+ upload: ProjectUploadSession;
14
30
  objectPath: string;
15
- bucket: string;
16
31
  expiresAt: string;
32
+ uploadCompletion: CompleteProjectUploadResponse;
17
33
  }
18
34
  export interface ProjectSummary {
19
35
  apiProjectId: string;
@@ -31,14 +47,16 @@ export interface ListProjectsResponse {
31
47
  export interface CreateGenerationResponse {
32
48
  generationId: string;
33
49
  apiProjectId: string;
34
- modelKey: ModelKey | string;
50
+ /** A string because generations created before a model was retired keep their original key. */
51
+ modelKey: string;
35
52
  status: string;
36
53
  idempotentReplay?: boolean;
37
54
  }
38
55
  export interface ProjectGenerationSummary {
39
56
  generationId: string;
40
57
  status: string;
41
- modelKey?: ModelKey | string | null;
58
+ /** Older generations keep the key of the model that produced them. */
59
+ modelKey?: string | null;
42
60
  progress?: number | null;
43
61
  createdAt?: string | null;
44
62
  updatedAt?: string | null;
@@ -56,6 +74,26 @@ export interface GenerationProgressStage {
56
74
  error?: string | null;
57
75
  errorDetails?: string | null;
58
76
  }
77
+ /** The one stage a generation is in, as `get_generation_progress` reports it. */
78
+ export type GenerationStage = "pending" | "preparing" | "transcribing" | "queued" | "starting" | "downloading" | "decoding" | "restoring" | "finalizing" | "uploading" | "complete" | "failed";
79
+ /** Progress within the current fleet stage; every field is optional and present only when known. */
80
+ export interface GenerationStageProgress {
81
+ overallPercent?: number;
82
+ stagePercent?: number;
83
+ bytesDone?: number;
84
+ bytesTotal?: number;
85
+ availableThroughSeconds?: number;
86
+ durationSeconds?: number;
87
+ }
88
+ /** Why a queued generation waits for a Mac fleet worker. */
89
+ export interface GenerationQueueStatus {
90
+ position: number | null;
91
+ connectedWorkers: number | null;
92
+ idleWorkers: number | null;
93
+ busyWorkers: number | null;
94
+ waitReason: "all_busy" | "no_workers" | "next_in_line" | string | null;
95
+ message: string;
96
+ }
59
97
  export interface GenerationProgressResponse {
60
98
  generationId: string;
61
99
  apiProjectId: string;
@@ -64,6 +102,12 @@ export interface GenerationProgressResponse {
64
102
  preProcessing: GenerationProgressStage;
65
103
  inference: GenerationProgressStage;
66
104
  restoredVideo?: GenerationProgressStage | null;
105
+ /** Omitted by older API versions. */
106
+ stage?: GenerationStage | string;
107
+ /** Present while a fleet stage reports progress. */
108
+ stageProgress?: GenerationStageProgress;
109
+ /** Present while the generation waits in the fleet queue. */
110
+ queue?: GenerationQueueStatus;
67
111
  /** Independent of media completion; omitted by older API versions. */
68
112
  transcription?: GenerationTranscription;
69
113
  error?: string | null;
@@ -76,7 +120,6 @@ export interface GenerationDownloadResponse {
76
120
  downloadUrl: string;
77
121
  fileName: string;
78
122
  storagePath: string;
79
- bucket: string;
80
123
  mimeType: string;
81
124
  }
82
125
  export interface AudioIsolationResult {
@@ -128,8 +171,9 @@ export interface GenerationWebhookEvent {
128
171
  generationId: string;
129
172
  status: GenerationWebhookStatus | string;
130
173
  hasVideo?: boolean | null;
131
- modelKey?: ModelKey | string | null;
132
- /** A completed Diffio 2.0 generation may still have a pending or unavailable transcript. */
174
+ /** Older generations keep the key of the model that produced them. */
175
+ modelKey?: string | null;
176
+ /** A completed generation may still have a pending or unavailable transcript. */
133
177
  transcription?: GenerationTranscription;
134
178
  error?: string | null;
135
179
  errorDetails?: string | null;
@@ -0,0 +1,62 @@
1
+ import { DiffioUploadError } from "../errors";
2
+ /** The upload session create_project returns; `uploadToken` authorizes edge calls for this one object. */
3
+ export interface EdgeUploadSession {
4
+ uploadSessionId: string;
5
+ edgeBaseUrl: string;
6
+ uploadToken: string;
7
+ objectKey: string;
8
+ partSizeBytes: number;
9
+ maxBytes: number;
10
+ expiresAt: string;
11
+ }
12
+ /** One planned multipart part: bytes [startByte, endByte) of the file. */
13
+ export interface EdgeUploadPartPlan {
14
+ partNumber: number;
15
+ startByte: number;
16
+ endByte: number;
17
+ }
18
+ /** A finished part as the edge acknowledges it, replayed on completion. */
19
+ export interface EdgeUploadPartReceipt {
20
+ partNumber: number;
21
+ etag: string;
22
+ }
23
+ /** The edge's answer to `POST /v1/uploads/complete`. */
24
+ export interface EdgeUploadCompletion {
25
+ objectKey: string;
26
+ sizeBytes: number;
27
+ etag: string;
28
+ }
29
+ /** One HTTP request to the edge; the body is a fixed-length buffer because parts need a Content-Length. */
30
+ export interface EdgeUploadHttpRequest {
31
+ method: "POST" | "PUT";
32
+ url: string;
33
+ bearerToken: string;
34
+ body: Uint8Array | string;
35
+ contentType: string;
36
+ }
37
+ /** The edge's answer to one request. Transport failures are thrown instead. */
38
+ export interface EdgeUploadHttpResponse {
39
+ status: number;
40
+ bodyText: string;
41
+ }
42
+ /** Inputs for one upload of a project's original media through the edge. */
43
+ export interface EdgeUploadOptions {
44
+ session: EdgeUploadSession;
45
+ sizeBytes: number;
46
+ /** Reads bytes [startByte, endByte) of the media; called once per part. */
47
+ readPartBytes: (startByte: number, endByte: number) => Promise<Uint8Array>;
48
+ sendEdgeRequest: (request: EdgeUploadHttpRequest) => Promise<EdgeUploadHttpResponse>;
49
+ partConcurrency?: number;
50
+ maxAttempts?: number;
51
+ sleep?: (delayMs: number) => Promise<void>;
52
+ }
53
+ /** Splits a file into fixed-size parts numbered from 1; an empty file is one empty part. */
54
+ export declare function planEdgeUploadParts(sizeBytes: number, partSizeBytes: number): EdgeUploadPartPlan[];
55
+ /** Backoff before retry `attempt` (2 = first retry): 1 s, 2 s, 4 s, capped at 8 s. */
56
+ export declare function resolveEdgeUploadRetryDelayMs(attempt: number): number;
57
+ /** Validates the `upload` object of a create_project response; null when it is missing or incomplete. */
58
+ export declare function parseEdgeUploadSession(value: unknown): EdgeUploadSession | null;
59
+ /** Classifies a non-2xx edge response (`{"error": {"code", "message"}}`) as a typed upload error. */
60
+ export declare function classifyEdgeUploadResponse(response: EdgeUploadHttpResponse): DiffioUploadError;
61
+ /** Uploads media through the edge in parts and completes the multipart object; the caller then confirms it. */
62
+ export declare function uploadProjectMediaToEdge(options: EdgeUploadOptions): Promise<EdgeUploadCompletion>;
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ // Client for the edge Worker upload API ("Uploads" in diffio-ui specs/MacFleetArchitecture.md).
3
+ // create_project opens the session and owns every auth decision; the edge only checks the
4
+ // session's upload token and streams fixed-size parts into R2. The flow mirrors the web app's
5
+ // uploader (diffio-ui app/services/edgeUploads.ts): start, PUT parts numbered from 1 with up to
6
+ // four attempts each, complete with the sorted receipts, and abort on a fatal failure.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.planEdgeUploadParts = planEdgeUploadParts;
9
+ exports.resolveEdgeUploadRetryDelayMs = resolveEdgeUploadRetryDelayMs;
10
+ exports.parseEdgeUploadSession = parseEdgeUploadSession;
11
+ exports.classifyEdgeUploadResponse = classifyEdgeUploadResponse;
12
+ exports.uploadProjectMediaToEdge = uploadProjectMediaToEdge;
13
+ const errors_1 = require("../errors");
14
+ const DEFAULT_EDGE_UPLOAD_PART_CONCURRENCY = 3;
15
+ const DEFAULT_EDGE_UPLOAD_MAX_ATTEMPTS = 4;
16
+ const defaultSleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
17
+ const isUnknownRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
18
+ /** Splits a file into fixed-size parts numbered from 1; an empty file is one empty part. */
19
+ function planEdgeUploadParts(sizeBytes, partSizeBytes) {
20
+ if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 0)
21
+ throw new Error("sizeBytes must be a non-negative integer");
22
+ if (!Number.isSafeInteger(partSizeBytes) || partSizeBytes <= 0) {
23
+ throw new Error("partSizeBytes must be a positive integer");
24
+ }
25
+ if (sizeBytes === 0)
26
+ return [{ partNumber: 1, startByte: 0, endByte: 0 }];
27
+ const parts = [];
28
+ for (let startByte = 0, partNumber = 1; startByte < sizeBytes; startByte += partSizeBytes, partNumber += 1) {
29
+ parts.push({ partNumber, startByte, endByte: Math.min(sizeBytes, startByte + partSizeBytes) });
30
+ }
31
+ return parts;
32
+ }
33
+ /** Backoff before retry `attempt` (2 = first retry): 1 s, 2 s, 4 s, capped at 8 s. */
34
+ function resolveEdgeUploadRetryDelayMs(attempt) {
35
+ return Math.min(8000, 1000 * 2 ** Math.max(0, attempt - 2));
36
+ }
37
+ /** Validates the `upload` object of a create_project response; null when it is missing or incomplete. */
38
+ function parseEdgeUploadSession(value) {
39
+ if (!isUnknownRecord(value))
40
+ return null;
41
+ const { uploadSessionId, uploadToken, edgeBaseUrl, objectKey, partSizeBytes, maxBytes, expiresAt } = value;
42
+ if (typeof uploadSessionId !== "string" || !uploadSessionId ||
43
+ typeof uploadToken !== "string" || !uploadToken ||
44
+ typeof edgeBaseUrl !== "string" || !/^https?:\/\//i.test(edgeBaseUrl) ||
45
+ typeof objectKey !== "string" || !objectKey ||
46
+ typeof partSizeBytes !== "number" || !Number.isSafeInteger(partSizeBytes) || partSizeBytes <= 0 ||
47
+ typeof maxBytes !== "number" || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
48
+ return null;
49
+ }
50
+ return {
51
+ uploadSessionId,
52
+ uploadToken,
53
+ edgeBaseUrl: edgeBaseUrl.replace(/\/+$/, ""),
54
+ objectKey,
55
+ partSizeBytes,
56
+ maxBytes,
57
+ expiresAt: typeof expiresAt === "string" ? expiresAt : ""
58
+ };
59
+ }
60
+ const parseJsonBody = (bodyText) => {
61
+ try {
62
+ const parsed = JSON.parse(bodyText);
63
+ return isUnknownRecord(parsed) ? parsed : {};
64
+ }
65
+ catch {
66
+ return {};
67
+ }
68
+ };
69
+ /** Classifies a non-2xx edge response (`{"error": {"code", "message"}}`) as a typed upload error. */
70
+ function classifyEdgeUploadResponse(response) {
71
+ const body = parseJsonBody(response.bodyText);
72
+ const error = isUnknownRecord(body.error) ? body.error : {};
73
+ const message = typeof error.message === "string" && error.message
74
+ ? error.message
75
+ : `Upload failed with HTTP ${response.status}`;
76
+ const edgeErrorCode = typeof error.code === "string" ? error.code : undefined;
77
+ const responseBody = Object.keys(body).length > 0 ? body : response.bodyText || null;
78
+ const base = { statusCode: response.status, responseBody, edgeErrorCode };
79
+ let code = "upload/rejected";
80
+ let retryable = false;
81
+ if (response.status === 413)
82
+ code = "upload/too-large";
83
+ else if (response.status === 401 || response.status === 403)
84
+ code = "upload/unauthorized";
85
+ else if (response.status === 408 || response.status === 429 || response.status >= 500) {
86
+ code = "upload/server";
87
+ retryable = true;
88
+ }
89
+ return new errors_1.DiffioUploadError(code, message, { ...base, retryable });
90
+ }
91
+ const isAbortError = (error) => isUnknownRecord(error) && error.name === "AbortError";
92
+ /** Turns a thrown transport failure into an upload error; caller aborts are final, other failures retryable. */
93
+ function toEdgeUploadError(error) {
94
+ if (error instanceof errors_1.DiffioUploadError)
95
+ return error;
96
+ if (isAbortError(error) || (error instanceof Error && error.name === "AbortError")) {
97
+ return new errors_1.DiffioUploadError("upload/canceled", "Upload was canceled.");
98
+ }
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ return new errors_1.DiffioUploadError("upload/network", message || "Network error during upload", { retryable: true });
101
+ }
102
+ /** Uploads media through the edge in parts and completes the multipart object; the caller then confirms it. */
103
+ async function uploadProjectMediaToEdge(options) {
104
+ const { session, sizeBytes, readPartBytes, sendEdgeRequest } = options;
105
+ const sleep = options.sleep ?? defaultSleep;
106
+ const concurrency = Math.max(1, options.partConcurrency ?? DEFAULT_EDGE_UPLOAD_PART_CONCURRENCY);
107
+ const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_EDGE_UPLOAD_MAX_ATTEMPTS);
108
+ if (sizeBytes > session.maxBytes) {
109
+ throw new errors_1.DiffioUploadError("upload/too-large", `This file is ${sizeBytes} bytes; the upload limit is ${session.maxBytes} bytes.`);
110
+ }
111
+ const sendOnce = async (request) => {
112
+ let response;
113
+ try {
114
+ response = await sendEdgeRequest({ ...request, bearerToken: session.uploadToken });
115
+ }
116
+ catch (error) {
117
+ throw toEdgeUploadError(error);
118
+ }
119
+ if (response.status < 200 || response.status >= 300)
120
+ throw classifyEdgeUploadResponse(response);
121
+ return parseJsonBody(response.bodyText);
122
+ };
123
+ // Set by the first part that fails for good; the other lanes then stop instead of retrying.
124
+ let fatalError = null;
125
+ // Start, parts, and complete are all safe to repeat: a repeated start only opens a fresh
126
+ // multipart upload, and the edge answers a repeated complete from the stored object.
127
+ const sendWithRetries = async (describe, readResult) => {
128
+ for (let attempt = 1;; attempt += 1) {
129
+ try {
130
+ return readResult(await sendOnce(await describe()));
131
+ }
132
+ catch (error) {
133
+ const uploadError = toEdgeUploadError(error);
134
+ if (fatalError || !uploadError.retryable || attempt >= maxAttempts)
135
+ throw fatalError ?? uploadError;
136
+ await sleep(resolveEdgeUploadRetryDelayMs(attempt + 1));
137
+ }
138
+ }
139
+ };
140
+ const jsonRequest = (path, body) => ({
141
+ method: "POST",
142
+ url: `${session.edgeBaseUrl}${path}`,
143
+ body: JSON.stringify(body),
144
+ contentType: "application/json"
145
+ });
146
+ const started = await sendWithRetries(() => jsonRequest("/v1/uploads/start", {}), (body) => {
147
+ if (typeof body.uploadId !== "string" || !body.uploadId) {
148
+ throw new errors_1.DiffioUploadError("upload/invalid-response", "The edge did not return an uploadId.");
149
+ }
150
+ const partSizeBytes = typeof body.partSizeBytes === "number" && Number.isSafeInteger(body.partSizeBytes) &&
151
+ body.partSizeBytes > 0
152
+ ? body.partSizeBytes
153
+ : session.partSizeBytes;
154
+ return { uploadId: body.uploadId, partSizeBytes };
155
+ });
156
+ const { uploadId } = started;
157
+ const parts = planEdgeUploadParts(sizeBytes, started.partSizeBytes);
158
+ const pending = [...parts];
159
+ const receipts = [];
160
+ const uploadPart = async (part) => {
161
+ let bytes = null;
162
+ const receipt = await sendWithRetries(async () => {
163
+ if (fatalError)
164
+ throw fatalError;
165
+ bytes = bytes ?? await readPartBytes(part.startByte, part.endByte);
166
+ return {
167
+ method: "PUT",
168
+ url: `${session.edgeBaseUrl}/v1/uploads/parts/${part.partNumber}?uploadId=${encodeURIComponent(uploadId)}`,
169
+ body: bytes,
170
+ contentType: "application/octet-stream"
171
+ };
172
+ }, (body) => {
173
+ if (typeof body.etag !== "string" || !body.etag) {
174
+ throw new errors_1.DiffioUploadError("upload/invalid-response", `Part ${part.partNumber} returned no etag.`);
175
+ }
176
+ return { partNumber: part.partNumber, etag: body.etag };
177
+ });
178
+ receipts.push(receipt);
179
+ };
180
+ const runLane = async () => {
181
+ while (pending.length > 0 && !fatalError) {
182
+ const part = pending.shift();
183
+ if (!part)
184
+ return;
185
+ try {
186
+ await uploadPart(part);
187
+ }
188
+ catch (error) {
189
+ fatalError = fatalError ?? toEdgeUploadError(error);
190
+ throw fatalError;
191
+ }
192
+ }
193
+ };
194
+ const lanes = Array.from({ length: Math.min(concurrency, parts.length) }, () => runLane());
195
+ const laneResults = await Promise.allSettled(lanes);
196
+ const failedLane = laneResults.find((result) => result.status === "rejected");
197
+ if (failedLane) {
198
+ const uploadError = fatalError ?? toEdgeUploadError(failedLane.reason);
199
+ // Release the partial upload; a failure here never masks the transfer error.
200
+ await sendOnce(jsonRequest("/v1/uploads/abort", { uploadId })).catch(() => undefined);
201
+ throw uploadError;
202
+ }
203
+ receipts.sort((left, right) => left.partNumber - right.partNumber);
204
+ const completion = await sendWithRetries(() => jsonRequest("/v1/uploads/complete", { uploadId, parts: receipts }), (body) => ({
205
+ objectKey: typeof body.objectKey === "string" && body.objectKey ? body.objectKey : session.objectKey,
206
+ sizeBytes: typeof body.sizeBytes === "number" ? body.sizeBytes : sizeBytes,
207
+ etag: typeof body.etag === "string" ? body.etag : ""
208
+ }));
209
+ if (completion.sizeBytes !== sizeBytes) {
210
+ throw new errors_1.DiffioUploadError("upload/invalid-response", `The edge stored ${completion.sizeBytes} bytes but the file has ${sizeBytes} bytes.`);
211
+ }
212
+ return completion;
213
+ }
@@ -9,6 +9,23 @@ export declare class DiffioApiError extends DiffioError {
9
9
  responseBody?: unknown;
10
10
  });
11
11
  }
12
+ /** Stable failure codes for uploads through the edge Worker, modeled on the Diffio web uploader's codes. */
13
+ export type EdgeUploadErrorCode = "upload/too-large" | "upload/unauthorized" | "upload/rejected" | "upload/network" | "upload/server" | "upload/invalid-response" | "upload/canceled";
14
+ /** A failed project media upload; `apiProjectId` names the project whose upload did not finish. */
15
+ export declare class DiffioUploadError extends DiffioApiError {
16
+ uploadErrorCode: EdgeUploadErrorCode;
17
+ /** The edge's machine-readable `error.code`, such as `token_expired`, when it sent one. */
18
+ edgeErrorCode?: string;
19
+ retryable: boolean;
20
+ apiProjectId?: string;
21
+ constructor(uploadErrorCode: EdgeUploadErrorCode, message: string, options?: {
22
+ statusCode?: number;
23
+ responseBody?: unknown;
24
+ edgeErrorCode?: string;
25
+ retryable?: boolean;
26
+ apiProjectId?: string;
27
+ });
28
+ }
12
29
  export declare class DiffioTimeoutError extends DiffioError {
13
30
  constructor(message: string);
14
31
  }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DiffioTimeoutError = exports.DiffioApiError = exports.DiffioError = void 0;
3
+ exports.DiffioTimeoutError = exports.DiffioUploadError = exports.DiffioApiError = exports.DiffioError = void 0;
4
4
  class DiffioError extends Error {
5
5
  constructor(message) {
6
6
  super(message);
@@ -17,6 +17,18 @@ class DiffioApiError extends DiffioError {
17
17
  }
18
18
  }
19
19
  exports.DiffioApiError = DiffioApiError;
20
+ /** A failed project media upload; `apiProjectId` names the project whose upload did not finish. */
21
+ class DiffioUploadError extends DiffioApiError {
22
+ constructor(uploadErrorCode, message, options) {
23
+ super(message, { statusCode: options?.statusCode, responseBody: options?.responseBody });
24
+ this.name = "DiffioUploadError";
25
+ this.uploadErrorCode = uploadErrorCode;
26
+ this.edgeErrorCode = options?.edgeErrorCode;
27
+ this.retryable = options?.retryable ?? false;
28
+ this.apiProjectId = options?.apiProjectId;
29
+ }
30
+ }
31
+ exports.DiffioUploadError = DiffioUploadError;
20
32
  class DiffioTimeoutError extends DiffioError {
21
33
  constructor(message) {
22
34
  super(message);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const DIFFIO_SDK_VERSION = "0.1.111";
1
+ export declare const DIFFIO_SDK_VERSION = "0.2.0";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DIFFIO_SDK_VERSION = void 0;
4
- exports.DIFFIO_SDK_VERSION = "0.1.111";
4
+ exports.DIFFIO_SDK_VERSION = "0.2.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffio",
3
- "version": "0.1.111",
3
+ "version": "0.2.0",
4
4
  "description": "Diffio API client for Node.js",
5
5
  "repository": {
6
6
  "type": "git",