ink-cartridge 3.6.1
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/LICENSE +21 -0
- package/README.md +249 -0
- package/dist/binary-storage/BinaryStorage.d.ts +308 -0
- package/dist/binary-storage/BinaryStorage.js +548 -0
- package/dist/binary-storage/StreamingReader.d.ts +53 -0
- package/dist/binary-storage/StreamingReader.js +530 -0
- package/dist/binary-storage/index.d.ts +91 -0
- package/dist/binary-storage/index.js +91 -0
- package/dist/binary-storage/types.d.ts +400 -0
- package/dist/binary-storage/types.js +15 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +214 -0
- package/dist/cli/initTheme.d.ts +14 -0
- package/dist/cli/initTheme.js +124 -0
- package/dist/cli/makeLanguageType.d.ts +46 -0
- package/dist/cli/makeLanguageType.js +264 -0
- package/dist/cli/makeThemeType.d.ts +43 -0
- package/dist/cli/makeThemeType.js +222 -0
- package/dist/components/badge/Badge.d.ts +7 -0
- package/dist/components/badge/Badge.js +9 -0
- package/dist/components/dialog/ConfirmDialog.d.ts +23 -0
- package/dist/components/dialog/ConfirmDialog.js +57 -0
- package/dist/components/dialog/types.d.ts +20 -0
- package/dist/components/dialog/types.js +1 -0
- package/dist/components/divider/Divider.d.ts +8 -0
- package/dist/components/divider/Divider.js +10 -0
- package/dist/components/fold/Fold.d.ts +3 -0
- package/dist/components/fold/Fold.js +44 -0
- package/dist/components/fold/types.d.ts +28 -0
- package/dist/components/fold/types.js +1 -0
- package/dist/components/form/Field.d.ts +30 -0
- package/dist/components/form/Field.js +59 -0
- package/dist/components/form/Form.d.ts +32 -0
- package/dist/components/form/Form.js +163 -0
- package/dist/components/form/context.d.ts +14 -0
- package/dist/components/form/context.js +20 -0
- package/dist/components/form/types.d.ts +115 -0
- package/dist/components/form/types.js +1 -0
- package/dist/components/key-hint/KeyHint.d.ts +10 -0
- package/dist/components/key-hint/KeyHint.js +12 -0
- package/dist/components/multi-select/MultiSelectInput.d.ts +125 -0
- package/dist/components/multi-select/MultiSelectInput.js +287 -0
- package/dist/components/multi-select/types.d.ts +100 -0
- package/dist/components/multi-select/types.js +1 -0
- package/dist/components/number-input/NumberInput.d.ts +11 -0
- package/dist/components/number-input/NumberInput.js +44 -0
- package/dist/components/progress-bar/ProgressBar.d.ts +11 -0
- package/dist/components/progress-bar/ProgressBar.js +9 -0
- package/dist/components/search-input/SearchInput.d.ts +10 -0
- package/dist/components/search-input/SearchInput.js +15 -0
- package/dist/components/select/SelectInput.d.ts +100 -0
- package/dist/components/select/SelectInput.js +147 -0
- package/dist/components/select/types.d.ts +63 -0
- package/dist/components/select/types.js +1 -0
- package/dist/components/spinner/Spinner.d.ts +12 -0
- package/dist/components/spinner/Spinner.js +24 -0
- package/dist/components/tabs/Tabs.d.ts +3 -0
- package/dist/components/tabs/Tabs.js +57 -0
- package/dist/components/tabs/types.d.ts +32 -0
- package/dist/components/tabs/types.js +1 -0
- package/dist/components/text/TextInput.d.ts +39 -0
- package/dist/components/text/TextInput.js +233 -0
- package/dist/components/text/types.d.ts +62 -0
- package/dist/components/text/types.js +1 -0
- package/dist/dev/dev-screen.d.ts +36 -0
- package/dist/dev/dev-screen.js +142 -0
- package/dist/dev/entrance.d.ts +51 -0
- package/dist/dev/entrance.js +67 -0
- package/dist/dev/globalKey-display.d.ts +2 -0
- package/dist/dev/globalKey-display.js +8 -0
- package/dist/dev/types.d.ts +9 -0
- package/dist/dev/types.js +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +50 -0
- package/dist/keyboard/check-global-key.d.ts +18 -0
- package/dist/keyboard/check-global-key.js +52 -0
- package/dist/keyboard/context.d.ts +398 -0
- package/dist/keyboard/context.js +9 -0
- package/dist/keyboard/global-key-processor/index.d.ts +16 -0
- package/dist/keyboard/global-key-processor/index.js +75 -0
- package/dist/keyboard/global-sequence-processor/index.d.ts +14 -0
- package/dist/keyboard/global-sequence-processor/index.js +165 -0
- package/dist/keyboard/hook.d.ts +51 -0
- package/dist/keyboard/hook.js +105 -0
- package/dist/keyboard/index.d.ts +5 -0
- package/dist/keyboard/index.js +3 -0
- package/dist/keyboard/keyNormalizer.d.ts +37 -0
- package/dist/keyboard/keyNormalizer.js +119 -0
- package/dist/keyboard/layer-handler.d.ts +57 -0
- package/dist/keyboard/layer-handler.js +274 -0
- package/dist/keyboard/modal-processor/index.d.ts +16 -0
- package/dist/keyboard/modal-processor/index.js +97 -0
- package/dist/keyboard/overlay-processor/index.d.ts +16 -0
- package/dist/keyboard/overlay-processor/index.js +30 -0
- package/dist/keyboard/pipeline/chain.d.ts +10 -0
- package/dist/keyboard/pipeline/chain.js +45 -0
- package/dist/keyboard/pipeline/context.d.ts +34 -0
- package/dist/keyboard/pipeline/context.js +42 -0
- package/dist/keyboard/pipeline/index.d.ts +3 -0
- package/dist/keyboard/pipeline/index.js +2 -0
- package/dist/keyboard/provider.d.ts +32 -0
- package/dist/keyboard/provider.js +985 -0
- package/dist/keyboard/screen-stack-processor/index.d.ts +14 -0
- package/dist/keyboard/screen-stack-processor/index.js +32 -0
- package/dist/keyboard/types.d.ts +632 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/language/context.d.ts +7 -0
- package/dist/language/context.js +7 -0
- package/dist/language/hook.d.ts +19 -0
- package/dist/language/hook.js +26 -0
- package/dist/language/index.d.ts +3 -0
- package/dist/language/index.js +2 -0
- package/dist/language/provider.d.ts +30 -0
- package/dist/language/provider.js +155 -0
- package/dist/language/types.d.ts +124 -0
- package/dist/language/types.js +1 -0
- package/dist/screen/ModalContext.d.ts +11 -0
- package/dist/screen/ModalContext.js +12 -0
- package/dist/screen/OverlayContext.d.ts +9 -0
- package/dist/screen/OverlayContext.js +10 -0
- package/dist/screen/context.d.ts +61 -0
- package/dist/screen/context.js +8 -0
- package/dist/screen/current-screen.d.ts +16 -0
- package/dist/screen/current-screen.js +38 -0
- package/dist/screen/hook.d.ts +13 -0
- package/dist/screen/hook.js +20 -0
- package/dist/screen/index.d.ts +7 -0
- package/dist/screen/index.js +5 -0
- package/dist/screen/provider.d.ts +112 -0
- package/dist/screen/provider.js +577 -0
- package/dist/screen/registry.d.ts +27 -0
- package/dist/screen/registry.js +71 -0
- package/dist/screen/types.d.ts +227 -0
- package/dist/screen/types.js +1 -0
- package/dist/storage/index.d.ts +45 -0
- package/dist/storage/index.js +46 -0
- package/dist/storage/storage.d.ts +139 -0
- package/dist/storage/storage.js +236 -0
- package/dist/storage/types.d.ts +145 -0
- package/dist/storage/types.js +1 -0
- package/dist/theme/context.d.ts +7 -0
- package/dist/theme/context.js +7 -0
- package/dist/theme/hook.d.ts +11 -0
- package/dist/theme/hook.js +18 -0
- package/dist/theme/index.d.ts +4 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/provider.d.ts +18 -0
- package/dist/theme/provider.js +308 -0
- package/dist/theme/types.d.ts +53 -0
- package/dist/theme/types.js +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
const TAG_SIZE = 1;
|
|
3
|
+
const NUM_SIZE = 8;
|
|
4
|
+
const BOOL_SIZE = 1;
|
|
5
|
+
const LEN_SIZE = 4;
|
|
6
|
+
/**
|
|
7
|
+
* Externally-resolvable Promise.
|
|
8
|
+
*
|
|
9
|
+
* A regular `new Promise(resolve => ...)` only gives you the resolve
|
|
10
|
+
* callback inside the constructor. Deferred stores both callbacks as
|
|
11
|
+
* properties so they can be called from stream event handlers or
|
|
12
|
+
* lifecycle methods (onData, onEnd, destroy, fail) that fire
|
|
13
|
+
* asynchronously and independently of the consumer's await point.
|
|
14
|
+
*/
|
|
15
|
+
class Deferred {
|
|
16
|
+
promise;
|
|
17
|
+
resolve;
|
|
18
|
+
reject;
|
|
19
|
+
constructor() {
|
|
20
|
+
this.promise = new Promise((res, rej) => {
|
|
21
|
+
this.resolve = res;
|
|
22
|
+
this.reject = rej;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* State machine for incremental chunk parsing.
|
|
28
|
+
*
|
|
29
|
+
* The Node.js ReadStream delivers data in chunks (default 64 KiB).
|
|
30
|
+
* A single chunk may split a value at any byte boundary — e.g. a
|
|
31
|
+
* 9-byte number could arrive as 4 bytes in one chunk and 5 in the
|
|
32
|
+
* next. The state machine tracks exactly which field is being
|
|
33
|
+
* decoded so parsing can pause mid-value and resume when more data
|
|
34
|
+
* arrives, without buffering the entire file.
|
|
35
|
+
*
|
|
36
|
+
* Transitions:
|
|
37
|
+
* NeedTag ────► NeedNum / NeedBool / NeedLen / ──► NeedTag (null)
|
|
38
|
+
* NeedLen ────► NeedVar
|
|
39
|
+
* NeedNum / NeedBool / NeedVar ────► NeedTag (value complete)
|
|
40
|
+
*/
|
|
41
|
+
var ParseState;
|
|
42
|
+
(function (ParseState) {
|
|
43
|
+
ParseState[ParseState["NeedTag"] = 0] = "NeedTag";
|
|
44
|
+
ParseState[ParseState["NeedNum"] = 1] = "NeedNum";
|
|
45
|
+
ParseState[ParseState["NeedBool"] = 2] = "NeedBool";
|
|
46
|
+
ParseState[ParseState["NeedLen"] = 3] = "NeedLen";
|
|
47
|
+
ParseState[ParseState["NeedVar"] = 4] = "NeedVar";
|
|
48
|
+
})(ParseState || (ParseState = {}));
|
|
49
|
+
/**
|
|
50
|
+
* Error thrown when the binary file is structurally invalid.
|
|
51
|
+
*
|
|
52
|
+
* Three conditions trigger this:
|
|
53
|
+
* 1. An unknown type tag byte (value outside 0x01–0x06).
|
|
54
|
+
* 2. The file ends mid-value (truncated — fewer bytes remaining than
|
|
55
|
+
* the length prefix declares).
|
|
56
|
+
* 3. Invalid JSON inside an Object or Array payload.
|
|
57
|
+
*
|
|
58
|
+
* The `offset` property gives the byte position in the file where
|
|
59
|
+
* corruption was first detected, aiding debugging.
|
|
60
|
+
*/
|
|
61
|
+
export class StreamCorruptError extends Error {
|
|
62
|
+
offset;
|
|
63
|
+
constructor(message, offset) {
|
|
64
|
+
super(`[Ink-Cartridge] StreamingReader: ${message} at byte ${offset}.`);
|
|
65
|
+
this.offset = offset;
|
|
66
|
+
this.name = 'StreamCorruptError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Sequential binary value reader backed by a Node.js ReadStream.
|
|
71
|
+
*
|
|
72
|
+
* Unlike {@link BinaryStorage} (which loads the entire file into one
|
|
73
|
+
* Buffer), this keeps only a small working buffer (~64 KB above the
|
|
74
|
+
* largest unparsed value) by:
|
|
75
|
+
*
|
|
76
|
+
* 1. A **state machine** (`ParseState`) that tracks which field of
|
|
77
|
+
* the current value is being decoded, so parsing can pause mid-byte
|
|
78
|
+
* and resume when the next chunk arrives.
|
|
79
|
+
* 2. **Buffer trimming** (`byteTrimming` + `advanceToNeedTag`) that
|
|
80
|
+
* discards already-consumed bytes once they exceed 64 KB, keeping
|
|
81
|
+
* memory bounded regardless of file size.
|
|
82
|
+
* 3. **Backpressure** (`maxQueueSize`) that pauses the underlying
|
|
83
|
+
* ReadStream when the parsed-value queue grows too large, and
|
|
84
|
+
* resumes it when consumers drain the queue.
|
|
85
|
+
*
|
|
86
|
+
* ## Lifecycle
|
|
87
|
+
*
|
|
88
|
+
* The stream starts in **paused** mode. It only resumes when there
|
|
89
|
+
* is consumer demand — a `readBatch()` call with insufficient queued
|
|
90
|
+
* values, or an active async-iterator `next()`. This guarantees that
|
|
91
|
+
* the file descriptor is not reading ahead of what the consumer can
|
|
92
|
+
* process, eliminating tail latency from excessive buffering.
|
|
93
|
+
*
|
|
94
|
+
* ## Error isolation
|
|
95
|
+
*
|
|
96
|
+
* Two separate flags track terminal states:
|
|
97
|
+
* - `fatalError` — set by corrupt/truncated/I/O errors. All subsequent
|
|
98
|
+
* calls throw the error. The reader is dead.
|
|
99
|
+
* - `destroyed` — set by user-initiated `destroy()`. Subsequent calls
|
|
100
|
+
* return empty results silently. Idempotent and safe.
|
|
101
|
+
*
|
|
102
|
+
* This distinction matters: a corrupt file must keep throwing so the
|
|
103
|
+
* user knows data is lost, while a graceful shutdown should not spam
|
|
104
|
+
* errors.
|
|
105
|
+
*/
|
|
106
|
+
class StreamingReader {
|
|
107
|
+
stream;
|
|
108
|
+
buf = Buffer.alloc(0);
|
|
109
|
+
offset = 0;
|
|
110
|
+
state = ParseState.NeedTag;
|
|
111
|
+
/** The type tag of the value currently being parsed (for NeedVar dispatch). */
|
|
112
|
+
currentTag = 6 /* TypeTag.Null */;
|
|
113
|
+
pendingLen = 0;
|
|
114
|
+
queue = [];
|
|
115
|
+
maxQueueSize;
|
|
116
|
+
streamEnded = false;
|
|
117
|
+
destroyed = false;
|
|
118
|
+
/** Set when a corruption / I/O error occurs. User destroy() does NOT set this. */
|
|
119
|
+
fatalError = null;
|
|
120
|
+
/**
|
|
121
|
+
* Accumulated bytes discarded from the front of the internal buffer
|
|
122
|
+
* via `advanceToNeedTag` trimming. Added to `this.offset` when
|
|
123
|
+
* computing absolute file offsets for error messages so that even
|
|
124
|
+
* after trimming 100 MB of processed data, a corrupt tag at
|
|
125
|
+
* file offset 100,000,100 is reported correctly.
|
|
126
|
+
*/
|
|
127
|
+
byteTrimming = 0;
|
|
128
|
+
/**
|
|
129
|
+
* Absolute file offset of the most recently read type tag.
|
|
130
|
+
* Used in `StreamCorruptError` to pinpoint where corruption was
|
|
131
|
+
* first detected, after accounting for buffer trimming.
|
|
132
|
+
*/
|
|
133
|
+
lastTagOffset = 0;
|
|
134
|
+
/** Waiters for readBatch(count) calls that haven't been satisfied yet. */
|
|
135
|
+
batchWaiters = [];
|
|
136
|
+
/**
|
|
137
|
+
* Waiters for async-iterator `next()` calls.
|
|
138
|
+
* Each entry represents one `for await` iteration waiting for a
|
|
139
|
+
* single value to become available.
|
|
140
|
+
*/
|
|
141
|
+
iterWaiters = [];
|
|
142
|
+
constructor(filePath, options) {
|
|
143
|
+
this.maxQueueSize = options?.maxQueueSize ?? 1000;
|
|
144
|
+
this.stream = fs.createReadStream(filePath, {
|
|
145
|
+
highWaterMark: options?.highWaterMark ?? 65536,
|
|
146
|
+
});
|
|
147
|
+
this.stream.on('data', (chunk) => this.onData(chunk));
|
|
148
|
+
this.stream.on('end', () => this.onEnd());
|
|
149
|
+
this.stream.on('error', (err) => this.onStreamError(err));
|
|
150
|
+
this.stream.on('close', () => {
|
|
151
|
+
// If the stream closes without 'end' or 'error' (possible on destroy),
|
|
152
|
+
// make sure streamEnded is true so waiters don't hang.
|
|
153
|
+
if (!this.streamEnded && !this.fatalError && !this.destroyed) {
|
|
154
|
+
this.streamEnded = true;
|
|
155
|
+
this.settleWaiters();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
// Start paused; resume only when there is demand.
|
|
159
|
+
this.stream.pause();
|
|
160
|
+
}
|
|
161
|
+
// ── Public API ──────────────────────────────────────────
|
|
162
|
+
async readBatch(count) {
|
|
163
|
+
if (this.fatalError)
|
|
164
|
+
throw this.fatalError;
|
|
165
|
+
if (this.destroyed)
|
|
166
|
+
return [];
|
|
167
|
+
if (count <= 0)
|
|
168
|
+
return [];
|
|
169
|
+
this.parseLoop();
|
|
170
|
+
if (this.fatalError)
|
|
171
|
+
throw this.fatalError;
|
|
172
|
+
if (this.queue.length > 0) {
|
|
173
|
+
const result = this.drainQueue(count);
|
|
174
|
+
if (result.length > 0)
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
if (this.streamEnded)
|
|
178
|
+
return [];
|
|
179
|
+
const waiter = { deferred: new Deferred(), count };
|
|
180
|
+
this.batchWaiters.push(waiter);
|
|
181
|
+
this.maybeResumeStream();
|
|
182
|
+
return waiter.deferred.promise;
|
|
183
|
+
}
|
|
184
|
+
[Symbol.asyncIterator]() {
|
|
185
|
+
const reader = this;
|
|
186
|
+
return {
|
|
187
|
+
async next() {
|
|
188
|
+
if (reader.fatalError)
|
|
189
|
+
throw reader.fatalError;
|
|
190
|
+
if (reader.destroyed)
|
|
191
|
+
return { done: true, value: undefined };
|
|
192
|
+
reader.parseLoop();
|
|
193
|
+
if (reader.fatalError)
|
|
194
|
+
throw reader.fatalError;
|
|
195
|
+
if (reader.queue.length > 0) {
|
|
196
|
+
const value = reader.queue.shift();
|
|
197
|
+
reader.maybeResumeIfLow();
|
|
198
|
+
return { done: false, value };
|
|
199
|
+
}
|
|
200
|
+
if (reader.streamEnded) {
|
|
201
|
+
return { done: true, value: undefined };
|
|
202
|
+
}
|
|
203
|
+
const d = new Deferred();
|
|
204
|
+
reader.iterWaiters.push(d);
|
|
205
|
+
reader.maybeResumeStream();
|
|
206
|
+
return d.promise;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
destroy() {
|
|
211
|
+
if (this.destroyed)
|
|
212
|
+
return;
|
|
213
|
+
this.destroyed = true;
|
|
214
|
+
this.stream.destroy();
|
|
215
|
+
this.queue.length = 0;
|
|
216
|
+
for (const w of this.batchWaiters)
|
|
217
|
+
w.deferred.resolve([]);
|
|
218
|
+
this.batchWaiters.length = 0;
|
|
219
|
+
for (const w of this.iterWaiters)
|
|
220
|
+
w.resolve({ done: true, value: undefined });
|
|
221
|
+
this.iterWaiters.length = 0;
|
|
222
|
+
}
|
|
223
|
+
// ── Stream event handlers ────────────────────────────────
|
|
224
|
+
onData(chunk) {
|
|
225
|
+
if (this.destroyed || this.fatalError)
|
|
226
|
+
return;
|
|
227
|
+
this.buf = Buffer.concat([this.buf, chunk]);
|
|
228
|
+
this.parseLoop();
|
|
229
|
+
this.settleWaiters();
|
|
230
|
+
// Pause the underlying read stream if the value queue is full.
|
|
231
|
+
// This is the core of backpressure: the consumer processes values
|
|
232
|
+
// at its own pace; we don't let the OS buffer exceed maxQueueSize.
|
|
233
|
+
if (this.queue.length >= this.maxQueueSize && !this.fatalError) {
|
|
234
|
+
this.stream.pause();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
onEnd() {
|
|
238
|
+
if (this.destroyed || this.fatalError)
|
|
239
|
+
return;
|
|
240
|
+
this.streamEnded = true;
|
|
241
|
+
// If there are leftover unprocessed bytes, the file is truncated.
|
|
242
|
+
if (this.offset < this.buf.length) {
|
|
243
|
+
this.fail(new StreamCorruptError('Incomplete data — file truncated mid-value', this.byteTrimming + this.lastTagOffset));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
this.settleWaiters();
|
|
247
|
+
}
|
|
248
|
+
onStreamError(err) {
|
|
249
|
+
if (this.destroyed || this.fatalError)
|
|
250
|
+
return;
|
|
251
|
+
this.streamEnded = true;
|
|
252
|
+
this.fail(err);
|
|
253
|
+
}
|
|
254
|
+
// ── Parse state machine ──────────────────────────────────
|
|
255
|
+
parseLoop() {
|
|
256
|
+
// Loop: keep parsing values as long as there are enough bytes
|
|
257
|
+
// in the internal buffer. `return` = need more data (chunk
|
|
258
|
+
// boundary hit). `continue` = finished one value, parse next.
|
|
259
|
+
while (true) {
|
|
260
|
+
switch (this.state) {
|
|
261
|
+
// ── NeedTag ──────────────────────────────────
|
|
262
|
+
// Read 1 byte, validate it's a known TypeTag (0x01–0x06),
|
|
263
|
+
// then transition to the appropriate payload state.
|
|
264
|
+
case ParseState.NeedTag: {
|
|
265
|
+
if (this.offset + TAG_SIZE > this.buf.length)
|
|
266
|
+
return;
|
|
267
|
+
const tag = this.buf.readUInt8(this.offset);
|
|
268
|
+
this.lastTagOffset = this.byteTrimming + this.offset;
|
|
269
|
+
this.offset += TAG_SIZE;
|
|
270
|
+
if (tag < 1 /* TypeTag.Number */ || tag > 6 /* TypeTag.Null */) {
|
|
271
|
+
this.fail(new StreamCorruptError(`Unknown type tag 0x${tag.toString(16)}`, this.lastTagOffset));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.currentTag = tag;
|
|
275
|
+
switch (tag) {
|
|
276
|
+
case 1 /* TypeTag.Number */:
|
|
277
|
+
this.state = ParseState.NeedNum;
|
|
278
|
+
break;
|
|
279
|
+
case 3 /* TypeTag.Boolean */:
|
|
280
|
+
this.state = ParseState.NeedBool;
|
|
281
|
+
break;
|
|
282
|
+
case 6 /* TypeTag.Null */:
|
|
283
|
+
this.queue.push(null);
|
|
284
|
+
this.advanceToNeedTag();
|
|
285
|
+
continue;
|
|
286
|
+
default:
|
|
287
|
+
// String, Object, Array — all length-prefixed.
|
|
288
|
+
this.state = ParseState.NeedLen;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
// ── NeedNum ──────────────────────────────────
|
|
294
|
+
// Expect 8 bytes of IEEE 754 float64 (little-endian).
|
|
295
|
+
case ParseState.NeedNum: {
|
|
296
|
+
if (this.offset + NUM_SIZE > this.buf.length)
|
|
297
|
+
return;
|
|
298
|
+
const val = this.buf.readDoubleLE(this.offset);
|
|
299
|
+
this.offset += NUM_SIZE;
|
|
300
|
+
this.queue.push(val);
|
|
301
|
+
this.advanceToNeedTag();
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// ── NeedBool ─────────────────────────────────
|
|
305
|
+
// Expect 1 byte: 0x00 = false, any non-zero = true.
|
|
306
|
+
case ParseState.NeedBool: {
|
|
307
|
+
if (this.offset + BOOL_SIZE > this.buf.length)
|
|
308
|
+
return;
|
|
309
|
+
const val = this.buf.readUInt8(this.offset) !== 0;
|
|
310
|
+
this.offset += BOOL_SIZE;
|
|
311
|
+
this.queue.push(val);
|
|
312
|
+
this.advanceToNeedTag();
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// ── NeedLen ──────────────────────────────────
|
|
316
|
+
// Expect 4 bytes of uint32 LE length prefix (for strings,
|
|
317
|
+
// objects, arrays). Read the length then transition to
|
|
318
|
+
// NeedVar to wait for the variable-length content.
|
|
319
|
+
case ParseState.NeedLen: {
|
|
320
|
+
if (this.offset + LEN_SIZE > this.buf.length)
|
|
321
|
+
return;
|
|
322
|
+
this.pendingLen = this.buf.readUInt32LE(this.offset);
|
|
323
|
+
this.offset += LEN_SIZE;
|
|
324
|
+
this.state = ParseState.NeedVar;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
// ── NeedVar ──────────────────────────────────
|
|
328
|
+
// Expect `pendingLen` bytes of content. Once read:
|
|
329
|
+
// - TypeTag.String → push as UTF-8 string
|
|
330
|
+
// - TypeTag.Object / TypeTag.Array → JSON-parse and push
|
|
331
|
+
case ParseState.NeedVar: {
|
|
332
|
+
if (this.offset + this.pendingLen > this.buf.length)
|
|
333
|
+
return;
|
|
334
|
+
const raw = this.buf.toString('utf-8', this.offset, this.offset + this.pendingLen);
|
|
335
|
+
this.offset += this.pendingLen;
|
|
336
|
+
if (this.currentTag === 2 /* TypeTag.String */) {
|
|
337
|
+
this.queue.push(raw);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
// Object or Array — JSON payload.
|
|
341
|
+
let val;
|
|
342
|
+
try {
|
|
343
|
+
val = JSON.parse(raw);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
this.fail(new StreamCorruptError('Invalid JSON in variable-length payload', this.lastTagOffset));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
this.queue.push(val);
|
|
350
|
+
}
|
|
351
|
+
this.advanceToNeedTag();
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
default:
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
advanceToNeedTag() {
|
|
360
|
+
this.state = ParseState.NeedTag;
|
|
361
|
+
// Periodically discard already-consumed bytes from the front of
|
|
362
|
+
// the internal buffer so memory doesn't grow unboundedly with
|
|
363
|
+
// file size. The threshold (65536 = 64 KB) means we waste at
|
|
364
|
+
// most 64 KB beyond the data currently being parsed, regardless
|
|
365
|
+
// of whether the file is 1 MB or 100 GB.
|
|
366
|
+
//
|
|
367
|
+
// We use `this.buf.subarray()` (zero-copy view) instead of
|
|
368
|
+
// `Buffer.from()` (copy) to keep trimming O(1). The original
|
|
369
|
+
// backing ArrayBuffer is retained until all views are GC'd, but
|
|
370
|
+
// since we're trimming in ~64 KB increments, the retained window
|
|
371
|
+
// is negligible compared to the total file size.
|
|
372
|
+
if (this.offset > 65536) {
|
|
373
|
+
this.byteTrimming += this.offset;
|
|
374
|
+
this.buf = this.buf.subarray(this.offset);
|
|
375
|
+
this.offset = 0;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// ── Waiter management ────────────────────────────────────
|
|
379
|
+
/**
|
|
380
|
+
* Resolve as many batch and iterator waiters as possible.
|
|
381
|
+
*
|
|
382
|
+
* Called after every `onData` (new values may have been parsed) and
|
|
383
|
+
* on `onEnd` / `onStreamError`. For batch waiters, if the queue has
|
|
384
|
+
* at least `count` items we drain immediately. For iterator waiters
|
|
385
|
+
* we deliver one value at a time.
|
|
386
|
+
*
|
|
387
|
+
* If a fatal error occurred, all waiters are rejected so the
|
|
388
|
+
* consumer sees the error at its await point.
|
|
389
|
+
*/
|
|
390
|
+
settleWaiters() {
|
|
391
|
+
// Batch waiters.
|
|
392
|
+
let i = 0;
|
|
393
|
+
while (i < this.batchWaiters.length) {
|
|
394
|
+
const w = this.batchWaiters[i];
|
|
395
|
+
if (this.fatalError) {
|
|
396
|
+
w.deferred.reject(this.fatalError);
|
|
397
|
+
this.batchWaiters.splice(i, 1);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (this.queue.length >= w.count || (this.streamEnded && this.queue.length > 0)) {
|
|
401
|
+
w.deferred.resolve(this.drainQueue(w.count));
|
|
402
|
+
this.batchWaiters.splice(i, 1);
|
|
403
|
+
}
|
|
404
|
+
else if (this.streamEnded && this.queue.length === 0) {
|
|
405
|
+
w.deferred.resolve([]);
|
|
406
|
+
this.batchWaiters.splice(i, 1);
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
i++;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Iterator waiters.
|
|
413
|
+
i = 0;
|
|
414
|
+
while (i < this.iterWaiters.length) {
|
|
415
|
+
const w = this.iterWaiters[i];
|
|
416
|
+
if (this.fatalError) {
|
|
417
|
+
w.reject(this.fatalError);
|
|
418
|
+
this.iterWaiters.splice(i, 1);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (this.queue.length > 0) {
|
|
422
|
+
const value = this.queue.shift();
|
|
423
|
+
w.resolve({ done: false, value });
|
|
424
|
+
this.iterWaiters.splice(i, 1);
|
|
425
|
+
this.maybeResumeIfLow();
|
|
426
|
+
}
|
|
427
|
+
else if (this.streamEnded) {
|
|
428
|
+
w.resolve({ done: true, value: undefined });
|
|
429
|
+
this.iterWaiters.splice(i, 1);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
i++;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Extract up to `count` values from the queue and hand them to the
|
|
438
|
+
* consumer. Resumes the underlying stream if the queue drops below
|
|
439
|
+
* maxQueueSize (backpressure release).
|
|
440
|
+
*/
|
|
441
|
+
drainQueue(count) {
|
|
442
|
+
const take = Math.min(count, this.queue.length);
|
|
443
|
+
const result = this.queue.splice(0, take);
|
|
444
|
+
this.maybeResumeIfLow();
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Transition to the fatal-error state.
|
|
449
|
+
*
|
|
450
|
+
* - Sets `fatalError` so all future calls throw.
|
|
451
|
+
* - Destroys the underlying ReadStream (file descriptor is closed).
|
|
452
|
+
* - Rejects all pending waiters so no consumer hangs.
|
|
453
|
+
*
|
|
454
|
+
* The caller guarantees that the error is non-recoverable — corrupt
|
|
455
|
+
* data, I/O failure, or truncated file. User-initiated `destroy()`
|
|
456
|
+
* does NOT call this method; it sets `destroyed` instead.
|
|
457
|
+
*/
|
|
458
|
+
fail(err) {
|
|
459
|
+
this.fatalError = err;
|
|
460
|
+
this.stream.destroy();
|
|
461
|
+
for (const w of this.batchWaiters)
|
|
462
|
+
w.deferred.reject(err);
|
|
463
|
+
this.batchWaiters.length = 0;
|
|
464
|
+
for (const w of this.iterWaiters)
|
|
465
|
+
w.reject(err);
|
|
466
|
+
this.iterWaiters.length = 0;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Resume the underlying stream if it is currently paused and the
|
|
470
|
+
* reader is still active. Used when a consumer calls readBatch or
|
|
471
|
+
* iterator next() and there aren't enough queued values yet.
|
|
472
|
+
*/
|
|
473
|
+
maybeResumeStream() {
|
|
474
|
+
if (!this.streamEnded && !this.destroyed && !this.fatalError && this.stream.isPaused()) {
|
|
475
|
+
this.stream.resume();
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Resume the underlying stream if the queue has drained below the
|
|
480
|
+
* maxQueueSize threshold. This is the backpressure release — called
|
|
481
|
+
* after each value is consumed (drainQueue or iterator shift).
|
|
482
|
+
*/
|
|
483
|
+
maybeResumeIfLow() {
|
|
484
|
+
if (!this.streamEnded &&
|
|
485
|
+
!this.destroyed &&
|
|
486
|
+
!this.fatalError &&
|
|
487
|
+
this.queue.length < this.maxQueueSize &&
|
|
488
|
+
this.stream.isPaused()) {
|
|
489
|
+
this.stream.resume();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Create a streaming binary reader for a file.
|
|
495
|
+
*
|
|
496
|
+
* Unlike {@link createBinaryStorage} (which loads the entire file into
|
|
497
|
+
* memory), this reads values sequentially from disk via a Node.js
|
|
498
|
+
* `ReadStream`. Values are parsed one at a time and returned in
|
|
499
|
+
* complete batches — the user never sees partial / truncated data.
|
|
500
|
+
*
|
|
501
|
+
* @param filePath — Absolute or relative path to the binary file.
|
|
502
|
+
* @param options — Optional tuning knobs (queue size, chunk size).
|
|
503
|
+
* @returns A reader with `readBatch()`, async iteration, and `destroy()`.
|
|
504
|
+
*
|
|
505
|
+
* @example
|
|
506
|
+
* ```ts
|
|
507
|
+
* import { createStreamingReader } from 'ink-cartridge';
|
|
508
|
+
*
|
|
509
|
+
* const reader = createStreamingReader('large.bin');
|
|
510
|
+
*
|
|
511
|
+
* let batch;
|
|
512
|
+
* while ((batch = await reader.readBatch(1000)).length > 0) {
|
|
513
|
+
* for (const value of batch) {
|
|
514
|
+
* console.log(value);
|
|
515
|
+
* }
|
|
516
|
+
* }
|
|
517
|
+
*
|
|
518
|
+
* // Or: async iteration
|
|
519
|
+
* for await (const value of reader) {
|
|
520
|
+
* console.log(value);
|
|
521
|
+
* }
|
|
522
|
+
* ```
|
|
523
|
+
*
|
|
524
|
+
* @throws `ENOENT` if the file does not exist.
|
|
525
|
+
* @throws `StreamCorruptError` if the file is corrupt or truncated.
|
|
526
|
+
*/
|
|
527
|
+
export function createStreamingReader(filePath, options) {
|
|
528
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
529
|
+
return new StreamingReader(filePath, options);
|
|
530
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { BinaryStorageOptions, BinaryStorageAPI } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Create a sequential binary storage station.
|
|
4
|
+
*
|
|
5
|
+
* Unlike {@link createStorage} (which is a key-value store backed by
|
|
6
|
+
* JSON), this creates a FIFO binary stream ideal for ordered data
|
|
7
|
+
* where keys are unnecessary overhead:
|
|
8
|
+
*
|
|
9
|
+
* - **Game replay recording** — timestamp, action, position per frame
|
|
10
|
+
* - **Sensor data logging** — time series of numeric readings
|
|
11
|
+
* - **Chat / event logs** — append-only sequential messages
|
|
12
|
+
* - **Checkpoint chains** — ordered snapshots of application state
|
|
13
|
+
*
|
|
14
|
+
* ## How it works
|
|
15
|
+
*
|
|
16
|
+
* Each value written to the stream is encoded with a 1-byte type tag
|
|
17
|
+
* followed by its payload. Reading consumes values in the same order.
|
|
18
|
+
* This guarantees type safety at the individual value level — if you
|
|
19
|
+
* call `read.num()` on a position that contains a string, you get an
|
|
20
|
+
* immediate error with the exact byte offset.
|
|
21
|
+
*
|
|
22
|
+
* The read and write cursors are independent, so you can write a
|
|
23
|
+
* sequence, `resetRead()`, and replay it immediately — all from
|
|
24
|
+
* memory, no disk I/O needed.
|
|
25
|
+
*
|
|
26
|
+
* ## Binary format
|
|
27
|
+
*
|
|
28
|
+
* ```
|
|
29
|
+
* [1 byte TypeTag] [payload...]
|
|
30
|
+
*
|
|
31
|
+
* Number: [0x01] [8 bytes float64 LE]
|
|
32
|
+
* String: [0x02] [4 bytes uint32 LE length] [UTF-8 bytes]
|
|
33
|
+
* Boolean: [0x03] [1 byte: 0x00=false, 0x01=true]
|
|
34
|
+
* Object: [0x04] [4 bytes uint32 LE length] [JSON UTF-8 bytes]
|
|
35
|
+
* Array: [0x05] [4 bytes uint32 LE length] [JSON UTF-8 bytes]
|
|
36
|
+
* Null: [0x06]
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @param options Optional directory, file name, and flush behaviour.
|
|
40
|
+
* @returns A typed binary storage API with positional read/write
|
|
41
|
+
* cursors and full seek/truncate support.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* import { createBinaryStorage } from 'ink-cartridge';
|
|
46
|
+
*
|
|
47
|
+
* const bin = createBinaryStorage({ file: 'replay.bin' });
|
|
48
|
+
*
|
|
49
|
+
* // ── Recording phase ──
|
|
50
|
+
* await bin.write.num(Date.now()); // timestamp
|
|
51
|
+
* await bin.write.str('attack'); // action
|
|
52
|
+
* await bin.write.num(120); // damage
|
|
53
|
+
* await bin.write.b(true); // critical hit
|
|
54
|
+
*
|
|
55
|
+
* // ── Playback phase ──
|
|
56
|
+
* bin.resetRead();
|
|
57
|
+
* while (bin.tellRead() < bin.tellWrite()) {
|
|
58
|
+
* const ts = await bin.read.num();
|
|
59
|
+
* const action = await bin.read.str();
|
|
60
|
+
* const damage = await bin.read.num();
|
|
61
|
+
* const crit = await bin.read.b();
|
|
62
|
+
* console.log(`${ts}: ${action} for ${damage} (crit=${crit})`);
|
|
63
|
+
* }
|
|
64
|
+
*
|
|
65
|
+
* // ── Bookmarking ──
|
|
66
|
+
* bin.resetRead();
|
|
67
|
+
* await bin.read.num(); // skip timestamp
|
|
68
|
+
* const pos = bin.tellRead();
|
|
69
|
+
* // ... later ...
|
|
70
|
+
* bin.seekRead(pos); // re-read from action
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* @throws Never throws during construction. Read errors (type
|
|
74
|
+
* mismatch, end of stream) are thrown as Promise rejections.
|
|
75
|
+
* Write errors from the filesystem propagate as standard
|
|
76
|
+
* Node.js exceptions.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createBinaryStorage(options?: BinaryStorageOptions): BinaryStorageAPI;
|
|
79
|
+
export type { BinaryStorageOptions, BinaryStorageAPI };
|
|
80
|
+
/**
|
|
81
|
+
* Low-level exports for advanced use cases.
|
|
82
|
+
*
|
|
83
|
+
* `TypeTag` is the numeric enum of type markers (0x01–0x06).
|
|
84
|
+
* `TAG_NAMES` maps each tag to its human-readable name.
|
|
85
|
+
*
|
|
86
|
+
* These are useful if you need to inspect or manipulate binary
|
|
87
|
+
* storage files outside the normal read/write API.
|
|
88
|
+
*/
|
|
89
|
+
export { TypeTag, TAG_NAMES } from './types.js';
|
|
90
|
+
export { createStreamingReader, StreamCorruptError } from './StreamingReader.js';
|
|
91
|
+
export type { StreamingReaderOptions, StreamingReaderAPI } from './types.js';
|