@botejs/core 0.5.0 → 0.7.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/dist/open.d.ts CHANGED
@@ -1,40 +1,47 @@
1
1
  import { type RootCursor } from './cursor.ts';
2
- import type { Source } from './sources.ts';
2
+ import type { SeekableSource, ForwardSource } from './source/base.ts';
3
3
  export declare const DEFAULT_SOURCE_CHUNK_BYTES: number;
4
4
  export interface OpenOptions {
5
5
  /**
6
- * Slot budget for the structural-index cache: one slot per cached container
7
- * plus one per tabled object member. When a scan tips the cache over this
8
- * budget, the deepest (least navigationally useful) containers are evicted
9
- * first, LRU-tiebroken, keeping the shallow backbone that resumes future
10
- * scans. Bounds resident cache memory regardless of document size. `0`
11
- * disables the cache entirely. Omit for the native default (1024).
6
+ * How much of the index that speeds up repeat lookups to keep in memory,
7
+ * measured in entries. Higher means faster repeat queries but more memory;
8
+ * lower means less memory but slower repeats. Set to `0` to turn the cache
9
+ * off. Defaults to 1024.
12
10
  */
13
11
  indexCacheEntries?: number;
14
12
  /**
15
- * Max object members tabled per walked container in the structural-index
16
- * cache. The table is a dense prefix; past the cap, lookups of later members
17
- * resume-scan from the cap boundary. Lower trades cache memory for resume work
18
- * on pathologically large objects. `0` disables object member indexing. Omit
19
- * for the native default (unbounded).
13
+ * How many keys per object to index for fast lookup. Higher speeds up access
14
+ * to keys later in large objects but uses more memory; lower saves memory at
15
+ * the cost of slower lookups for those keys. Set to `0` to skip indexing
16
+ * object keys. Defaults to unlimited.
20
17
  */
21
18
  objectMemberCap?: number;
22
19
  /**
23
- * Element-index stride between sampled array members in the structural-index
24
- * cache. A later index resumes from the nearest array member at or before it, so
25
- * a smaller stride means denser array members (more memory, shorter resume
26
- * scans). `0` disables array-member indexing. Omit for the native default (16).
27
- *
28
- * Setting both `objectMemberCap` and `arrayIndexInterval` to `0` disables the
29
- * cache entirely (no source bytes are ever cached either way), as does
30
- * `indexCacheEntries: 0`.
20
+ * How often to index array positions, e.g. every 16th element. Lower means
21
+ * faster access to arbitrary array elements but more memory; higher saves
22
+ * memory at the cost of slower access. Set to `0` to skip indexing array
23
+ * positions. Defaults to 16.
31
24
  */
32
25
  arrayIndexInterval?: number;
33
26
  }
34
27
  /**
35
- * Open a cursor over a seekable source.
28
+ * Options for a forward source: every cache knob is forbidden (the structural-index
29
+ * cache is forced off). `Omit` would collapse to `{}` and silently permit them, so
30
+ * the knobs are mapped to `never` to reject them at compile time.
31
+ */
32
+ export type ForwardOpenOptions = {
33
+ [K in keyof OpenOptions]?: never;
34
+ };
35
+ /**
36
+ * Open a cursor over a source.
37
+ *
38
+ * A seekable source (`fromFile`/`fromBuffer`/`fromHttpRange`) supports the cache
39
+ * and repeated, out-of-order queries. A forward source (`fromReadable`/`fromHttpStream`)
40
+ * is a single forward pass: the cache is forced off, so its cache knobs are
41
+ * rejected at compile time and at runtime.
36
42
  *
37
- * The returned `RootCursor` owns the reader: `close()` (or `await using`)
38
- * drives the reader's own `close()` exactly once.
43
+ * The returned `RootCursor` owns the reader: `close()` (or `await using`) drives
44
+ * the reader's own `close()` exactly once.
39
45
  */
40
- export declare function open(source: Source, options?: OpenOptions): Promise<RootCursor>;
46
+ export declare function open(source: SeekableSource, options?: OpenOptions): Promise<RootCursor>;
47
+ export declare function open(source: ForwardSource, options?: ForwardOpenOptions): Promise<RootCursor>;
package/dist/open.js CHANGED
@@ -1,28 +1,32 @@
1
1
  import { open as openNative } from '@botejs/native';
2
2
  import { wrap } from "./cursor.js";
3
3
  export const DEFAULT_SOURCE_CHUNK_BYTES = 64 * 1024;
4
- /**
5
- * Open a cursor over a seekable source.
6
- *
7
- * The returned `RootCursor` owns the reader: `close()` (or `await using`)
8
- * drives the reader's own `close()` exactly once.
9
- */
4
+ const CACHE_KNOBS = ['indexCacheEntries', 'objectMemberCap', 'arrayIndexInterval'];
10
5
  export async function open(source, options) {
11
- const { indexCacheEntries, objectMemberCap, arrayIndexInterval } = options ?? {};
12
- for (const [name, value] of [
13
- ['indexCacheEntries', indexCacheEntries],
14
- ['objectMemberCap', objectMemberCap],
15
- ['arrayIndexInterval', arrayIndexInterval],
16
- ]) {
6
+ if (!source.seekable) {
7
+ for (const name of CACHE_KNOBS) {
8
+ if (options?.[name] !== undefined) {
9
+ throw new RangeError(`open: ${name} is not allowed for a forward source; the structural-index cache is forced off`);
10
+ }
11
+ }
12
+ }
13
+ for (const name of CACHE_KNOBS) {
14
+ const value = options?.[name];
17
15
  if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
18
16
  throw new RangeError(`open: ${name} must be a non-negative integer (0 disables), got ${value}`);
19
17
  }
20
18
  }
19
+ // A forward source disables every cache dimension, so the engine never resolves
20
+ // a cached container offset into a backward read on a stream it cannot rewind.
21
+ const knobs = source.seekable ? options : { indexCacheEntries: 0, objectMemberCap: 0, arrayIndexInterval: 0 };
21
22
  const reader = await source.open();
22
23
  const chunkBytes = reader.chunkBytes ?? DEFAULT_SOURCE_CHUNK_BYTES;
23
24
  let native;
24
25
  try {
25
- if (!Number.isInteger(reader.size) || reader.size < 0) {
26
+ if (reader.seekable && reader.size === undefined) {
27
+ throw new RangeError('open: a seekable source must report a size');
28
+ }
29
+ if (reader.size !== undefined && (!Number.isInteger(reader.size) || reader.size < 0)) {
26
30
  throw new RangeError(`open: source size must be a non-negative integer, got ${reader.size}`);
27
31
  }
28
32
  if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0) {
@@ -34,10 +38,10 @@ export async function open(source, options) {
34
38
  native = openNative({
35
39
  size: reader.size,
36
40
  chunkBytes,
37
- indexCacheEntries,
38
- objectMemberCap,
39
- arrayIndexInterval,
40
- read: async ({ offset, length }) => reader.read(offset, length),
41
+ objectMemberCap: knobs?.objectMemberCap,
42
+ indexCacheEntries: knobs?.indexCacheEntries,
43
+ arrayIndexInterval: knobs?.arrayIndexInterval,
44
+ read: ({ offset, length }) => reader.read(offset, length),
41
45
  });
42
46
  }
43
47
  catch (err) {
@@ -46,15 +50,17 @@ export async function open(source, options) {
46
50
  await closeReader(reader);
47
51
  }
48
52
  catch (closeErr) {
49
- if (err instanceof Error)
53
+ if (err instanceof Error) {
50
54
  err.cause ??= closeErr;
55
+ }
51
56
  }
52
57
  throw err;
53
58
  }
54
59
  const state = { closed: false };
55
60
  const close = async () => {
56
- if (state.closed)
61
+ if (state.closed) {
57
62
  return;
63
+ }
58
64
  state.closed = true;
59
65
  await closeReader(reader);
60
66
  };
@@ -64,6 +70,7 @@ export async function open(source, options) {
64
70
  });
65
71
  }
66
72
  async function closeReader(reader) {
67
- if (reader.close)
73
+ if (reader.close) {
68
74
  await reader.close();
75
+ }
69
76
  }
package/dist/path.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- import type { Segment } from './validate.ts';
1
+ export type Segment = string | number;
2
+ export type Path = readonly Segment[];
2
3
  /** Upper bound on numeric segments (napi takes them as `u32`). 2^32 - 1
3
4
  * comfortably covers any in-memory JSON array. */
4
5
  export declare const MAX_ARRAY_INDEX = 4294967295;
5
6
  export declare function validatePath(path: readonly unknown[]): asserts path is readonly Segment[];
7
+ export declare function formatPath(path: Path): string;
package/dist/path.js CHANGED
@@ -4,17 +4,41 @@ export const MAX_ARRAY_INDEX = 0xffffffff;
4
4
  export function validatePath(path) {
5
5
  for (let i = 0; i < path.length; i++) {
6
6
  const s = path[i];
7
- if (typeof s === 'string')
7
+ if (typeof s === 'string') {
8
8
  continue;
9
- if (typeof s === 'number' && Number.isInteger(s) && s >= 0 && s <= MAX_ARRAY_INDEX)
9
+ }
10
+ if (typeof s === 'number' && Number.isInteger(s) && s >= 0 && s <= MAX_ARRAY_INDEX) {
10
11
  continue;
12
+ }
11
13
  throw new TypeError(`path segment ${i}: expected string or non-negative integer (<= ${MAX_ARRAY_INDEX}), got ${describeBadSegment(s)}`);
12
14
  }
13
15
  }
16
+ export function formatPath(path) {
17
+ if (path.length === 0) {
18
+ return '(root)';
19
+ }
20
+ let out = '';
21
+ for (let i = 0; i < path.length; i++) {
22
+ const seg = path[i];
23
+ if (typeof seg === 'number') {
24
+ out += `[${seg}]`;
25
+ continue;
26
+ }
27
+ if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(seg)) {
28
+ out += i === 0 ? seg : `.${seg}`;
29
+ }
30
+ else {
31
+ out += `[${JSON.stringify(seg)}]`;
32
+ }
33
+ }
34
+ return out;
35
+ }
14
36
  function describeBadSegment(s) {
15
- if (typeof s === 'number')
37
+ if (typeof s === 'number') {
16
38
  return `${s}`;
17
- if (s === null)
39
+ }
40
+ if (s === null) {
18
41
  return 'null';
42
+ }
19
43
  return typeof s;
20
44
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The bytes a `read` resolves to, plus an end-of-stream flag. `eof` is `true`
3
+ * iff this read reached the end of the underlying stream. A seekable reader can
4
+ * compute it from `size`; a forward reader discovers it as the stream drains.
5
+ */
6
+ export interface ReadResult {
7
+ readonly data: Uint8Array;
8
+ readonly eof: boolean;
9
+ }
10
+ /**
11
+ * A handle on an opened byte stream. The reader owns whatever resources back the
12
+ * stream (a file handle, a `fetch` body, an `AbortController`, etc.) and surfaces
13
+ * them through `close()`. Constructed by `Source.open()`; never by callers directly.
14
+ *
15
+ * `seekable` declares the access model:
16
+ * - `true`: `read(offset, length)` may be called at any offset, in any order.
17
+ * `size` is required. This random access is what lets the structural-index
18
+ * cache resume scans near a target.
19
+ * - `false`: a single forward pass. `read` is called with non-decreasing
20
+ * offsets; the cache is forced off. `size` may be omitted (the end is found
21
+ * via `eof`). Rewinding to an earlier offset re-acquires the stream (see
22
+ * `fromReadable`'s `rewind` option) or throws {@link ForwardReplayError}.
23
+ */
24
+ export interface Reader {
25
+ /** Whether reads may target any offset/order (`true`) or a single forward pass (`false`). */
26
+ readonly seekable: boolean;
27
+ /** Total length in bytes. Required for seekable readers; optional for forward ones. */
28
+ readonly size?: number;
29
+ /** Preferred read granularity in bytes. Must be a non-zero multiple of 64. */
30
+ readonly chunkBytes?: number;
31
+ /**
32
+ * Read up to `length` bytes starting at `offset`. `data.byteLength` is the
33
+ * actual count read (`<= length`); `eof` is `true` iff the read reached the
34
+ * end of the stream.
35
+ */
36
+ read(offset: number, length: number): Promise<ReadResult>;
37
+ /** Release resources held by the reader. Driven once by the `open()` lifecycle. */
38
+ close?(): Promise<void> | void;
39
+ }
40
+ /**
41
+ * Describes how to obtain a byte stream. `open()` is called once per cursor for
42
+ * a seekable source; a forward source's reader re-acquires its stream on demand.
43
+ * Provide your own object to plug in a custom backend.
44
+ */
45
+ export interface Source {
46
+ /** Mirrors {@link Reader.seekable}; lets `open()` enforce the right knobs at compile time. */
47
+ readonly seekable: boolean;
48
+ /** Acquire the stream. Resolves to a `Reader` that owns any underlying resources. */
49
+ open(): Promise<Reader>;
50
+ }
51
+ /**
52
+ * A {@link Source} statically known to be seekable (random access, cache-eligible).
53
+ * `open()` discriminates on this, so a custom backend must brand its `seekable`
54
+ * as the literal `true` (annotate the object `SeekableSource`) to be accepted.
55
+ */
56
+ export type SeekableSource = Source & {
57
+ readonly seekable: true;
58
+ };
59
+ /**
60
+ * A {@link Source} statically known to be forward-only (single pass, cache forced
61
+ * off). Brand a custom backend's object `ForwardSource` so `open()` accepts it and
62
+ * rejects cache knobs at compile time.
63
+ */
64
+ export type ForwardSource = Source & {
65
+ readonly seekable: false;
66
+ };
67
+ export interface FactoryOptions {
68
+ /** Override the factory's default chunk size. Must be a non-zero multiple of 64. */
69
+ chunkBytes?: number;
70
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import type { FactoryOptions, ForwardSource } from './base.ts';
2
+ /**
3
+ * A function that produces a fresh readable stream each time it is called. A
4
+ * forward reader invokes it once up front, and again on every re-acquisition, so
5
+ * it is also the seam for per-acquisition customization (a freshly minted auth
6
+ * token, a new `AbortSignal`, etc.).
7
+ */
8
+ export type ReadableProducer = () => NodeJS.ReadableStream | ReadableStream<Uint8Array> | Promise<NodeJS.ReadableStream | ReadableStream<Uint8Array>>;
9
+ export interface ReadableOptions extends FactoryOptions {
10
+ /** Known total length, if any. Forwarded to the engine so it can skip rediscovering the end. */
11
+ size?: number;
12
+ /** Transform applied to every (re)acquired stream, e.g. `s => s.pipeThrough(new DecompressionStream('gzip'))`. */
13
+ decode?: (raw: ReadableStream<Uint8Array>) => ReadableStream<Uint8Array>;
14
+ /**
15
+ * What a later query that must re-read from an earlier offset does. Defaults
16
+ * to `'forbid'`. The three settings trade resident memory for re-read ability:
17
+ * - `'forbid'`: a single forward pass; a rewind throws {@link ForwardReplayError}.
18
+ * - `'replay'`: re-acquire the stream from the start. Only safe when the
19
+ * producer is idempotent (yields the same bytes each call). No extra memory.
20
+ * - `'buffer'`: snapshot the whole stream into memory on first read, enabling
21
+ * random access at O(n) resident memory.
22
+ */
23
+ rewind?: 'forbid' | 'replay' | 'buffer';
24
+ }
25
+ export interface HttpRequestOptions extends Omit<ReadableOptions, 'size'> {
26
+ /** Merged into every `fetch` (headers, credentials, signal, etc.). */
27
+ init?: RequestInit;
28
+ }
29
+ /**
30
+ * A forward-only source backed by a re-openable readable stream. `produce` is
31
+ * called to acquire each pass: once up front, and again on a rewind when
32
+ * `rewind: 'replay'` is set. A plain `Readable` instance cannot be re-streamed,
33
+ * so pass a thunk (`() => createReadStream(path)`), not a live stream.
34
+ *
35
+ * Because every cursor operation is an independent scan from the start, a single
36
+ * forward pass serves exactly one query; a second query (or `hop` then `get`)
37
+ * rewinds. By default that throws {@link ForwardReplayError}; opt into
38
+ * `rewind: 'replay'` (idempotent producer) or `rewind: 'buffer'` (in-memory
39
+ * snapshot) for multi-query access.
40
+ */
41
+ export declare function fromReadable(produce: ReadableProducer, options?: ReadableOptions): ForwardSource;
42
+ /**
43
+ * A forward-only source over an HTTP request (GET is default), streamed in a single pass. A
44
+ * convenience wrapper around {@link fromReadable} whose producer re-fetches `url`
45
+ * (reusing `init`, so auth headers, credentials, and an `AbortSignal` survive
46
+ * each acquisition). For repeated or random access over HTTP, prefer the seekable
47
+ * {@link fromHttpRange}.
48
+ */
49
+ export declare function fromHttpRequest(url: string, options?: HttpRequestOptions): ForwardSource;
@@ -0,0 +1,219 @@
1
+ import { Readable } from 'node:stream';
2
+ import { ForwardReplayError } from "../error.js";
3
+ /** Default chunk size, in bytes, for forward streams: a large pull keeps a streamed scan moving. */
4
+ const DEFAULT_STREAM_CHUNK_BYTES = 256 * 1024;
5
+ const EMPTY = new Uint8Array(0);
6
+ /**
7
+ * A forward-only source backed by a re-openable readable stream. `produce` is
8
+ * called to acquire each pass: once up front, and again on a rewind when
9
+ * `rewind: 'replay'` is set. A plain `Readable` instance cannot be re-streamed,
10
+ * so pass a thunk (`() => createReadStream(path)`), not a live stream.
11
+ *
12
+ * Because every cursor operation is an independent scan from the start, a single
13
+ * forward pass serves exactly one query; a second query (or `hop` then `get`)
14
+ * rewinds. By default that throws {@link ForwardReplayError}; opt into
15
+ * `rewind: 'replay'` (idempotent producer) or `rewind: 'buffer'` (in-memory
16
+ * snapshot) for multi-query access.
17
+ */
18
+ export function fromReadable(produce, options) {
19
+ const chunkBytes = options?.chunkBytes ?? DEFAULT_STREAM_CHUNK_BYTES;
20
+ const size = options?.size;
21
+ const decode = options?.decode;
22
+ const rewind = options?.rewind ?? 'forbid';
23
+ return {
24
+ seekable: false,
25
+ open: () => makeForwardReader(produce, { chunkBytes, size, decode, rewind }),
26
+ };
27
+ }
28
+ /**
29
+ * A forward-only source over an HTTP request (GET is default), streamed in a single pass. A
30
+ * convenience wrapper around {@link fromReadable} whose producer re-fetches `url`
31
+ * (reusing `init`, so auth headers, credentials, and an `AbortSignal` survive
32
+ * each acquisition). For repeated or random access over HTTP, prefer the seekable
33
+ * {@link fromHttpRange}.
34
+ */
35
+ export function fromHttpRequest(url, options) {
36
+ const { init, ...readable } = options ?? {};
37
+ const produce = async () => {
38
+ const res = await fetch(url, { ...init });
39
+ if (!res.ok) {
40
+ throw new Error(`${url} failed: ${res.status} ${res.statusText}`);
41
+ }
42
+ if (!res.body) {
43
+ throw new Error(`${url} returned no body`);
44
+ }
45
+ return res.body;
46
+ };
47
+ return fromReadable(produce, readable);
48
+ }
49
+ async function makeForwardReader(produce, config) {
50
+ const { chunkBytes, size, decode, rewind } = config;
51
+ const replay = rewind === 'replay';
52
+ const buffer = rewind === 'buffer';
53
+ let reader = null;
54
+ let pos = 0; // bytes consumed from the active pass (served + skipped)
55
+ let leftover = null; // tail of the last pulled chunk, not yet served
56
+ let done = false;
57
+ let snapshot = null;
58
+ let chain = Promise.resolve();
59
+ const acquire = async () => {
60
+ let web = toWebStream(await produce());
61
+ if (decode) {
62
+ web = decode(web);
63
+ }
64
+ reader = web.getReader();
65
+ pos = 0;
66
+ leftover = null;
67
+ done = false;
68
+ };
69
+ const release = async () => {
70
+ const active = reader;
71
+ reader = null;
72
+ if (active) {
73
+ try {
74
+ await active.cancel();
75
+ }
76
+ catch {
77
+ // cancelling an already-errored/closed stream is not actionable
78
+ }
79
+ }
80
+ };
81
+ // Next run of bytes available from the active pass, or null once it is drained.
82
+ const pull = async () => {
83
+ if (leftover) {
84
+ const chunk = leftover;
85
+ leftover = null;
86
+ return chunk;
87
+ }
88
+ if (done || !reader) {
89
+ return null;
90
+ }
91
+ const { value, done: streamDone } = await reader.read();
92
+ if (streamDone) {
93
+ done = true;
94
+ return null;
95
+ }
96
+ return value;
97
+ };
98
+ const readForward = async (offset, length) => {
99
+ if (offset < pos) {
100
+ if (!replay) {
101
+ throw new ForwardReplayError(offset, pos);
102
+ }
103
+ await release();
104
+ await acquire();
105
+ }
106
+ if (!reader && !done) {
107
+ await acquire();
108
+ }
109
+ while (pos < offset) {
110
+ const chunk = await pull();
111
+ if (chunk === null) {
112
+ break; // stream ended before the requested offset; the read returns eof
113
+ }
114
+ const skip = offset - pos;
115
+ if (chunk.byteLength <= skip) {
116
+ pos += chunk.byteLength;
117
+ }
118
+ else {
119
+ leftover = chunk.subarray(skip);
120
+ pos += skip;
121
+ }
122
+ }
123
+ const parts = [];
124
+ let got = 0;
125
+ while (got < length) {
126
+ const chunk = await pull();
127
+ if (chunk === null) {
128
+ break;
129
+ }
130
+ if (chunk.byteLength === 0) {
131
+ continue;
132
+ }
133
+ const take = Math.min(chunk.byteLength, length - got);
134
+ parts.push(take === chunk.byteLength ? chunk : chunk.subarray(0, take));
135
+ if (take < chunk.byteLength) {
136
+ leftover = chunk.subarray(take);
137
+ }
138
+ pos += take;
139
+ got += take;
140
+ }
141
+ return { data: concat(parts, got), eof: done && leftover === null };
142
+ };
143
+ const readSnapshot = (offset, length) => {
144
+ const data = snapshot;
145
+ if (offset >= data.byteLength) {
146
+ return { data: EMPTY, eof: true };
147
+ }
148
+ const end = Math.min(offset + length, data.byteLength);
149
+ return { data: data.subarray(offset, end), eof: end >= data.byteLength };
150
+ };
151
+ // A forward pass has shared position state, so overlapping scans (e.g. two
152
+ // un-awaited queries) must not interleave their pulls. Serializing reads keeps
153
+ // bookkeeping intact and turns concurrent misuse into a ForwardReplayError.
154
+ const read = (offset, length) => {
155
+ const result = chain.then(async () => {
156
+ if (buffer) {
157
+ if (!snapshot) {
158
+ snapshot = await drainAll(produce, decode);
159
+ }
160
+ return readSnapshot(offset, length);
161
+ }
162
+ return readForward(offset, length);
163
+ });
164
+ chain = result.catch(() => { });
165
+ return result;
166
+ };
167
+ if (!buffer) {
168
+ await acquire();
169
+ }
170
+ return { seekable: false, size, chunkBytes, read, close: release };
171
+ }
172
+ async function drainAll(produce, decode) {
173
+ let web = toWebStream(await produce());
174
+ if (decode) {
175
+ web = decode(web);
176
+ }
177
+ const reader = web.getReader();
178
+ const parts = [];
179
+ let total = 0;
180
+ try {
181
+ for (;;) {
182
+ const { value, done } = await reader.read();
183
+ if (done) {
184
+ break;
185
+ }
186
+ if (value && value.byteLength > 0) {
187
+ parts.push(value);
188
+ total += value.byteLength;
189
+ }
190
+ }
191
+ }
192
+ finally {
193
+ try {
194
+ await reader.cancel();
195
+ }
196
+ catch {
197
+ // already drained/errored
198
+ }
199
+ }
200
+ return concat(parts, total);
201
+ }
202
+ function toWebStream(stream) {
203
+ if (typeof stream.getReader === 'function') {
204
+ return stream;
205
+ }
206
+ return Readable.toWeb(stream);
207
+ }
208
+ function concat(parts, total) {
209
+ if (parts.length === 1 && parts[0].byteLength === total) {
210
+ return parts[0];
211
+ }
212
+ const out = new Uint8Array(total);
213
+ let offset = 0;
214
+ for (const part of parts) {
215
+ out.set(part, offset);
216
+ offset += part.byteLength;
217
+ }
218
+ return out;
219
+ }
@@ -0,0 +1,8 @@
1
+ import type { FactoryOptions, SeekableSource } from './base.ts';
2
+ export interface HttpRangeOptions extends FactoryOptions {
3
+ /** Merged into every request (headers, credentials, signal, etc.). */
4
+ init?: RequestInit;
5
+ }
6
+ export declare function fromBuffer(buf: Uint8Array | ArrayBuffer, options?: FactoryOptions): SeekableSource;
7
+ export declare function fromFile(path: string, options?: FactoryOptions): SeekableSource;
8
+ export declare function fromHttpRange(url: string, options?: HttpRangeOptions): SeekableSource;
@@ -9,37 +9,47 @@ export function fromBuffer(buf, options) {
9
9
  const view = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
10
10
  const chunkBytes = options?.chunkBytes ?? DEFAULT_BUFFER_CHUNK_BYTES;
11
11
  return {
12
+ seekable: true,
12
13
  open: () => Promise.resolve({
14
+ seekable: true,
13
15
  size: view.byteLength,
14
16
  chunkBytes,
15
- read: (offset, length) => Promise.resolve(view.subarray(offset, Math.min(offset + length, view.byteLength))),
17
+ read: (offset, length) => {
18
+ const data = view.subarray(offset, Math.min(offset + length, view.byteLength));
19
+ return Promise.resolve({ data, eof: offset + data.byteLength >= view.byteLength });
20
+ },
16
21
  }),
17
22
  };
18
23
  }
19
24
  export function fromFile(path, options) {
20
25
  const chunkBytes = options?.chunkBytes ?? DEFAULT_FILE_CHUNK_BYTES;
21
26
  return {
27
+ seekable: true,
22
28
  open: async () => {
23
29
  const handle = await fsOpen(path, 'r');
24
30
  const stat = await handle.stat();
31
+ const size = stat.size;
25
32
  let closed = false;
26
33
  return {
27
- size: stat.size,
34
+ seekable: true,
35
+ size,
28
36
  chunkBytes,
29
37
  read: async (offset, length) => {
30
38
  const buf = Buffer.allocUnsafe(length);
31
39
  let filled = 0;
32
40
  while (filled < length) {
33
41
  const { bytesRead } = await handle.read(buf, filled, length - filled, offset + filled);
34
- if (bytesRead === 0)
42
+ if (bytesRead === 0) {
35
43
  break;
44
+ }
36
45
  filled += bytesRead;
37
46
  }
38
- return buf.subarray(0, filled);
47
+ return { data: buf.subarray(0, filled), eof: offset + filled >= size };
39
48
  },
40
49
  close: async () => {
41
- if (closed)
50
+ if (closed) {
42
51
  return;
52
+ }
43
53
  closed = true;
44
54
  await handle.close();
45
55
  },
@@ -51,6 +61,7 @@ export function fromHttpRange(url, options) {
51
61
  const init = options?.init;
52
62
  const chunkBytes = options?.chunkBytes ?? DEFAULT_URL_CHUNK_BYTES;
53
63
  return {
64
+ seekable: true,
54
65
  open: async () => {
55
66
  const controller = new AbortController();
56
67
  const userSignal = init?.signal;
@@ -79,6 +90,7 @@ export function fromHttpRange(url, options) {
79
90
  }
80
91
  let closed = false;
81
92
  return {
93
+ seekable: true,
82
94
  size,
83
95
  chunkBytes,
84
96
  read: async (offset, length) => {
@@ -87,21 +99,23 @@ export function fromHttpRange(url, options) {
87
99
  const headers = new Headers(init?.headers);
88
100
  headers.set('Range', `bytes=${offset}-${end}`);
89
101
  headers.set('Accept-Encoding', 'identity');
90
- const res = await fetch(url, { ...init, headers, method: 'GET', signal: controller.signal });
102
+ const res = await fetch(url, { ...init, headers, signal: controller.signal });
91
103
  if (res.status === 206) {
92
- return new Uint8Array(await res.arrayBuffer());
104
+ const data = new Uint8Array(await res.arrayBuffer());
105
+ return { data, eof: offset + data.byteLength >= size };
93
106
  }
94
107
  // A 200 means the server ignored our Range request and returned the full
95
108
  // body. We throw here since the point of using ranges is to not have to
96
109
  // buffer the whole thing in memory.
97
110
  if (res.status === 200) {
98
- throw new Error(`Range GET ${url} (bytes=${offset}-${end}) ignored Range and returned 200.`);
111
+ throw new Error(`Range ${url} (bytes=${offset}-${end}) ignored Range and returned 200.`);
99
112
  }
100
- throw new Error(`Range GET ${url} (bytes=${offset}-${end}) failed: ${res.status}`);
113
+ throw new Error(`Range ${url} (bytes=${offset}-${end}) failed: ${res.status}`);
101
114
  },
102
115
  close: async () => {
103
- if (closed)
116
+ if (closed) {
104
117
  return;
118
+ }
105
119
  closed = true;
106
120
  controller.abort();
107
121
  },