@siafoundation/sia-storage 0.1.2 → 0.1.4

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
@@ -96,6 +96,36 @@ button.onclick = () => streams.download(object, { name: 'clip.mp4' })
96
96
  streams.close()
97
97
  ```
98
98
 
99
+ ### Status and progress
100
+
101
+ `url()` and `download()` take three optional callbacks, for a loading state, a progress readout or a map of the hosts serving the file. They work the same whether the worker streams the file or the page reads it itself.
102
+
103
+ ```ts
104
+ const video = await streams.url(object, {
105
+ name: 'clip.mp4',
106
+ type: 'video/mp4',
107
+ onStatus: (status) => setStatus(status),
108
+ onProgress: (bytes) => setReceived(bytes),
109
+ onShard: (shard) => markHost(shard.hostKey, shard.slabIndex),
110
+ })
111
+ ```
112
+
113
+ - **`onStatus`** goes `connecting`, `downloading`, `idle`, and around again for each new read, such as a seek. `connecting` lasts until the first bytes arrive, and on the URL's first read it covers the worker connecting to the indexer. `idle` means no read is in flight, after the last one finished, was cancelled or failed.
114
+ - **`onProgress`** is the total bytes the URL has received. It counts transfer, not coverage: a range read again after a seek counts again, so it can pass the file size, and a new URL for the same file starts from zero. For how much of a video is buffered, use the media element's `buffered`. A `download()` reads the file once, so its total ends at the file size.
115
+ - **`onShard`** is called for each piece the SDK reads from a host: the host key, slab, shard index, bytes read and time taken. A slab is read in chunks, so the same shard comes up once per chunk.
116
+
117
+ A URL without these callbacks costs nothing, and one with only some of them gets only those reports. The worker sends bytes and shards in batches, at most every 100 ms and just before each status change, and stops when the URL is released. A callback that throws is reported to the console and does not stop the stream or the other callbacks.
118
+
119
+ ### Start sooner
120
+
121
+ A stream connects to the hosts that hold the file when the video asks for its first bytes, which takes a few seconds. `warm()` does it ahead of time, by reading one byte, which asks each host of the file's first part for a single 64-byte segment. Call it when a play is likely, such as when the pointer reaches a Play button. It reports no status or progress and never rejects.
122
+
123
+ ```ts
124
+ const video = await streams.url(object, { name: 'clip.mp4', type: 'video/mp4' })
125
+ videoElement.src = video.url
126
+ playButton.onpointerenter = () => void video.warm()
127
+ ```
128
+
99
129
  ### Serve the worker
100
130
 
101
131
  The worker is one file, `sia-storage-sw.js`, served from your site's root. Use the line for your setup.
@@ -188,6 +218,7 @@ Near-identical surfaces. Real differences:
188
218
  - App identifier: `appMeta.appId: string (hex)` on browser, `appMeta.id: Buffer(32)` on Node.
189
219
  - Numeric sizes are `number` on browser, `bigint` on Node. Byte arrays are `Uint8Array` on browser, `Buffer` on Node (`Buffer` is a `Uint8Array` subclass; both accept either as input).
190
220
  - Sharing key seeds are hex `string` on browser, `Buffer` on Node. This covers `SharingKey.seed()`, `SharingKey.fromSeed(seed)`, and `SharedSdk.connect(indexerUrl, seed)`.
221
+ - `sharedSdk.objectSummaries(offset, limit)` resolves `ObjectSummary` class instances on browser, which own WASM memory and are disposable like `PinnedObject`. On Node it resolves plain objects.
191
222
  - `sdk.unshareObject(key, object)` takes a `PinnedObject` on browser and an object id `string` on Node.
192
223
  - `sdk.hosts(query?)` and `sharedSdk.hosts(query?)` accept a `HostQuery` on browser; on Node they take no arguments.
193
224
  - `openStreams` gives a browser page URLs that a service worker serves. Node reads objects with `sdk.download(object, { offset, length })`, which already streams a range, so it needs no worker. On Node, `enableStreaming()` resolves false, and `url` and `download` on an `openStreams` handle reject.
@@ -219,7 +250,7 @@ Returned from `Builder.register()`, `Builder.connected()`, or `Builder.connectPr
219
250
  | `updateObjectMetadata(object)` | Push local metadata changes to the indexer. |
220
251
  | `objectShareUrl(object, validUntil)` / `objectFromShareUrl(url)` | Create / consume share URLs. |
221
252
  | `objectEvents(cursor?, limit)` | Paginated change feed. |
222
- | `hosts(query?)` / `slab(id)` / `account()` / `pruneSlabs()` | Indexer reads. |
253
+ | `hosts(query?)` / `account()` / `pruneSlabs(before?)` | Indexer reads. `pruneSlabs` unpins slabs no object uses; `before` limits it to slabs pinned before that date, and omitting it leaves the cutoff to the indexer. |
223
254
  | `createSharingKey(description, expiresAt?)` | Create a `SharingKey` for read-only sharing. |
224
255
  | `sharingKeys(offset, limit)` / `sharingKey(key)` | List keys / fetch one key's `KeyRecord`. |
225
256
  | `shareObject(key, object)` / `unshareObject(key, object)` | Attach / detach an object on a sharing key. |
@@ -280,20 +311,27 @@ sharing key's seed. Downloads are paid for by the key's owner.
280
311
  |---|---|
281
312
  | `stats()` | The key's `KeyStats` snapshot. |
282
313
  | `object(id)` / `objects(offset, limit)` | Read the objects the key grants access to. |
314
+ | `objectSummaries(offset, limit)` | Lists the key's objects without their slabs, which is much smaller and faster than `objects`. Summaries describe an object but cannot be downloaded. Fetch the full object with `object(id)` for that. |
283
315
  | `download(object, options?)` | Returns a `ReadableStream`. |
284
316
  | `hosts(query?)` | Hosts serving this key's objects. |
285
317
 
318
+ ### `ObjectSummary`
319
+
320
+ From `sharedSdk.objectSummaries()`.
321
+
322
+ `id` · `size` · `metadata` · `createdAt` · `updatedAt`
323
+
286
324
  ### Streaming
287
325
 
288
326
  | | |
289
327
  |---|---|
290
328
  | `openStreams(sdk, credentials)` | `Streams` for one SDK's objects. An `Sdk` takes `{ indexerUrl, appMeta }` and a `SharedSdk` takes `{ indexerUrl, seed }`, typed by which one you pass. |
291
- | `streams.url(object, options)` | `{ url, blob?, release() }`. Streams where it can, else reads the whole object into `blob`. |
329
+ | `streams.url(object, options)` | `{ url, blob?, warm(), release() }`. Streams where it can, else reads the whole object into `blob`. |
292
330
  | `streams.download(object, options)` | Saves the object. Resolves `'streaming'`, `'saved'` or `'cancelled'`. Call it from a click handler. |
293
331
  | `streams.close()` | Cancels this handle's streams. Other handles on the same SDK keep theirs. |
294
332
  | `enableStreaming(options?)` | Registers the worker and resolves whether streaming is on. `openStreams` calls it for you, so call it only to pass `workerUrl`, `scope` or `type`. |
295
333
 
296
- `options` for `url` and `download`: `name`, `type` (MIME), `onError(message)` for failures after streaming starts (logged when omitted), `onProgress(bytes)` when the page reads the object itself, and `signal` for `url`.
334
+ `options` for `url` and `download`: `name`, `type` (MIME), `onError(message)` for failures after streaming starts (logged when omitted), `onStatus`, `onProgress` and `onShard` (see [Status and progress](#status-and-progress)), and `signal` for `url`.
297
335
 
298
336
  ### `@siafoundation/sia-storage/stream-worker`
299
337
 
@@ -14,6 +14,7 @@ function reportStreamError(error, name, range) {
14
14
  }
15
15
 
16
16
  // src/stream/protocol.ts
17
+ var STREAM_EVENTS = ["status", "progress", "shards"];
17
18
  var STREAM_PATH = /\/__sia_stream__\/([^/]+)\/([^/]+)\/[^/]+$/;
18
19
  function streamPath(scope, clientId, token) {
19
20
  return `${scope}__sia_stream__/${clientId}/${token}/file`;
@@ -26,14 +27,17 @@ var strings = (data, fields) => fields.every((field) => typeof data?.[field] ===
26
27
  function isAppMetadata(value) {
27
28
  return strings(value, ["appId", "name", "description", "serviceUrl"]);
28
29
  }
30
+ var isKeyHex = (value) => typeof value === "string" && /^[0-9a-f]{64}$/i.test(value);
29
31
  function isConnection(value) {
30
32
  const data = value;
31
- if (data?.kind === "shared") return strings(data, ["indexerUrl", "seed"]);
33
+ if (data?.kind === "shared") {
34
+ return strings(data, ["indexerUrl", "seed"]) && (data.sealKey === void 0 || isKeyHex(data.sealKey));
35
+ }
32
36
  return data?.kind === "app" && strings(data, ["indexerUrl", "appKey"]) && isAppMetadata(data.appMeta);
33
37
  }
34
38
  function isSourceReply(reply) {
35
39
  const data = reply;
36
- return data?.type === "source" && strings(data, ["session", "objectId", "name", "mime"]) && isConnection(data.connection) && (data.connection.kind === "shared" || typeof data.sealed === "object" && data.sealed !== null) && Number.isSafeInteger(data.size) && data.size >= 0;
40
+ return data?.type === "source" && strings(data, ["session", "objectId", "name", "mime"]) && isConnection(data.connection) && (data.connection.kind === "shared" || typeof data.sealed === "object" && data.sealed !== null) && Number.isSafeInteger(data.size) && data.size >= 0 && (data.events === void 0 || Array.isArray(data.events) && data.events.every((event) => STREAM_EVENTS.includes(event)));
37
41
  }
38
42
  function requestReply(target, message, { timeout, signal }) {
39
43
  return new Promise((resolve, reject) => {
@@ -71,4 +75,4 @@ export {
71
75
  isSourceReply,
72
76
  requestReply
73
77
  };
74
- //# sourceMappingURL=chunk-QYQQIWFH.js.map
78
+ //# sourceMappingURL=chunk-YOMDUMYW.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/stream/errors.ts","../src/stream/protocol.ts"],"sourcesContent":["/** Only sanitized strings cross the diagnostic boundary, never raw SDK objects. */\nexport function streamErrorMessage(error: unknown): string {\n const message =\n error instanceof Error\n ? error.message\n : typeof error === 'string'\n ? error\n : 'Unknown streaming failure'\n return message\n .replace(/\\b(?:https?|wss?|sia):\\/\\/[^\\s\"'<>]+/gi, '[URL redacted]')\n .replace(/(?:bearer\\s+)[^\\s,;]+/gi, 'Bearer [redacted]')\n .replace(\n /((?:seed|share|token|key|authorization)\\s*[=:]\\s*)[^\\s,;]+/gi,\n '$1[redacted]',\n )\n .replace(/[a-f0-9]{64,}/gi, '[redacted]')\n .replace(/[\\r\\n\\t]/g, ' ')\n .slice(0, 1000)\n}\n\nexport function reportStreamError(\n error: unknown,\n name: string,\n range: { offset: number; length: number } | undefined,\n) {\n const message = streamErrorMessage(error)\n // One string rather than an object, so it reads the same in every console.\n const bytes = range\n ? ` bytes ${range.offset}-${range.offset + range.length - 1}`\n : ''\n console.error(`[Sia stream] ${streamErrorMessage(name)}${bytes}: ${message}`)\n return message\n}\n","/**\n * Messages between a page and its streaming service worker.\n *\n * Stream URLs carry only the page's client id and a random token. When the\n * worker meets a token it does not know, it asks the page that made it for the\n * file and the share's credentials. The page and a worker can come from\n * different deployed versions, so the worker checks the page's reply before\n * using it.\n */\nimport type {\n AppMetadata,\n SealedObject,\n ShardProgress,\n} from '../../wasm/sia_storage_wasm.js'\n\n/**\n * What a stream URL is doing. `connecting` is a request waiting for its first\n * bytes, which on the URL's first request includes the worker connecting its\n * SDK and finding the object. `downloading` means bytes are arriving. `idle`\n * means no request for the URL is in flight, after the last one finished, was\n * cancelled or failed.\n */\nexport type StreamStatus = 'connecting' | 'downloading' | 'idle'\n\n/** The reports a page can ask for, one per callback it was given. */\nexport type StreamEvent = 'status' | 'progress' | 'shards'\nconst STREAM_EVENTS: readonly unknown[] = ['status', 'progress', 'shards']\n\n/**\n * What the worker connects its own SDK with. A `SharedSdk` reconnects from its\n * sharing key's seed, and an `Sdk` from its app key, as hex, and app metadata.\n * A `SharedSdk`'s `sealKey` is the hex of the random key the page seals its\n * objects with. A page from before it existed sends none.\n */\nexport type Connection =\n | { kind: 'shared'; indexerUrl: string; seed: string; sealKey?: string }\n | { kind: 'app'; indexerUrl: string; appKey: string; appMeta: AppMetadata }\n\nexport type SourceDetails = {\n // One per opened SDK, so the worker can release it as a whole.\n session: string\n connection: Connection\n objectId: string\n // The object, sealed with its app key or the share's `sealKey`, so the\n // worker can open it without a lookup. Without it the worker looks the\n // object up by `objectId`.\n sealed?: SealedObject\n name: string\n mime: string\n size: number\n // The reports the page wants for this URL. The worker sends no others, and\n // asks the SDK for shard reports only when `shards` is here.\n events?: StreamEvent[]\n}\n\n/** Page to worker. */\nexport type PageMessage =\n | { type: 'sia-client' }\n | { type: 'sia-release'; token: string }\n | { type: 'sia-release-share'; session: string }\n\n/** Worker to page. */\nexport type WorkerMessage =\n | { type: 'sia-source'; token: string }\n | {\n type: 'sia-stream-error'\n token: string\n message: string\n offset?: number\n length?: number\n }\n | { type: 'sia-stream-status'; token: string; status: StreamStatus }\n | {\n type: 'sia-stream-progress'\n token: string\n // Bytes served since the previous progress message.\n bytes: number\n shards: ShardProgress[]\n }\n\n/** The worker's answer to `sia-client`. */\nexport type ClientReply = { clientId: string; supported: boolean }\n\n/** The page's answer to `sia-source`. */\nexport type SourceReply =\n | ({ type: 'source' } & SourceDetails)\n | { type: 'error' }\n\n// Stream URLs sit under the worker's scope, so a download's navigation, which\n// only a worker whose scope covers the URL can answer, reaches it on sites\n// served from a sub-path too.\nconst STREAM_PATH = /\\/__sia_stream__\\/([^/]+)\\/([^/]+)\\/[^/]+$/\n\n/** `scope` is the worker registration's scope URL, which ends in a slash. */\nexport function streamPath(scope: string, clientId: string, token: string) {\n return `${scope}__sia_stream__/${clientId}/${token}/file`\n}\n\nexport function parseStreamPath(pathname: string) {\n const match = STREAM_PATH.exec(pathname)\n return match ? { clientId: match[1]!, token: match[2]! } : undefined\n}\n\ntype Fields = Record<string, unknown> | null | undefined\n\nconst strings = (data: Fields, fields: string[]) =>\n fields.every((field) => typeof data?.[field] === 'string')\n\n/** Checked on both sides, so bad metadata fails in `openStreams`, not per request. */\nexport function isAppMetadata(value: unknown): value is AppMetadata {\n return strings(value as Fields, ['appId', 'name', 'description', 'serviceUrl'])\n}\n\n// 32 bytes as hex, which is what the worker builds an AppKey from.\nconst isKeyHex = (value: unknown) =>\n typeof value === 'string' && /^[0-9a-f]{64}$/i.test(value)\n\nfunction isConnection(value: unknown): value is Connection {\n const data = value as Fields\n if (data?.kind === 'shared') {\n return (\n strings(data, ['indexerUrl', 'seed']) &&\n (data.sealKey === undefined || isKeyHex(data.sealKey))\n )\n }\n return (\n data?.kind === 'app' &&\n strings(data, ['indexerUrl', 'appKey']) &&\n isAppMetadata(data.appMeta)\n )\n}\n\nexport function isSourceReply(\n reply: unknown,\n): reply is { type: 'source' } & SourceDetails {\n const data = reply as Fields\n return (\n data?.type === 'source' &&\n strings(data, ['session', 'objectId', 'name', 'mime']) &&\n isConnection(data.connection) &&\n (data.connection.kind === 'shared' ||\n (typeof data.sealed === 'object' && data.sealed !== null)) &&\n Number.isSafeInteger(data.size) &&\n (data.size as number) >= 0 &&\n (data.events === undefined ||\n (Array.isArray(data.events) &&\n data.events.every((event) => STREAM_EVENTS.includes(event))))\n )\n}\n\n/**\n * Posts `message` with a reply port and resolves with the first reply. Rejects\n * after `timeout` ms, or when `signal` aborts.\n */\nexport function requestReply(\n target: { postMessage(message: unknown, transfer: Transferable[]): void },\n message: unknown,\n { timeout, signal }: { timeout?: number; signal?: AbortSignal },\n): Promise<unknown> {\n return new Promise((resolve, reject) => {\n const { port1, port2 } = new MessageChannel()\n const timer =\n timeout === undefined\n ? undefined\n : setTimeout(\n () => settle(() => reject(new Error('No reply in time.'))),\n timeout,\n )\n const abort = () => settle(() => reject(signal?.reason))\n function settle(outcome: () => void) {\n clearTimeout(timer)\n signal?.removeEventListener('abort', abort)\n port1.close()\n port2.close()\n outcome()\n }\n if (signal?.aborted) return abort()\n signal?.addEventListener('abort', abort, { once: true })\n port1.onmessage = ({ data }) => settle(() => resolve(data))\n port1.onmessageerror = () =>\n settle(() => reject(new Error('Unreadable reply.')))\n try {\n target.postMessage(message, [port2])\n } catch (error) {\n settle(() => reject(error))\n }\n })\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,8 +1,53 @@
1
- import { AppMetadata, Sdk, SharedSdk, PinnedObject } from '../wasm/sia_storage_wasm.js';
2
- export { Account, AppKey, AppMetadata, Builder, DownloadOptions, Host, HostQuery, KeyRecord, KeyStats, ObjectEvent, PackedUpload, PinnedObject, PinnedSlab, Sdk, SealedObject, Sector, ShardProgress, SharedSdk, SharingKey, Slab, UploadOptions, encodedSize, generateRecoveryPhrase, setLogger, validateRecoveryPhrase } from '../wasm/sia_storage_wasm.js';
1
+ import { ShardProgress, AppMetadata, Sdk, SharedSdk, PinnedObject } from '../wasm/sia_storage_wasm.js';
2
+ export { Account, AppKey, AppMetadata, Builder, DownloadOptions, Host, HostQuery, KeyRecord, KeyStats, ObjectEvent, ObjectSummary, PackedUpload, PinnedObject, Sdk, SealedObject, Sector, ShardProgress, SharedSdk, SharingKey, Slab, UploadOptions, encodedSize, generateRecoveryPhrase, setLogger, validateRecoveryPhrase } from '../wasm/sia_storage_wasm.js';
3
3
 
4
4
  declare function detectPrivateRelay(): Promise<boolean>;
5
5
 
6
+ /**
7
+ * Messages between a page and its streaming service worker.
8
+ *
9
+ * Stream URLs carry only the page's client id and a random token. When the
10
+ * worker meets a token it does not know, it asks the page that made it for the
11
+ * file and the share's credentials. The page and a worker can come from
12
+ * different deployed versions, so the worker checks the page's reply before
13
+ * using it.
14
+ */
15
+
16
+ /**
17
+ * What a stream URL is doing. `connecting` is a request waiting for its first
18
+ * bytes, which on the URL's first request includes the worker connecting its
19
+ * SDK and finding the object. `downloading` means bytes are arriving. `idle`
20
+ * means no request for the URL is in flight, after the last one finished, was
21
+ * cancelled or failed.
22
+ */
23
+ type StreamStatus = 'connecting' | 'downloading' | 'idle';
24
+
25
+ /**
26
+ * Previews and downloads for pages that cannot stream through the service
27
+ * worker, where the page reads the object itself. A preview holds the whole
28
+ * file in memory as a Blob. A download writes straight to disk through the
29
+ * save picker where the browser has one, and buffers a Blob where it does not.
30
+ */
31
+
32
+ /** What a file reports while it downloads, on the worker or in the page. */
33
+ type FileEvents = {
34
+ /**
35
+ * Total bytes this URL has received. It is a transfer count, not how much of
36
+ * the file is held: a range read again after a seek counts again, so it can
37
+ * pass the file size, and a new URL for the same file starts from zero. For
38
+ * how much of a video is buffered, read the media element's `buffered`.
39
+ */
40
+ onProgress?: (bytesDownloaded: number) => void;
41
+ /**
42
+ * One call per piece the SDK read from a host. A download reads each slab in
43
+ * chunks, so the same slab and shard come again for every chunk, and only
44
+ * the first `minShards` hosts to answer for a chunk are reported.
45
+ */
46
+ onShard?: (progress: ShardProgress) => void;
47
+ /** Each change of what the URL is doing. See `StreamStatus`. */
48
+ onStatus?: (status: StreamStatus) => void;
49
+ };
50
+
6
51
  /**
7
52
  * The page side of streaming.
8
53
  *
@@ -34,21 +79,29 @@ type AppCredentials = {
34
79
  };
35
80
  /** The credentials `openStreams` takes for each kind of SDK. */
36
81
  type CredentialsFor<S extends Sdk | SharedSdk> = S extends SharedSdk ? SharedCredentials : AppCredentials;
37
- type FileOptions = {
82
+ type FileOptions = FileEvents & {
38
83
  /** Used for downloads and for the browser's media handling. */
39
84
  name: string;
40
85
  /** The MIME type. Defaults to `application/octet-stream`. */
41
86
  type?: string;
42
87
  /** A failure after streaming started. Without it, failures are logged. */
43
88
  onError?: (message: string) => void;
44
- /** Bytes read so far, when the page reads the object itself. */
45
- onProgress?: (bytesDownloaded: number) => void;
46
89
  };
47
90
  /** A URL for an `<img>`, `<video>`, `<audio>`, `<iframe>` or `fetch`. */
48
91
  type StreamedFile = {
49
92
  url: string;
50
93
  /** The whole file, when the page read it itself instead of streaming. */
51
94
  blob?: Blob;
95
+ /**
96
+ * Opens connections to the hosts holding the start of the file, so a play
97
+ * that follows gets its first bytes sooner. It reads one byte, which asks
98
+ * each host of the first slab for a single 64-byte segment, and reports no
99
+ * status or progress. Resolves when the byte arrives, or at once for a file
100
+ * the page read itself. Never rejects, since a play still works without it.
101
+ * Call it when a play is likely, such as when the pointer reaches a Play
102
+ * button. The earlier before the play, the more it saves.
103
+ */
104
+ warm(): Promise<void>;
52
105
  /** Frees the URL. Call it when the element showing it goes away. */
53
106
  release(): void;
54
107
  };
@@ -91,4 +144,4 @@ declare function openStreams<S extends Sdk | SharedSdk>(sdk: S, credentials: Cre
91
144
  /** Initialize the WASM module. Safe to call multiple times. */
92
145
  declare function initSia(): Promise<void>;
93
146
 
94
- export { type AppCredentials, type CredentialsFor, type FileOptions, type SharedCredentials, type StreamedFile, type StreamingOptions, type Streams, detectPrivateRelay, enableStreaming, initSia, openStreams };
147
+ export { type AppCredentials, type CredentialsFor, type FileEvents, type FileOptions, type SharedCredentials, type StreamStatus, type StreamedFile, type StreamingOptions, type Streams, detectPrivateRelay, enableStreaming, initSia, openStreams };
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  requestReply,
5
5
  streamErrorMessage,
6
6
  streamPath
7
- } from "./chunk-QYQQIWFH.js";
7
+ } from "./chunk-YOMDUMYW.js";
8
8
 
9
9
  // src/index.ts
10
10
  import wasmInit from "../wasm/sia_storage_wasm.js";
@@ -31,26 +31,61 @@ async function checkPrivateRelay(userAgent, request) {
31
31
  }
32
32
  }
33
33
 
34
+ // src/wasm.ts
35
+ import { AppKey } from "../wasm/sia_storage_wasm.js";
36
+
34
37
  // src/stream/fallback.ts
35
38
  function chunks(stream) {
36
39
  return stream.getReader();
37
40
  }
38
- async function readBlob(sdk, object, type, onProgress, signal) {
39
- const reader = chunks(sdk.download(object));
40
- const onAbort = () => void reader.cancel();
41
+ function notify(callback, value) {
42
+ if (!callback) return;
43
+ try {
44
+ callback(value);
45
+ } catch (error) {
46
+ reportError(error);
47
+ }
48
+ }
49
+ function download(sdk, object, events) {
50
+ const { onShard } = events;
51
+ return sdk.download(
52
+ object,
53
+ onShard ? { onShardDownloaded: (shard) => notify(onShard, shard) } : {}
54
+ );
55
+ }
56
+ function track({ onProgress, onStatus }) {
57
+ let received = 0;
58
+ let started = false;
59
+ notify(onStatus, "connecting");
60
+ return {
61
+ chunk(length) {
62
+ if (!started) notify(onStatus, "downloading");
63
+ started = true;
64
+ received += length;
65
+ notify(onProgress, received);
66
+ },
67
+ end() {
68
+ notify(onStatus, "idle");
69
+ }
70
+ };
71
+ }
72
+ async function readBlob(sdk, object, type, events, signal) {
73
+ const progress = track(events);
74
+ let reader;
75
+ const onAbort = () => void reader?.cancel();
41
76
  signal.addEventListener("abort", onAbort);
42
77
  const parts = [];
43
- let bytesDownloaded = 0;
44
78
  try {
79
+ reader = chunks(download(sdk, object, events));
45
80
  while (true) {
46
81
  const { done, value } = await reader.read();
47
82
  if (done) break;
48
83
  parts.push(value);
49
- bytesDownloaded += value.length;
50
- onProgress(bytesDownloaded);
84
+ progress.chunk(value.length);
51
85
  }
52
86
  } finally {
53
87
  signal.removeEventListener("abort", onAbort);
88
+ progress.end();
54
89
  }
55
90
  signal.throwIfAborted();
56
91
  return new Blob(parts, type ? { type } : {});
@@ -63,14 +98,14 @@ function saveBlob(blob, name) {
63
98
  a.click();
64
99
  setTimeout(() => URL.revokeObjectURL(url), 0);
65
100
  }
66
- async function saveToDisk(sdk, object, name, type, onProgress) {
101
+ async function saveToDisk(sdk, object, name, type, events) {
67
102
  const picker = window.showSaveFilePicker;
68
103
  if (!picker) {
69
104
  const blob = await readBlob(
70
105
  sdk,
71
106
  object,
72
107
  type,
73
- onProgress,
108
+ events,
74
109
  new AbortController().signal
75
110
  );
76
111
  saveBlob(blob, name);
@@ -83,16 +118,19 @@ async function saveToDisk(sdk, object, name, type, onProgress) {
83
118
  if (e instanceof DOMException && e.name === "AbortError") return false;
84
119
  throw e;
85
120
  }
86
- let bytesDownloaded = 0;
87
- const progress = new TransformStream({
121
+ const file = await handle.createWritable();
122
+ const progress = track(events);
123
+ const counter = new TransformStream({
88
124
  transform(chunk, controller) {
89
- bytesDownloaded += chunk.length;
90
- onProgress(bytesDownloaded);
125
+ progress.chunk(chunk.length);
91
126
  controller.enqueue(chunk);
92
127
  }
93
128
  });
94
- const file = await handle.createWritable();
95
- await sdk.download(object).pipeThrough(progress).pipeTo(file);
129
+ try {
130
+ await download(sdk, object, events).pipeThrough(counter).pipeTo(file);
131
+ } finally {
132
+ progress.end();
133
+ }
96
134
  return true;
97
135
  }
98
136
 
@@ -181,12 +219,17 @@ function openStreams(sdk, credentials) {
181
219
  sdk,
182
220
  object,
183
221
  options.type,
184
- options.onProgress ?? (() => {
185
- }),
222
+ options,
186
223
  options.signal ?? new AbortController().signal
187
224
  );
188
225
  const url = URL.createObjectURL(blob);
189
- return { url, blob, release: () => URL.revokeObjectURL(url) };
226
+ return {
227
+ url,
228
+ blob,
229
+ warm: async () => {
230
+ },
231
+ release: () => URL.revokeObjectURL(url)
232
+ };
190
233
  },
191
234
  async download(object, options) {
192
235
  const stream = streamUrl(handle, object, options);
@@ -202,8 +245,7 @@ function openStreams(sdk, credentials) {
202
245
  object,
203
246
  options.name,
204
247
  options.type,
205
- options.onProgress ?? (() => {
206
- })
248
+ options
207
249
  );
208
250
  return saved ? "saved" : "cancelled";
209
251
  },
@@ -226,17 +268,35 @@ function openedFor(sdk, credentials) {
226
268
  appKey: toHex(appKey.export()),
227
269
  appMeta
228
270
  };
229
- return { session, connection, appKey };
271
+ return { session, connection, sealKey: appKey };
230
272
  }
231
273
  if (typeof indexerUrl !== "string" || typeof seed !== "string") {
232
274
  throw new TypeError("openStreams(SharedSdk) needs { indexerUrl, seed }.");
233
275
  }
234
- return { session, connection: { kind: "shared", indexerUrl, seed } };
276
+ const sealing = randomSealKey();
277
+ return {
278
+ session,
279
+ connection: {
280
+ kind: "shared",
281
+ indexerUrl,
282
+ seed,
283
+ ...sealing && { sealKey: sealing.hex }
284
+ },
285
+ ...sealing && { sealKey: sealing.key }
286
+ };
287
+ }
288
+ function randomSealKey() {
289
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
290
+ try {
291
+ return { key: new AppKey(bytes), hex: toHex(bytes) };
292
+ } catch {
293
+ return void 0;
294
+ }
235
295
  }
236
296
  function toHex(bytes) {
237
297
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
238
298
  }
239
- function streamUrl(handle, object, { name, type, onError }) {
299
+ function streamUrl(handle, object, { name, type, onError, onProgress, onShard, onStatus }) {
240
300
  if (!worker || !navigator.serviceWorker.controller || !opened.has(handle)) {
241
301
  return void 0;
242
302
  }
@@ -244,14 +304,26 @@ function streamUrl(handle, object, { name, type, onError }) {
244
304
  entries.set(token, {
245
305
  handle,
246
306
  objectId: object.id(),
247
- sealed: handle.appKey && object.seal(handle.appKey),
307
+ sealed: handle.sealKey && object.seal(handle.sealKey),
248
308
  size: object.size(),
249
309
  name,
250
310
  mime: type || "application/octet-stream",
251
- onError
311
+ onError,
312
+ events: { onProgress, onShard, onStatus },
313
+ received: 0
252
314
  });
315
+ const url = streamPath(worker.scope, worker.clientId, token);
253
316
  return {
254
- url: streamPath(worker.scope, worker.clientId, token),
317
+ url,
318
+ async warm() {
319
+ try {
320
+ const response = await fetch(`${url}?warm=1`, {
321
+ headers: { Range: "bytes=0-0" }
322
+ });
323
+ await response.arrayBuffer();
324
+ } catch {
325
+ }
326
+ },
255
327
  release() {
256
328
  entries.delete(token);
257
329
  const message = { type: "sia-release", token };
@@ -264,6 +336,7 @@ function releaseStreams(handle) {
264
336
  if (entry.handle === handle) entries.delete(token);
265
337
  }
266
338
  if (!opened.delete(handle)) return;
339
+ handle.sealKey?.free();
267
340
  const message = {
268
341
  type: "sia-release-share",
269
342
  session: handle.session
@@ -280,10 +353,25 @@ function onWorkerMessage(event) {
280
353
  const range = offset !== void 0 && length !== void 0 ? { offset, length } : void 0;
281
354
  if (entry.onError) entry.onError(streamErrorMessage(data.message));
282
355
  else reportStreamError(data.message, entry.name, range);
356
+ } else if (data?.type === "sia-stream-status") {
357
+ notify(entries.get(data.token)?.events.onStatus, data.status);
358
+ } else if (data?.type === "sia-stream-progress") {
359
+ const entry = entries.get(data.token);
360
+ if (!entry || !Array.isArray(data.shards)) return;
361
+ const { onProgress, onShard } = entry.events;
362
+ for (const shard of data.shards) notify(onShard, shard);
363
+ if (Number.isSafeInteger(data.bytes) && data.bytes > 0) {
364
+ entry.received += data.bytes;
365
+ notify(onProgress, entry.received);
366
+ }
283
367
  } else if (data?.type === "sia-source") {
284
368
  const port = event.ports[0];
285
369
  if (!port) return;
286
370
  const entry = entries.get(data.token);
371
+ const events = [];
372
+ if (entry?.events.onStatus) events.push("status");
373
+ if (entry?.events.onProgress) events.push("progress");
374
+ if (entry?.events.onShard) events.push("shards");
287
375
  const reply = entry ? {
288
376
  type: "source",
289
377
  session: entry.handle.session,
@@ -292,7 +380,8 @@ function onWorkerMessage(event) {
292
380
  ...entry.sealed && { sealed: entry.sealed },
293
381
  size: entry.size,
294
382
  mime: entry.mime,
295
- name: entry.name
383
+ name: entry.name,
384
+ ...events.length > 0 && { events }
296
385
  } : { type: "error" };
297
386
  port.postMessage(reply);
298
387
  port.close();
@@ -301,10 +390,11 @@ function onWorkerMessage(event) {
301
390
 
302
391
  // src/index.ts
303
392
  import {
304
- AppKey,
393
+ AppKey as AppKey2,
305
394
  Builder,
306
395
  KeyRecord,
307
396
  ObjectEvent,
397
+ ObjectSummary,
308
398
  PackedUpload,
309
399
  PinnedObject,
310
400
  Sdk,
@@ -321,10 +411,11 @@ async function initSia() {
321
411
  await initPromise;
322
412
  }
323
413
  export {
324
- AppKey,
414
+ AppKey2 as AppKey,
325
415
  Builder,
326
416
  KeyRecord,
327
417
  ObjectEvent,
418
+ ObjectSummary,
328
419
  PackedUpload,
329
420
  PinnedObject,
330
421
  Sdk,