@mikrojs/native 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,15 +23,25 @@ typedef struct MIKRunOptions {
23
23
  bool use_psram_heap;
24
24
  } MIKRunOptions;
25
25
 
26
+ /* File-log flush policies. Mirror of `LogFlush` in the host-side TS type. */
27
+ typedef enum MIKLogFlush {
28
+ MIK_LOG_FLUSH_ERROR = 0, /* default: buffer, flush on warn/error or buffer full */
29
+ MIK_LOG_FLUSH_LINE = 1,
30
+ } MIKLogFlush;
31
+
26
32
  typedef struct MIKConfig {
27
- bool restart_on_uncaught_exception;
28
- int restart_delay_ms;
33
+ int panic_restart_delay_ms;
29
34
  size_t stack_size;
30
35
  uint32_t mem_reserved;
31
36
  uint32_t fs_read_max; /* 0 = keep runtime default (65536) */
32
37
  char entry_point[128];
33
38
  char wifi_country[3]; /* Two-letter country code + NUL, e.g. "NO" */
34
39
  char wifi_hostname[64]; /* DHCP hostname; empty = use mikrojs-<device-id> default */
40
+ /* File-log config. log_dir empty disables file logging; otherwise the
41
+ * file lives at "<log_dir>/log.txt" (rotated as ".../log.txt.1"). */
42
+ char log_dir[64];
43
+ uint32_t log_max_size;
44
+ MIKLogFlush log_flush;
35
45
  } MIKConfig;
36
46
 
37
47
  void MIK_DefaultConfig(MIKConfig* config);
@@ -306,6 +316,20 @@ void mik__proto_send(MIKReplTransport* transport, uint8_t type, const void* data
306
316
  void mik__proto_send_ok(MIKReplTransport* transport);
307
317
  void mik__proto_send_err(MIKReplTransport* transport, const char* msg);
308
318
 
319
+ /* Tap called by mik__repl_proto_send_output with the raw body bytes (no
320
+ * TLV framing) before they're sent on the wire. Single slot — owned by
321
+ * the file logger on the device side. msg_type is the MIK_MSG_* value so
322
+ * the tap can pick the appropriate log level. */
323
+ typedef void (*MIKLogEmitFn)(uint8_t msg_type, const void* data, size_t len);
324
+ void mik__set_log_emit_tap(MIKLogEmitFn fn);
325
+
326
+ /* True while mik__proto_send is writing TLV bytes to the transport.
327
+ * Platform-side transports (mik__console_write on the ESP32) check this
328
+ * to skip any installed wire-byte taps that would otherwise capture the
329
+ * framing in addition to the body. Single-task code path, so plain bool
330
+ * is sufficient — there's no other writer of this flag. */
331
+ extern bool mik__proto_send_in_progress;
332
+
309
333
  /* ── Session primitives ─────────────────────────────────────────────
310
334
  * A protocol session is scoped to a transport (one client connection).
311
335
  * Runtimes are attached/detached within the session; a supervisor can
@@ -56,6 +56,18 @@ struct MIKRuntime {
56
56
  bool is_worker;
57
57
  bool freeing;
58
58
  bool stop_requested; /* Set by promise rejection tracker to stop the loop */
59
+ /* Set by MIK_EnableTestHelpers — the test supervisor wants stop_requested
60
+ * to bubble up cleanly so it can synthesize a failing-test event and move
61
+ * to the next file. Without this flag, MIK_Stop would arm a panic-restart
62
+ * deadline and reboot the whole device mid-manifest on the first async
63
+ * rejection. */
64
+ bool test_mode;
65
+ /* Deferred restart deadline (boot_us, 0 = no pending restart). Set by
66
+ * MIK_Stop when an uncaught exception happens with a protocol REPL
67
+ * attached: the serve loop keeps reading commands until the deadline so
68
+ * the host can still deploy/clean/--recover during the grace window,
69
+ * then platform->restart() fires from MIK_Loop. */
70
+ int64_t restart_at_us;
59
71
  const char* fs_base_path;
60
72
  const char* fs_root; /* Sandbox root for mikrojs/fs operations (separate from module resolution) */
61
73
  size_t fs_limit; /* Max bytes for fs_root (0 = unlimited) */
@@ -259,6 +271,9 @@ void mik__repl_set_paused(bool paused);
259
271
  #define MIK_MSG_ERR 0x81
260
272
  #define MIK_MSG_CHECKSUM_RESULT 0x82
261
273
  #define MIK_MSG_CONFIG_ENTRIES 0x83
274
+ /* Chunk of file bytes streamed in response to MIK_CMD_FS_GET.
275
+ * Multiple frames precede a final MSG_OK signaling end-of-file. */
276
+ #define MIK_MSG_FS_CHUNK 0x84
262
277
 
263
278
  /* CLI → Device command types */
264
279
  #define MIK_CMD_EVAL 0x10
@@ -284,6 +299,10 @@ void mik__repl_set_paused(bool paused);
284
299
  #define MIK_CMD_RESTART 0x28
285
300
  #define MIK_CMD_RUNTIME_PAUSE 0x29
286
301
  #define MIK_CMD_RUNTIME_RESUME 0x2A
302
+ /* Pull a file off the device. Payload: u16le path_len | path.
303
+ * Device replies with zero-or-more MIK_MSG_FS_CHUNK frames followed by
304
+ * MIK_MSG_OK on EOF, or MIK_MSG_ERR if the path can't be opened. */
305
+ #define MIK_CMD_FS_GET 0x2B
287
306
 
288
307
  #define MIK_CMD_CONFIG_LIST 0x40
289
308
  #define MIK_CMD_CONFIG_SET 0x41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -78,7 +78,7 @@
78
78
  "cmake-js": "^8.0.0",
79
79
  "node-addon-api": "^8.7.0",
80
80
  "node-gyp-build": "^4.8.4",
81
- "@mikrojs/quickjs": "0.6.1"
81
+ "@mikrojs/quickjs": "0.7.0"
82
82
  },
83
83
  "devDependencies": {
84
84
  "@swc/core": "^1.15.30",
package/runtime/fs/fs.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {ok} from 'mikrojs/result'
1
+ import {err, ok} from 'mikrojs/result'
2
2
  import * as native from 'native:fs'
3
3
 
4
4
  import type {Result} from '../result/types.js'
@@ -61,28 +61,26 @@ export const exists = native.exists
61
61
  export function readStream(
62
62
  path: string,
63
63
  options?: ReadStreamOptions,
64
- ): Result<AsyncIterable<Uint8Array>, FSError> {
64
+ ): Result<AsyncIterable<Result<Uint8Array, FSError>>, FSError> {
65
65
  const openResult = native.open(path)
66
66
  if (!openResult.ok) return openResult
67
67
  const fh = openResult.value
68
68
  const chunkSize = options?.chunkSize ?? 512
69
69
 
70
- async function* iterate(): AsyncIterable<Uint8Array> {
70
+ async function* iterate(): AsyncIterable<Result<Uint8Array, FSError>> {
71
71
  try {
72
72
  while (true) {
73
73
  const r = fh.read(chunkSize)
74
74
  if (!r.ok) {
75
75
  // Handle-level errors (BadFileDescriptor, Unknown) don't carry a
76
- // path from the native side. All other variants include `path`
77
- // when emitted by path-level ops but fh.read doesn't have the
78
- // path, so we decorate here to preserve the original readStream
79
- // context.
76
+ // path from the native side. Path-aware variants are decorated
77
+ // here so consumers see the original readStream context.
80
78
  const e = r.error
81
- if (e.name === 'BadFileDescriptor' || e.name === 'Unknown') throw e
82
- throw {...e, path}
79
+ yield e.name === 'BadFileDescriptor' || e.name === 'Unknown' ? err(e) : err({...e, path})
80
+ return
83
81
  }
84
82
  if (r.value === undefined) break
85
- yield r.value
83
+ yield ok(r.value)
86
84
  }
87
85
  } finally {
88
86
  fh.close()
@@ -79,8 +79,8 @@ export interface ReadStreamOptions {
79
79
 
80
80
  /**
81
81
  * Open a file as a stream of byte chunks. Returns a Result because the
82
- * initial open can fail; once iterating, mid-stream read errors propagate
83
- * via rejected promises from `next()`.
82
+ * initial open can fail; once iterating, mid-stream read errors arrive
83
+ * as a final `err(...)` item and end the stream.
84
84
  *
85
85
  * Compose with `mikrojs/stream` helpers (`decodeUtf8`, `splitLines`) for
86
86
  * text/line streams. Closes the underlying handle on EOF, mid-stream
@@ -89,4 +89,4 @@ export interface ReadStreamOptions {
89
89
  export declare function readStream(
90
90
  path: string,
91
91
  options?: ReadStreamOptions,
92
- ): Result<AsyncIterable<Uint8Array>, FSError>
92
+ ): Result<AsyncIterable<Result<Uint8Array, FSError>>, FSError>
@@ -3,7 +3,9 @@
3
3
  // LTE modem over UART) implement the `Request` type directly and reuse
4
4
  // `prepareBody` + `makeResponse` for the boring parts.
5
5
 
6
- import type {Result} from 'mikrojs/result'
6
+ import {err, ok} from 'mikrojs/result'
7
+
8
+ import type {Result} from '../result/types.js'
7
9
 
8
10
  export interface RequestOptions {
9
11
  method?: string
@@ -25,21 +27,24 @@ export interface Response {
25
27
  headers: [string, string][]
26
28
  ok: boolean
27
29
  /**
28
- * Response body as an async iterable of chunks. Single-shot: after any of
29
- * `body`, `text()`, `bytes()`, or `json()` has started draining, calling
30
- * another consumer throws `BodyConsumedError`.
30
+ * Response body as an async iterable of byte chunks. Iteration yields
31
+ * `Result<Uint8Array, RequestError>` so mid-stream failures (network
32
+ * drop, abort, timeout) compose with the rest of the Result-based API.
33
+ * Single-shot: after any of `body`, `text()`, `bytes()`, or `json()`
34
+ * has started draining, the next consumer call throws
35
+ * `BodyConsumedError`.
31
36
  */
32
- body: AsyncIterable<Uint8Array>
37
+ body: AsyncIterable<Result<Uint8Array, RequestError>>
33
38
  /** First matching header value, case-insensitive, or undefined if absent. */
34
39
  get(name: string): string | undefined
35
40
  /** All matching header values in order, case-insensitive. Empty array if absent. */
36
41
  getAll(name: string): string[]
37
42
  /** Drain body as a UTF-8 string. Throws `BodyConsumedError` on second consumer call. */
38
- text(): Promise<string>
43
+ text(): Promise<Result<string, RequestError>>
39
44
  /** Drain body and parse as JSON. Throws `BodyConsumedError` on second consumer call. */
40
- json(): Promise<unknown>
45
+ json(): Promise<Result<unknown, RequestError>>
41
46
  /** Drain body to a single `Uint8Array`. Throws `BodyConsumedError` on second consumer call. */
42
- bytes(): Promise<Uint8Array>
47
+ bytes(): Promise<Result<Uint8Array, RequestError>>
43
48
  /**
44
49
  * Cancel the request and release any native resources. Safe to call after
45
50
  * the body has been consumed (no-op). Safe to call multiple times.
@@ -63,16 +68,11 @@ export const RequestError = {
63
68
  Aborted: (message: string) => ({name: 'Aborted', message}) as const,
64
69
  /** Transport is at its concurrent-request cap. Retry after in-flight requests settle. */
65
70
  TooManyPending: () => ({name: 'TooManyPending'}) as const,
71
+ /** Body drained but JSON.parse rejected the payload. */
72
+ InvalidJson: (message: string) => ({name: 'InvalidJson', message}) as const,
66
73
  }
67
74
 
68
- export type RequestError =
69
- | ReturnType<typeof RequestError.Hardware>
70
- | ReturnType<typeof RequestError.Network>
71
- | ReturnType<typeof RequestError.Timeout>
72
- | ReturnType<typeof RequestError.BodyTooLarge>
73
- | ReturnType<typeof RequestError.InvalidResponse>
74
- | ReturnType<typeof RequestError.Aborted>
75
- | ReturnType<typeof RequestError.TooManyPending>
75
+ export type RequestError = ReturnType<(typeof RequestError)[keyof typeof RequestError]>
76
76
 
77
77
  export class BodyConsumedError extends Error {
78
78
  readonly name = 'BodyConsumed'
@@ -99,9 +99,9 @@ export function prepareBody(opts: RequestOptions): {
99
99
  }
100
100
 
101
101
  /**
102
- * Wrap a transport's raw response (async-iterable body) into the public
103
- * `Response` shape with `text()`/`json()`/`bytes()`/`get()`/`getAll()`/`close()`
104
- * and a single-shot body claim.
102
+ * Wrap a transport's raw response (Result-yielding async-iterable body) into
103
+ * the public `Response` shape with `text()`/`json()`/`bytes()`/`get()`/
104
+ * `getAll()`/`close()` and a single-shot body claim.
105
105
  */
106
106
  export function makeResponse(raw: {
107
107
  status: number
@@ -109,7 +109,7 @@ export function makeResponse(raw: {
109
109
  url: string
110
110
  redirected: boolean
111
111
  headers: [string, string][]
112
- body: AsyncIterable<Uint8Array>
112
+ body: AsyncIterable<Result<Uint8Array, RequestError>>
113
113
  }): Response {
114
114
  let consumed = false
115
115
  const claim = () => {
@@ -117,7 +117,7 @@ export function makeResponse(raw: {
117
117
  consumed = true
118
118
  }
119
119
 
120
- const body: AsyncIterable<Uint8Array> = {
120
+ const body: AsyncIterable<Result<Uint8Array, RequestError>> = {
121
121
  [Symbol.asyncIterator]() {
122
122
  claim()
123
123
  return raw.body[Symbol.asyncIterator]()
@@ -153,7 +153,13 @@ export function makeResponse(raw: {
153
153
  },
154
154
  json: async () => {
155
155
  claim()
156
- return JSON.parse(await drainAsText(raw.body))
156
+ const text = await drainAsText(raw.body)
157
+ if (!text.ok) return text
158
+ try {
159
+ return ok(JSON.parse(text.value))
160
+ } catch (e) {
161
+ return err(RequestError.InvalidJson(e instanceof Error ? e.message : String(e)))
162
+ }
157
163
  },
158
164
  bytes: async () => {
159
165
  claim()
@@ -177,29 +183,35 @@ function normalizeHeaders(
177
183
  return out
178
184
  }
179
185
 
180
- async function drain(source: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
186
+ async function drain(
187
+ source: AsyncIterable<Result<Uint8Array, RequestError>>,
188
+ ): Promise<Result<Uint8Array, RequestError>> {
181
189
  const parts: Uint8Array[] = []
182
190
  let total = 0
183
- for await (const chunk of source) {
184
- parts.push(chunk)
185
- total += chunk.length
191
+ for await (const r of source) {
192
+ if (!r.ok) return r
193
+ parts.push(r.value)
194
+ total += r.value.length
186
195
  }
187
- if (parts.length === 1) return parts[0]!
196
+ if (parts.length === 1) return ok(parts[0]!)
188
197
  const out = new Uint8Array(total)
189
198
  let offset = 0
190
199
  for (const p of parts) {
191
200
  out.set(p, offset)
192
201
  offset += p.length
193
202
  }
194
- return out
203
+ return ok(out)
195
204
  }
196
205
 
197
- async function drainAsText(source: AsyncIterable<Uint8Array>): Promise<string> {
206
+ async function drainAsText(
207
+ source: AsyncIterable<Result<Uint8Array, RequestError>>,
208
+ ): Promise<Result<string, RequestError>> {
198
209
  const decoder = new TextDecoder()
199
210
  let out = ''
200
- for await (const chunk of source) {
201
- out += decoder.decode(chunk, {stream: true})
211
+ for await (const r of source) {
212
+ if (!r.ok) return r
213
+ out += decoder.decode(r.value, {stream: true})
202
214
  }
203
215
  out += decoder.decode()
204
- return out
216
+ return ok(out)
205
217
  }
@@ -92,10 +92,10 @@ export function createRequestFromNative(native: NativeHttpModule): Request {
92
92
  const {status, headers: responseHeaders} = headersResult.value
93
93
 
94
94
  let done = false
95
- const rawBody: AsyncIterable<Uint8Array> = {
95
+ const rawBody: AsyncIterable<Result<Uint8Array, RequestError>> = {
96
96
  [Symbol.asyncIterator]() {
97
97
  return {
98
- async next() {
98
+ async next(): Promise<IteratorResult<Result<Uint8Array, RequestError>>> {
99
99
  if (done) return {done: true, value: undefined}
100
100
  let msg
101
101
  try {
@@ -103,19 +103,28 @@ export function createRequestFromNative(native: NativeHttpModule): Request {
103
103
  } catch (e) {
104
104
  done = true
105
105
  cleanup()
106
- throw e
106
+ const message = e instanceof Error ? e.message : String(e)
107
+ return {done: false, value: err(RequestError.Network(message))}
107
108
  }
108
109
  if (msg.kind === 'chunk') {
109
- return {done: false, value: msg.data}
110
+ return {done: false, value: ok(msg.data)}
110
111
  }
111
112
  done = true
112
113
  cleanup()
113
114
  if (msg.kind === 'end') return {done: true, value: undefined}
114
115
  /* error */
115
116
  if (msg.cancelled) {
116
- throw RequestError.Aborted(msg.message || String(options.signal?.reason ?? 'aborted'))
117
+ return {
118
+ done: false,
119
+ value: err(
120
+ RequestError.Aborted(msg.message || String(options.signal?.reason ?? 'aborted')),
121
+ ),
122
+ }
123
+ }
124
+ return {
125
+ done: false,
126
+ value: err(RequestError.Network(msg.message || 'HTTP request failed')),
117
127
  }
118
- throw RequestError.Network(msg.message || 'HTTP request failed')
119
128
  },
120
129
  async return() {
121
130
  if (!done) {
@@ -318,8 +318,8 @@ declare module 'native:uart' {
318
318
 
319
319
  read(): Result<
320
320
  {
321
- next(): Promise<IteratorResult<Uint8Array>>
322
- return(): Promise<IteratorResult<Uint8Array>>
321
+ next(): Promise<IteratorResult<Result<Uint8Array, UartError>>>
322
+ return(): Promise<IteratorResult<Result<Uint8Array, UartError>>>
323
323
  },
324
324
  UartError
325
325
  >
@@ -1,4 +1,4 @@
1
- import {defineError, err, ok} from 'mikrojs/result'
1
+ import {err, ok} from 'mikrojs/result'
2
2
  // Typed loosely to avoid deep type instantiation from Infer<S> in parse's generics.
3
3
  // Type safety is provided by the storage interface overloads in types.ts.
4
4
  import {parse as _parse} from 'mikrojs/schema'
@@ -8,12 +8,14 @@ import type {NativeError} from '../result/types.js'
8
8
  type ParseResult = {ok: true; value: unknown} | {ok: false; error: {message: string; path: string}}
9
9
  const parse = _parse as unknown as (schema: unknown, value: unknown) => ParseResult
10
10
 
11
- export const KVError = defineError('KVError', {
12
- StorageFull: (message: string) => ({message}),
13
- EncodeFailed: (message: string) => ({message}),
14
- WriteFailed: (message: string) => ({message}),
15
- ValidationFailed: (message: string, path: string) => ({message, path}),
16
- })
11
+ export const KVError = {
12
+ StorageFull: (message: string) => ({name: 'StorageFull', message}) as const,
13
+ EncodeFailed: (message: string) => ({name: 'EncodeFailed', message}) as const,
14
+ WriteFailed: (message: string) => ({name: 'WriteFailed', message}) as const,
15
+ ValidationFailed: (message: string, path: string) =>
16
+ ({name: 'ValidationFailed', message, path}) as const,
17
+ Unknown: (code: number, message: string) => ({name: 'Unknown', code, message}) as const,
18
+ }
17
19
 
18
20
  /* Error codes from errors.h (MIK_ERR_BASE + 0xD0..0xD4) */
19
21
  const KV_INVALID_KEY = 0x80d0
@@ -43,7 +45,7 @@ function mapKvError(e: NativeError) {
43
45
  case KV_WRITE:
44
46
  return KVError.WriteFailed(e.message)
45
47
  default:
46
- throw new Error(`unexpected native kv error: code=0x${e.code.toString(16)}`)
48
+ return KVError.Unknown(e.code, e.message)
47
49
  }
48
50
  }
49
51
 
@@ -55,6 +55,7 @@ export type KVError =
55
55
  | {name: 'EncodeFailed'; message: string}
56
56
  | {name: 'WriteFailed'; message: string}
57
57
  | {name: 'ValidationFailed'; message: string; path: string}
58
+ | {name: 'Unknown'; code: number; message: string}
58
59
 
59
60
  export type KVOptions<S> =
60
61
  | {
@@ -9,12 +9,19 @@
9
9
  * lazy-load efficiency.
10
10
  */
11
11
 
12
+ import {err, ok} from 'mikrojs/result'
13
+
14
+ import type {Result} from '../result/types.js'
15
+
16
+ export type ReaderError = {name: 'Timeout'; ms: number} | {name: 'StreamClosed'}
17
+
12
18
  /**
13
- * A buffered byte reader over an async iterator of byte chunks.
19
+ * A buffered byte reader over a Result-yielding async iterator of
20
+ * byte chunks.
14
21
  *
15
- * Wraps any `AsyncIterable<Uint8Array>` (typically `uart.read()`,
16
- * eventually also TCP sockets) and exposes three primitives that
17
- * share the same underlying byte buffer:
22
+ * Wraps any `AsyncIterable<Result<Uint8Array, E>>` (typically
23
+ * `uart.read()`, `fs.readStream`, an HTTP body) and exposes three
24
+ * primitives that share the same underlying byte buffer:
18
25
  *
19
26
  * - `readUntil(delimiter, {timeoutMs})` — pull chunks until the
20
27
  * buffer contains `delimiter`, return everything before it and
@@ -30,12 +37,12 @@
30
37
  * underlying iterator. Useful before sending a new command when
31
38
  * stale bytes from a previous response might be left over.
32
39
  *
33
- * Both `readUntil` and `readBytes` throw `TimeoutError` (an Error
34
- * with `name === 'TimeoutError'`) if the deadline expires before
35
- * the request is satisfied, and `StreamClosed` (an Error with
36
- * `name === 'StreamClosed'`) if the underlying iterator finishes
37
- * before the request is satisfied. Callers may wrap those in their
38
- * own Result type if they prefer structured error handling.
40
+ * Both `readUntil` and `readBytes` return `Result<Uint8Array, E |
41
+ * ReaderError>`: `err({name: 'Timeout', ms})` if the deadline expires
42
+ * before the request is satisfied, `err({name: 'StreamClosed'})` if
43
+ * the underlying iterator finishes early, or whatever error variant
44
+ * the source yields. `RangeError` is still thrown for invalid
45
+ * arguments (programmer mistakes, not stream errors).
39
46
  *
40
47
  * Timeout semantics: when a pull races against a `setTimeout` and
41
48
  * the timer wins, the pending `iter.next()` promise is kept so the
@@ -48,18 +55,21 @@
48
55
  * not protected against — the caller is responsible for
49
56
  * sequential ordering.
50
57
  */
51
- export class BufferedReader {
52
- readonly #source: AsyncIterator<Uint8Array>
58
+ export class BufferedReader<E = never> {
59
+ readonly #source: AsyncIterator<Result<Uint8Array, E>>
53
60
  #buffer: Uint8Array
54
- #pending: Promise<IteratorResult<Uint8Array>> | null
61
+ #pending: Promise<IteratorResult<Result<Uint8Array, E>>> | null
55
62
 
56
- constructor(source: AsyncIterable<Uint8Array>) {
57
- this.#source = source[Symbol.asyncIterator]() as AsyncIterator<Uint8Array>
63
+ constructor(source: AsyncIterable<Result<Uint8Array, E>>) {
64
+ this.#source = source[Symbol.asyncIterator]() as AsyncIterator<Result<Uint8Array, E>>
58
65
  this.#buffer = new Uint8Array(0)
59
66
  this.#pending = null
60
67
  }
61
68
 
62
- async readUntil(delimiter: Uint8Array, options: {timeoutMs: number}): Promise<Uint8Array> {
69
+ async readUntil(
70
+ delimiter: Uint8Array,
71
+ options: {timeoutMs: number},
72
+ ): Promise<Result<Uint8Array, E | ReaderError>> {
63
73
  if (delimiter.length === 0) {
64
74
  throw new RangeError('BufferedReader.readUntil: delimiter must be non-empty')
65
75
  }
@@ -69,24 +79,29 @@ export class BufferedReader {
69
79
  if (idx !== -1) {
70
80
  const out = this.#buffer.slice(0, idx)
71
81
  this.#buffer = this.#buffer.slice(idx + delimiter.length)
72
- return out
82
+ return ok(out)
73
83
  }
74
- await this.#pull(deadline)
84
+ const pull = await this.#pull(deadline, options.timeoutMs)
85
+ if (!pull.ok) return pull
75
86
  }
76
87
  }
77
88
 
78
- async readBytes(count: number, options: {timeoutMs: number}): Promise<Uint8Array> {
89
+ async readBytes(
90
+ count: number,
91
+ options: {timeoutMs: number},
92
+ ): Promise<Result<Uint8Array, E | ReaderError>> {
79
93
  if (count < 0) {
80
94
  throw new RangeError('BufferedReader.readBytes: count must be non-negative')
81
95
  }
82
- if (count === 0) return new Uint8Array(0)
96
+ if (count === 0) return ok(new Uint8Array(0))
83
97
  const deadline = Date.now() + options.timeoutMs
84
98
  while (this.#buffer.length < count) {
85
- await this.#pull(deadline)
99
+ const pull = await this.#pull(deadline, options.timeoutMs)
100
+ if (!pull.ok) return pull
86
101
  }
87
102
  const out = this.#buffer.slice(0, count)
88
103
  this.#buffer = this.#buffer.slice(count)
89
- return out
104
+ return ok(out)
90
105
  }
91
106
 
92
107
  /**
@@ -100,13 +115,9 @@ export class BufferedReader {
100
115
  this.#buffer = new Uint8Array(0)
101
116
  }
102
117
 
103
- async #pull(deadline: number): Promise<void> {
118
+ async #pull(deadline: number, timeoutMs: number): Promise<Result<void, E | ReaderError>> {
104
119
  const remaining = deadline - Date.now()
105
- if (remaining <= 0) {
106
- const err = new Error(`BufferedReader: timeout waiting for bytes`)
107
- err.name = 'TimeoutError'
108
- throw err
109
- }
120
+ if (remaining <= 0) return err({name: 'Timeout' as const, ms: timeoutMs})
110
121
 
111
122
  if (this.#pending === null) {
112
123
  this.#pending = this.#source.next()
@@ -116,24 +127,24 @@ export class BufferedReader {
116
127
  let timerId: ReturnType<typeof setTimeout> | undefined
117
128
  try {
118
129
  const result = await Promise.race([
119
- pending.then((v): {kind: 'value'; v: IteratorResult<Uint8Array>} => ({kind: 'value', v})),
130
+ pending.then((v): {kind: 'value'; v: IteratorResult<Result<Uint8Array, E>>} => ({
131
+ kind: 'value',
132
+ v,
133
+ })),
120
134
  new Promise<{kind: 'timeout'}>((resolve) => {
121
135
  timerId = setTimeout(() => resolve({kind: 'timeout'}), remaining)
122
136
  }),
123
137
  ])
124
138
  if (result.kind === 'timeout') {
125
139
  // Leave #pending intact so the next pull resumes on this chunk.
126
- const err = new Error(`BufferedReader: timeout after ${remaining}ms waiting for bytes`)
127
- err.name = 'TimeoutError'
128
- throw err
140
+ return err({name: 'Timeout' as const, ms: timeoutMs})
129
141
  }
130
142
  this.#pending = null
131
- if (result.v.done) {
132
- const err = new Error('BufferedReader: stream closed before read satisfied')
133
- err.name = 'StreamClosed'
134
- throw err
135
- }
136
- this.#buffer = concatBytes(this.#buffer, result.v.value)
143
+ if (result.v.done) return err({name: 'StreamClosed' as const})
144
+ const item = result.v.value
145
+ if (!item.ok) return item
146
+ this.#buffer = concatBytes(this.#buffer, item.value)
147
+ return ok()
137
148
  } finally {
138
149
  if (timerId !== undefined) clearTimeout(timerId)
139
150
  }
@@ -7,10 +7,14 @@
7
7
  * `packages/mikrojs` and by user apps via their tsconfig.
8
8
  */
9
9
 
10
+ import type {Result} from 'mikrojs/result'
11
+
12
+ export type ReaderError = {name: 'Timeout'; ms: number} | {name: 'StreamClosed'}
13
+
10
14
  /**
11
- * A buffered byte reader over an async iterator of `Uint8Array`
12
- * chunks. Offers three operations that share the same underlying
13
- * buffer:
15
+ * A buffered byte reader over a Result-yielding async iterator of
16
+ * `Uint8Array` chunks. Offers three operations that share the same
17
+ * underlying buffer:
14
18
  *
15
19
  * - `readUntil(delimiter, {timeoutMs})` — return everything before
16
20
  * the first occurrence of `delimiter`, consume the delimiter.
@@ -19,16 +23,23 @@
19
23
  * - `drain()` — discard buffered bytes without affecting the
20
24
  * underlying iterator.
21
25
  *
22
- * Throws an Error with `name === 'TimeoutError'` if the deadline
23
- * expires before the request is satisfied, or `name === 'StreamClosed'`
24
- * if the underlying iterator finishes before the request is satisfied.
26
+ * Returns `err({name: 'Timeout', ms})` if the deadline expires,
27
+ * `err({name: 'StreamClosed'})` if the underlying iterator finishes
28
+ * early, or the source's own error variant when it yields one.
29
+ * `RangeError` is still thrown for invalid arguments.
25
30
  *
26
31
  * Single-consumer only: concurrent `readUntil` / `readBytes` calls on
27
32
  * the same instance are unsupported.
28
33
  */
29
- export declare class BufferedReader {
30
- constructor(source: AsyncIterable<Uint8Array>)
31
- readUntil(delimiter: Uint8Array, options: {timeoutMs: number}): Promise<Uint8Array>
32
- readBytes(count: number, options: {timeoutMs: number}): Promise<Uint8Array>
34
+ export declare class BufferedReader<E = never> {
35
+ constructor(source: AsyncIterable<Result<Uint8Array, E>>)
36
+ readUntil(
37
+ delimiter: Uint8Array,
38
+ options: {timeoutMs: number},
39
+ ): Promise<Result<Uint8Array, E | ReaderError>>
40
+ readBytes(
41
+ count: number,
42
+ options: {timeoutMs: number},
43
+ ): Promise<Result<Uint8Array, E | ReaderError>>
33
44
  drain(): void
34
45
  }