edfcore 0.1.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.
Files changed (188) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +319 -0
  3. package/dist/bytes/latin1.d.ts +43 -0
  4. package/dist/bytes/latin1.d.ts.map +1 -0
  5. package/dist/bytes/latin1.js +84 -0
  6. package/dist/bytes/latin1.js.map +1 -0
  7. package/dist/bytes/numbers.d.ts +45 -0
  8. package/dist/bytes/numbers.d.ts.map +1 -0
  9. package/dist/bytes/numbers.js +87 -0
  10. package/dist/bytes/numbers.js.map +1 -0
  11. package/dist/bytes/view.d.ts +27 -0
  12. package/dist/bytes/view.d.ts.map +1 -0
  13. package/dist/bytes/view.js +53 -0
  14. package/dist/bytes/view.js.map +1 -0
  15. package/dist/constants.d.ts +113 -0
  16. package/dist/constants.d.ts.map +1 -0
  17. package/dist/constants.js +83 -0
  18. package/dist/constants.js.map +1 -0
  19. package/dist/decode/digital.d.ts +49 -0
  20. package/dist/decode/digital.d.ts.map +1 -0
  21. package/dist/decode/digital.js +180 -0
  22. package/dist/decode/digital.js.map +1 -0
  23. package/dist/decode/physical.d.ts +37 -0
  24. package/dist/decode/physical.d.ts.map +1 -0
  25. package/dist/decode/physical.js +178 -0
  26. package/dist/decode/physical.js.map +1 -0
  27. package/dist/diagnostics/codes.d.ts +38 -0
  28. package/dist/diagnostics/codes.d.ts.map +1 -0
  29. package/dist/diagnostics/codes.js +134 -0
  30. package/dist/diagnostics/codes.js.map +1 -0
  31. package/dist/diagnostics/collector.d.ts +76 -0
  32. package/dist/diagnostics/collector.d.ts.map +1 -0
  33. package/dist/diagnostics/collector.js +95 -0
  34. package/dist/diagnostics/collector.js.map +1 -0
  35. package/dist/diagnostics/format.d.ts +21 -0
  36. package/dist/diagnostics/format.d.ts.map +1 -0
  37. package/dist/diagnostics/format.js +125 -0
  38. package/dist/diagnostics/format.js.map +1 -0
  39. package/dist/errors.d.ts +143 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/errors.js +135 -0
  42. package/dist/errors.js.map +1 -0
  43. package/dist/header/dates.d.ts +101 -0
  44. package/dist/header/dates.d.ts.map +1 -0
  45. package/dist/header/dates.js +378 -0
  46. package/dist/header/dates.js.map +1 -0
  47. package/dist/header/fields.d.ts +105 -0
  48. package/dist/header/fields.d.ts.map +1 -0
  49. package/dist/header/fields.js +363 -0
  50. package/dist/header/fields.js.map +1 -0
  51. package/dist/header/identification.d.ts +47 -0
  52. package/dist/header/identification.d.ts.map +1 -0
  53. package/dist/header/identification.js +156 -0
  54. package/dist/header/identification.js.map +1 -0
  55. package/dist/header/lookup.d.ts +33 -0
  56. package/dist/header/lookup.d.ts.map +1 -0
  57. package/dist/header/lookup.js +67 -0
  58. package/dist/header/lookup.js.map +1 -0
  59. package/dist/header/parse.d.ts +32 -0
  60. package/dist/header/parse.d.ts.map +1 -0
  61. package/dist/header/parse.js +319 -0
  62. package/dist/header/parse.js.map +1 -0
  63. package/dist/header/scale.d.ts +68 -0
  64. package/dist/header/scale.d.ts.map +1 -0
  65. package/dist/header/scale.js +154 -0
  66. package/dist/header/scale.js.map +1 -0
  67. package/dist/header/signals.d.ts +64 -0
  68. package/dist/header/signals.d.ts.map +1 -0
  69. package/dist/header/signals.js +374 -0
  70. package/dist/header/signals.js.map +1 -0
  71. package/dist/header/variant.d.ts +67 -0
  72. package/dist/header/variant.d.ts.map +1 -0
  73. package/dist/header/variant.js +180 -0
  74. package/dist/header/variant.js.map +1 -0
  75. package/dist/index.d.ts +46 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +59 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/inspect.d.ts +31 -0
  80. package/dist/inspect.d.ts.map +1 -0
  81. package/dist/inspect.js +165 -0
  82. package/dist/inspect.js.map +1 -0
  83. package/dist/io/blob.d.ts +14 -0
  84. package/dist/io/blob.d.ts.map +1 -0
  85. package/dist/io/blob.js +29 -0
  86. package/dist/io/blob.js.map +1 -0
  87. package/dist/io/bytes.d.ts +11 -0
  88. package/dist/io/bytes.d.ts.map +1 -0
  89. package/dist/io/bytes.js +26 -0
  90. package/dist/io/bytes.js.map +1 -0
  91. package/dist/io/cached.d.ts +19 -0
  92. package/dist/io/cached.d.ts.map +1 -0
  93. package/dist/io/cached.js +129 -0
  94. package/dist/io/cached.js.map +1 -0
  95. package/dist/io/http.d.ts +20 -0
  96. package/dist/io/http.d.ts.map +1 -0
  97. package/dist/io/http.js +225 -0
  98. package/dist/io/http.js.map +1 -0
  99. package/dist/io/read.d.ts +42 -0
  100. package/dist/io/read.d.ts.map +1 -0
  101. package/dist/io/read.js +119 -0
  102. package/dist/io/read.js.map +1 -0
  103. package/dist/io/source.d.ts +33 -0
  104. package/dist/io/source.d.ts.map +1 -0
  105. package/dist/io/source.js +72 -0
  106. package/dist/io/source.js.map +1 -0
  107. package/dist/node.d.ts +70 -0
  108. package/dist/node.d.ts.map +1 -0
  109. package/dist/node.js +109 -0
  110. package/dist/node.js.map +1 -0
  111. package/dist/record-index.d.ts +58 -0
  112. package/dist/record-index.d.ts.map +1 -0
  113. package/dist/record-index.js +290 -0
  114. package/dist/record-index.js.map +1 -0
  115. package/dist/recording.d.ts +57 -0
  116. package/dist/recording.d.ts.map +1 -0
  117. package/dist/recording.js +178 -0
  118. package/dist/recording.js.map +1 -0
  119. package/dist/tal/annotations.d.ts +36 -0
  120. package/dist/tal/annotations.d.ts.map +1 -0
  121. package/dist/tal/annotations.js +437 -0
  122. package/dist/tal/annotations.js.map +1 -0
  123. package/dist/tal/grammar.d.ts +111 -0
  124. package/dist/tal/grammar.d.ts.map +1 -0
  125. package/dist/tal/grammar.js +324 -0
  126. package/dist/tal/grammar.js.map +1 -0
  127. package/dist/tal/ticks.d.ts +69 -0
  128. package/dist/tal/ticks.d.ts.map +1 -0
  129. package/dist/tal/ticks.js +137 -0
  130. package/dist/tal/ticks.js.map +1 -0
  131. package/dist/time/segments.d.ts +33 -0
  132. package/dist/time/segments.d.ts.map +1 -0
  133. package/dist/time/segments.js +87 -0
  134. package/dist/time/segments.js.map +1 -0
  135. package/dist/time/timeline.d.ts +62 -0
  136. package/dist/time/timeline.d.ts.map +1 -0
  137. package/dist/time/timeline.js +227 -0
  138. package/dist/time/timeline.js.map +1 -0
  139. package/dist/time/window.d.ts +54 -0
  140. package/dist/time/window.d.ts.map +1 -0
  141. package/dist/time/window.js +232 -0
  142. package/dist/time/window.js.map +1 -0
  143. package/dist/types.d.ts +409 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +16 -0
  146. package/dist/types.js.map +1 -0
  147. package/dist/validate.d.ts +53 -0
  148. package/dist/validate.d.ts.map +1 -0
  149. package/dist/validate.js +483 -0
  150. package/dist/validate.js.map +1 -0
  151. package/package.json +77 -0
  152. package/src/bytes/latin1.ts +87 -0
  153. package/src/bytes/numbers.ts +115 -0
  154. package/src/bytes/view.ts +59 -0
  155. package/src/constants.ts +96 -0
  156. package/src/decode/digital.ts +258 -0
  157. package/src/decode/physical.ts +235 -0
  158. package/src/diagnostics/codes.ts +209 -0
  159. package/src/diagnostics/collector.ts +129 -0
  160. package/src/diagnostics/format.ts +143 -0
  161. package/src/errors.ts +208 -0
  162. package/src/header/dates.ts +453 -0
  163. package/src/header/fields.ts +455 -0
  164. package/src/header/identification.ts +196 -0
  165. package/src/header/lookup.ts +81 -0
  166. package/src/header/parse.ts +398 -0
  167. package/src/header/scale.ts +268 -0
  168. package/src/header/signals.ts +550 -0
  169. package/src/header/variant.ts +239 -0
  170. package/src/index.ts +151 -0
  171. package/src/inspect.ts +181 -0
  172. package/src/io/blob.ts +31 -0
  173. package/src/io/bytes.ts +29 -0
  174. package/src/io/cached.ts +136 -0
  175. package/src/io/http.ts +299 -0
  176. package/src/io/read.ts +152 -0
  177. package/src/io/source.ts +87 -0
  178. package/src/node.ts +138 -0
  179. package/src/record-index.ts +389 -0
  180. package/src/recording.ts +234 -0
  181. package/src/tal/annotations.ts +574 -0
  182. package/src/tal/grammar.ts +540 -0
  183. package/src/tal/ticks.ts +172 -0
  184. package/src/time/segments.ts +109 -0
  185. package/src/time/timeline.ts +290 -0
  186. package/src/time/window.ts +304 -0
  187. package/src/types.ts +490 -0
  188. package/src/validate.ts +610 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The only cache in edfcore: opt-in, visible at the call site, and removed by deleting one
3
+ * wrapper from the expression that built the source.
4
+ *
5
+ * Layer 5. A block-aligned LRU over an arbitrary `ByteSource`. Two properties matter more than
6
+ * the hit rate:
7
+ *
8
+ * 1. A read returns a COPY, never a view into a retained block. `types.ts` gives the returned
9
+ * array to the caller, and a caller who writes into it must not be able to corrupt what the
10
+ * next reader sees.
11
+ * 2. Concurrent reads wanting the same block issue ONE underlying read. Over HTTP the
12
+ * difference between one request and eight is the difference between usable and not.
13
+ *
14
+ * Block boundaries are byte-aligned, not record-aligned: this module is format-independent by
15
+ * construction and has no access to a header, so there is no record size to align to.
16
+ */
17
+
18
+ import type { ByteSource, CacheOptions, ReadOptions } from '../types.js';
19
+ import { assertExactRead, assertReadRange, throwIfAborted } from './source.js';
20
+
21
+ const DEFAULT_BLOCK_BYTES = 1024 * 1024;
22
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
23
+
24
+ /** Delegation with the contract still enforced, for configurations that cannot cache. */
25
+ function passThrough(source: ByteSource): ByteSource {
26
+ return {
27
+ byteLength: source.byteLength,
28
+ async read(offset: number, length: number, options?: ReadOptions): Promise<Uint8Array> {
29
+ return assertExactRead(await source.read(offset, length, options), offset, length);
30
+ },
31
+ async close(): Promise<void> {
32
+ await source.close?.();
33
+ },
34
+ };
35
+ }
36
+
37
+ export function cachedSource(source: ByteSource, options?: CacheOptions): ByteSource {
38
+ const byteLength = source.byteLength;
39
+ const maxBytes = Math.max(0, Math.floor(options?.maxBytes ?? DEFAULT_MAX_BYTES));
40
+ const requestedBlockBytes = Math.max(1, Math.floor(options?.blockBytes ?? DEFAULT_BLOCK_BYTES));
41
+ // A block wider than the whole budget would evict itself on every insert, so the block is
42
+ // clamped to the budget rather than the cache being left in a state that can never hold one.
43
+ const blockBytes = Math.min(requestedBlockBytes, maxBytes);
44
+ if (blockBytes < 1) return passThrough(source);
45
+
46
+ /** Insertion order is LRU order, oldest first; a hit re-inserts to move to the back. */
47
+ const blocks = new Map<number, Uint8Array>();
48
+ const inflight = new Map<number, Promise<Uint8Array>>();
49
+ let cachedBytes = 0;
50
+
51
+ function evict(): void {
52
+ // Deleting during Map iteration is well defined and never revisits an entry, so this walks
53
+ // strictly from the oldest block forward.
54
+ for (const [index, block] of blocks) {
55
+ if (cachedBytes <= maxBytes) return;
56
+ blocks.delete(index);
57
+ cachedBytes -= block.byteLength;
58
+ }
59
+ }
60
+
61
+ function admit(index: number, block: Uint8Array): void {
62
+ if (blocks.has(index)) return;
63
+ blocks.set(index, block);
64
+ cachedBytes += block.byteLength;
65
+ evict();
66
+ }
67
+
68
+ async function fetchBlock(index: number, options?: ReadOptions): Promise<Uint8Array> {
69
+ const start = index * blockBytes;
70
+ const length = Math.min(blockBytes, byteLength - start);
71
+ return assertExactRead(await source.read(start, length, options), start, length);
72
+ }
73
+
74
+ function blockFor(index: number, options?: ReadOptions): Promise<Uint8Array> {
75
+ const cached = blocks.get(index);
76
+ if (cached !== undefined) {
77
+ blocks.delete(index);
78
+ blocks.set(index, cached);
79
+ return Promise.resolve(cached);
80
+ }
81
+ const pending = inflight.get(index);
82
+ // Deduped readers share the first caller's options, which is inherent to issuing one read
83
+ // for all of them.
84
+ if (pending !== undefined) return pending;
85
+
86
+ const started = fetchBlock(index, options)
87
+ .then((block) => {
88
+ admit(index, block);
89
+ return block;
90
+ })
91
+ .finally(() => {
92
+ inflight.delete(index);
93
+ });
94
+ inflight.set(index, started);
95
+ return started;
96
+ }
97
+
98
+ return {
99
+ byteLength,
100
+ async read(offset: number, length: number, options?: ReadOptions): Promise<Uint8Array> {
101
+ throwIfAborted(options);
102
+ assertReadRange(offset, length, byteLength);
103
+ if (length === 0) return new Uint8Array(0);
104
+ // A read wider than the entire budget cannot benefit from the cache and would evict every
105
+ // block on its way through, so it goes straight to the source.
106
+ if (length > maxBytes) {
107
+ return assertExactRead(await source.read(offset, length, options), offset, length);
108
+ }
109
+
110
+ const firstBlock = Math.floor(offset / blockBytes);
111
+ const lastBlock = Math.floor((offset + length - 1) / blockBytes);
112
+ const pending: Array<Promise<Uint8Array>> = [];
113
+ for (let index = firstBlock; index <= lastBlock; index += 1) {
114
+ pending.push(blockFor(index, options));
115
+ }
116
+ const resolved = await Promise.all(pending);
117
+ throwIfAborted(options);
118
+
119
+ const out = new Uint8Array(length);
120
+ for (const [position, block] of resolved.entries()) {
121
+ const blockStart = (firstBlock + position) * blockBytes;
122
+ const from = Math.max(offset, blockStart);
123
+ const to = Math.min(offset + length, blockStart + block.byteLength);
124
+ if (to <= from) continue;
125
+ out.set(block.subarray(from - blockStart, to - blockStart), from - offset);
126
+ }
127
+ return assertExactRead(out, offset, length);
128
+ },
129
+ async close(): Promise<void> {
130
+ blocks.clear();
131
+ inflight.clear();
132
+ cachedBytes = 0;
133
+ await source.close?.();
134
+ },
135
+ };
136
+ }
package/src/io/http.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The HTTP Range adapter.
3
+ *
4
+ * Layer 5. Turns a URL into random access without downloading the recording, which is the
5
+ * whole reason a 13 GiB BDF can be opened in a browser tab.
6
+ *
7
+ * Three things here are load-bearing and easy to get wrong:
8
+ *
9
+ * 1. A byte range is INCLUSIVE at both ends. `bytes=0-0` is one byte.
10
+ * 2. A `200 OK` answer to a Range request means the server ignored the header and is sending
11
+ * the whole resource. That is refused by default — silently buffering gigabytes because a
12
+ * CDN is misconfigured is exactly the kind of invisible cost this library exists to refuse.
13
+ * 3. The source length must be known before any read, because random access is meaningless
14
+ * without it. Three ways are tried, cheapest first, and failing to find one is fatal.
15
+ */
16
+
17
+ import { EdfSourceError } from '../errors.js';
18
+ import type {
19
+ AbortSignalLike,
20
+ ByteSource,
21
+ FetchLike,
22
+ HttpResponseLike,
23
+ HttpSourceOptions,
24
+ ReadOptions,
25
+ } from '../types.js';
26
+ import { assertExactRead, assertReadRange, throwIfAborted } from './source.js';
27
+
28
+ /** Small enough to stay polite to a shared origin, large enough to hide latency. */
29
+ const DEFAULT_MAX_CONCURRENCY = 4;
30
+
31
+ const HTTP_PARTIAL_CONTENT = 206;
32
+ const HTTP_OK = 200;
33
+
34
+ type RequestInitLike = { headers: Record<string, string>; method?: string };
35
+
36
+ /**
37
+ * A promise semaphore. A released slot is handed straight to the next waiter rather than being
38
+ * returned to the pool, so the in-flight count can never overshoot the limit between the
39
+ * release and the waiter resuming on its microtask.
40
+ */
41
+ interface Gate {
42
+ acquire(): Promise<void>;
43
+ release(): void;
44
+ }
45
+
46
+ function createGate(limit: number): Gate {
47
+ let active = 0;
48
+ const waiting: Array<() => void> = [];
49
+
50
+ return {
51
+ async acquire(): Promise<void> {
52
+ if (active < limit) {
53
+ active += 1;
54
+ return;
55
+ }
56
+ await new Promise<void>((resolve) => {
57
+ waiting.push(resolve);
58
+ });
59
+ // The releaser transferred its slot to us, so `active` is deliberately left unchanged.
60
+ },
61
+ release(): void {
62
+ const next = waiting.shift();
63
+ if (next !== undefined) {
64
+ next();
65
+ return;
66
+ }
67
+ active -= 1;
68
+ },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * A real `Headers` lookup is case-insensitive, but `HttpResponseLike` is structural and a
74
+ * hand-written test double usually is not. Both spellings are tried.
75
+ */
76
+ function headerOf(response: HttpResponseLike, name: string): string | null {
77
+ return response.headers.get(name) ?? response.headers.get(name.toLowerCase());
78
+ }
79
+
80
+ function parseNonNegativeInteger(text: string | null): number | undefined {
81
+ if (text === null) return undefined;
82
+ const trimmed = text.trim();
83
+ if (!/^\d+$/.test(trimmed)) return undefined;
84
+ const value = Number(trimmed);
85
+ return Number.isSafeInteger(value) ? value : undefined;
86
+ }
87
+
88
+ /** `Content-Range: bytes 0-0/12345` -> 12345. A `/*` total is unknown, not zero. */
89
+ function totalFromContentRange(value: string | null): number | undefined {
90
+ if (value === null) return undefined;
91
+ const slash = value.lastIndexOf('/');
92
+ if (slash < 0) return undefined;
93
+ return parseNonNegativeInteger(value.slice(slash + 1));
94
+ }
95
+
96
+ function isSuccess(status: number): boolean {
97
+ return status >= 200 && status < 300;
98
+ }
99
+
100
+ function hrefOf(url: string | { readonly href: string }): string {
101
+ return typeof url === 'string' ? url : url.href;
102
+ }
103
+
104
+ function resolveFetch(options: HttpSourceOptions | undefined): FetchLike {
105
+ const provided = options?.fetch;
106
+ if (provided !== undefined) return provided;
107
+ // `fetch` cannot be named as a global without the DOM lib, so it is reached structurally.
108
+ const ambient = (globalThis as { fetch?: FetchLike }).fetch;
109
+ if (ambient !== undefined) return ambient;
110
+ throw new EdfSourceError(
111
+ 'httpSource() found no fetch implementation: this runtime does not expose globalThis.fetch ' +
112
+ 'and options.fetch was not given. Next: pass options.fetch with any function matching ' +
113
+ 'FetchLike.',
114
+ { offset: 0, requestedLength: 0 },
115
+ );
116
+ }
117
+
118
+ /**
119
+ * `FetchLike` deliberately does not name `signal`: naming it would pull in the real DOM
120
+ * `AbortSignal` by parameter contravariance, the exact dependency the shims exist to avoid. It
121
+ * is still handed to the implementation at runtime.
122
+ *
123
+ * It is attached only when it carries `addEventListener`, i.e. when it genuinely is an
124
+ * `AbortSignal`. The platform `fetch` throws a `TypeError` on anything else, and a caller who
125
+ * passed a bare `{ aborted }` shim is still served by the `throwIfAborted` polls around the
126
+ * request.
127
+ */
128
+ function attachSignal(init: RequestInitLike, signal: AbortSignalLike | undefined): void {
129
+ if (signal === undefined) return;
130
+ if (typeof (signal as { addEventListener?: unknown }).addEventListener !== 'function') return;
131
+ (init as { signal?: unknown }).signal = signal;
132
+ }
133
+
134
+ function request(
135
+ fetchImpl: FetchLike,
136
+ href: string,
137
+ headers: Record<string, string>,
138
+ method: 'GET' | 'HEAD',
139
+ signal: AbortSignalLike | undefined,
140
+ ): Promise<HttpResponseLike> {
141
+ const init: RequestInitLike = { headers, method };
142
+ attachSignal(init, signal);
143
+ return fetchImpl(href, init);
144
+ }
145
+
146
+ function rangeIgnoredError(href: string, offset: number, length: number): EdfSourceError {
147
+ const lastByte = offset + length - 1;
148
+ return new EdfSourceError(
149
+ `The server answered ${HTTP_OK} OK instead of ${HTTP_PARTIAL_CONTENT} Partial Content for ` +
150
+ `Range bytes=${offset}-${lastByte} on ${href}, so it ignored the Range header and is ` +
151
+ 'sending the whole resource (HTTP_RANGE_IGNORED). edfcore will not silently buffer a ' +
152
+ 'recording nobody asked for. Next: serve the file from an origin or CDN that supports ' +
153
+ 'byte ranges, or pass allowFullDownload: true to fetch it once and serve reads from ' +
154
+ 'memory.',
155
+ { offset, requestedLength: length },
156
+ );
157
+ }
158
+
159
+ /** What `httpSource` learns before it can serve a single read. */
160
+ interface ResolvedSource {
161
+ readonly byteLength: number;
162
+ /** Set only when the length probe already had to download everything. */
163
+ readonly body: Uint8Array | undefined;
164
+ }
165
+
166
+ async function resolveSource(
167
+ fetchImpl: FetchLike,
168
+ href: string,
169
+ baseHeaders: Record<string, string>,
170
+ options: HttpSourceOptions | undefined,
171
+ ): Promise<ResolvedSource> {
172
+ const signal = options?.signal;
173
+
174
+ const declared = options?.byteLength;
175
+ if (declared !== undefined) {
176
+ if (!Number.isSafeInteger(declared) || declared < 0) {
177
+ throw new EdfSourceError(
178
+ `httpSource() was given options.byteLength ${declared}, which is not a non-negative ` +
179
+ 'safe integer. Next: pass the real resource size in bytes, or omit it and let ' +
180
+ 'edfcore probe for it.',
181
+ { offset: 0, requestedLength: 0 },
182
+ );
183
+ }
184
+ return { byteLength: declared, body: undefined };
185
+ }
186
+
187
+ try {
188
+ const head = await request(fetchImpl, href, baseHeaders, 'HEAD', signal);
189
+ if (isSuccess(head.status)) {
190
+ const length = parseNonNegativeInteger(headerOf(head, 'Content-Length'));
191
+ if (length !== undefined) return { byteLength: length, body: undefined };
192
+ }
193
+ } catch {
194
+ // A rejected or forbidden HEAD is common (CORS, some object stores). Fall through to the
195
+ // one-byte range probe rather than failing on it.
196
+ }
197
+
198
+ const probeHeaders = { ...baseHeaders, Range: 'bytes=0-0' };
199
+ const probe = await request(fetchImpl, href, probeHeaders, 'GET', signal);
200
+
201
+ if (probe.status === HTTP_PARTIAL_CONTENT) {
202
+ const total = totalFromContentRange(headerOf(probe, 'Content-Range'));
203
+ if (total !== undefined) return { byteLength: total, body: undefined };
204
+ } else if (probe.status === HTTP_OK) {
205
+ // The probe already committed the server to sending everything. Refusing now costs the
206
+ // caller nothing, and accepting means one download instead of two.
207
+ if (options?.allowFullDownload !== true) throw rangeIgnoredError(href, 0, 1);
208
+ const body = new Uint8Array(await probe.arrayBuffer());
209
+ return { byteLength: body.byteLength, body };
210
+ } else if (!isSuccess(probe.status)) {
211
+ throw new EdfSourceError(
212
+ `httpSource() could not read ${href}: the server answered HTTP ${probe.status} to a ` +
213
+ 'Range probe. Next: check the URL, its authentication headers and its CORS policy.',
214
+ { offset: 0, requestedLength: 1 },
215
+ );
216
+ }
217
+
218
+ throw new EdfSourceError(
219
+ `httpSource() could not determine the size of ${href}: HEAD returned no usable ` +
220
+ 'Content-Length and a Range probe returned no Content-Range total, so no byte offset can ' +
221
+ 'be addressed and random access is impossible. Next: pass options.byteLength if you know ' +
222
+ 'the size, or serve the file from an origin that reports one.',
223
+ { offset: 0, requestedLength: 0 },
224
+ );
225
+ }
226
+
227
+ export async function httpSource(
228
+ url: string | { readonly href: string },
229
+ options?: HttpSourceOptions,
230
+ ): Promise<ByteSource> {
231
+ const href = hrefOf(url);
232
+ const fetchImpl = resolveFetch(options);
233
+ const baseHeaders: Record<string, string> = { ...options?.headers };
234
+ const gate = createGate(
235
+ Math.max(1, Math.floor(options?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY)),
236
+ );
237
+
238
+ const resolved = await resolveSource(fetchImpl, href, baseHeaders, options);
239
+ const byteLength = resolved.byteLength;
240
+ /** Set once, and only ever when the server ignored Range and the caller allowed it. */
241
+ let fullBody: Uint8Array | undefined = resolved.body;
242
+
243
+ async function fetchRange(
244
+ offset: number,
245
+ length: number,
246
+ readOptions: ReadOptions | undefined,
247
+ ): Promise<Uint8Array> {
248
+ const signal = readOptions?.signal ?? options?.signal;
249
+ // INCLUSIVE end: `bytes=0-0` is one byte, so the last addressed byte is offset+length-1.
250
+ const headers = { ...baseHeaders, Range: `bytes=${offset}-${offset + length - 1}` };
251
+
252
+ await gate.acquire();
253
+ try {
254
+ throwIfAborted(readOptions);
255
+ const response = await request(fetchImpl, href, headers, 'GET', signal);
256
+
257
+ if (response.status === HTTP_PARTIAL_CONTENT) {
258
+ const bytes = new Uint8Array(await response.arrayBuffer());
259
+ return assertExactRead(bytes, offset, length);
260
+ }
261
+
262
+ if (response.status === HTTP_OK) {
263
+ if (options?.allowFullDownload !== true) throw rangeIgnoredError(href, offset, length);
264
+ const body = new Uint8Array(await response.arrayBuffer());
265
+ // A concurrent read may have won the race; either copy is the same resource, so keep
266
+ // whichever landed first and let this one be collected.
267
+ if (fullBody === undefined) fullBody = body;
268
+ return sliceFullBody(fullBody, offset, length);
269
+ }
270
+
271
+ throw new EdfSourceError(
272
+ `Reading bytes ${offset}..${offset + length - 1} of ${href} failed: the server ` +
273
+ `answered HTTP ${response.status}. Next: check the URL, its authentication headers ` +
274
+ 'and whether a signed URL has expired.',
275
+ { offset, requestedLength: length },
276
+ );
277
+ } finally {
278
+ gate.release();
279
+ }
280
+ }
281
+
282
+ return {
283
+ byteLength,
284
+ async read(offset: number, length: number, readOptions?: ReadOptions): Promise<Uint8Array> {
285
+ throwIfAborted(readOptions);
286
+ assertReadRange(offset, length, byteLength);
287
+ if (length === 0) return new Uint8Array(0);
288
+ if (fullBody !== undefined) return sliceFullBody(fullBody, offset, length);
289
+ const bytes = await fetchRange(offset, length, readOptions);
290
+ throwIfAborted(readOptions);
291
+ return bytes;
292
+ },
293
+ };
294
+ }
295
+
296
+ /** `slice`, not `subarray`: the buffered body is retained state and the caller owns its result. */
297
+ function sliceFullBody(body: Uint8Array, offset: number, length: number): Uint8Array {
298
+ return assertExactRead(body.slice(offset, offset + length), offset, length);
299
+ }
package/src/io/read.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Header prefetch and record-range translation.
3
+ *
4
+ * Layer 6. Nothing here interprets a byte: this module decides WHICH bytes to ask a `ByteSource`
5
+ * for, and hands them to the pure layer unchanged. Two rules are the whole file.
6
+ *
7
+ * 1. A header costs exactly TWO source reads — 256 bytes to learn the signal count, then the
8
+ * remaining `256 * ns` as one range. Never one read per signal block, and never a speculative
9
+ * read of a size the file has not stated.
10
+ * 2. The unit of I/O is the RECORD RANGE, never the channel range. `readRecordBytes` issues one
11
+ * contiguous read covering every signal over the requested records, and de-interleaving
12
+ * happens in memory afterwards. There is no cheap single-channel read in EDF — ten seconds of
13
+ * one channel out of thirty is a 27x overread spread over ten requests, against a single
14
+ * 153,600-byte read for all thirty — and this API says so instead of hiding it.
15
+ *
16
+ * The exact-length contract is re-verified here even though every bundled adapter already checks
17
+ * it, because a `ByteSource` may be the caller's own and a silently short read is
18
+ * indistinguishable from a truncated file.
19
+ */
20
+
21
+ import { parseEdfInteger } from '../bytes/numbers.js';
22
+ import { readAsciiField } from '../bytes/view.js';
23
+ import {
24
+ DEFAULT_MAX_MATERIALIZE_BYTES,
25
+ EDF_HEADER_BLOCK_BYTES,
26
+ EDF_MAX_SIGNAL_COUNT,
27
+ HEADER_FIELDS,
28
+ } from '../constants.js';
29
+ import { EdfBudgetError, EdfRangeError } from '../errors.js';
30
+ import { parseHeader } from '../header/parse.js';
31
+ import type { ByteSource, EdfHeader, OpenOptions, ReadOptions, RecordRange } from '../types.js';
32
+ import { assertExactRead } from './source.js';
33
+
34
+ /**
35
+ * The signal count, read leniently and reported to nobody.
36
+ *
37
+ * This is a PREFETCH HINT and nothing else: it decides how many bytes the second read asks for.
38
+ * Every judgement about the field — the 1..9999 range, the layout, which diagnostic a bad value
39
+ * deserves and in which order — belongs to `header/parse.ts`, which owns the pinned check order.
40
+ * Returning `undefined` here simply means the second read is skipped and `parseHeader` gets the
41
+ * 256 bytes we have, so it can produce the right error rather than this module guessing at one.
42
+ */
43
+ function signalCountHint(fixedHeader: Uint8Array): number | undefined {
44
+ if (fixedHeader.length < EDF_HEADER_BLOCK_BYTES) return undefined;
45
+ const { offset, length } = HEADER_FIELDS.signalCount;
46
+ const parse = parseEdfInteger(readAsciiField(fixedHeader, offset, length));
47
+ if (!parse.ok || parse.value < 1 || parse.value > EDF_MAX_SIGNAL_COUNT) return undefined;
48
+ return parse.value;
49
+ }
50
+
51
+ /**
52
+ * Parse the header of `source`, reading it in exactly two ranges.
53
+ *
54
+ * Both reads are clamped to the source length so that a file too short for the header it declares
55
+ * reaches `parseHeader` and is reported as `SOURCE_TOO_SMALL` — a file defect — instead of
56
+ * surfacing as an `EdfSourceError` about a range past the end, which would blame the source for
57
+ * the file's problem.
58
+ */
59
+ export async function readHeader(source: ByteSource, options?: OpenOptions): Promise<EdfHeader> {
60
+ const sourceByteLength = source.byteLength;
61
+ const firstLength = Math.min(EDF_HEADER_BLOCK_BYTES, sourceByteLength);
62
+ const fixedHeader = assertExactRead(await source.read(0, firstLength, options), 0, firstLength);
63
+
64
+ const signalCount = signalCountHint(fixedHeader);
65
+ if (signalCount === undefined) return parseHeader(fixedHeader, sourceByteLength, options);
66
+
67
+ const headerByteLength = EDF_HEADER_BLOCK_BYTES * (signalCount + 1);
68
+ const remaining = Math.min(headerByteLength, sourceByteLength) - firstLength;
69
+ if (remaining <= 0) return parseHeader(fixedHeader, sourceByteLength, options);
70
+
71
+ const rest = assertExactRead(
72
+ await source.read(firstLength, remaining, options),
73
+ firstLength,
74
+ remaining,
75
+ );
76
+
77
+ // One contiguous buffer, because `parseHeader` addresses per-signal fields by absolute offset.
78
+ // At most 256 * 10000 bytes, so this allocation needs no budget of its own.
79
+ const headerBytes = new Uint8Array(firstLength + remaining);
80
+ headerBytes.set(fixedHeader, 0);
81
+ headerBytes.set(rest, firstLength);
82
+ return parseHeader(headerBytes, sourceByteLength, options);
83
+ }
84
+
85
+ function describeRange(range: RecordRange): string {
86
+ return `{ start: ${range.start}, count: ${range.count} }`;
87
+ }
88
+
89
+ function assertRecordRange(header: EdfHeader, records: RecordRange): void {
90
+ const available: RecordRange = { start: 0, count: header.recordCount };
91
+ const startValid = Number.isSafeInteger(records.start) && records.start >= 0;
92
+ const countValid = Number.isSafeInteger(records.count) && records.count >= 0;
93
+ if (startValid && countValid && records.start + records.count <= header.recordCount) return;
94
+ throw new EdfRangeError(
95
+ `readRecordBytes(): records ${describeRange(records)} is not inside the ` +
96
+ `${header.recordCount} data records this file contains. Next: clamp the range against ` +
97
+ 'header.recordCount, or call index.locate(seconds) to find a record index for a time.',
98
+ { requested: records, available },
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Refused BEFORE the allocation, never during it.
104
+ *
105
+ * A record range is the one read in edfcore whose size the caller controls directly, so it is the
106
+ * one that can take a browser tab down by honest arithmetic. A typed error naming both numbers
107
+ * beats an out-of-memory crash with no attribution.
108
+ */
109
+ function assertWithinBudget(
110
+ requiredBytes: number,
111
+ records: RecordRange,
112
+ options?: ReadOptions,
113
+ ): void {
114
+ const budgetBytes = options?.maxMaterializeBytes ?? DEFAULT_MAX_MATERIALIZE_BYTES;
115
+ if (requiredBytes <= budgetBytes) return;
116
+ throw new EdfBudgetError(
117
+ `Reading records ${describeRange(records)} needs a ${requiredBytes}-byte buffer, above the ` +
118
+ `${budgetBytes}-byte maxMaterializeBytes budget, so the read was refused before anything ` +
119
+ 'was allocated. Next: read fewer records per call, or raise options.maxMaterializeBytes.',
120
+ { requiredBytes, budgetBytes },
121
+ );
122
+ }
123
+
124
+ /**
125
+ * The bytes of a record range: ONE contiguous read covering every signal.
126
+ *
127
+ * The returned buffer is exactly `records.count * header.recordByteLength` bytes and begins at
128
+ * record `records.start`, which is precisely what `decodeDigital` and `decodeAnnotations` demand
129
+ * — pass it to them unsliced.
130
+ *
131
+ * A zero-record range issues no read at all. A zero-length HTTP range is not expressible (`bytes=
132
+ * n--1`), and there is nothing to fetch, so returning the empty buffer is both cheaper and more
133
+ * honest than asking for it.
134
+ */
135
+ export async function readRecordBytes(
136
+ source: ByteSource,
137
+ header: EdfHeader,
138
+ records: RecordRange,
139
+ options?: ReadOptions,
140
+ ): Promise<Uint8Array> {
141
+ assertRecordRange(header, records);
142
+ const byteLength = records.count * header.recordByteLength;
143
+ if (byteLength === 0) return new Uint8Array(0);
144
+
145
+ assertWithinBudget(byteLength, records, options);
146
+ const byteOffset = header.headerByteLength + records.start * header.recordByteLength;
147
+ return assertExactRead(
148
+ await source.read(byteOffset, byteLength, options),
149
+ byteOffset,
150
+ byteLength,
151
+ );
152
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The `ByteSource` contract guard.
3
+ *
4
+ * Layer 5, and the only file in `io/` that imports an error class. Nothing here knows anything
5
+ * about EDF.
6
+ *
7
+ * The contract is one sentence — a read resolves with EXACTLY `length` bytes or rejects, it
8
+ * never pads and never truncates — and it is *checked on every call*, including calls into a
9
+ * source the caller wrote. A source that quietly returns a short buffer is indistinguishable
10
+ * from a truncated file, so without this guard the parser would confidently report the wrong
11
+ * cause for the wrong thing.
12
+ */
13
+
14
+ import { EdfSourceError } from '../errors.js';
15
+ import type { ReadOptions } from '../types.js';
16
+
17
+ /**
18
+ * A misbehaving source may resolve with something that is not a byte array at all, which is
19
+ * why `EdfSourceError.receivedLength` is `number | undefined` rather than `number`.
20
+ */
21
+ function receivedLengthOf(received: unknown): number | undefined {
22
+ if (received === null || received === undefined) return undefined;
23
+ const length = (received as { length?: unknown }).length;
24
+ return typeof length === 'number' ? length : undefined;
25
+ }
26
+
27
+ /**
28
+ * Enforces the exact-length contract and returns the value unchanged so it can wrap a read
29
+ * expression directly.
30
+ */
31
+ export function assertExactRead(received: Uint8Array, offset: number, length: number): Uint8Array {
32
+ const receivedLength = receivedLengthOf(received);
33
+ if (receivedLength === length) return received;
34
+ const got =
35
+ receivedLength === undefined ? 'a value that is not a byte array' : `${receivedLength} bytes`;
36
+ throw new EdfSourceError(
37
+ `ByteSource.read(offset ${offset}, length ${length}) resolved with ${got}. A ByteSource ` +
38
+ 'must resolve with exactly the requested number of bytes or reject: padding or ' +
39
+ 'truncating makes a short read indistinguishable from a truncated file. Next: make ' +
40
+ 'read() loop until `length` bytes have arrived, and reject if they never do.',
41
+ { offset, requestedLength: length, receivedLength },
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Validates a requested range against the source length, with the real numbers in the message.
47
+ *
48
+ * Offsets are plain JS numbers throughout edfcore — exact to 2^53 — so this checks
49
+ * safe-integer-ness rather than truncating with `| 0`, which silently wraps past 2 GiB.
50
+ */
51
+ export function assertReadRange(offset: number, length: number, byteLength: number): void {
52
+ if (!Number.isSafeInteger(offset) || offset < 0) {
53
+ throw new EdfSourceError(
54
+ `ByteSource.read was given offset ${offset}, which is not a non-negative safe integer. ` +
55
+ 'Next: pass a plain integer byte offset; edfcore never truncates offsets to 32 bits.',
56
+ { offset, requestedLength: length },
57
+ );
58
+ }
59
+ if (!Number.isSafeInteger(length) || length < 0) {
60
+ throw new EdfSourceError(
61
+ `ByteSource.read was given length ${length}, which is not a non-negative safe integer. ` +
62
+ 'Next: pass a plain integer byte count.',
63
+ { offset, requestedLength: length },
64
+ );
65
+ }
66
+ if (offset + length > byteLength) {
67
+ throw new EdfSourceError(
68
+ `ByteSource.read(offset ${offset}, length ${length}) ends at byte ${offset + length}, ` +
69
+ `past the end of a ${byteLength}-byte source. Next: clamp the request, or check that ` +
70
+ 'the source was built over the whole file rather than a prefix of it.',
71
+ { offset, requestedLength: length },
72
+ );
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Aborts a read when the caller's signal is already aborted.
78
+ *
79
+ * `DOMException` cannot be named without the DOM lib, and the thing consumers actually branch
80
+ * on is `error.name === 'AbortError'`, so that is what this produces.
81
+ */
82
+ export function throwIfAborted(options?: ReadOptions): void {
83
+ if (options?.signal?.aborted !== true) return;
84
+ const error = new Error('The read was aborted through options.signal.');
85
+ error.name = 'AbortError';
86
+ throw error;
87
+ }