@mikrojs/native 0.0.7
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/CMakeLists.txt +198 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/cmake/mikrojs_bytecode.cmake +146 -0
- package/cmake.js +22 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +43 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/include/byteorder_apple.h +11 -0
- package/include/byteorder_windows.h +12 -0
- package/include/mikrojs/cbor_helpers.h +24 -0
- package/include/mikrojs/cutils_wrap.h +59 -0
- package/include/mikrojs/errors.h +144 -0
- package/include/mikrojs/mem.h +11 -0
- package/include/mikrojs/mik_color.h +32 -0
- package/include/mikrojs/mikrojs.h +331 -0
- package/include/mikrojs/platform.h +82 -0
- package/include/mikrojs/private.h +281 -0
- package/include/mikrojs/utils.h +125 -0
- package/package.json +100 -0
- package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
- package/runtime/ble/ble.ts +231 -0
- package/runtime/ble/types.ts +194 -0
- package/runtime/ble/uuid.ts +89 -0
- package/runtime/ble/validators.ts +61 -0
- package/runtime/cbor/cbor.ts +1 -0
- package/runtime/cbor/types.ts +8 -0
- package/runtime/console/types.ts +50 -0
- package/runtime/env/env.ts +17 -0
- package/runtime/env/types.ts +12 -0
- package/runtime/format/types.ts +4 -0
- package/runtime/fs/fs.ts +93 -0
- package/runtime/fs/types.ts +92 -0
- package/runtime/globals.d.ts +87 -0
- package/runtime/http/helpers.ts +222 -0
- package/runtime/http/native.ts +151 -0
- package/runtime/http/request.ts +25 -0
- package/runtime/i2c/i2c.ts +35 -0
- package/runtime/i2c/types.ts +55 -0
- package/runtime/inspect/types.ts +10 -0
- package/runtime/internal.d.ts +456 -0
- package/runtime/kv/nvs.ts +17 -0
- package/runtime/kv/rtc.ts +17 -0
- package/runtime/kv/shared.ts +107 -0
- package/runtime/kv/types.ts +150 -0
- package/runtime/neopixel/neopixel.ts +38 -0
- package/runtime/neopixel/types.ts +27 -0
- package/runtime/pin/pin.ts +51 -0
- package/runtime/pin/types.ts +49 -0
- package/runtime/pwm/pwm.ts +32 -0
- package/runtime/pwm/types.ts +29 -0
- package/runtime/reader/reader.ts +167 -0
- package/runtime/reader/types.ts +34 -0
- package/runtime/result/native-result.node-shim.ts +44 -0
- package/runtime/result/result.ts +26 -0
- package/runtime/result/types.ts +60 -0
- package/runtime/schema/schema.ts +321 -0
- package/runtime/schema/types.ts +152 -0
- package/runtime/sleep/sleep.ts +14 -0
- package/runtime/sleep/types.ts +44 -0
- package/runtime/sntp/sntp.ts +54 -0
- package/runtime/sntp/types.ts +38 -0
- package/runtime/spi/spi.ts +31 -0
- package/runtime/spi/types.ts +42 -0
- package/runtime/stdio/stdio.ts +44 -0
- package/runtime/stdio/types.ts +22 -0
- package/runtime/stream/stream.ts +150 -0
- package/runtime/stream/types.ts +47 -0
- package/runtime/sys/sys.ts +90 -0
- package/runtime/sys/types.ts +131 -0
- package/runtime/test/test.ts +595 -0
- package/runtime/test/types.ts +97 -0
- package/runtime/uart/types.ts +75 -0
- package/runtime/uart/uart.ts +51 -0
- package/runtime/wifi/types.ts +156 -0
- package/runtime/wifi/wifi.ts +208 -0
- package/scripts/bundle-runtime.js +149 -0
- package/scripts/compare-minifiers.js +189 -0
- package/scripts/compile-bytecode.sh +38 -0
- package/scripts/copy-prebuild.js +20 -0
- package/scripts/generate-symbol-map.js +146 -0
- package/src/builtins.cpp +82 -0
- package/src/cutils_compat.c +38 -0
- package/src/eval_bytecode.cpp +42 -0
- package/src/fs.cpp +878 -0
- package/src/mem.cpp +63 -0
- package/src/mik_abort.cpp +160 -0
- package/src/mik_app_config.cpp +358 -0
- package/src/mik_cbor.cpp +334 -0
- package/src/mik_color.cpp +46 -0
- package/src/mik_console.cpp +422 -0
- package/src/mik_inspect.cpp +850 -0
- package/src/mik_repl.cpp +1122 -0
- package/src/mik_result.cpp +344 -0
- package/src/mik_stdio.cpp +147 -0
- package/src/mik_sys.cpp +239 -0
- package/src/mik_text_encoding.cpp +443 -0
- package/src/mikrojs.cpp +942 -0
- package/src/modules.cpp +944 -0
- package/src/platform_posix.cpp +134 -0
- package/src/timers.cpp +208 -0
- package/src/utils.cpp +173 -0
package/runtime/fs/fs.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {ok} from 'mikrojs/result'
|
|
2
|
+
import * as native from 'native:fs'
|
|
3
|
+
|
|
4
|
+
import type {Result} from '../result/types.js'
|
|
5
|
+
import type {
|
|
6
|
+
DirEntry,
|
|
7
|
+
FSError,
|
|
8
|
+
MkdirOptions,
|
|
9
|
+
ReadStreamOptions,
|
|
10
|
+
StatResult,
|
|
11
|
+
WriteFileOptions,
|
|
12
|
+
} from './types.js'
|
|
13
|
+
|
|
14
|
+
// native:fs emits typed FSError variants directly (see mik__fs_err_result in
|
|
15
|
+
// src/fs.cpp), including the `path` field for path-level operations. These
|
|
16
|
+
// wrappers are thin pass-throughs that only exist to expose a typed public
|
|
17
|
+
// API surface for mikrojs/fs.
|
|
18
|
+
|
|
19
|
+
export function readFile(path: string): Result<Uint8Array, FSError>
|
|
20
|
+
export function readFile(path: string, encoding: 'utf-8'): Result<string, FSError>
|
|
21
|
+
export function readFile(path: string, encoding?: 'utf-8'): Result<Uint8Array | string, FSError> {
|
|
22
|
+
return encoding ? native.readFile(path, encoding) : native.readFile(path)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeFile(
|
|
26
|
+
path: string,
|
|
27
|
+
contents: string | Uint8Array,
|
|
28
|
+
options?: WriteFileOptions,
|
|
29
|
+
): Result<void, FSError> {
|
|
30
|
+
return typeof contents === 'string'
|
|
31
|
+
? native.writeFile(path, contents, options)
|
|
32
|
+
: native.writeFile(path, contents, options)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stat(path: string): Result<StatResult, FSError> {
|
|
36
|
+
return native.stat(path)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readDir(path: string): Result<DirEntry[], FSError> {
|
|
40
|
+
return native.readDir(path)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function unlink(path: string): Result<void, FSError> {
|
|
44
|
+
return native.unlink(path)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function rename(from: string, to: string): Result<void, FSError> {
|
|
48
|
+
return native.rename(from, to)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function mkdir(path: string, options?: MkdirOptions): Result<void, FSError> {
|
|
52
|
+
return native.mkdir(path, options)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function rmdir(path: string): Result<void, FSError> {
|
|
56
|
+
return native.rmdir(path)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const exists = native.exists
|
|
60
|
+
|
|
61
|
+
export function readStream(
|
|
62
|
+
path: string,
|
|
63
|
+
options?: ReadStreamOptions,
|
|
64
|
+
): Result<AsyncIterable<Uint8Array>, FSError> {
|
|
65
|
+
const openResult = native.open(path)
|
|
66
|
+
if (!openResult.ok) return openResult
|
|
67
|
+
const fh = openResult.value
|
|
68
|
+
const chunkSize = options?.chunkSize ?? 512
|
|
69
|
+
|
|
70
|
+
async function* iterate(): AsyncIterable<Uint8Array> {
|
|
71
|
+
try {
|
|
72
|
+
while (true) {
|
|
73
|
+
const r = fh.read(chunkSize)
|
|
74
|
+
if (!r.ok) {
|
|
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.
|
|
80
|
+
const e = r.error
|
|
81
|
+
if (e.name === 'BadFileDescriptor' || e.name === 'Unknown') throw e
|
|
82
|
+
throw {...e, path}
|
|
83
|
+
}
|
|
84
|
+
if (r.value === undefined) break
|
|
85
|
+
yield r.value
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
fh.close()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return ok(iterate())
|
|
93
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type {Result} from '../result/types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Curated FSError variants. Each carries only the context that's
|
|
5
|
+
* meaningful for that error:
|
|
6
|
+
* - `path` for most entries
|
|
7
|
+
* - `limit` for TooLarge (the readFile cap; currently always absent
|
|
8
|
+
* because native doesn't report the configured fs_read_max back)
|
|
9
|
+
* - `code`/`errno`/`message` for Unknown, carrying the raw native
|
|
10
|
+
* error through for debugging when a POSIX errno falls outside
|
|
11
|
+
* the curated set
|
|
12
|
+
*
|
|
13
|
+
* Discriminate on `.name`.
|
|
14
|
+
*/
|
|
15
|
+
export type FSError =
|
|
16
|
+
| {name: 'NotFound'; path: string}
|
|
17
|
+
| {name: 'AlreadyExists'; path: string}
|
|
18
|
+
| {name: 'AccessDenied'; path: string}
|
|
19
|
+
| {name: 'NoSpace'; path: string}
|
|
20
|
+
| {name: 'TooLarge'; path: string; limit?: number}
|
|
21
|
+
| {name: 'IsDirectory'; path: string}
|
|
22
|
+
| {name: 'NotDirectory'; path: string}
|
|
23
|
+
| {name: 'BadFileDescriptor'}
|
|
24
|
+
| {name: 'Unknown'; code: number; errno: number | undefined; message: string}
|
|
25
|
+
|
|
26
|
+
export interface WriteFileOptions {
|
|
27
|
+
/** If false, fail with NotFound when the file doesn't exist. Default true. */
|
|
28
|
+
create?: boolean
|
|
29
|
+
/** If true, append instead of truncating. Default false. */
|
|
30
|
+
append?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export declare function writeFile(
|
|
34
|
+
path: string,
|
|
35
|
+
contents: string | Uint8Array,
|
|
36
|
+
options?: WriteFileOptions,
|
|
37
|
+
): Result<void, FSError>
|
|
38
|
+
|
|
39
|
+
export declare function readFile(path: string): Result<Uint8Array, FSError>
|
|
40
|
+
export declare function readFile(path: string, encoding: 'utf-8'): Result<string, FSError>
|
|
41
|
+
|
|
42
|
+
export interface StatResult {
|
|
43
|
+
size: number
|
|
44
|
+
isDirectory: boolean
|
|
45
|
+
isFile: boolean
|
|
46
|
+
/** Modification time in ms since epoch. Absent if the platform doesn't
|
|
47
|
+
* track it (e.g. LittleFS without CONFIG_LITTLEFS_USE_MTIME). */
|
|
48
|
+
mtime?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export declare function stat(path: string): Result<StatResult, FSError>
|
|
52
|
+
|
|
53
|
+
export interface DirEntry {
|
|
54
|
+
name: string
|
|
55
|
+
isFile: boolean
|
|
56
|
+
isDirectory: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export declare function readDir(path: string): Result<DirEntry[], FSError>
|
|
60
|
+
export declare function unlink(path: string): Result<void, FSError>
|
|
61
|
+
export declare function rename(from: string, to: string): Result<void, FSError>
|
|
62
|
+
|
|
63
|
+
export interface MkdirOptions {
|
|
64
|
+
/** Create intermediate directories as needed (like `mkdir -p`). Default false. */
|
|
65
|
+
recursive?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export declare function mkdir(path: string, options?: MkdirOptions): Result<void, FSError>
|
|
69
|
+
export declare function rmdir(path: string): Result<void, FSError>
|
|
70
|
+
|
|
71
|
+
/** Returns true when the path exists and is reachable, false otherwise.
|
|
72
|
+
* Never fails — resolution errors collapse to false. */
|
|
73
|
+
export declare function exists(path: string): boolean
|
|
74
|
+
|
|
75
|
+
export interface ReadStreamOptions {
|
|
76
|
+
/** Bytes to read per chunk. Default 512. */
|
|
77
|
+
chunkSize?: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
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()`.
|
|
84
|
+
*
|
|
85
|
+
* Compose with `mikrojs/stream` helpers (`decodeUtf8`, `splitLines`) for
|
|
86
|
+
* text/line streams. Closes the underlying handle on EOF, mid-stream
|
|
87
|
+
* error, or early consumer break.
|
|
88
|
+
*/
|
|
89
|
+
export declare function readStream(
|
|
90
|
+
path: string,
|
|
91
|
+
options?: ReadStreamOptions,
|
|
92
|
+
): Result<AsyncIterable<Uint8Array>, FSError>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Global APIs available in the QuickJS runtime environment
|
|
2
|
+
|
|
3
|
+
declare function setTimeout(callback: (...args: any[]) => void, ms?: number): number
|
|
4
|
+
declare function clearTimeout(id: number): void
|
|
5
|
+
|
|
6
|
+
declare function setInterval(callback: (...args: any[]) => void, ms?: number): number
|
|
7
|
+
declare function clearInterval(id: number): void
|
|
8
|
+
|
|
9
|
+
declare const console: {
|
|
10
|
+
log(...args: unknown[]): void
|
|
11
|
+
warn(...args: unknown[]): void
|
|
12
|
+
error(...args: unknown[]): void
|
|
13
|
+
info(...args: unknown[]): void
|
|
14
|
+
debug(...args: unknown[]): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
declare function btoa(data: string): string
|
|
18
|
+
declare function atob(data: string): string
|
|
19
|
+
|
|
20
|
+
declare class TextEncoder {
|
|
21
|
+
encode(input?: string): Uint8Array
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
declare class TextDecoder {
|
|
25
|
+
constructor(label?: 'utf-8' | 'utf8')
|
|
26
|
+
decode(input?: ArrayBufferView | ArrayBuffer, options?: {stream?: boolean}): string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare class AbortError extends Error {
|
|
30
|
+
constructor(message?: string)
|
|
31
|
+
readonly name: 'AbortError'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
declare class TimeoutError extends Error {
|
|
35
|
+
constructor(message?: string)
|
|
36
|
+
readonly name: 'TimeoutError'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
declare class AbortSignal {
|
|
40
|
+
readonly aborted: boolean
|
|
41
|
+
readonly reason: unknown
|
|
42
|
+
throwIfAborted(): void
|
|
43
|
+
onabort: (() => void) | null
|
|
44
|
+
addEventListener(type: 'abort', fn: () => void): void
|
|
45
|
+
removeEventListener(type: 'abort', fn: () => void): void
|
|
46
|
+
static abort(reason?: unknown): AbortSignal
|
|
47
|
+
static timeout(ms: number): AbortSignal
|
|
48
|
+
static any(signals: AbortSignal[]): AbortSignal
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
declare class AbortController {
|
|
52
|
+
readonly signal: AbortSignal
|
|
53
|
+
abort(reason?: unknown): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
declare interface ImportMeta {
|
|
57
|
+
readonly url: string
|
|
58
|
+
readonly main: boolean
|
|
59
|
+
readonly dirname: string
|
|
60
|
+
readonly basename: string
|
|
61
|
+
readonly path: string
|
|
62
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
63
|
+
/**
|
|
64
|
+
* Evict a module from the runtime's module cache, recursively freeing
|
|
65
|
+
* any transitive dependency whose only importer was the unloaded module.
|
|
66
|
+
* Builtin modules (native:*, mikrojs/*, @mikrojs/*) are anchored
|
|
67
|
+
* and cannot be unloaded.
|
|
68
|
+
*
|
|
69
|
+
* Intended for use with dynamic imports: a static `import` at the top of
|
|
70
|
+
* the module creates a binding that pins the imported module's exports,
|
|
71
|
+
* so unloading it while the binding is live reclaims nothing. Prefer
|
|
72
|
+
* `const x = await import(spec)` + local scope + `import.meta.unload(spec)`.
|
|
73
|
+
*
|
|
74
|
+
* **Incompatible with `bundle: true`.** When the build bundles modules
|
|
75
|
+
* into chunks, source-level specifiers like `./phases/display.js` no
|
|
76
|
+
* longer correspond to loaded module names (they become chunk names like
|
|
77
|
+
* `display-GYVGL5MB.js`) — the unload call silently resolves to 0 and
|
|
78
|
+
* frees nothing. Projects that rely on dynamic-import-then-unload for
|
|
79
|
+
* memory reclaim need `bundle: false` so specifier strings survive to
|
|
80
|
+
* runtime unchanged.
|
|
81
|
+
*
|
|
82
|
+
* Resolves to the number of modules actually freed (root + transitive
|
|
83
|
+
* orphans), after a GC pass has run so a subsequent `memoryUsage()`
|
|
84
|
+
* snapshot reflects the reclaim.
|
|
85
|
+
*/
|
|
86
|
+
unload(specifier: string): Promise<number>
|
|
87
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Transport-agnostic HTTP request/response primitives. No dependency on
|
|
2
|
+
// `native:http`. Apps driving HTTP through a non-lwIP transport (e.g. an
|
|
3
|
+
// LTE modem over UART) implement the `Request` type directly and reuse
|
|
4
|
+
// `prepareBody` + `makeResponse` for the boring parts.
|
|
5
|
+
|
|
6
|
+
import type {Result} from 'mikrojs/result'
|
|
7
|
+
|
|
8
|
+
export interface RequestOptions {
|
|
9
|
+
method?: string
|
|
10
|
+
/** Headers as pairs or a record. Keys used as-is (no implicit case change). */
|
|
11
|
+
headers?: [string, string][] | Record<string, string>
|
|
12
|
+
/** Shortcut: stringify as JSON and set `content-type: application/json`. */
|
|
13
|
+
json?: unknown
|
|
14
|
+
/** Raw body. Mutually exclusive with `json`. */
|
|
15
|
+
body?: Uint8Array | string
|
|
16
|
+
/** Total wallclock deadline; transport enforces. */
|
|
17
|
+
timeoutMs?: number
|
|
18
|
+
/** Transport owns listener install + cleanup. Maps abort to RequestError.Aborted. */
|
|
19
|
+
signal?: AbortSignal
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Response {
|
|
23
|
+
status: number
|
|
24
|
+
statusText: string
|
|
25
|
+
url: string
|
|
26
|
+
redirected: boolean
|
|
27
|
+
headers: [string, string][]
|
|
28
|
+
ok: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Response body as an async iterable of chunks. Single-shot: after any of
|
|
31
|
+
* `body`, `text()`, `bytes()`, or `json()` has started draining, calling
|
|
32
|
+
* another consumer throws `BodyConsumedError`.
|
|
33
|
+
*/
|
|
34
|
+
body: AsyncIterable<Uint8Array>
|
|
35
|
+
/** First matching header value, case-insensitive, or undefined if absent. */
|
|
36
|
+
get(name: string): string | undefined
|
|
37
|
+
/** All matching header values in order, case-insensitive. Empty array if absent. */
|
|
38
|
+
getAll(name: string): string[]
|
|
39
|
+
/** Drain body as a UTF-8 string. Throws `BodyConsumedError` on second consumer call. */
|
|
40
|
+
text(): Promise<string>
|
|
41
|
+
/** Drain body and parse as JSON. Throws `BodyConsumedError` on second consumer call. */
|
|
42
|
+
json(): Promise<unknown>
|
|
43
|
+
/** Drain body to a single `Uint8Array`. Throws `BodyConsumedError` on second consumer call. */
|
|
44
|
+
bytes(): Promise<Uint8Array>
|
|
45
|
+
/**
|
|
46
|
+
* Cancel the request and release any native resources. Safe to call after
|
|
47
|
+
* the body has been consumed (no-op). Safe to call multiple times.
|
|
48
|
+
*/
|
|
49
|
+
close(): Promise<void>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type Request = (
|
|
53
|
+
url: string,
|
|
54
|
+
options?: RequestOptions,
|
|
55
|
+
) => Promise<Result<Response, RequestError>>
|
|
56
|
+
|
|
57
|
+
export const RequestError = {
|
|
58
|
+
Hardware: (message: string) => ({name: 'Hardware', message}) as const,
|
|
59
|
+
Network: (message: string) => ({name: 'Network', message}) as const,
|
|
60
|
+
Timeout: (message: string) => ({name: 'Timeout', message}) as const,
|
|
61
|
+
/** Request body exceeded the transport's per-request cap. */
|
|
62
|
+
BodyTooLarge: (size: number, cap: number) => ({name: 'BodyTooLarge', size, cap}) as const,
|
|
63
|
+
/** Transport returned bytes that could not be parsed as an HTTP response. */
|
|
64
|
+
InvalidResponse: (message: string) => ({name: 'InvalidResponse', message}) as const,
|
|
65
|
+
Aborted: (message: string) => ({name: 'Aborted', message}) as const,
|
|
66
|
+
/** Transport is at its concurrent-request cap. Retry after in-flight requests settle. */
|
|
67
|
+
TooManyPending: () => ({name: 'TooManyPending'}) as const,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type RequestError =
|
|
71
|
+
| ReturnType<typeof RequestError.Hardware>
|
|
72
|
+
| ReturnType<typeof RequestError.Network>
|
|
73
|
+
| ReturnType<typeof RequestError.Timeout>
|
|
74
|
+
| ReturnType<typeof RequestError.BodyTooLarge>
|
|
75
|
+
| ReturnType<typeof RequestError.InvalidResponse>
|
|
76
|
+
| ReturnType<typeof RequestError.Aborted>
|
|
77
|
+
| ReturnType<typeof RequestError.TooManyPending>
|
|
78
|
+
|
|
79
|
+
export class BodyConsumedError extends Error {
|
|
80
|
+
readonly name = 'BodyConsumed'
|
|
81
|
+
constructor() {
|
|
82
|
+
super('response body already consumed')
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const textEncoder = new TextEncoder()
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Normalize `RequestOptions` into the byte-oriented shape a transport actually
|
|
90
|
+
* sends: final body bytes (or null) and header pairs. Applies the `json`
|
|
91
|
+
* shortcut (encodes + sets content-type unless already present) and
|
|
92
|
+
* UTF-8-encodes string bodies.
|
|
93
|
+
*/
|
|
94
|
+
export function prepareBody(opts: RequestOptions): {
|
|
95
|
+
body: Uint8Array | null
|
|
96
|
+
headers: [string, string][]
|
|
97
|
+
} {
|
|
98
|
+
const headers = normalizeHeaders(opts.headers)
|
|
99
|
+
if (opts.json !== undefined) {
|
|
100
|
+
if (!hasHeader(headers, 'content-type')) {
|
|
101
|
+
headers.push(['content-type', 'application/json'])
|
|
102
|
+
}
|
|
103
|
+
return {body: textEncoder.encode(JSON.stringify(opts.json)), headers}
|
|
104
|
+
}
|
|
105
|
+
if (opts.body === undefined) return {body: null, headers}
|
|
106
|
+
if (typeof opts.body === 'string') return {body: textEncoder.encode(opts.body), headers}
|
|
107
|
+
return {body: opts.body, headers}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Wrap a transport's raw response (async-iterable body) into the public
|
|
112
|
+
* `Response` shape with `text()`/`json()`/`bytes()`/`get()`/`getAll()`/`close()`
|
|
113
|
+
* and a single-shot body claim.
|
|
114
|
+
*/
|
|
115
|
+
export function makeResponse(raw: {
|
|
116
|
+
status: number
|
|
117
|
+
statusText: string
|
|
118
|
+
url: string
|
|
119
|
+
redirected: boolean
|
|
120
|
+
headers: [string, string][]
|
|
121
|
+
body: AsyncIterable<Uint8Array>
|
|
122
|
+
}): Response {
|
|
123
|
+
let consumed = false
|
|
124
|
+
const claim = () => {
|
|
125
|
+
if (consumed) throw new BodyConsumedError()
|
|
126
|
+
consumed = true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const body: AsyncIterable<Uint8Array> = {
|
|
130
|
+
[Symbol.asyncIterator]() {
|
|
131
|
+
claim()
|
|
132
|
+
return raw.body[Symbol.asyncIterator]()
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
status: raw.status,
|
|
138
|
+
statusText: raw.statusText,
|
|
139
|
+
url: raw.url,
|
|
140
|
+
redirected: raw.redirected,
|
|
141
|
+
headers: raw.headers,
|
|
142
|
+
ok: raw.status >= 200 && raw.status < 300,
|
|
143
|
+
body,
|
|
144
|
+
get: (name) => {
|
|
145
|
+
const lower = name.toLowerCase()
|
|
146
|
+
for (const [k, v] of raw.headers) {
|
|
147
|
+
if (k.toLowerCase() === lower) return v
|
|
148
|
+
}
|
|
149
|
+
return undefined
|
|
150
|
+
},
|
|
151
|
+
getAll: (name) => {
|
|
152
|
+
const lower = name.toLowerCase()
|
|
153
|
+
const out: string[] = []
|
|
154
|
+
for (const [k, v] of raw.headers) {
|
|
155
|
+
if (k.toLowerCase() === lower) out.push(v)
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
},
|
|
159
|
+
text: async () => {
|
|
160
|
+
claim()
|
|
161
|
+
return await drainAsText(raw.body)
|
|
162
|
+
},
|
|
163
|
+
json: async () => {
|
|
164
|
+
claim()
|
|
165
|
+
return JSON.parse(await drainAsText(raw.body))
|
|
166
|
+
},
|
|
167
|
+
bytes: async () => {
|
|
168
|
+
claim()
|
|
169
|
+
return await drain(raw.body)
|
|
170
|
+
},
|
|
171
|
+
close: async () => {
|
|
172
|
+
consumed = true
|
|
173
|
+
const it = raw.body[Symbol.asyncIterator]()
|
|
174
|
+
if (it.return) await it.return()
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeHeaders(
|
|
180
|
+
input: [string, string][] | Record<string, string> | undefined,
|
|
181
|
+
): [string, string][] {
|
|
182
|
+
if (!input) return []
|
|
183
|
+
if (Array.isArray(input)) return input.map(([k, v]) => [k, v])
|
|
184
|
+
const out: [string, string][] = []
|
|
185
|
+
for (const key of Object.keys(input)) out.push([key, input[key]!])
|
|
186
|
+
return out
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasHeader(headers: [string, string][], name: string): boolean {
|
|
190
|
+
const lower = name.toLowerCase()
|
|
191
|
+
for (const [k] of headers) {
|
|
192
|
+
if (k.toLowerCase() === lower) return true
|
|
193
|
+
}
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function drain(source: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
|
|
198
|
+
const parts: Uint8Array[] = []
|
|
199
|
+
let total = 0
|
|
200
|
+
for await (const chunk of source) {
|
|
201
|
+
parts.push(chunk)
|
|
202
|
+
total += chunk.length
|
|
203
|
+
}
|
|
204
|
+
if (parts.length === 1) return parts[0]!
|
|
205
|
+
const out = new Uint8Array(total)
|
|
206
|
+
let offset = 0
|
|
207
|
+
for (const p of parts) {
|
|
208
|
+
out.set(p, offset)
|
|
209
|
+
offset += p.length
|
|
210
|
+
}
|
|
211
|
+
return out
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function drainAsText(source: AsyncIterable<Uint8Array>): Promise<string> {
|
|
215
|
+
const decoder = new TextDecoder()
|
|
216
|
+
let out = ''
|
|
217
|
+
for await (const chunk of source) {
|
|
218
|
+
out += decoder.decode(chunk, {stream: true})
|
|
219
|
+
}
|
|
220
|
+
out += decoder.decode()
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Internal: native:http-backed request implementation. Bundled into
|
|
2
|
+
// http/request.js; not exposed as its own subpath. Exists as a seam so host
|
|
3
|
+
// tests can inject a fake native module.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
makeResponse,
|
|
7
|
+
prepareBody,
|
|
8
|
+
type Request,
|
|
9
|
+
RequestError,
|
|
10
|
+
type RequestOptions,
|
|
11
|
+
} from 'mikrojs/http/helpers'
|
|
12
|
+
import {err, ok} from 'mikrojs/result'
|
|
13
|
+
|
|
14
|
+
import type {ErrResult, Result} from '../result/types.js'
|
|
15
|
+
|
|
16
|
+
type HttpRequestStart =
|
|
17
|
+
| {
|
|
18
|
+
ok: true
|
|
19
|
+
id: number
|
|
20
|
+
headers: Promise<Result<{status: number; headers: [string, string][]}, RequestError>>
|
|
21
|
+
}
|
|
22
|
+
| ErrResult<RequestError>
|
|
23
|
+
|
|
24
|
+
export interface NativeHttpModule {
|
|
25
|
+
request: (
|
|
26
|
+
url: string,
|
|
27
|
+
options?: {method?: string; body?: Uint8Array; headers?: [string, string][]},
|
|
28
|
+
) => HttpRequestStart
|
|
29
|
+
nextMessage: (
|
|
30
|
+
id: number,
|
|
31
|
+
) => Promise<
|
|
32
|
+
| {kind: 'chunk'; data: Uint8Array}
|
|
33
|
+
| {kind: 'end'}
|
|
34
|
+
| {kind: 'error'; cancelled: boolean; message: string}
|
|
35
|
+
>
|
|
36
|
+
cancel: (id: number) => void
|
|
37
|
+
pendingCount: () => number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a `Request` function driven by a `native:http`-shaped module. Owns
|
|
42
|
+
* AbortSignal listener lifecycle and total-wallclock timeout enforcement.
|
|
43
|
+
*
|
|
44
|
+
* Callers are responsible for releasing the native pending slot by either
|
|
45
|
+
* draining the response body or calling `response.close()`. Leaks are
|
|
46
|
+
* observable via `pendingCount()`.
|
|
47
|
+
*/
|
|
48
|
+
export function createRequestFromNative(native: NativeHttpModule): Request {
|
|
49
|
+
return async (url: string, options: RequestOptions = {}) => {
|
|
50
|
+
if (options.signal?.aborted) {
|
|
51
|
+
return err(RequestError.Aborted(String(options.signal.reason ?? 'aborted')))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const {body, headers} = prepareBody(options)
|
|
55
|
+
const method = (options.method ?? 'GET').toUpperCase()
|
|
56
|
+
|
|
57
|
+
const start = native.request(url, {
|
|
58
|
+
method,
|
|
59
|
+
body: body ?? undefined,
|
|
60
|
+
headers,
|
|
61
|
+
})
|
|
62
|
+
if (!start.ok) return start
|
|
63
|
+
|
|
64
|
+
const id = start.id
|
|
65
|
+
|
|
66
|
+
let cleaned = false
|
|
67
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
68
|
+
let abortHandler: (() => void) | undefined
|
|
69
|
+
const cleanup = () => {
|
|
70
|
+
if (cleaned) return
|
|
71
|
+
cleaned = true
|
|
72
|
+
if (abortHandler && options.signal) {
|
|
73
|
+
options.signal.removeEventListener('abort', abortHandler)
|
|
74
|
+
}
|
|
75
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (options.timeoutMs !== undefined) {
|
|
79
|
+
timeoutId = setTimeout(() => native.cancel(id), options.timeoutMs)
|
|
80
|
+
}
|
|
81
|
+
if (options.signal) {
|
|
82
|
+
abortHandler = () => native.cancel(id)
|
|
83
|
+
options.signal.addEventListener('abort', abortHandler)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const headersResult = await start.headers
|
|
87
|
+
if (!headersResult.ok) {
|
|
88
|
+
cleanup()
|
|
89
|
+
return headersResult
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const {status, headers: responseHeaders} = headersResult.value
|
|
93
|
+
|
|
94
|
+
let done = false
|
|
95
|
+
const rawBody: AsyncIterable<Uint8Array> = {
|
|
96
|
+
[Symbol.asyncIterator]() {
|
|
97
|
+
return {
|
|
98
|
+
async next() {
|
|
99
|
+
if (done) return {done: true, value: undefined}
|
|
100
|
+
let msg
|
|
101
|
+
try {
|
|
102
|
+
msg = await native.nextMessage(id)
|
|
103
|
+
} catch (e) {
|
|
104
|
+
done = true
|
|
105
|
+
cleanup()
|
|
106
|
+
throw e
|
|
107
|
+
}
|
|
108
|
+
if (msg.kind === 'chunk') {
|
|
109
|
+
return {done: false, value: msg.data}
|
|
110
|
+
}
|
|
111
|
+
done = true
|
|
112
|
+
cleanup()
|
|
113
|
+
if (msg.kind === 'end') return {done: true, value: undefined}
|
|
114
|
+
/* error */
|
|
115
|
+
if (msg.cancelled) {
|
|
116
|
+
throw RequestError.Aborted(msg.message || String(options.signal?.reason ?? 'aborted'))
|
|
117
|
+
}
|
|
118
|
+
throw RequestError.Network(msg.message || 'HTTP request failed')
|
|
119
|
+
},
|
|
120
|
+
async return() {
|
|
121
|
+
if (!done) {
|
|
122
|
+
done = true
|
|
123
|
+
cleanup()
|
|
124
|
+
native.cancel(id)
|
|
125
|
+
try {
|
|
126
|
+
for (;;) {
|
|
127
|
+
const msg = await native.nextMessage(id)
|
|
128
|
+
if (msg.kind !== 'chunk') break
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
/* ignore */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {done: true, value: undefined}
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return ok(
|
|
141
|
+
makeResponse({
|
|
142
|
+
status,
|
|
143
|
+
statusText: '',
|
|
144
|
+
url,
|
|
145
|
+
redirected: false,
|
|
146
|
+
headers: responseHeaders,
|
|
147
|
+
body: rawBody,
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as native from 'native:http'
|
|
2
|
+
|
|
3
|
+
import {createRequestFromNative} from './native.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default HTTP request for the mikrojs runtime. Uses the ESP32 `native:http`
|
|
7
|
+
* transport, which streams response bodies chunk-by-chunk with reader-driven
|
|
8
|
+
* backpressure, integrates with `AbortSignal`, and enforces total-wallclock
|
|
9
|
+
* timeouts.
|
|
10
|
+
*
|
|
11
|
+
* For `RequestError`, `BodyConsumedError`, `prepareBody`, `makeResponse`, or
|
|
12
|
+
* any of the shared types, import from `mikrojs/http/helpers`. That subpath
|
|
13
|
+
* has no dependency on `native:http`, so importing it from custom transports
|
|
14
|
+
* (e.g. an LTE modem) doesn't retain the WiFi-backed HTTP stack.
|
|
15
|
+
*/
|
|
16
|
+
export const request = createRequestFromNative(native)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Number of in-flight requests whose terminal message has not yet been
|
|
20
|
+
* consumed. Primarily useful in tests and diagnostic code to verify that
|
|
21
|
+
* cancel + drain correctly releases request slots.
|
|
22
|
+
*/
|
|
23
|
+
export function pendingCount(): number {
|
|
24
|
+
return native.pendingCount()
|
|
25
|
+
}
|