utilium 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A generic ArrayBufferView (typed array) constructor
3
+ */
4
+ export interface ArrayBufferViewConstructor {
5
+ readonly prototype: ArrayBufferView<ArrayBufferLike>;
6
+ new (length: number): ArrayBufferView<ArrayBuffer>;
7
+ new (array: ArrayLike<number>): ArrayBufferView<ArrayBuffer>;
8
+ new <TArrayBuffer extends ArrayBufferLike = ArrayBuffer>(buffer: TArrayBuffer, byteOffset?: number, length?: number): ArrayBufferView<TArrayBuffer>;
9
+ new (array: ArrayLike<number> | ArrayBuffer): ArrayBufferView<ArrayBuffer>;
10
+ }
11
+ /**
12
+ * Grows a buffer if it isn't large enough
13
+ * @returns The original buffer if resized successfully, or a newly created buffer
14
+ */
15
+ export declare function extendBuffer<T extends ArrayBufferLike | ArrayBufferView>(buffer: T, newByteLength: number): T;
package/dist/buffer.js ADDED
@@ -0,0 +1,32 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unused-expressions */
2
+ /**
3
+ * Grows a buffer if it isn't large enough
4
+ * @returns The original buffer if resized successfully, or a newly created buffer
5
+ */
6
+ export function extendBuffer(buffer, newByteLength) {
7
+ if (buffer.byteLength >= newByteLength)
8
+ return buffer;
9
+ if (ArrayBuffer.isView(buffer)) {
10
+ const newBuffer = extendBuffer(buffer.buffer, newByteLength);
11
+ return new buffer.constructor(newBuffer, buffer.byteOffset, newByteLength);
12
+ }
13
+ const isShared = typeof SharedArrayBuffer !== 'undefined' && buffer instanceof SharedArrayBuffer;
14
+ // Note: If true, the buffer must be resizable/growable because of the first check.
15
+ if (buffer.maxByteLength > newByteLength) {
16
+ isShared ? buffer.grow(newByteLength) : buffer.resize(newByteLength);
17
+ return buffer;
18
+ }
19
+ if (isShared) {
20
+ const newBuffer = new SharedArrayBuffer(newByteLength);
21
+ new Uint8Array(newBuffer).set(new Uint8Array(buffer));
22
+ return newBuffer;
23
+ }
24
+ try {
25
+ return buffer.transfer(newByteLength);
26
+ }
27
+ catch {
28
+ const newBuffer = new ArrayBuffer(newByteLength);
29
+ new Uint8Array(newBuffer).set(new Uint8Array(buffer));
30
+ return newBuffer;
31
+ }
32
+ }
@@ -0,0 +1,13 @@
1
+ export interface CreateLoggerOptions {
2
+ output?: (...args: any[]) => void;
3
+ stringify?: (value: unknown) => string;
4
+ className?: boolean;
5
+ separator?: string;
6
+ returnValue?: boolean;
7
+ }
8
+ type LoggableDecoratorContext = Exclude<DecoratorContext, ClassFieldDecoratorContext>;
9
+ /**
10
+ * Create a function that can be used to decorate classes and non-field members.
11
+ */
12
+ export declare function createLogDecorator(options: CreateLoggerOptions): <T extends (...args: any[]) => any>(value: T, context: LoggableDecoratorContext) => T;
13
+ export {};
@@ -0,0 +1,30 @@
1
+ function defaultStringify(value) {
2
+ if (value === null)
3
+ return 'null';
4
+ if (value === undefined)
5
+ return 'undefined';
6
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
7
+ return value.toString();
8
+ }
9
+ /**
10
+ * Create a function that can be used to decorate classes and non-field members.
11
+ */
12
+ export function createLogDecorator(options) {
13
+ const { output = console.log, separator = '#', returnValue = false, stringify = defaultStringify, className = true } = options;
14
+ return function log(value, context) {
15
+ if (context.kind == 'class') {
16
+ return function (...args) {
17
+ output(`new ${value.name} (${args.map(stringify).join(', ')})`);
18
+ return Reflect.construct(value, args);
19
+ };
20
+ }
21
+ return function (...args) {
22
+ const prefix = (className ? this.constructor.name + separator : '') + context.name.toString();
23
+ output(`${prefix}(${args.map(stringify).join(', ')})`);
24
+ const result = value.call(this, ...args);
25
+ if (returnValue)
26
+ output(' => ' + stringify(result));
27
+ return result;
28
+ };
29
+ };
30
+ }
package/dist/fs.d.ts CHANGED
@@ -9,7 +9,7 @@ export declare abstract class FileMap<V> implements Map<string, V> {
9
9
  abstract has(key: string): boolean;
10
10
  abstract set(key: string, value: V): this;
11
11
  get size(): number;
12
- get [Symbol.iterator](): () => IterableIterator<[string, V]>;
12
+ get [Symbol.iterator](): () => MapIterator<[string, V]>;
13
13
  get keys(): typeof this._map.keys;
14
14
  get values(): typeof this._map.values;
15
15
  get entries(): typeof this._map.entries;
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // This file should not be added to
2
+ // For better tree shaking, import from whichever file is actually needed
1
3
  export * from './list.js';
2
4
  export * from './misc.js';
3
5
  export * from './numbers.js';
@@ -19,6 +19,7 @@ export declare const init: typeof Symbol.struct_init;
19
19
  export interface Options {
20
20
  align: number;
21
21
  bigEndian: boolean;
22
+ isUnion: boolean;
22
23
  }
23
24
  export interface Member {
24
25
  type: primitive.Type | Static;
package/dist/misc.d.ts CHANGED
@@ -1,3 +1,11 @@
1
1
  export declare function wait(time: number): Promise<unknown>;
2
2
  export declare const greekLetterNames: string[];
3
3
  export declare function isHex(str: string): boolean;
4
+ /** Prevent infinite loops */
5
+ export declare function canary(error?: Error): () => void;
6
+ /**
7
+ * A wrapper for throwing things in an expression context.
8
+ * You will likely want to remove this if you can just use `throw` in expressions.
9
+ * @see https://github.com/tc39/proposal-throw-expressions
10
+ */
11
+ export declare function _throw(e: unknown): never;
package/dist/misc.js CHANGED
@@ -31,3 +31,18 @@ const hexRegex = /^[0-9a-f-.]+$/;
31
31
  export function isHex(str) {
32
32
  return hexRegex.test(str);
33
33
  }
34
+ /** Prevent infinite loops */
35
+ export function canary(error = new Error()) {
36
+ const timeout = setTimeout(() => {
37
+ throw error;
38
+ }, 5000);
39
+ return () => clearTimeout(timeout);
40
+ }
41
+ /**
42
+ * A wrapper for throwing things in an expression context.
43
+ * You will likely want to remove this if you can just use `throw` in expressions.
44
+ * @see https://github.com/tc39/proposal-throw-expressions
45
+ */
46
+ export function _throw(e) {
47
+ throw e;
48
+ }
@@ -0,0 +1,45 @@
1
+ export interface ResourceCacheOptions {
2
+ /**
3
+ * If true, use multiple buffers to cache a file.
4
+ * This is useful when working with small parts of large files,
5
+ * since we don't need to allocate a large buffer that is mostly unused
6
+ * @default true
7
+ */
8
+ sparse?: boolean;
9
+ /**
10
+ * The threshold for whether to combine regions or not
11
+ * @see CacheRegion
12
+ * @default 0xfff // 4 KiB
13
+ */
14
+ regionGapThreshold?: number;
15
+ }
16
+ export type RequestError = {
17
+ tag: 'status';
18
+ response: Response;
19
+ } | {
20
+ tag: 'buffer';
21
+ response: Response;
22
+ message: string;
23
+ } | {
24
+ tag: 'fetch' | 'size';
25
+ message: string;
26
+ } | Error;
27
+ export interface RequestOptions extends ResourceCacheOptions {
28
+ /**
29
+ * When using range requests,
30
+ * a HEAD request must normally be used to determine the full size of the resource.
31
+ * This allows that request to be skipped
32
+ */
33
+ size?: number;
34
+ /** The start of the range */
35
+ start?: number;
36
+ /** The end of the range */
37
+ end?: number;
38
+ /** Optionally provide a function for logging warnings */
39
+ warn?(message: string): unknown;
40
+ }
41
+ /**
42
+ * Make a GET request without worrying about ranges
43
+ * @throws RequestError
44
+ */
45
+ export declare function GET(url: string, options: RequestOptions, init?: RequestInit): Promise<Uint8Array>;
@@ -0,0 +1,172 @@
1
+ /* Utilities for `fetch` when using range requests. It also allows you to handle errors easier */
2
+ import { extendBuffer } from './buffer.js';
3
+ /** The cache for a specific resource */
4
+ class ResourceCache {
5
+ url;
6
+ size;
7
+ options;
8
+ /** Regions used to reduce unneeded allocations. Think of sparse arrays. */
9
+ regions = [];
10
+ constructor(
11
+ /** The resource URL */
12
+ url,
13
+ /** The full size of the resource */
14
+ size, options) {
15
+ this.url = url;
16
+ this.size = size;
17
+ this.options = options;
18
+ options.sparse ??= true;
19
+ if (!options.sparse)
20
+ this.regions.push({ offset: 0, data: new Uint8Array(size), ranges: [] });
21
+ requestsCache.set(url, this);
22
+ }
23
+ /** Combines adjacent regions and combines adjacent ranges within a region */
24
+ collect() {
25
+ if (!this.options.sparse)
26
+ return;
27
+ const { regionGapThreshold = 0xfff } = this.options;
28
+ for (let i = 0; i < this.regions.length - 1;) {
29
+ const current = this.regions[i];
30
+ const next = this.regions[i + 1];
31
+ if (next.offset - (current.offset + current.data.byteLength) > regionGapThreshold) {
32
+ i++;
33
+ continue;
34
+ }
35
+ // Combine ranges
36
+ current.ranges.push(...next.ranges);
37
+ current.ranges.sort((a, b) => a.start - b.start);
38
+ // Combine overlapping/adjacent ranges
39
+ current.ranges = current.ranges.reduce((acc, range) => {
40
+ if (!acc.length || acc.at(-1).end < range.start) {
41
+ acc.push(range);
42
+ }
43
+ else {
44
+ acc.at(-1).end = Math.max(acc.at(-1).end, range.end);
45
+ }
46
+ return acc;
47
+ }, []);
48
+ // Extend buffer to include the new region
49
+ current.data = extendBuffer(current.data, next.offset + next.data.byteLength);
50
+ current.data.set(next.data, next.offset - current.offset);
51
+ // Remove the next region after merging
52
+ this.regions.splice(i + 1, 1);
53
+ }
54
+ }
55
+ /** Takes an initial range and finds the sub-ranges that are not in the cache */
56
+ missing(start, end) {
57
+ const missingRanges = [];
58
+ for (const region of this.regions) {
59
+ if (region.offset >= end)
60
+ break;
61
+ for (const range of region.ranges) {
62
+ if (range.end <= start)
63
+ continue;
64
+ if (range.start >= end)
65
+ break;
66
+ if (range.start > start) {
67
+ missingRanges.push({ start, end: Math.min(range.start, end) });
68
+ }
69
+ // Adjust the current start if the region overlaps
70
+ if (range.end > start)
71
+ start = Math.max(start, range.end);
72
+ if (start >= end)
73
+ break;
74
+ }
75
+ if (start >= end)
76
+ break;
77
+ }
78
+ // If there are still missing parts at the end
79
+ if (start < end)
80
+ missingRanges.push({ start, end });
81
+ return missingRanges;
82
+ }
83
+ /** Get the region who's ranges include an offset */
84
+ regionAt(offset) {
85
+ if (!this.regions.length)
86
+ return;
87
+ for (const region of this.regions) {
88
+ if (region.offset > offset)
89
+ break;
90
+ // Check if the offset is within this region
91
+ if (offset >= region.offset && offset < region.offset + region.data.byteLength)
92
+ return region;
93
+ }
94
+ }
95
+ /** Add new data to the cache at given specified offset */
96
+ add(data, start, end = start + data.byteLength) {
97
+ const region = this.regionAt(start);
98
+ if (region) {
99
+ region.data = extendBuffer(region.data, end);
100
+ region.ranges.push({ start, end });
101
+ region.ranges.sort((a, b) => a.start - b.start);
102
+ return this;
103
+ }
104
+ // Find the correct index to insert the new region
105
+ const newRegion = { data, offset: start, ranges: [{ start, end }] };
106
+ const insertIndex = this.regions.findIndex(region => region.offset > start);
107
+ // Insert at the right index to keep regions sorted
108
+ if (insertIndex == -1) {
109
+ this.regions.push(newRegion); // Append if no later region exists
110
+ }
111
+ else {
112
+ this.regions.splice(insertIndex, 0, newRegion); // Insert before the first region with a greater offset
113
+ }
114
+ return this;
115
+ }
116
+ }
117
+ const requestsCache = new Map();
118
+ /**
119
+ * Wraps `fetch`
120
+ * @throws RequestError
121
+ */
122
+ async function _fetchBuffer(input, init = {}) {
123
+ const response = await fetch(input, init).catch((error) => {
124
+ throw { tag: 'fetch', message: error.message };
125
+ });
126
+ if (!response.ok)
127
+ throw { tag: 'status', response };
128
+ const arrayBuffer = await response.arrayBuffer().catch((error) => {
129
+ throw { tag: 'buffer', response, message: error.message };
130
+ });
131
+ return { response, data: new Uint8Array(arrayBuffer) };
132
+ }
133
+ /**
134
+ * Make a GET request without worrying about ranges
135
+ * @throws RequestError
136
+ */
137
+ export async function GET(url, options, init = {}) {
138
+ const req = new Request(url, init);
139
+ // Request no using ranges
140
+ if (typeof options.start != 'number' || typeof options.end != 'number') {
141
+ const { data } = await _fetchBuffer(url, init);
142
+ new ResourceCache(url, data.byteLength, options).add(data, 0);
143
+ return data;
144
+ }
145
+ // Range requests
146
+ if (typeof options.size != 'number') {
147
+ options.warn?.(url + ': Size not provided, an additional HEAD request is being made');
148
+ const { headers } = await fetch(req, { method: 'HEAD' });
149
+ const size = parseInt(headers.get('Content-Length') ?? '');
150
+ if (typeof size != 'number')
151
+ throw { tag: 'size', message: 'Response is missing content-length header and no size was provided' };
152
+ options.size = size;
153
+ }
154
+ const { size, start, end } = options;
155
+ const cache = requestsCache.get(url) ?? new ResourceCache(url, size, options);
156
+ req.headers.set('If-Range', new Date().toUTCString());
157
+ for (const { start: from, end: to } of cache.missing(start, end)) {
158
+ const { data, response } = await _fetchBuffer(req, { headers: { Range: `bytes=${from}-${to}` } });
159
+ if (response.status == 206) {
160
+ cache.add(data, from);
161
+ continue;
162
+ }
163
+ // The first response doesn't have a "partial content" (206) status
164
+ options.warn?.(url + ': Remote does not support range requests with bytes. Falling back to full data.');
165
+ new ResourceCache(url, size, options).add(data, 0);
166
+ return data.subarray(start, end);
167
+ }
168
+ // This ensures we get a single buffer with the entire requested range
169
+ cache.collect();
170
+ const region = cache.regionAt(start);
171
+ return region.data.subarray(start - region.offset, end - region.offset);
172
+ }
package/dist/struct.js CHANGED
@@ -36,11 +36,12 @@ export function struct(options = {}) {
36
36
  throw new TypeError('Not a valid type: ' + type);
37
37
  }
38
38
  members.set(name, {
39
- offset: size,
39
+ offset: options.isUnion ? 0 : size,
40
40
  type: primitive.isValid(type) ? primitive.normalize(type) : type,
41
41
  length,
42
42
  });
43
- size += sizeof(type) * (length || 1);
43
+ const memberSize = sizeof(type) * (length || 1);
44
+ size = options.isUnion ? Math.max(size, memberSize) : size + memberSize;
44
45
  size = align(size, options.align || 1);
45
46
  }
46
47
  context.metadata[Symbol.struct_metadata] = { options, members, size };
@@ -74,6 +75,7 @@ export function serialize(instance) {
74
75
  const { options, members } = instance.constructor[symbol_metadata(instance.constructor)][Symbol.struct_metadata];
75
76
  const buffer = new Uint8Array(sizeof(instance));
76
77
  const view = new DataView(buffer.buffer);
78
+ // for unions we should write members in ascending last modified order, but we don't have that info.
77
79
  for (const [name, { type, length, offset }] of members) {
78
80
  for (let i = 0; i < (length || 1); i++) {
79
81
  const iOff = offset + sizeof(type) * i;
package/dist/types.d.ts CHANGED
@@ -192,4 +192,14 @@ export type WithRequired<T, K extends keyof T> = T & {
192
192
  * Makes properties with keys assignable to K in T optional
193
193
  */
194
194
  export type WithOptional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
195
+ /**
196
+ * Nothing in T
197
+ */
198
+ export type Never<T> = {
199
+ [K in keyof T]?: never;
200
+ };
201
+ /**
202
+ * All of the properties in T or none of them
203
+ */
204
+ export type AllOrNone<T> = T | Never<T>;
195
205
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "utilium",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Typescript utilities",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -42,15 +42,13 @@
42
42
  "homepage": "https://github.com/james-pre/utilium#readme",
43
43
  "devDependencies": {
44
44
  "@eslint/js": "^9.12.0",
45
- "@stylistic/eslint-plugin": "^2.9.0",
46
- "@types/eslint__js": "^8.42.3",
47
45
  "@types/node": "^20.12.7",
48
46
  "eslint": "^9.12.0",
49
47
  "globals": "^15.10.0",
50
48
  "prettier": "^3.2.5",
51
49
  "tsx": "^4.19.1",
52
- "typedoc": "^0.26.6",
53
- "typescript": "^5.5.4",
50
+ "typedoc": "^0.27.6",
51
+ "typescript": "^5.7.2",
54
52
  "typescript-eslint": "^8.8.0"
55
53
  },
56
54
  "dependencies": {
package/readme.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  A bunch of utilities for Typescript. This includes:
4
4
 
5
- - Structs (using decorators)
6
- - Compile-time math types
7
- - Debugging types
8
- - Convenience types and functions for strings and objects
9
- - RNG functions
10
- - `List`, a class that combines the best aspects of `Set` and arrays
11
- - `JSONFileMap` and `FolderMap`
12
- - Version utilities
13
- - Xterm.js shell handling (arrows, home/end, prompting, etc.)
5
+ - Structs (using decorators)
6
+ - Compile-time math types
7
+ - Debugging types and functions
8
+ - Utilities for using `fetch` with [HTTP range requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests)
9
+ - Extending buffers easier with `extendBuffer`
10
+ - Convenience types and functions for strings and objects
11
+ - RNG functions
12
+ - `List`, a class that combines the best aspects of `Set` and arrays
13
+ - `JSONFileMap` and `FolderMap`
14
+ - Version utilities
15
+ - Xterm.js shell handling (arrows, home/end, prompting, etc.)
package/src/buffer.ts ADDED
@@ -0,0 +1,47 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unused-expressions */
2
+
3
+ /**
4
+ * A generic ArrayBufferView (typed array) constructor
5
+ */
6
+ export interface ArrayBufferViewConstructor {
7
+ readonly prototype: ArrayBufferView<ArrayBufferLike>;
8
+ new (length: number): ArrayBufferView<ArrayBuffer>;
9
+ new (array: ArrayLike<number>): ArrayBufferView<ArrayBuffer>;
10
+ new <TArrayBuffer extends ArrayBufferLike = ArrayBuffer>(buffer: TArrayBuffer, byteOffset?: number, length?: number): ArrayBufferView<TArrayBuffer>;
11
+ new (array: ArrayLike<number> | ArrayBuffer): ArrayBufferView<ArrayBuffer>;
12
+ }
13
+
14
+ /**
15
+ * Grows a buffer if it isn't large enough
16
+ * @returns The original buffer if resized successfully, or a newly created buffer
17
+ */
18
+ export function extendBuffer<T extends ArrayBufferLike | ArrayBufferView>(buffer: T, newByteLength: number): T {
19
+ if (buffer.byteLength >= newByteLength) return buffer;
20
+
21
+ if (ArrayBuffer.isView(buffer)) {
22
+ const newBuffer = extendBuffer(buffer.buffer, newByteLength);
23
+ return new (buffer.constructor as ArrayBufferViewConstructor)(newBuffer, buffer.byteOffset, newByteLength) as T;
24
+ }
25
+
26
+ const isShared = typeof SharedArrayBuffer !== 'undefined' && buffer instanceof SharedArrayBuffer;
27
+
28
+ // Note: If true, the buffer must be resizable/growable because of the first check.
29
+ if (buffer.maxByteLength > newByteLength) {
30
+ isShared ? buffer.grow(newByteLength) : (buffer as ArrayBuffer).resize(newByteLength);
31
+ return buffer;
32
+ }
33
+
34
+ if (isShared) {
35
+ const newBuffer = new SharedArrayBuffer(newByteLength) as T & SharedArrayBuffer;
36
+ new Uint8Array(newBuffer).set(new Uint8Array(buffer));
37
+ return newBuffer;
38
+ }
39
+
40
+ try {
41
+ return (buffer as ArrayBuffer).transfer(newByteLength) as T;
42
+ } catch {
43
+ const newBuffer = new ArrayBuffer(newByteLength) as T & ArrayBuffer;
44
+ new Uint8Array(newBuffer).set(new Uint8Array(buffer));
45
+ return newBuffer;
46
+ }
47
+ }
@@ -0,0 +1,45 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ export interface CreateLoggerOptions {
3
+ output?: (...args: any[]) => void;
4
+ stringify?: (value: unknown) => string;
5
+ className?: boolean;
6
+ separator?: string;
7
+ returnValue?: boolean;
8
+ }
9
+
10
+ function defaultStringify(value: unknown): string {
11
+ if (value === null) return 'null';
12
+ if (value === undefined) return 'undefined';
13
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
14
+ return value.toString();
15
+ }
16
+
17
+ type LoggableDecoratorContext = Exclude<DecoratorContext, ClassFieldDecoratorContext>;
18
+
19
+ /**
20
+ * Create a function that can be used to decorate classes and non-field members.
21
+ */
22
+ export function createLogDecorator(options: CreateLoggerOptions) {
23
+ const { output = console.log, separator = '#', returnValue = false, stringify = defaultStringify, className = true } = options;
24
+
25
+ return function log<T extends (...args: any[]) => any>(value: T, context: LoggableDecoratorContext): T {
26
+ if (context.kind == 'class') {
27
+ return function (...args: any[]) {
28
+ output(`new ${value.name} (${args.map(stringify).join(', ')})`);
29
+ return Reflect.construct(value, args);
30
+ } as T;
31
+ }
32
+
33
+ return function (this: any, ...args: any[]) {
34
+ const prefix = (className ? this.constructor.name + separator : '') + context.name.toString();
35
+
36
+ output(`${prefix}(${args.map(stringify).join(', ')})`);
37
+
38
+ const result = value.call(this, ...args);
39
+
40
+ if (returnValue) output(' => ' + stringify(result));
41
+
42
+ return result;
43
+ } as T;
44
+ };
45
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ // This file should not be added to
2
+ // For better tree shaking, import from whichever file is actually needed
3
+
1
4
  export * from './list.js';
2
5
  export * from './misc.js';
3
6
  export * from './numbers.js';
@@ -29,6 +29,7 @@ export const init: typeof Symbol.struct_init = Symbol.struct_init;
29
29
  export interface Options {
30
30
  align: number;
31
31
  bigEndian: boolean;
32
+ isUnion: boolean;
32
33
  }
33
34
 
34
35
  export interface Member {
package/src/misc.ts CHANGED
@@ -34,3 +34,21 @@ const hexRegex = /^[0-9a-f-.]+$/;
34
34
  export function isHex(str: string) {
35
35
  return hexRegex.test(str);
36
36
  }
37
+
38
+ /** Prevent infinite loops */
39
+ export function canary(error: Error = new Error()) {
40
+ const timeout = setTimeout(() => {
41
+ throw error;
42
+ }, 5000);
43
+
44
+ return () => clearTimeout(timeout);
45
+ }
46
+
47
+ /**
48
+ * A wrapper for throwing things in an expression context.
49
+ * You will likely want to remove this if you can just use `throw` in expressions.
50
+ * @see https://github.com/tc39/proposal-throw-expressions
51
+ */
52
+ export function _throw(e: unknown): never {
53
+ throw e;
54
+ }
@@ -0,0 +1,251 @@
1
+ /* Utilities for `fetch` when using range requests. It also allows you to handle errors easier */
2
+
3
+ import { extendBuffer } from './buffer.js';
4
+
5
+ /* eslint-disable @typescript-eslint/only-throw-error */
6
+
7
+ export interface ResourceCacheOptions {
8
+ /**
9
+ * If true, use multiple buffers to cache a file.
10
+ * This is useful when working with small parts of large files,
11
+ * since we don't need to allocate a large buffer that is mostly unused
12
+ * @default true
13
+ */
14
+ sparse?: boolean;
15
+
16
+ /**
17
+ * The threshold for whether to combine regions or not
18
+ * @see CacheRegion
19
+ * @default 0xfff // 4 KiB
20
+ */
21
+ regionGapThreshold?: number;
22
+ }
23
+
24
+ type CacheRange = { start: number; end: number };
25
+
26
+ interface CacheRegion {
27
+ /** The region's offset from the start of the resource */
28
+ offset: number;
29
+
30
+ /** Ranges cached in this region. These are absolute! */
31
+ ranges: CacheRange[];
32
+
33
+ /** Data for this region */
34
+ data: Uint8Array;
35
+ }
36
+
37
+ /** The cache for a specific resource */
38
+ class ResourceCache {
39
+ /** Regions used to reduce unneeded allocations. Think of sparse arrays. */
40
+ protected readonly regions: CacheRegion[] = [];
41
+
42
+ public constructor(
43
+ /** The resource URL */
44
+ public readonly url: string,
45
+ /** The full size of the resource */
46
+ public readonly size: number,
47
+ protected readonly options: ResourceCacheOptions
48
+ ) {
49
+ options.sparse ??= true;
50
+ if (!options.sparse) this.regions.push({ offset: 0, data: new Uint8Array(size), ranges: [] });
51
+
52
+ requestsCache.set(url, this);
53
+ }
54
+
55
+ /** Combines adjacent regions and combines adjacent ranges within a region */
56
+ public collect(): void {
57
+ if (!this.options.sparse) return;
58
+ const { regionGapThreshold = 0xfff } = this.options;
59
+
60
+ for (let i = 0; i < this.regions.length - 1; ) {
61
+ const current = this.regions[i];
62
+ const next = this.regions[i + 1];
63
+
64
+ if (next.offset - (current.offset + current.data.byteLength) > regionGapThreshold) {
65
+ i++;
66
+ continue;
67
+ }
68
+
69
+ // Combine ranges
70
+ current.ranges.push(...next.ranges);
71
+ current.ranges.sort((a, b) => a.start - b.start);
72
+
73
+ // Combine overlapping/adjacent ranges
74
+ current.ranges = current.ranges.reduce((acc: CacheRange[], range) => {
75
+ if (!acc.length || acc.at(-1)!.end < range.start) {
76
+ acc.push(range);
77
+ } else {
78
+ acc.at(-1)!.end = Math.max(acc.at(-1)!.end, range.end);
79
+ }
80
+ return acc;
81
+ }, []);
82
+
83
+ // Extend buffer to include the new region
84
+ current.data = extendBuffer(current.data, next.offset + next.data.byteLength);
85
+ current.data.set(next.data, next.offset - current.offset);
86
+
87
+ // Remove the next region after merging
88
+ this.regions.splice(i + 1, 1);
89
+ }
90
+ }
91
+
92
+ /** Takes an initial range and finds the sub-ranges that are not in the cache */
93
+ public missing(start: number, end: number): CacheRange[] {
94
+ const missingRanges: CacheRange[] = [];
95
+
96
+ for (const region of this.regions) {
97
+ if (region.offset >= end) break;
98
+
99
+ for (const range of region.ranges) {
100
+ if (range.end <= start) continue;
101
+
102
+ if (range.start >= end) break;
103
+
104
+ if (range.start > start) {
105
+ missingRanges.push({ start, end: Math.min(range.start, end) });
106
+ }
107
+
108
+ // Adjust the current start if the region overlaps
109
+ if (range.end > start) start = Math.max(start, range.end);
110
+
111
+ if (start >= end) break;
112
+ }
113
+
114
+ if (start >= end) break;
115
+ }
116
+
117
+ // If there are still missing parts at the end
118
+ if (start < end) missingRanges.push({ start, end });
119
+
120
+ return missingRanges;
121
+ }
122
+
123
+ /** Get the region who's ranges include an offset */
124
+ public regionAt(offset: number): CacheRegion | undefined {
125
+ if (!this.regions.length) return;
126
+
127
+ for (const region of this.regions) {
128
+ if (region.offset > offset) break;
129
+
130
+ // Check if the offset is within this region
131
+ if (offset >= region.offset && offset < region.offset + region.data.byteLength) return region;
132
+ }
133
+ }
134
+
135
+ /** Add new data to the cache at given specified offset */
136
+ public add(data: Uint8Array, start: number, end: number = start + data.byteLength): this {
137
+ const region = this.regionAt(start);
138
+
139
+ if (region) {
140
+ region.data = extendBuffer(region.data, end);
141
+ region.ranges.push({ start, end });
142
+ region.ranges.sort((a, b) => a.start - b.start);
143
+
144
+ return this;
145
+ }
146
+
147
+ // Find the correct index to insert the new region
148
+ const newRegion: CacheRegion = { data, offset: start, ranges: [{ start, end }] };
149
+ const insertIndex = this.regions.findIndex(region => region.offset > start);
150
+
151
+ // Insert at the right index to keep regions sorted
152
+ if (insertIndex == -1) {
153
+ this.regions.push(newRegion); // Append if no later region exists
154
+ } else {
155
+ this.regions.splice(insertIndex, 0, newRegion); // Insert before the first region with a greater offset
156
+ }
157
+
158
+ return this;
159
+ }
160
+ }
161
+
162
+ const requestsCache = new Map<string, ResourceCache | null>();
163
+
164
+ export type RequestError = { tag: 'status'; response: Response } | { tag: 'buffer'; response: Response; message: string } | { tag: 'fetch' | 'size'; message: string } | Error;
165
+
166
+ /**
167
+ * Wraps `fetch`
168
+ * @throws RequestError
169
+ */
170
+ async function _fetchBuffer(input: RequestInfo, init: RequestInit = {}): Promise<{ response: Response; data: Uint8Array }> {
171
+ const response = await fetch(input, init).catch((error: Error) => {
172
+ throw { tag: 'fetch', message: error.message } satisfies RequestError;
173
+ });
174
+
175
+ if (!response.ok) throw { tag: 'status', response } satisfies RequestError;
176
+
177
+ const arrayBuffer = await response.arrayBuffer().catch((error: Error) => {
178
+ throw { tag: 'buffer', response, message: error.message } satisfies RequestError;
179
+ });
180
+
181
+ return { response, data: new Uint8Array(arrayBuffer) };
182
+ }
183
+
184
+ export interface RequestOptions extends ResourceCacheOptions {
185
+ /**
186
+ * When using range requests,
187
+ * a HEAD request must normally be used to determine the full size of the resource.
188
+ * This allows that request to be skipped
189
+ */
190
+ size?: number;
191
+
192
+ /** The start of the range */
193
+ start?: number;
194
+
195
+ /** The end of the range */
196
+ end?: number;
197
+
198
+ /** Optionally provide a function for logging warnings */
199
+ warn?(message: string): unknown;
200
+ }
201
+
202
+ /**
203
+ * Make a GET request without worrying about ranges
204
+ * @throws RequestError
205
+ */
206
+ export async function GET(url: string, options: RequestOptions, init: RequestInit = {}): Promise<Uint8Array> {
207
+ const req = new Request(url, init);
208
+
209
+ // Request no using ranges
210
+ if (typeof options.start != 'number' || typeof options.end != 'number') {
211
+ const { data } = await _fetchBuffer(url, init);
212
+ new ResourceCache(url, data.byteLength, options).add(data, 0);
213
+ return data;
214
+ }
215
+
216
+ // Range requests
217
+
218
+ if (typeof options.size != 'number') {
219
+ options.warn?.(url + ': Size not provided, an additional HEAD request is being made');
220
+
221
+ const { headers } = await fetch(req, { method: 'HEAD' });
222
+ const size = parseInt(headers.get('Content-Length') ?? '');
223
+ if (typeof size != 'number') throw { tag: 'size', message: 'Response is missing content-length header and no size was provided' } satisfies RequestError;
224
+ options.size = size;
225
+ }
226
+
227
+ const { size, start, end } = options;
228
+ const cache = requestsCache.get(url) ?? new ResourceCache(url, size, options);
229
+
230
+ req.headers.set('If-Range', new Date().toUTCString());
231
+
232
+ for (const { start: from, end: to } of cache.missing(start, end)) {
233
+ const { data, response } = await _fetchBuffer(req, { headers: { Range: `bytes=${from}-${to}` } });
234
+
235
+ if (response.status == 206) {
236
+ cache.add(data, from);
237
+ continue;
238
+ }
239
+
240
+ // The first response doesn't have a "partial content" (206) status
241
+ options.warn?.(url + ': Remote does not support range requests with bytes. Falling back to full data.');
242
+ new ResourceCache(url, size, options).add(data, 0);
243
+ return data.subarray(start, end);
244
+ }
245
+
246
+ // This ensures we get a single buffer with the entire requested range
247
+ cache.collect();
248
+
249
+ const region = cache.regionAt(start)!;
250
+ return region.data.subarray(start - region.offset, end - region.offset);
251
+ }
package/src/struct.ts CHANGED
@@ -45,11 +45,12 @@ export function struct(options: Partial<Options> = {}) {
45
45
  throw new TypeError('Not a valid type: ' + type);
46
46
  }
47
47
  members.set(name, {
48
- offset: size,
48
+ offset: options.isUnion ? 0 : size,
49
49
  type: primitive.isValid(type) ? primitive.normalize(type) : type,
50
50
  length,
51
51
  });
52
- size += sizeof(type) * (length || 1);
52
+ const memberSize = sizeof(type) * (length || 1);
53
+ size = options.isUnion ? Math.max(size, memberSize) : size + memberSize;
53
54
  size = align(size, options.align || 1);
54
55
  }
55
56
 
@@ -90,6 +91,7 @@ export function serialize(instance: unknown): Uint8Array {
90
91
  const buffer = new Uint8Array(sizeof(instance));
91
92
  const view = new DataView(buffer.buffer);
92
93
 
94
+ // for unions we should write members in ascending last modified order, but we don't have that info.
93
95
  for (const [name, { type, length, offset }] of members) {
94
96
  for (let i = 0; i < (length || 1); i++) {
95
97
  const iOff = offset + sizeof(type) * i;
package/src/types.ts CHANGED
@@ -232,3 +232,13 @@ export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
232
232
  * Makes properties with keys assignable to K in T optional
233
233
  */
234
234
  export type WithOptional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
235
+
236
+ /**
237
+ * Nothing in T
238
+ */
239
+ export type Never<T> = { [K in keyof T]?: never };
240
+
241
+ /**
242
+ * All of the properties in T or none of them
243
+ */
244
+ export type AllOrNone<T> = T | Never<T>;