@mikrojs/native 0.11.0 → 0.12.0-next.7.g0a0155c

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.11.0",
3
+ "version": "0.12.0-next.7.g0a0155c",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -50,6 +50,7 @@
50
50
  "./runtime/env/types": "./runtime/env/types.ts",
51
51
  "./runtime/format/types": "./runtime/format/types.ts",
52
52
  "./runtime/http/helpers": "./runtime/http/helpers.ts",
53
+ "./runtime/http/server-impl": "./runtime/http/server-impl.ts",
53
54
  "./runtime/fs/types": "./runtime/fs/types.ts",
54
55
  "./runtime/i2c/types": "./runtime/i2c/types.ts",
55
56
  "./runtime/inspect/types": "./runtime/inspect/types.ts",
@@ -79,7 +80,7 @@
79
80
  "cmake-js": "^8.0.0",
80
81
  "node-addon-api": "^8.7.0",
81
82
  "node-gyp-build": "^4.8.4",
82
- "@mikrojs/quickjs": "0.11.0"
83
+ "@mikrojs/quickjs": "0.12.0-next.7.g0a0155c"
83
84
  },
84
85
  "devDependencies": {
85
86
  "@swc/core": "^1.15.30",
@@ -0,0 +1,308 @@
1
+ // Internal: native:http_server-backed server implementation. Bundled into
2
+ // http/server.js; not exposed as its own subpath. Exists as a seam so host
3
+ // tests can inject a fake native module (mirrors http/native.ts for the client).
4
+
5
+ import {makeResponse, RequestError} from 'mikro/http/helpers'
6
+ import {err, ok} from 'mikro/result'
7
+
8
+ import type {Result} from '../result/types.js'
9
+
10
+ /* Error returned by listen(). The native layer produces these via
11
+ * mik__result_err_*, so the variant names must match mik_http_server.cpp. */
12
+ export const ServerError = {
13
+ /** A server is already listening; close() before listening again. */
14
+ AlreadyListening: () => ({name: 'AlreadyListening'}) as const,
15
+ OutOfMemory: (message: string) => ({name: 'OutOfMemory', message}) as const,
16
+ /** httpd_start failed (e.g. port unavailable). Message carries the esp_err. */
17
+ StartFailed: (message: string) => ({name: 'StartFailed', message}) as const,
18
+ }
19
+ export type ServerError = ReturnType<(typeof ServerError)[keyof typeof ServerError]>
20
+
21
+ /**
22
+ * Lazy, non-enumerable request header accessor. esp_http_server cannot list a
23
+ * request's headers, so there is no full array — these fetch a header by name.
24
+ * `getAll` returns `[value]` or `[]` (duplicate request headers collapse to one
25
+ * on this platform).
26
+ */
27
+ export interface RequestHeaders {
28
+ get(name: string): string | undefined
29
+ getAll(name: string): string[]
30
+ }
31
+
32
+ export interface ServerRequest {
33
+ method: string
34
+ /** Path plus query string, e.g. "/hello?x=1". */
35
+ url: string
36
+ headers: RequestHeaders
37
+ /**
38
+ * Request body as a single-shot async iterable of byte chunks (v1 buffers the
39
+ * whole body up to `maxBodySize` before the handler runs). After any of
40
+ * `body`/`text()`/`json()`/`bytes()` starts draining, the next consumer call
41
+ * throws `BodyConsumedError`.
42
+ */
43
+ body: AsyncIterable<Result<Uint8Array, RequestError>>
44
+ text(): Promise<Result<string, RequestError>>
45
+ json(): Promise<Result<unknown, RequestError>>
46
+ bytes(): Promise<Result<Uint8Array, RequestError>>
47
+ }
48
+
49
+ export interface ServerResponse {
50
+ status: number
51
+ headers?: [string, string][] | Record<string, string>
52
+ body?: string | Uint8Array | AsyncIterable<Uint8Array | string>
53
+ }
54
+
55
+ export type Handler = (req: ServerRequest) => ServerResponse | Promise<ServerResponse>
56
+
57
+ /**
58
+ * Called when a handler panics (throws or rejects) — an unexpected failure, not
59
+ * the place for normal error handling. Receives the thrown value and the
60
+ * request. Return a `ServerResponse` to send it; return nothing to fall back to
61
+ * a plain 500. If the hook itself throws, the 500 fallback is used.
62
+ */
63
+ export type PanicHandler = (
64
+ error: unknown,
65
+ req: ServerRequest,
66
+ ) => ServerResponse | undefined | void | Promise<ServerResponse | undefined | void>
67
+
68
+ export interface ServerOptions {
69
+ /** Cap on the buffered request body, in bytes (default 16 KiB). */
70
+ maxBodySize?: number
71
+ /** Hook invoked when a handler panics (throws). See {@link PanicHandler}. */
72
+ onPanic?: PanicHandler
73
+ }
74
+
75
+ export interface Server {
76
+ /** Bind and start serving. Synchronous; returns Err on bind failure. */
77
+ listen(opts: {port: number}): Result<void, ServerError>
78
+ /** Stop serving and release the port. Safe to call when not listening. */
79
+ close(): void
80
+ }
81
+
82
+ export type CreateServer = (handler: Handler, opts?: ServerOptions) => Server
83
+
84
+ interface NativeRequestDescriptor {
85
+ id: number
86
+ method: string
87
+ url: string
88
+ body: Uint8Array
89
+ bodyTooLarge: boolean
90
+ contentLength: number
91
+ }
92
+
93
+ export interface NativeHttpServerModule {
94
+ start: (port: number, maxBodySize?: number) => Result<void, ServerError>
95
+ stop: () => void
96
+ nextRequest: () => Promise<NativeRequestDescriptor | {closed: true}>
97
+ /** One-shot response: status + headers + full body, sent with Content-Length. */
98
+ respond: (id: number, status: number, headers: [string, string][], body?: Uint8Array) => void
99
+ /** Begin a chunked (streamed) response. Resolves once status + headers are sent. */
100
+ respondStart: (id: number, status: number, headers: [string, string][]) => Promise<void>
101
+ /** Send one body chunk. Resolves to false if the client has gone (stop sending). */
102
+ respondChunk: (id: number, data: Uint8Array) => Promise<boolean>
103
+ /** Finish a chunked response. */
104
+ respondEnd: (id: number) => void
105
+ getHeader: (id: number, name: string) => string | undefined
106
+ }
107
+
108
+ const encoder = new TextEncoder()
109
+ const DEFAULT_MAX_BODY = 16384
110
+
111
+ /* ── Request / response plumbing ───────────────────────────────────── */
112
+
113
+ // CR, LF, and NUL in a header name or value could split the response or
114
+ // truncate it. esp_http_server stores header pointers verbatim and does not
115
+ // sanitize, so an app that reflects untrusted input into a header (e.g. a
116
+ // redirect Location) must not be able to inject headers through it.
117
+ const UNSAFE_HEADER_CHAR = /[\r\n\0]/
118
+
119
+ function normalizeHeaders(
120
+ input: [string, string][] | Record<string, string> | undefined,
121
+ ): [string, string][] {
122
+ if (!input) return []
123
+ const tuples: [string, string][] = Array.isArray(input)
124
+ ? input
125
+ : Object.keys(input).map((k) => [k, input[k]!])
126
+ // Drop offending headers rather than throw: a throw here would leave the
127
+ // httpd worker un-responded and hang the request.
128
+ return tuples.filter(([k, v]) => !UNSAFE_HEADER_CHAR.test(k) && !UNSAFE_HEADER_CHAR.test(v))
129
+ }
130
+
131
+ function bodyIterable(
132
+ desc: NativeRequestDescriptor,
133
+ cap: number,
134
+ ): AsyncIterable<Result<Uint8Array, RequestError>> {
135
+ return {
136
+ [Symbol.asyncIterator]() {
137
+ let sent = false
138
+ return {
139
+ async next(): Promise<IteratorResult<Result<Uint8Array, RequestError>>> {
140
+ if (sent) return {done: true, value: undefined}
141
+ sent = true
142
+ if (desc.bodyTooLarge) {
143
+ return {done: false, value: err(RequestError.BodyTooLarge(desc.contentLength, cap))}
144
+ }
145
+ if (desc.body.length === 0) return {done: true, value: undefined}
146
+ return {done: false, value: ok(desc.body)}
147
+ },
148
+ }
149
+ },
150
+ }
151
+ }
152
+
153
+ function makeServerRequest(
154
+ native: NativeHttpServerModule,
155
+ desc: NativeRequestDescriptor,
156
+ cap: number,
157
+ ): ServerRequest {
158
+ // Borrow the client's single-shot body draining (text/json/bytes share one
159
+ // consumed flag with body). Headers are NOT borrowed: esp_http_server can't
160
+ // enumerate them, so they're lazy by-name lookups via native.getHeader.
161
+ const drained = makeResponse({
162
+ status: 0,
163
+ statusText: '',
164
+ url: desc.url,
165
+ redirected: false,
166
+ headers: [],
167
+ body: bodyIterable(desc, cap),
168
+ })
169
+ return {
170
+ method: desc.method,
171
+ url: desc.url,
172
+ headers: {
173
+ get: (name) => native.getHeader(desc.id, name),
174
+ getAll: (name) => {
175
+ const v = native.getHeader(desc.id, name)
176
+ return v === undefined ? [] : [v]
177
+ },
178
+ },
179
+ body: drained.body,
180
+ text: drained.text,
181
+ json: drained.json,
182
+ bytes: drained.bytes,
183
+ }
184
+ }
185
+
186
+ async function streamResponse(
187
+ native: NativeHttpServerModule,
188
+ id: number,
189
+ status: number,
190
+ headers: [string, string][],
191
+ body: AsyncIterable<Uint8Array | string>,
192
+ ): Promise<void> {
193
+ let started = false
194
+ try {
195
+ await native.respondStart(id, status, headers)
196
+ started = true
197
+ for await (const chunk of body) {
198
+ const bytes = typeof chunk === 'string' ? encoder.encode(chunk) : chunk
199
+ if (bytes.length === 0) continue
200
+ // respondChunk resolves once the chunk is flushed (backpressure); false
201
+ // means the client disconnected, so stop pulling from the generator.
202
+ if (!(await native.respondChunk(id, bytes))) break
203
+ }
204
+ } catch {
205
+ // The generator threw after status + headers were already sent, so the
206
+ // status can't change. End the response below to truncate it cleanly; the
207
+ // server keeps running.
208
+ } finally {
209
+ // Only end a stream we actually started, so the worker is always released
210
+ // exactly once.
211
+ if (started) native.respondEnd(id)
212
+ }
213
+ }
214
+
215
+ async function sendResponse(
216
+ native: NativeHttpServerModule,
217
+ id: number,
218
+ response: ServerResponse,
219
+ ): Promise<void> {
220
+ const headers = normalizeHeaders(response.headers)
221
+ const body = response.body
222
+ if (body === undefined) {
223
+ native.respond(id, response.status, headers, undefined)
224
+ } else if (typeof body === 'string') {
225
+ native.respond(id, response.status, headers, encoder.encode(body))
226
+ } else if (body instanceof Uint8Array) {
227
+ native.respond(id, response.status, headers, body)
228
+ } else {
229
+ await streamResponse(native, id, response.status, headers, body)
230
+ }
231
+ }
232
+
233
+ const FALLBACK_500: ServerResponse = {status: 500, body: 'Internal Server Error'}
234
+
235
+ // A handler that panics (throws) is isolated to its one request so the server
236
+ // keeps serving: run the optional onPanic hook for a custom response (and
237
+ // server-side visibility), else send a plain 500. The httpd worker is blocked
238
+ // until we respond, so every branch must produce exactly one response.
239
+ async function handlePanic(
240
+ onPanic: PanicHandler | undefined,
241
+ error: unknown,
242
+ req: ServerRequest,
243
+ ): Promise<ServerResponse> {
244
+ if (!onPanic) return FALLBACK_500
245
+ try {
246
+ return (await onPanic(error, req)) ?? FALLBACK_500
247
+ } catch {
248
+ // The hook itself threw; don't let its bug hang the worker.
249
+ return FALLBACK_500
250
+ }
251
+ }
252
+
253
+ async function serveLoop(
254
+ native: NativeHttpServerModule,
255
+ handler: Handler,
256
+ isRunning: () => boolean,
257
+ cap: number,
258
+ onPanic: PanicHandler | undefined,
259
+ ): Promise<void> {
260
+ while (isRunning()) {
261
+ let desc: NativeRequestDescriptor | {closed: true}
262
+ try {
263
+ desc = await native.nextRequest()
264
+ } catch {
265
+ break
266
+ }
267
+ if ('closed' in desc) break
268
+
269
+ const req = makeServerRequest(native, desc, cap)
270
+ // The whole per-request cycle (handler + send) is isolated so neither a
271
+ // throwing handler nor a failed send can break the serve loop and stall
272
+ // the server.
273
+ try {
274
+ let response: ServerResponse
275
+ try {
276
+ response = await handler(req)
277
+ } catch (e) {
278
+ response = await handlePanic(onPanic, e, req)
279
+ }
280
+ await sendResponse(native, desc.id, response)
281
+ } catch {
282
+ // Sending the response failed; the request is already past the point
283
+ // where we can change it. Keep serving the next request.
284
+ }
285
+ }
286
+ }
287
+
288
+ export function createServerFromNative(native: NativeHttpServerModule): CreateServer {
289
+ return (handler, opts = {}) => {
290
+ const cap = opts.maxBodySize ?? DEFAULT_MAX_BODY
291
+ let running = false
292
+ return {
293
+ listen({port}) {
294
+ if (running) return err(ServerError.AlreadyListening())
295
+ const started = native.start(port, opts.maxBodySize)
296
+ if (!started.ok) return started
297
+ running = true
298
+ void serveLoop(native, handler, () => running, cap, opts.onPanic)
299
+ return ok()
300
+ },
301
+ close() {
302
+ if (!running) return
303
+ running = false
304
+ native.stop()
305
+ },
306
+ }
307
+ }
308
+ }
@@ -0,0 +1,30 @@
1
+ import * as native from 'native:http_server'
2
+
3
+ import {createServerFromNative} from './server-impl.js'
4
+
5
+ /**
6
+ * Create an HTTP server backed by the ESP32 `esp_http_server`. Lazy: nothing
7
+ * binds until `listen()`. The handler runs per request on the event loop and
8
+ * returns `{status, headers?, body?}`; `body` may be a string, `Uint8Array`, or
9
+ * an async iterable (e.g. an async generator) for a streamed response.
10
+ *
11
+ * const server = createServer((req) => {
12
+ * if (req.url === '/') return {status: 200, body: 'Hello'}
13
+ * return {status: 404, body: 'not found'}
14
+ * })
15
+ * const r = server.listen({port: 80})
16
+ * if (!r.ok) return // ServerError
17
+ */
18
+ export const createServer = createServerFromNative(native)
19
+
20
+ export type {
21
+ CreateServer,
22
+ Handler,
23
+ PanicHandler,
24
+ RequestHeaders,
25
+ Server,
26
+ ServerOptions,
27
+ ServerRequest,
28
+ ServerResponse,
29
+ } from './server-impl.js'
30
+ export {ServerError} from './server-impl.js'
@@ -163,6 +163,38 @@ declare module 'native:http' {
163
163
  export function cancel(id: number): void
164
164
  export function pendingCount(): number
165
165
  }
166
+
167
+ declare module 'native:http_server' {
168
+ import type {Result} from 'mikro/result'
169
+
170
+ import type {ServerError} from './http/server-impl.js'
171
+
172
+ export type NativeRequestDescriptor = {
173
+ id: number
174
+ method: string
175
+ url: string
176
+ body: Uint8Array
177
+ bodyTooLarge: boolean
178
+ contentLength: number
179
+ }
180
+ export function start(port: number, maxBodySize?: number): Result<void, ServerError>
181
+ export function stop(): void
182
+ export function nextRequest(): Promise<NativeRequestDescriptor | {closed: true}>
183
+ export function respond(
184
+ id: number,
185
+ status: number,
186
+ headers: [string, string][],
187
+ body?: Uint8Array,
188
+ ): void
189
+ export function respondStart(
190
+ id: number,
191
+ status: number,
192
+ headers: [string, string][],
193
+ ): Promise<void>
194
+ export function respondChunk(id: number, data: Uint8Array): Promise<boolean>
195
+ export function respondEnd(id: number): void
196
+ export function getHeader(id: number, name: string): string | undefined
197
+ }
166
198
  declare module 'native:i2c' {
167
199
  import type {I2cError} from '@mikrojs/native/runtime/i2c/types'
168
200
  import type {Result} from 'mikro/result'
package/src/mikrojs.cpp CHANGED
@@ -150,13 +150,13 @@ static void mik__promise_rejection_tracker(JSContext* ctx, JSValue promise, JSVa
150
150
  * Don't JS_Throw here — we're inside a QuickJS callback
151
151
  * during promise resolution; throwing would corrupt engine
152
152
  * state and cause mik__execute_jobs to dump the error a
153
- * second time. MIK_Stop itself gates on whether we're inside
154
- * an interactive REPL eval so a typo at the prompt doesn't
155
- * reboot the device. */
153
+ * second time. MIK_Stop gates on whether we're inside an
154
+ * interactive REPL eval (skipping both the stop request and
155
+ * the restart) so a typo at the prompt doesn't reboot the
156
+ * device. */
156
157
  if (mik_rt->error_handler_fn) {
157
158
  mik_rt->error_handler_fn(ctx, reason, mik_rt->error_handler_opaque);
158
159
  }
159
- mik_rt->stop_requested = true;
160
160
  MIK_Stop(mik_rt);
161
161
  return;
162
162
  }
@@ -192,7 +192,6 @@ static void mik__promise_rejection_tracker(JSContext* ctx, JSValue promise, JSVa
192
192
  if (mik_rt->error_handler_fn) {
193
193
  mik_rt->error_handler_fn(ctx, reason, mik_rt->error_handler_opaque);
194
194
  }
195
- mik_rt->stop_requested = true;
196
195
  MIK_Stop(mik_rt);
197
196
  }
198
197
  }
@@ -680,17 +679,24 @@ bool MIK_IsStopRequested(MIKRuntime* mik_rt) {
680
679
 
681
680
  void MIK_Stop(MIKRuntime* mik_rt) {
682
681
  CHECK_NOT_NULL(mik_rt);
682
+ /* A throw inside an interactive REPL eval is a user typo, not an app
683
+ * crash; the eval path already reports it. Don't request a stop or
684
+ * reboot. (Evaluating implies the REPL is active, so this is checked
685
+ * before the IsReplActive guard below.) */
686
+ if (mik__repl_is_evaluating()) {
687
+ return;
688
+ }
689
+ /* Signal the loop to halt. This is the single place stop_requested is
690
+ * set, so the repl-eval gate above can't be bypassed by a caller. Host
691
+ * embedders (Node addon) observe it via MIK_Loop's return value; the
692
+ * firmware test supervisor reads it via MIK_IsStopRequested. */
693
+ mik_rt->stop_requested = true;
683
694
  /* Only firmware (protocol REPL attached) auto-restarts on uncaught
684
695
  * exceptions. Host embedders (Node addon, standalone tests) own their
685
696
  * own process lifecycle and surface errors via the error handler. */
686
697
  if (!MIK_IsReplActive()) {
687
698
  return;
688
699
  }
689
- /* A throw inside an interactive REPL eval is a user typo, not an app
690
- * crash — don't reboot the device. */
691
- if (mik__repl_is_evaluating()) {
692
- return;
693
- }
694
700
  /* In test mode, the supervisor wants stop_requested to bubble up so it
695
701
  * can synthesize a failing-test event and move to the next file. Arming
696
702
  * a panic-restart here would reboot the device mid-manifest on the