@mikrojs/native 0.4.0 → 0.5.0-pr-26.g363c07b

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 CHANGED
@@ -49,6 +49,7 @@ set(MIKROJS_CORE_SOURCES
49
49
  src/mik_sys.cpp
50
50
  src/mik_repl.cpp
51
51
  src/mik_app_config.cpp
52
+ src/mik_udp.cpp
52
53
  )
53
54
 
54
55
  add_library(mikrojs STATIC
@@ -79,7 +80,7 @@ endif()
79
80
  include(cmake/mikrojs_bytecode.cmake)
80
81
  mikrojs_generate_bytecode(
81
82
  RUNTIME_DIR "${CMAKE_CURRENT_SOURCE_DIR}/runtime"
82
- MODULES cbor env result schema fs http/helpers http/request i2c kv/nvs kv/rtc kv/shared neopixel pin pwm reader sleep spi sntp stdio stream sys test uart wifi
83
+ MODULES cbor env result schema fs http/helpers http/request i2c kv/nvs kv/rtc kv/shared neopixel pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi
83
84
  MODULE_PREFIX "mikrojs"
84
85
  SYMBOL_PREFIX "mikrojs"
85
86
  TARGET gen_bytecode
@@ -159,6 +160,7 @@ if(BUILD_TESTING)
159
160
  test/reader_test.cpp
160
161
  test/stream_test.cpp
161
162
  test/runtime_recycle_test.cpp
163
+ test/udp_test.cpp
162
164
  )
163
165
 
164
166
  target_link_libraries(mikrojs_tests PRIVATE mikrojs)
@@ -212,6 +212,9 @@ JSModuleDef* mik__cbor_init(JSContext* ctx);
212
212
  /* Result module (mik_result.cpp) */
213
213
  JSModuleDef* mik__result_init(JSContext* ctx);
214
214
 
215
+ /* UDP module (mik_udp.cpp) */
216
+ JSModuleDef* mik__udp_init(JSContext* ctx);
217
+
215
218
  bool mik__repl_is_evaluating(void);
216
219
 
217
220
  /* REPL protocol mode (mik_repl.cpp) — used by mik_console.cpp, mik_stdio.cpp */
@@ -0,0 +1,7 @@
1
+ #pragma once
2
+
3
+ #include "quickjs.h"
4
+
5
+ /* Register the `native:udp` module on the given context.
6
+ * Called explicitly from MIK_NewRuntime (see mikrojs.cpp). */
7
+ JSModuleDef* mik__udp_init(JSContext* ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.4.0",
3
+ "version": "0.5.0-pr-26.g363c07b",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -69,13 +69,14 @@
69
69
  "./runtime/sys/types": "./runtime/sys/types.ts",
70
70
  "./runtime/test/types": "./runtime/test/types.ts",
71
71
  "./runtime/uart/types": "./runtime/uart/types.ts",
72
+ "./runtime/udp/types": "./runtime/udp/types.ts",
72
73
  "./runtime/wifi/types": "./runtime/wifi/types.ts"
73
74
  },
74
75
  "dependencies": {
75
76
  "cmake-js": "^8.0.0",
76
77
  "node-addon-api": "^8.7.0",
77
78
  "node-gyp-build": "^4.8.4",
78
- "@mikrojs/quickjs": "0.4.0"
79
+ "@mikrojs/quickjs": "0.5.0-pr-26.g363c07b"
79
80
  },
80
81
  "devDependencies": {
81
82
  "@swc/core": "^1.15.30",
@@ -454,3 +454,27 @@ declare module 'native:wifi' {
454
454
  new (): Wifi
455
455
  }
456
456
  }
457
+
458
+ declare module 'native:udp' {
459
+ import type {
460
+ BindOptions,
461
+ PeerAddress,
462
+ UdpError,
463
+ UdpFamily,
464
+ } from '@mikrojs/native/runtime/udp/types'
465
+ import type {Result} from 'mikrojs/result'
466
+
467
+ export interface NativeUdpSocket {
468
+ readonly port: number
469
+ readonly family: UdpFamily | 'dual'
470
+ dropped: number
471
+
472
+ setOnMessage(fn: ((msg: Uint8Array, from: PeerAddress) => void) | null): void
473
+ send(data: Uint8Array, to: PeerAddress): Promise<Result<void, UdpError>>
474
+ joinMulticastGroup(address: string): Result<void, UdpError>
475
+ leaveMulticastGroup(address: string): Result<void, UdpError>
476
+ close(): void
477
+ }
478
+
479
+ export function bind(opts: BindOptions): Promise<Result<NativeUdpSocket, UdpError>>
480
+ }
@@ -388,9 +388,11 @@ function formatThrown(e: unknown): string {
388
388
  if (e instanceof Error) return e.message
389
389
  if (e && typeof e === 'object') {
390
390
  const obj = e as {name?: unknown; message?: unknown}
391
- if (typeof obj.message === 'string') {
392
- return typeof obj.name === 'string' ? `${obj.name}: ${obj.message}` : obj.message
393
- }
391
+ const name = typeof obj.name === 'string' ? obj.name : undefined
392
+ const message = typeof obj.message === 'string' ? obj.message : undefined
393
+ if (name && message) return `${name}: ${message}`
394
+ if (message) return message
395
+ if (name) return name
394
396
  }
395
397
  return String(e)
396
398
  }
@@ -0,0 +1,54 @@
1
+ import type {Result} from '../result/types.js'
2
+
3
+ export type UdpFamily = 'ipv4' | 'ipv6'
4
+
5
+ export interface BindOptions {
6
+ port: number
7
+ address?: string
8
+ family?: UdpFamily
9
+ recvQueue?: number
10
+ }
11
+
12
+ export interface PeerAddress {
13
+ address: string
14
+ port: number
15
+ family: UdpFamily
16
+ }
17
+
18
+ export interface MulticastGroup {
19
+ address: string
20
+ }
21
+
22
+ export type UdpError =
23
+ | {name: 'BindFailed'; message: string}
24
+ | {name: 'AddressInUse'}
25
+ | {name: 'SendFailed'; message: string}
26
+ | {name: 'MessageTooLarge'}
27
+ | {name: 'NotReachable'}
28
+ | {name: 'OutOfMemory'}
29
+ | {name: 'JoinGroupFailed'; message: string}
30
+ | {name: 'LeaveGroupFailed'; message: string}
31
+ | {name: 'Closed'}
32
+ | {name: 'NotBound'}
33
+
34
+ export interface UdpSocket {
35
+ readonly port: number
36
+ readonly family: UdpFamily | 'dual'
37
+ /**
38
+ * Counter incremented each time an inbound datagram is dropped:
39
+ * - the per-socket queue is full,
40
+ * - no `onMessage` handler is set when a packet arrives,
41
+ * - or the datagram is larger than the 1500-byte receive buffer
42
+ * (typical Ethernet MTU; covers CoAP / mDNS / SNTP / DNS).
43
+ */
44
+ dropped: number
45
+
46
+ onMessage: ((msg: Uint8Array, from: PeerAddress) => void) | null
47
+
48
+ send(data: Uint8Array | string, to: PeerAddress): Promise<Result<void, UdpError>>
49
+ joinMulticastGroup(group: MulticastGroup): Result<void, UdpError>
50
+ leaveMulticastGroup(group: MulticastGroup): Result<void, UdpError>
51
+ close(): void
52
+ }
53
+
54
+ export declare function bind(opts: BindOptions): Promise<Result<UdpSocket, UdpError>>
@@ -0,0 +1,57 @@
1
+ import {ok} from 'mikrojs/result'
2
+ import {bind as nativeBind, type NativeUdpSocket} from 'native:udp'
3
+
4
+ import type {Result} from '../result/types.js'
5
+ import type {BindOptions, MulticastGroup, PeerAddress, UdpError, UdpSocket} from './types.js'
6
+
7
+ const utf8 = new TextEncoder()
8
+
9
+ function toBytes(data: Uint8Array | string): Uint8Array {
10
+ return typeof data === 'string' ? utf8.encode(data) : data
11
+ }
12
+
13
+ function makeSocket(handle: NativeUdpSocket): UdpSocket {
14
+ let onMessage: ((msg: Uint8Array, from: PeerAddress) => void) | null = null
15
+
16
+ const sock: UdpSocket = {
17
+ get port() {
18
+ return handle.port
19
+ },
20
+ get family() {
21
+ return handle.family
22
+ },
23
+ get dropped() {
24
+ return handle.dropped
25
+ },
26
+ set dropped(value: number) {
27
+ handle.dropped = value
28
+ },
29
+ get onMessage() {
30
+ return onMessage
31
+ },
32
+ set onMessage(fn) {
33
+ onMessage = fn
34
+ handle.setOnMessage(fn)
35
+ },
36
+ send(data, to) {
37
+ return handle.send(toBytes(data), to)
38
+ },
39
+ joinMulticastGroup(group: MulticastGroup) {
40
+ return handle.joinMulticastGroup(group.address)
41
+ },
42
+ leaveMulticastGroup(group: MulticastGroup) {
43
+ return handle.leaveMulticastGroup(group.address)
44
+ },
45
+ close() {
46
+ handle.close()
47
+ },
48
+ }
49
+
50
+ return sock
51
+ }
52
+
53
+ export async function bind(opts: BindOptions): Promise<Result<UdpSocket, UdpError>> {
54
+ const result = await nativeBind(opts)
55
+ if (!result.ok) return result
56
+ return ok(makeSocket(result.value))
57
+ }
@@ -0,0 +1,784 @@
1
+ #include "mikrojs/udp.h"
2
+
3
+ #include "mikrojs/mikrojs.h"
4
+ #include "mikrojs/platform.h"
5
+ #include "mikrojs/utils.h"
6
+
7
+ #include <arpa/inet.h>
8
+ #include <fcntl.h>
9
+ #include <netinet/in.h>
10
+ #include <sys/socket.h>
11
+ #include <unistd.h>
12
+
13
+ #include <cerrno>
14
+ #include <cstdint>
15
+ #include <cstdio>
16
+ #include <cstring>
17
+ #include <vector>
18
+
19
+ /* ── Per-socket state ────────────────────────────────────────────────
20
+ *
21
+ * One of these per open UDP socket. Lifetime is tied to the JS UdpSocket
22
+ * object; the finalizer (mik__udp_finalizer) closes the OS socket and
23
+ * removes the entry from g_open_sockets.
24
+ *
25
+ * Closed sockets are kept in g_open_sockets with closed=true until the
26
+ * GC actually finalizes the JS object — mik__udp_consume skips them.
27
+ */
28
+ struct UdpSocketState {
29
+ JSContext* ctx; /* weak ref; do not free */
30
+ int fd;
31
+ int family; /* AF_INET, AF_INET6 (dual via V6ONLY=0) */
32
+ bool dual; /* true => v6 socket with V6ONLY=0 */
33
+ int port; /* actual bound port */
34
+ JSValue on_message; /* duped JSValue, JS_UNDEFINED if not set */
35
+ uint32_t dropped;
36
+ int recv_queue; /* per-tick drain limit */
37
+ bool closed;
38
+ bool dead; /* finalized; pending sweep (see consume) */
39
+ };
40
+
41
+ static JSClassID udp_socket_class_id;
42
+
43
+ /* Global registry of open sockets across all runtimes. The loop consumer
44
+ * filters by ctx, so multi-runtime scenarios are handled even though the
45
+ * vector is process-wide. Single-threaded access is assumed (one tick at
46
+ * a time per runtime; sockets aren't shared across runtimes). */
47
+ static std::vector<UdpSocketState*> g_open_sockets;
48
+
49
+ /* Runtimes that have had the loop consumer registered. Tracking per-
50
+ * runtime rather than process-global lets multiple runtimes coexist
51
+ * and lets new runtimes get a fresh consumer after old ones are freed.
52
+ * A vector with linear scan is sufficient: typical N=1, never more
53
+ * than a handful, and avoids pulling in std::unordered_set's hash-
54
+ * table machinery just for membership checks. */
55
+ static std::vector<MIKRuntime*> g_consumer_registered_runtimes;
56
+
57
+ /* Re-entrance guard for mik__udp_consume. A user onMessage callback can
58
+ * synchronously trigger a UdpSocket finalizer (e.g. by dropping the last
59
+ * reference to a sibling socket). Erasing from g_open_sockets mid-iteration
60
+ * would invalidate indices; deleting `s` for the currently-iterating socket
61
+ * would be a UAF. While iterating, the finalizer marks `s->dead = true` and
62
+ * defers cleanup; consume sweeps dead entries when the depth returns to 0. */
63
+ static int g_iteration_depth = 0;
64
+
65
+ /* ── Forward declarations ────────────────────────────────────────────*/
66
+ static void mik__udp_consume(JSContext* ctx);
67
+ static void mik__udp_destroy(JSContext* ctx);
68
+ static JSValue mik__udp_err(JSContext* ctx, const char* name, const char* fmt, ...)
69
+ __attribute__((format(printf, 3, 4)));
70
+
71
+ /* ── Helpers ────────────────────────────────────────────────────────*/
72
+
73
+ static JSValue mik__udp_err(JSContext* ctx, const char* name, const char* fmt, ...) {
74
+ char buf[160];
75
+ va_list ap;
76
+ va_start(ap, fmt);
77
+ vsnprintf(buf, sizeof(buf), fmt, ap);
78
+ va_end(ap);
79
+ return mik__result_err_named(ctx, name, "%s", buf);
80
+ }
81
+
82
+ static JSValue mik__udp_err_tag(JSContext* ctx, const char* name) {
83
+ return mik__result_err_tag(ctx, name);
84
+ }
85
+
86
+ /* Parse an address string + port into a sockaddr_storage. Returns 0 on
87
+ * success, -1 on parse failure. Family hint: AF_UNSPEC tries v4 first
88
+ * then v6; AF_INET / AF_INET6 are forced. */
89
+ static int mik__parse_addr(const char* address, int port, int family_hint,
90
+ struct sockaddr_storage* out, socklen_t* out_len) {
91
+ memset(out, 0, sizeof(*out));
92
+
93
+ /* IPv4 attempt */
94
+ if (family_hint == AF_UNSPEC || family_hint == AF_INET) {
95
+ struct sockaddr_in* sin = (struct sockaddr_in*)out;
96
+ if (inet_pton(AF_INET, address, &sin->sin_addr) == 1) {
97
+ sin->sin_family = AF_INET;
98
+ sin->sin_port = htons((uint16_t)port);
99
+ *out_len = sizeof(*sin);
100
+ return 0;
101
+ }
102
+ if (family_hint == AF_INET) return -1;
103
+ }
104
+
105
+ /* IPv6 attempt — strip optional %scope suffix */
106
+ char tmp[80];
107
+ const char* pct = strchr(address, '%');
108
+ const char* a = address;
109
+ uint32_t scope_id = 0;
110
+ if (pct) {
111
+ size_t n = (size_t)(pct - address);
112
+ if (n >= sizeof(tmp)) return -1;
113
+ memcpy(tmp, address, n);
114
+ tmp[n] = '\0';
115
+ a = tmp;
116
+ scope_id = (uint32_t)strtoul(pct + 1, nullptr, 10);
117
+ }
118
+
119
+ struct sockaddr_in6* sin6 = (struct sockaddr_in6*)out;
120
+ if (inet_pton(AF_INET6, a, &sin6->sin6_addr) == 1) {
121
+ sin6->sin6_family = AF_INET6;
122
+ sin6->sin6_port = htons((uint16_t)port);
123
+ sin6->sin6_scope_id = scope_id;
124
+ *out_len = sizeof(*sin6);
125
+ return 0;
126
+ }
127
+
128
+ return -1;
129
+ }
130
+
131
+ /* Format sockaddr_storage into address string + port. v4-mapped v6
132
+ * addresses are normalized back to plain v4 form. Returns the family
133
+ * tag ('ipv4' / 'ipv6') for the JS object. */
134
+ static const char* mik__format_addr(const struct sockaddr_storage* sa, char* buf, size_t buflen,
135
+ int* out_port) {
136
+ if (sa->ss_family == AF_INET) {
137
+ const struct sockaddr_in* sin = (const struct sockaddr_in*)sa;
138
+ inet_ntop(AF_INET, &sin->sin_addr, buf, buflen);
139
+ *out_port = ntohs(sin->sin_port);
140
+ return "ipv4";
141
+ }
142
+ const struct sockaddr_in6* sin6 = (const struct sockaddr_in6*)sa;
143
+
144
+ /* v4-mapped: ::ffff:x.x.x.x → x.x.x.x */
145
+ if (IN6_IS_ADDR_V4MAPPED(&sin6->sin6_addr)) {
146
+ struct in_addr v4;
147
+ memcpy(&v4, ((const uint8_t*)&sin6->sin6_addr) + 12, sizeof(v4));
148
+ inet_ntop(AF_INET, &v4, buf, buflen);
149
+ *out_port = ntohs(sin6->sin6_port);
150
+ return "ipv4";
151
+ }
152
+
153
+ inet_ntop(AF_INET6, &sin6->sin6_addr, buf, buflen);
154
+ /* Append %scope if present */
155
+ if (sin6->sin6_scope_id) {
156
+ size_t cur = strlen(buf);
157
+ snprintf(buf + cur, buflen - cur, "%%%u", sin6->sin6_scope_id);
158
+ }
159
+ *out_port = ntohs(sin6->sin6_port);
160
+ return "ipv6";
161
+ }
162
+
163
+ /* Build a PeerAddress JS object: {address, port, family} */
164
+ static JSValue mik__make_peer(JSContext* ctx, const char* address, int port, const char* family) {
165
+ JSValue obj = JS_NewObject(ctx);
166
+ JS_SetPropertyStr(ctx, obj, "address", JS_NewString(ctx, address));
167
+ JS_SetPropertyStr(ctx, obj, "port", JS_NewInt32(ctx, port));
168
+ JS_SetPropertyStr(ctx, obj, "family", JS_NewString(ctx, family));
169
+ return obj;
170
+ }
171
+
172
+ /* Read {address, port} (and optional family) from a JS PeerAddress. */
173
+ static int mik__read_peer(JSContext* ctx, JSValue obj, char* addr_out, size_t addr_size,
174
+ int* port_out) {
175
+ JSValue addr_val = JS_GetPropertyStr(ctx, obj, "address");
176
+ JSValue port_val = JS_GetPropertyStr(ctx, obj, "port");
177
+ int rc = -1;
178
+
179
+ if (JS_IsString(addr_val) && JS_IsNumber(port_val)) {
180
+ const char* s = JS_ToCString(ctx, addr_val);
181
+ if (s) {
182
+ size_t len = strlen(s);
183
+ if (len < addr_size) {
184
+ memcpy(addr_out, s, len + 1);
185
+ int32_t p;
186
+ if (JS_ToInt32(ctx, &p, port_val) == 0) {
187
+ *port_out = (int)p;
188
+ rc = 0;
189
+ }
190
+ }
191
+ JS_FreeCString(ctx, s);
192
+ }
193
+ }
194
+
195
+ JS_FreeValue(ctx, addr_val);
196
+ JS_FreeValue(ctx, port_val);
197
+ return rc;
198
+ }
199
+
200
+ static UdpSocketState* mik__socket_get(JSContext* ctx, JSValue obj) {
201
+ return (UdpSocketState*)JS_GetOpaque2(ctx, obj, udp_socket_class_id);
202
+ }
203
+
204
+ /* ── Methods ─────────────────────────────────────────────────────────*/
205
+
206
+ static JSValue mik__udp_send(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
207
+ UdpSocketState* s = mik__socket_get(ctx, this_val);
208
+ if (!s) {
209
+ JSValue err = mik__udp_err_tag(ctx, "Closed");
210
+ return MIK_NewResolvedPromise(ctx, 1, &err);
211
+ }
212
+ if (s->closed) {
213
+ JSValue err = mik__udp_err_tag(ctx, "Closed");
214
+ return MIK_NewResolvedPromise(ctx, 1, &err);
215
+ }
216
+ if (argc < 2) {
217
+ return JS_ThrowTypeError(ctx, "send: expected (data, to)");
218
+ }
219
+
220
+ /* Get bytes */
221
+ size_t data_len;
222
+ uint8_t* data = JS_GetUint8Array(ctx, &data_len, argv[0]);
223
+ if (!data) {
224
+ return JS_ThrowTypeError(ctx, "send: expected data to be Uint8Array");
225
+ }
226
+
227
+ /* Get peer address */
228
+ char addr[80];
229
+ int port;
230
+ if (mik__read_peer(ctx, argv[1], addr, sizeof(addr), &port) != 0) {
231
+ return JS_ThrowTypeError(ctx, "send: invalid peer address");
232
+ }
233
+
234
+ struct sockaddr_storage dst;
235
+ socklen_t dst_len = 0;
236
+ int hint = AF_UNSPEC;
237
+ if (s->family == AF_INET) hint = AF_INET;
238
+ else if (s->family == AF_INET6 && !s->dual) hint = AF_INET6;
239
+
240
+ if (mik__parse_addr(addr, port, hint, &dst, &dst_len) != 0) {
241
+ JSValue err = mik__udp_err(ctx, "SendFailed", "invalid address: %s", addr);
242
+ return MIK_NewResolvedPromise(ctx, 1, &err);
243
+ }
244
+
245
+ /* For dual-stack v6 socket sending to an IPv4 peer, convert to v4-mapped. */
246
+ if (s->dual && dst.ss_family == AF_INET) {
247
+ struct sockaddr_in6 mapped;
248
+ memset(&mapped, 0, sizeof(mapped));
249
+ mapped.sin6_family = AF_INET6;
250
+ mapped.sin6_port = ((struct sockaddr_in*)&dst)->sin_port;
251
+ /* ::ffff:0:0 + the v4 address */
252
+ uint8_t* p = (uint8_t*)&mapped.sin6_addr;
253
+ p[10] = 0xff;
254
+ p[11] = 0xff;
255
+ memcpy(p + 12, &((struct sockaddr_in*)&dst)->sin_addr, 4);
256
+ memcpy(&dst, &mapped, sizeof(mapped));
257
+ dst_len = sizeof(mapped);
258
+ }
259
+
260
+ ssize_t n = sendto(s->fd, data, data_len, 0, (struct sockaddr*)&dst, dst_len);
261
+ if (n < 0) {
262
+ const char* name = "SendFailed";
263
+ if (errno == EMSGSIZE) name = "MessageTooLarge";
264
+ else if (errno == ENETUNREACH || errno == EHOSTUNREACH) name = "NotReachable";
265
+ else if (errno == ENOMEM || errno == ENOBUFS) name = "OutOfMemory";
266
+ JSValue err = mik__udp_err(ctx, name, "sendto: %s", strerror(errno));
267
+ return MIK_NewResolvedPromise(ctx, 1, &err);
268
+ }
269
+
270
+ JSValue ok = mik__result_ok_void(ctx);
271
+ return MIK_NewResolvedPromise(ctx, 1, &ok);
272
+ }
273
+
274
+ static JSValue mik__udp_join_group(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
275
+ UdpSocketState* s = mik__socket_get(ctx, this_val);
276
+ if (!s || s->closed) return mik__udp_err_tag(ctx, "Closed");
277
+ if (argc < 1 || !JS_IsString(argv[0])) {
278
+ return JS_ThrowTypeError(ctx, "joinMulticastGroup: expected address string");
279
+ }
280
+ const char* addr = JS_ToCString(ctx, argv[0]);
281
+ if (!addr) return JS_EXCEPTION;
282
+
283
+ int rc;
284
+ if (s->family == AF_INET) {
285
+ struct ip_mreq req;
286
+ memset(&req, 0, sizeof(req));
287
+ if (inet_pton(AF_INET, addr, &req.imr_multiaddr) != 1) {
288
+ JS_FreeCString(ctx, addr);
289
+ return mik__udp_err(ctx, "JoinGroupFailed", "invalid IPv4 group");
290
+ }
291
+ req.imr_interface.s_addr = htonl(INADDR_ANY);
292
+ rc = setsockopt(s->fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &req, sizeof(req));
293
+ } else {
294
+ struct ipv6_mreq req;
295
+ memset(&req, 0, sizeof(req));
296
+ if (inet_pton(AF_INET6, addr, &req.ipv6mr_multiaddr) != 1) {
297
+ JS_FreeCString(ctx, addr);
298
+ return mik__udp_err(ctx, "JoinGroupFailed", "invalid IPv6 group");
299
+ }
300
+ req.ipv6mr_interface = 0;
301
+ rc = setsockopt(s->fd, IPPROTO_IPV6, IPV6_JOIN_GROUP, &req, sizeof(req));
302
+ }
303
+ JS_FreeCString(ctx, addr);
304
+
305
+ if (rc < 0) {
306
+ return mik__udp_err(ctx, "JoinGroupFailed", "%s", strerror(errno));
307
+ }
308
+ return mik__result_ok_void(ctx);
309
+ }
310
+
311
+ static JSValue mik__udp_leave_group(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
312
+ UdpSocketState* s = mik__socket_get(ctx, this_val);
313
+ if (!s || s->closed) return mik__udp_err_tag(ctx, "Closed");
314
+ if (argc < 1 || !JS_IsString(argv[0])) {
315
+ return JS_ThrowTypeError(ctx, "leaveMulticastGroup: expected address string");
316
+ }
317
+ const char* addr = JS_ToCString(ctx, argv[0]);
318
+ if (!addr) return JS_EXCEPTION;
319
+
320
+ int rc;
321
+ if (s->family == AF_INET) {
322
+ struct ip_mreq req;
323
+ memset(&req, 0, sizeof(req));
324
+ if (inet_pton(AF_INET, addr, &req.imr_multiaddr) != 1) {
325
+ JS_FreeCString(ctx, addr);
326
+ return mik__udp_err(ctx, "LeaveGroupFailed", "invalid IPv4 group");
327
+ }
328
+ req.imr_interface.s_addr = htonl(INADDR_ANY);
329
+ rc = setsockopt(s->fd, IPPROTO_IP, IP_DROP_MEMBERSHIP, &req, sizeof(req));
330
+ } else {
331
+ struct ipv6_mreq req;
332
+ memset(&req, 0, sizeof(req));
333
+ if (inet_pton(AF_INET6, addr, &req.ipv6mr_multiaddr) != 1) {
334
+ JS_FreeCString(ctx, addr);
335
+ return mik__udp_err(ctx, "LeaveGroupFailed", "invalid IPv6 group");
336
+ }
337
+ req.ipv6mr_interface = 0;
338
+ rc = setsockopt(s->fd, IPPROTO_IPV6, IPV6_LEAVE_GROUP, &req, sizeof(req));
339
+ }
340
+ JS_FreeCString(ctx, addr);
341
+
342
+ if (rc < 0) {
343
+ return mik__udp_err(ctx, "LeaveGroupFailed", "%s", strerror(errno));
344
+ }
345
+ return mik__result_ok_void(ctx);
346
+ }
347
+
348
+ static JSValue mik__udp_close(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
349
+ UdpSocketState* s = mik__socket_get(ctx, this_val);
350
+ if (!s || s->closed) return JS_UNDEFINED;
351
+ s->closed = true;
352
+ if (s->fd >= 0) {
353
+ close(s->fd);
354
+ s->fd = -1;
355
+ }
356
+ return JS_UNDEFINED;
357
+ }
358
+
359
+ static JSValue mik__udp_set_on_message(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
360
+ UdpSocketState* s = mik__socket_get(ctx, this_val);
361
+ if (!s) return JS_UNDEFINED;
362
+ JS_FreeValue(ctx, s->on_message);
363
+ s->on_message = (argc > 0 && JS_IsFunction(ctx, argv[0])) ? JS_DupValue(ctx, argv[0])
364
+ : JS_UNDEFINED;
365
+ return JS_UNDEFINED;
366
+ }
367
+
368
+ /* port / family are written as values at bind time (see install_methods),
369
+ * not as live getters — they don't change after bind. dropped IS a live
370
+ * getter/setter because the loop consumer increments it. */
371
+
372
+ static JSValue mik__udp_get_dropped(JSContext* ctx, JSValue this_val) {
373
+ UdpSocketState* s = mik__socket_get(ctx, this_val);
374
+ if (!s) return JS_NewInt32(ctx, 0);
375
+ return JS_NewUint32(ctx, s->dropped);
376
+ }
377
+
378
+ static JSValue mik__udp_set_dropped(JSContext* ctx, JSValue this_val, JSValue val) {
379
+ UdpSocketState* s = mik__socket_get(ctx, this_val);
380
+ if (!s) return JS_UNDEFINED;
381
+ uint32_t v;
382
+ if (JS_ToUint32(ctx, &v, val) == 0) s->dropped = v;
383
+ return JS_UNDEFINED;
384
+ }
385
+
386
+ /* ── Class definition ────────────────────────────────────────────────*/
387
+
388
+ static void mik__udp_finalizer(JSRuntime* rt, JSValue val) {
389
+ UdpSocketState* s = (UdpSocketState*)JS_GetOpaque(val, udp_socket_class_id);
390
+ if (!s) return;
391
+
392
+ if (!s->closed && s->fd >= 0) {
393
+ close(s->fd);
394
+ s->fd = -1;
395
+ }
396
+ JS_FreeValueRT(rt, s->on_message);
397
+ s->on_message = JS_UNDEFINED;
398
+ s->closed = true;
399
+
400
+ if (g_iteration_depth > 0) {
401
+ /* Inside consume: defer erase + delete to the post-iteration sweep
402
+ * so we don't shift indices or free `s` while it's being walked. */
403
+ s->dead = true;
404
+ return;
405
+ }
406
+
407
+ for (auto it = g_open_sockets.begin(); it != g_open_sockets.end(); ++it) {
408
+ if (*it == s) {
409
+ g_open_sockets.erase(it);
410
+ break;
411
+ }
412
+ }
413
+ delete s;
414
+ }
415
+
416
+ /* Cycle-collector hook: expose the held onMessage closure so QuickJS can
417
+ * traverse through the opaque socket. Without this, a closure that captures
418
+ * the socket (e.g. via globalThis) creates a cycle the GC can't break. */
419
+ static void mik__udp_gc_mark(JSRuntime* rt, JSValue val, JS_MarkFunc* mark_func) {
420
+ UdpSocketState* s = (UdpSocketState*)JS_GetOpaque(val, udp_socket_class_id);
421
+ if (!s) return;
422
+ JS_MarkValue(rt, s->on_message, mark_func);
423
+ }
424
+
425
+ static JSClassDef mik__udp_socket_class = {
426
+ .class_name = "UdpSocket",
427
+ .finalizer = mik__udp_finalizer,
428
+ .gc_mark = mik__udp_gc_mark,
429
+ };
430
+
431
+ /* Methods are installed directly on each socket object (not via proto) —
432
+ * ESP-IDF's 32-bit QuickJS NaN-boxed build did not reliably resolve
433
+ * proto-installed accessors on JS_NewObjectClass instances, even though
434
+ * JS_GetOpaque2 and the class registration both worked. Putting methods
435
+ * directly on the object sidesteps the class-proto resolution path. */
436
+ static void mik__udp_install_methods(JSContext* ctx, JSValue obj, UdpSocketState* s) {
437
+ JS_DefinePropertyValueStr(ctx, obj, "send",
438
+ JS_NewCFunction(ctx, mik__udp_send, "send", 2),
439
+ JS_PROP_C_W_E);
440
+ JS_DefinePropertyValueStr(ctx, obj, "joinMulticastGroup",
441
+ JS_NewCFunction(ctx, mik__udp_join_group, "joinMulticastGroup", 1),
442
+ JS_PROP_C_W_E);
443
+ JS_DefinePropertyValueStr(ctx, obj, "leaveMulticastGroup",
444
+ JS_NewCFunction(ctx, mik__udp_leave_group, "leaveMulticastGroup", 1),
445
+ JS_PROP_C_W_E);
446
+ JS_DefinePropertyValueStr(ctx, obj, "close",
447
+ JS_NewCFunction(ctx, mik__udp_close, "close", 0),
448
+ JS_PROP_C_W_E);
449
+ JS_DefinePropertyValueStr(ctx, obj, "setOnMessage",
450
+ JS_NewCFunction(ctx, mik__udp_set_on_message, "setOnMessage", 1),
451
+ JS_PROP_C_W_E);
452
+ /* port / family are stable for the socket's lifetime — set as values */
453
+ JS_DefinePropertyValueStr(ctx, obj, "port", JS_NewInt32(ctx, s->port), JS_PROP_C_W_E);
454
+ if (s->dual) {
455
+ JS_DefinePropertyValueStr(ctx, obj, "family", JS_NewString(ctx, "dual"), JS_PROP_C_W_E);
456
+ } else {
457
+ JS_DefinePropertyValueStr(
458
+ ctx, obj, "family",
459
+ JS_NewString(ctx, s->family == AF_INET ? "ipv4" : "ipv6"), JS_PROP_C_W_E);
460
+ }
461
+ /* dropped is a live getter/setter onto s->dropped; install via the
462
+ * function-list helper so the type-erased function casts go through
463
+ * the same path the rest of the codebase uses. */
464
+ static const JSCFunctionListEntry dropped_entry[] = {
465
+ MIK_CGETSET_DEF("dropped", mik__udp_get_dropped, mik__udp_set_dropped),
466
+ };
467
+ JS_SetPropertyFunctionList(ctx, obj, dropped_entry, 1);
468
+ }
469
+
470
+ /* ── bind() factory ──────────────────────────────────────────────────*/
471
+
472
+ static JSValue mik__udp_bind(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
473
+ if (argc < 1 || !JS_IsObject(argv[0])) {
474
+ return JS_ThrowTypeError(ctx, "bind: expected options object");
475
+ }
476
+
477
+ JSValue opts = argv[0];
478
+
479
+ /* port: number (required) */
480
+ JSValue port_val = JS_GetPropertyStr(ctx, opts, "port");
481
+ int32_t port = 0;
482
+ if (JS_ToInt32(ctx, &port, port_val)) {
483
+ JS_FreeValue(ctx, port_val);
484
+ return JS_ThrowTypeError(ctx, "bind: port must be a number");
485
+ }
486
+ JS_FreeValue(ctx, port_val);
487
+
488
+ /* family: 'ipv4' | 'ipv6' (optional, default dual) */
489
+ JSValue family_val = JS_GetPropertyStr(ctx, opts, "family");
490
+ int family = AF_INET6;
491
+ bool dual = true;
492
+ if (JS_IsString(family_val)) {
493
+ const char* fs = JS_ToCString(ctx, family_val);
494
+ if (fs && strcmp(fs, "ipv4") == 0) {
495
+ family = AF_INET;
496
+ dual = false;
497
+ } else if (fs && strcmp(fs, "ipv6") == 0) {
498
+ family = AF_INET6;
499
+ dual = false;
500
+ }
501
+ if (fs) JS_FreeCString(ctx, fs);
502
+ }
503
+ JS_FreeValue(ctx, family_val);
504
+
505
+ /* address: string (optional) */
506
+ JSValue address_val = JS_GetPropertyStr(ctx, opts, "address");
507
+ const char* address = nullptr;
508
+ if (JS_IsString(address_val)) {
509
+ address = JS_ToCString(ctx, address_val);
510
+ }
511
+
512
+ /* recvQueue: number (optional, default 8) */
513
+ JSValue rq_val = JS_GetPropertyStr(ctx, opts, "recvQueue");
514
+ int32_t recv_queue = 8;
515
+ if (JS_IsNumber(rq_val)) {
516
+ JS_ToInt32(ctx, &recv_queue, rq_val);
517
+ }
518
+ JS_FreeValue(ctx, rq_val);
519
+ if (recv_queue < 1) recv_queue = 1;
520
+
521
+ /* Create socket */
522
+ int fd = socket(family, SOCK_DGRAM, 0);
523
+ if (fd < 0) {
524
+ if (address) JS_FreeCString(ctx, address);
525
+ JS_FreeValue(ctx, address_val);
526
+ JSValue err = mik__udp_err(ctx, "BindFailed", "socket: %s", strerror(errno));
527
+ return MIK_NewResolvedPromise(ctx, 1, &err);
528
+ }
529
+
530
+ /* Set non-blocking */
531
+ int flags = fcntl(fd, F_GETFL, 0);
532
+ fcntl(fd, F_SETFL, flags | O_NONBLOCK);
533
+
534
+ /* Configure dual-stack on v6 */
535
+ if (family == AF_INET6) {
536
+ int v6only = dual ? 0 : 1;
537
+ setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only));
538
+ }
539
+
540
+ /* Bind. Route through mik__parse_addr so the same scope-id handling
541
+ * (`fe80::1%2`) used on send applies on bind too. */
542
+ struct sockaddr_storage local;
543
+ socklen_t local_len;
544
+ memset(&local, 0, sizeof(local));
545
+ if (family == AF_INET) {
546
+ struct sockaddr_in* sin = (struct sockaddr_in*)&local;
547
+ sin->sin_family = AF_INET;
548
+ sin->sin_port = htons((uint16_t)port);
549
+ if (address) {
550
+ if (mik__parse_addr(address, port, AF_INET, &local, &local_len) != 0) {
551
+ close(fd);
552
+ JS_FreeCString(ctx, address);
553
+ JS_FreeValue(ctx, address_val);
554
+ JSValue err = mik__udp_err(ctx, "BindFailed", "invalid address");
555
+ return MIK_NewResolvedPromise(ctx, 1, &err);
556
+ }
557
+ } else {
558
+ sin->sin_addr.s_addr = htonl(INADDR_ANY);
559
+ local_len = sizeof(*sin);
560
+ }
561
+ } else {
562
+ struct sockaddr_in6* sin6 = (struct sockaddr_in6*)&local;
563
+ sin6->sin6_family = AF_INET6;
564
+ sin6->sin6_port = htons((uint16_t)port);
565
+ if (address) {
566
+ if (mik__parse_addr(address, port, AF_INET6, &local, &local_len) != 0) {
567
+ close(fd);
568
+ JS_FreeCString(ctx, address);
569
+ JS_FreeValue(ctx, address_val);
570
+ JSValue err = mik__udp_err(ctx, "BindFailed", "invalid address");
571
+ return MIK_NewResolvedPromise(ctx, 1, &err);
572
+ }
573
+ } else {
574
+ /* in6addr_any */
575
+ local_len = sizeof(*sin6);
576
+ }
577
+ }
578
+
579
+ if (address) JS_FreeCString(ctx, address);
580
+ JS_FreeValue(ctx, address_val);
581
+
582
+ if (bind(fd, (struct sockaddr*)&local, local_len) < 0) {
583
+ const char* err_name = (errno == EADDRINUSE) ? "AddressInUse" : "BindFailed";
584
+ int saved_errno = errno;
585
+ close(fd);
586
+ JSValue err = mik__udp_err(ctx, err_name, "bind: %s", strerror(saved_errno));
587
+ return MIK_NewResolvedPromise(ctx, 1, &err);
588
+ }
589
+
590
+ /* Read back actual port (handles port 0 / ephemeral) */
591
+ struct sockaddr_storage actual;
592
+ socklen_t actual_len = sizeof(actual);
593
+ int actual_port = port;
594
+ if (getsockname(fd, (struct sockaddr*)&actual, &actual_len) == 0) {
595
+ if (actual.ss_family == AF_INET) {
596
+ actual_port = ntohs(((struct sockaddr_in*)&actual)->sin_port);
597
+ } else if (actual.ss_family == AF_INET6) {
598
+ actual_port = ntohs(((struct sockaddr_in6*)&actual)->sin6_port);
599
+ }
600
+ }
601
+
602
+ /* Build state + JS object */
603
+ UdpSocketState* s = new UdpSocketState();
604
+ s->ctx = ctx;
605
+ s->fd = fd;
606
+ s->family = family;
607
+ s->dual = dual;
608
+ s->port = actual_port;
609
+ s->on_message = JS_UNDEFINED;
610
+ s->dropped = 0;
611
+ s->recv_queue = recv_queue;
612
+ s->closed = false;
613
+ s->dead = false;
614
+
615
+ JSValue obj = JS_NewObjectClass(ctx, udp_socket_class_id);
616
+ if (JS_IsException(obj)) {
617
+ close(fd);
618
+ delete s;
619
+ return obj;
620
+ }
621
+ JS_SetOpaque(obj, s);
622
+
623
+ /* Install methods and properties directly on the object */
624
+ mik__udp_install_methods(ctx, obj, s);
625
+
626
+ /* Register loop consumer once per runtime; first bind triggers it. */
627
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
628
+ if (mik_rt) {
629
+ bool already = false;
630
+ for (auto* rt : g_consumer_registered_runtimes) {
631
+ if (rt == mik_rt) {
632
+ already = true;
633
+ break;
634
+ }
635
+ }
636
+ if (!already) {
637
+ g_consumer_registered_runtimes.push_back(mik_rt);
638
+ MIK_RegisterLoopConsumer(mik_rt, mik__udp_consume, mik__udp_destroy);
639
+ }
640
+ }
641
+
642
+ g_open_sockets.push_back(s);
643
+
644
+ JSValue ok = mik__result_ok(ctx, obj);
645
+ return MIK_NewResolvedPromise(ctx, 1, &ok);
646
+ }
647
+
648
+ /* ── Loop consumer ───────────────────────────────────────────────────*/
649
+
650
+ static void mik__udp_consume(JSContext* ctx) {
651
+ g_iteration_depth++;
652
+
653
+ /* Walk by current size each iteration: callbacks may bind new sockets
654
+ * (push_back at the end — drained next tick) and finalize others
655
+ * (marked dead, swept after the loop). Indexed access stays valid
656
+ * because nothing is erased while g_iteration_depth > 0. */
657
+ for (size_t i = 0; i < g_open_sockets.size(); i++) {
658
+ UdpSocketState* s = g_open_sockets[i];
659
+ if (!s || s->dead) continue;
660
+ if (s->ctx != ctx) continue;
661
+ if (s->closed) continue;
662
+
663
+ for (int drained = 0; drained < s->recv_queue; drained++) {
664
+ /* 1500 bytes covers the unfragmented Ethernet MTU, which is
665
+ * the realistic ceiling for the listed targets (CoAP, mDNS,
666
+ * SNTP, DNS). MSG_TRUNC reports the original length so larger
667
+ * datagrams are observable as drops rather than silent
668
+ * truncation. */
669
+ uint8_t buf[1500];
670
+ struct sockaddr_storage src;
671
+ socklen_t src_len = sizeof(src);
672
+ ssize_t r = recvfrom(s->fd, buf, sizeof(buf), MSG_TRUNC,
673
+ (struct sockaddr*)&src, &src_len);
674
+ if (r < 0) {
675
+ /* EAGAIN/EWOULDBLOCK — buffer empty, move on */
676
+ break;
677
+ }
678
+ if ((size_t)r > sizeof(buf)) {
679
+ /* Datagram larger than our recv buffer; drop it rather
680
+ * than deliver a truncated payload. */
681
+ s->dropped++;
682
+ continue;
683
+ }
684
+
685
+ if (!JS_IsFunction(ctx, s->on_message)) {
686
+ s->dropped++;
687
+ continue;
688
+ }
689
+
690
+ char addr_str[80];
691
+ int port;
692
+ const char* family = mik__format_addr(&src, addr_str, sizeof(addr_str), &port);
693
+
694
+ /* Allocate fresh Uint8Array per packet (no buffer reuse) */
695
+ uint8_t* data = (uint8_t*)js_malloc(ctx, (size_t)r);
696
+ if (!data) {
697
+ s->dropped++;
698
+ continue;
699
+ }
700
+ memcpy(data, buf, (size_t)r);
701
+
702
+ JSValue msg = MIK_NewUint8Array(ctx, data, (size_t)r);
703
+ JSValue peer = mik__make_peer(ctx, addr_str, port, family);
704
+
705
+ JSValue args[2] = {msg, peer};
706
+ JSValue ret = JS_Call(ctx, s->on_message, JS_UNDEFINED, 2, args);
707
+ JS_FreeValue(ctx, msg);
708
+ JS_FreeValue(ctx, peer);
709
+ if (JS_IsException(ret)) {
710
+ /* Clear the pending exception so it doesn't leak into the
711
+ * next callback / native call, and surface it via the
712
+ * runtime's error path. One bad callback shouldn't tear
713
+ * the loop down. */
714
+ mik_dump_error(ctx);
715
+ }
716
+ JS_FreeValue(ctx, ret);
717
+
718
+ /* The callback may have closed or finalized this socket. */
719
+ if (s->dead || s->closed || s->fd < 0) break;
720
+ }
721
+ }
722
+
723
+ g_iteration_depth--;
724
+
725
+ /* Sweep entries marked dead during this (outermost) iteration. */
726
+ if (g_iteration_depth == 0) {
727
+ for (auto it = g_open_sockets.begin(); it != g_open_sockets.end();) {
728
+ if ((*it)->dead) {
729
+ delete *it;
730
+ it = g_open_sockets.erase(it);
731
+ } else {
732
+ ++it;
733
+ }
734
+ }
735
+ }
736
+ }
737
+
738
+ static void mik__udp_destroy(JSContext* ctx) {
739
+ MIKRuntime* mik_rt = MIK_GetRuntime(ctx);
740
+ if (mik_rt) {
741
+ for (auto it = g_consumer_registered_runtimes.begin();
742
+ it != g_consumer_registered_runtimes.end(); ++it) {
743
+ if (*it == mik_rt) {
744
+ g_consumer_registered_runtimes.erase(it);
745
+ break;
746
+ }
747
+ }
748
+ }
749
+
750
+ /* Close OS fds for sockets belonging to this runtime. The JS objects
751
+ * are about to be freed by GC, which calls mik__udp_finalizer for each;
752
+ * the finalizer also closes if not already closed, so this is just to
753
+ * release fds promptly during shutdown. */
754
+ for (auto* s : g_open_sockets) {
755
+ if (s->ctx != ctx) continue;
756
+ if (!s->closed && s->fd >= 0) {
757
+ close(s->fd);
758
+ s->fd = -1;
759
+ s->closed = true;
760
+ }
761
+ }
762
+ }
763
+
764
+ /* ── Module init ─────────────────────────────────────────────────────*/
765
+
766
+ static int mik__udp_module_init(JSContext* ctx, JSModuleDef* m) {
767
+ JS_SetModuleExport(ctx, m, "bind", JS_NewCFunction(ctx, mik__udp_bind, "bind", 1));
768
+ return 0;
769
+ }
770
+
771
+ JSModuleDef* mik__udp_init(JSContext* ctx) {
772
+ JSRuntime* rt = JS_GetRuntime(ctx);
773
+
774
+ /* The class only carries the opaque pointer, finalizer, and gc_mark.
775
+ * Methods/properties go directly on each socket object (see
776
+ * mik__udp_install_methods). No class prototype is set. */
777
+ JS_NewClassID(rt, &udp_socket_class_id);
778
+ JS_NewClass(rt, udp_socket_class_id, &mik__udp_socket_class);
779
+
780
+ JSModuleDef* m = JS_NewCModule(ctx, "native:udp", mik__udp_module_init);
781
+ if (!m) return nullptr;
782
+ JS_AddModuleExport(ctx, m, "bind");
783
+ return m;
784
+ }
package/src/mikrojs.cpp CHANGED
@@ -304,6 +304,7 @@ MIKRuntime* MIK_NewRuntimeInternal(MIKRunOptions* options) {
304
304
  * rt->result_proto being set. */
305
305
  mik__result_init(ctx);
306
306
  mik__cbor_init(ctx);
307
+ mik__udp_init(ctx);
307
308
 
308
309
  /* Native mikrojs modules (replace bytecode builtins) */
309
310
  mik__inspect_register(ctx);