@mikrojs/native 0.17.0-pr-276.20260721233726 → 0.17.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.
package/CMakeLists.txt CHANGED
@@ -82,7 +82,7 @@ endif()
82
82
  include(cmake/mikrojs_bytecode.cmake)
83
83
  mikrojs_generate_bytecode(
84
84
  RUNTIME_DIR "${CMAKE_CURRENT_SOURCE_DIR}/runtime"
85
- MODULES abort cbor env result schema fs http/helpers http/request i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi
85
+ MODULES abort cbor env result schema fs http/helpers http/request i2c kv/nvs kv/rtc kv/shared module neopixel observable observable/lazy observable/operators ota ota/client pin pwm reader sleep spi sntp stdio stream sys test uart udp wifi
86
86
  MODULE_PREFIX "mikro"
87
87
  SYMBOL_PREFIX "mikro"
88
88
  TARGET gen_bytecode
@@ -340,11 +340,17 @@ void mik__repl_set_paused(bool paused);
340
340
  * log.txt, and replies MIK_MSG_OK. No-op (still OK) when file logging
341
341
  * is disabled. */
342
342
  #define MIK_CMD_LOG_RESET 0x2C
343
- /* Adopt a streamed .tgz build as the live app via the OTA install path,
344
- * establishing the rollback baseline (.ota-last-good.tgz). The build must
345
- * already be staged via DEPLOY_PUT("/.build.tgz", size) + PUT_CHUNK frames.
343
+ /* Stage a streamed .tgz build for install at the next boot. The build must
344
+ * already be staged via DEPLOY_PUT("/.build.tgz", size) + PUT_CHUNK frames;
345
+ * the device verifies its SHA-256 now and the boot reconcile installs it on
346
+ * a clean heap (adopt mode: comes up GOOD, no trial, no rollback baseline).
346
347
  * Payload: u16le checksum_len | checksum bytes. */
347
348
  #define MIK_CMD_DEPLOY_BUILD 0x2D
349
+ /* Report and clear the outcome of the last adopt-mode boot install. No
350
+ * payload. Reply is MIK_MSG_OK with:
351
+ * u8 status (0 none | 1 ok | 2 fail) | u16le chk_len | chk
352
+ * | u16le reason_len | reason | u16le detail_len | detail. */
353
+ #define MIK_CMD_DEPLOY_RESULT 0x2E
348
354
 
349
355
  #define MIK_CMD_CONFIG_LIST 0x40
350
356
  #define MIK_CMD_CONFIG_SET 0x41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikrojs/native",
3
- "version": "0.17.0-pr-276.20260721233726",
3
+ "version": "0.17.0",
4
4
  "description": "Mikro.js C++ runtime library and Node.js native addon",
5
5
  "keywords": [
6
6
  "esp32",
@@ -64,6 +64,7 @@
64
64
  "./runtime/neopixel/types": "./runtime/neopixel/types.ts",
65
65
  "./runtime/observable/operators": "./runtime/observable/operators.ts",
66
66
  "./runtime/observable/types": "./runtime/observable/types.ts",
67
+ "./runtime/ota/client-impl": "./runtime/ota/client-impl.ts",
67
68
  "./runtime/ota/types": "./runtime/ota/types.ts",
68
69
  "./runtime/pin/types": "./runtime/pin/types.ts",
69
70
  "./runtime/pwm/types": "./runtime/pwm/types.ts",
@@ -85,14 +86,14 @@
85
86
  "cmake-js": "^8.0.0",
86
87
  "node-addon-api": "^8.7.0",
87
88
  "node-gyp-build": "^4.8.4",
88
- "@mikrojs/quickjs": "0.17.0-pr-276.20260721233726+1bcc126"
89
+ "@mikrojs/quickjs": "0.17.0"
89
90
  },
90
91
  "devDependencies": {
91
92
  "@swc/core": "^1.15.30",
92
93
  "@types/node": "^24.12.2",
93
94
  "esbuild": "^0.28.0",
94
95
  "terser": "^5.46.2",
95
- "@mikrojs/registry": "0.17.0-pr-276.20260721233726+1bcc126"
96
+ "@mikrojs/registry": "0.17.0"
96
97
  },
97
98
  "engines": {
98
99
  "node": ">=24.0.0"
@@ -0,0 +1,590 @@
1
+ // Internal: the OTA client state machine behind `mikro/ota/client`. Bundled
2
+ // into ota/client.js; not exposed as its own subpath. Exists as a seam so host
3
+ // tests can drive the whole client against fakes — no hardware, no registry,
4
+ // no real restart.
5
+
6
+ import type {Request, RequestError} from 'mikro/http/helpers'
7
+ import {err, ok} from 'mikro/result'
8
+
9
+ import type {CborError} from '../cbor/types.js'
10
+ import type {Result} from '../result/types.js'
11
+ import type {DeviceName} from '../sys/types.js'
12
+ import type {Diagnostic, Offer, Ota, OtaError, Update} from './types.js'
13
+
14
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
15
+
16
+ /** Immutable facts about this device and the firmware under it. */
17
+ export interface DeviceIdentity {
18
+ deviceId: string
19
+ /** Firmware version string, and the hash of that image. A build published
20
+ * against a different firmware is withheld rather than offered. */
21
+ firmware: string
22
+ firmwareHash: string
23
+ /** QuickJS bytecode version the linked engine reads; a build compiled for
24
+ * another one cannot run here. */
25
+ bytecode: number
26
+ }
27
+
28
+ /**
29
+ * Every effect the client can have on the world, in one record. Nothing below
30
+ * `createOtaClient` reaches for a runtime import — effects arrive here, so the
31
+ * policy can run against a fake with no hardware and no risk of a real
32
+ * restart().
33
+ */
34
+ export interface ClientIo {
35
+ /** Wall-clock delay. The only clock the client has. */
36
+ sleep(ms: number): Promise<void>
37
+ /** HTTP. The only network the client has. */
38
+ request: Request
39
+ /** Uniform [0, 1). Injected so tests can pin the jitter. */
40
+ random(): number
41
+ /** Serial diagnostics. Format string and args pass through untouched. */
42
+ log(level: LogLevel, format: string, ...args: unknown[]): void
43
+ /** CBOR codec for the check-in wire. */
44
+ encode(value: unknown): Result<Uint8Array, CborError>
45
+ decode(data: Uint8Array): Result<unknown, CborError>
46
+
47
+ /** The `mikro/ota` policy surface (staging, trials, retry budgets). */
48
+ ota: Ota
49
+ identity(): DeviceIdentity
50
+ /** Bytes free on the partition a build is staged onto, or undefined when the
51
+ * platform cannot report it. */
52
+ storageFree(): number | undefined
53
+ deviceName(): DeviceName
54
+ setDeviceName(name: DeviceName): void
55
+ restart(): never
56
+ }
57
+
58
+ export interface CheckOptions {
59
+ /** Budget for the check-in round trip. Default 10s. */
60
+ checkinTimeoutMs?: number
61
+ /** Budget for the build download. Separate from the check-in's because it is
62
+ * a total wallclock deadline that cancels the transfer mid-stream, and an
63
+ * image needs orders of magnitude more of it than a check-in body does.
64
+ * Default 5m. */
65
+ downloadTimeoutMs?: number
66
+ /** Require a completed check-in (via `ota.confirm()`, which the client fires
67
+ * itself) before an installed build is kept. Default true. */
68
+ requireConfirm?: boolean
69
+ /** Clean boots a trial may consume before an unconfirmed build reverts.
70
+ * A deep-sleep wake counts as a clean boot, so wake-cycle devices on flaky
71
+ * networks should raise this above the default 1. */
72
+ trialBoots?: number
73
+ }
74
+
75
+ /** Runs after its round settles — after the check and any download, and before
76
+ * an auto-restart — so the network brought up in `beforeCheck` can go down. */
77
+ export type Teardown = () => void | Promise<void>
78
+
79
+ export interface WatchOptions extends CheckOptions {
80
+ /** Steady interval between rounds, end-of-round to start-of-next. Default 30m. */
81
+ checkinIntervalMs?: number
82
+ /** Delay before the first round. Default 5s. */
83
+ initialDelayMs?: number
84
+ /** Interval after a failed round, capped at `checkinIntervalMs`. Default 1m. */
85
+ retryAfterFailureMs?: number
86
+ /** Spread every scheduled sleep by ±10%, so a fleet that lost power together
87
+ * does not check in phase-locked forever. Default true; pass false for
88
+ * exact intervals (a demo watching for the update to land, a single
89
+ * device where the spread only delays it). */
90
+ jitter?: boolean
91
+ /** Bring the network up for one round. `err` skips the round (retried at the
92
+ * failure interval) and no teardown runs — partial setup is the hook's own
93
+ * job to unwind. State shared with the teardown stays in this one scope. */
94
+ beforeCheck?(): Promise<Result<Teardown, unknown>>
95
+ }
96
+
97
+ export interface Watcher {
98
+ /** Prevent future rounds and cancel the pending sleep. An in-flight round
99
+ * completes, but a build it stages no longer auto-restarts — it stays armed
100
+ * for the next natural reboot. */
101
+ stop(): void
102
+ }
103
+
104
+ /** Why an offered build was not armed. Each is the policy working as intended;
105
+ * the distinction is logged and returned because the app's next move differs. */
106
+ export type DeclineReason =
107
+ | 'trial-pending'
108
+ | 'current'
109
+ | 'abandoned'
110
+ | 'exhausted'
111
+ | 'download-failed'
112
+ | 'install-failed'
113
+
114
+ /** The check-in never completed. */
115
+ export type CheckError = RequestError | CborError | {name: 'Status'; status: number}
116
+
117
+ export type CheckResult =
118
+ /** Build downloaded, verified, and armed. The app restarts when ready. */
119
+ | {status: 'staged'; offer: Offer}
120
+ | {status: 'up-to-date'}
121
+ /** An offer arrived but was not armed. */
122
+ | {status: 'not-staged'; reason: DeclineReason; error?: OtaError}
123
+ /** Transient: the check-in did not complete, so the running trial (if any)
124
+ * was not confirmed. */
125
+ | {status: 'failed'; error: CheckError}
126
+ /** The registry rejected the update key (HTTP 401). Permanent until
127
+ * re-enrollment over the cable. */
128
+ | {status: 'unauthorized'}
129
+ | {status: 'not-enrolled'}
130
+
131
+ export interface OtaClient {
132
+ check(options?: CheckOptions): Promise<CheckResult>
133
+ watch(options?: WatchOptions): Watcher
134
+ }
135
+
136
+ const DEFAULT_CHECKIN_TIMEOUT_MS = 10_000
137
+ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 300_000
138
+ const DEFAULT_INTERVAL_MS = 30 * 60_000
139
+ const DEFAULT_INITIAL_DELAY_MS = 5_000
140
+ const DEFAULT_RETRY_MS = 60_000
141
+
142
+ /** What the watch loop does after a round: retry at the shortened interval, or
143
+ * wait the full one. Deliberately not "did the check-in succeed" — a rejected
144
+ * update key also waits the full interval (a dead key mustn't hammer the
145
+ * registry every retry interval forever). */
146
+ type NextRound = 'soon' | 'later'
147
+
148
+ interface Enrollment {
149
+ registryUrl: string
150
+ bearer: string
151
+ }
152
+
153
+ export function createOtaClient(io: ClientIo): OtaClient {
154
+ // One check at a time: staging is a single native session, and two
155
+ // interleaved check-ins would corrupt it. Later calls queue behind the
156
+ // running one; each still gets its own result.
157
+ let chain: Promise<unknown> = Promise.resolve()
158
+
159
+ // reconcile() clears its report as it reads, so it runs once per boot and
160
+ // the report is held here until a check-in actually delivers it — a failed
161
+ // first check-in no longer loses it.
162
+ let reconciled = false
163
+ let lastInstall: Diagnostic | undefined
164
+
165
+ let warnedInsecure = false
166
+
167
+ function enqueue<T>(task: () => Promise<T>): Promise<T> {
168
+ const run = chain.then(task)
169
+ chain = run.then(
170
+ () => undefined,
171
+ () => undefined,
172
+ )
173
+ return run
174
+ }
175
+
176
+ function enrollment(): Enrollment | undefined {
177
+ // Written as a pair over the cable by `mikro ota enroll`; enrollment is the
178
+ // opt-in. Update keys never arrive over the network.
179
+ const registryUrl = io.ota.registry()
180
+ const bearer = io.ota.bearer()
181
+ if (registryUrl === undefined || bearer === undefined) return undefined
182
+ return {registryUrl, bearer}
183
+ }
184
+
185
+ function reconcileOnce(): void {
186
+ if (reconciled) return
187
+ reconciled = true
188
+ const report = io.ota.reconcile()
189
+ lastInstall = report.lastInstall
190
+ if (report.reverted) {
191
+ io.log('warn', 'ota: previous update failed its trial and was rolled back')
192
+ }
193
+ if (report.installed) {
194
+ io.log('info', 'ota: installed build %s on this boot', report.installed.slice(0, 12))
195
+ }
196
+ }
197
+
198
+ async function runCheck(enrolled: Enrollment, options: CheckOptions): Promise<CheckResult> {
199
+ const {registryUrl, bearer} = enrolled
200
+ const allowInsecure = isPrivateHttp(registryUrl)
201
+ if (allowInsecure && !warnedInsecure) {
202
+ // Every boot, not once at enrollment: a device that quietly runs a
203
+ // forgeable update channel should say so for as long as it does.
204
+ warnedInsecure = true
205
+ io.log(
206
+ 'warn',
207
+ 'ota: registry is http:// on a private network — updates are NOT authenticated',
208
+ )
209
+ }
210
+ reconcileOnce()
211
+
212
+ const running = io.ota.running()
213
+ const identity = io.identity()
214
+ const named = io.deviceName()
215
+ const free = io.storageFree()
216
+ // The shape is a contract with the registry's /api/v1/checkin: these are
217
+ // the inputs it arbitrates an offer from. Optional keys are omitted, not
218
+ // sent as undefined — the registry reads an absent `free` as "no figure to
219
+ // report", and JSON clients drop the key the same way.
220
+ const report = {
221
+ ...identity,
222
+ running,
223
+ // The name pair: `[rev]` when cleared, `[rev, name]` otherwise. Sent
224
+ // every time so a lost response settles on the next check-in.
225
+ name: named.name === undefined ? [named.rev] : [named.rev, named.name],
226
+ ...(free === undefined ? {} : {free}),
227
+ ...(lastInstall === undefined ? {} : {lastInstall}),
228
+ }
229
+ io.log(
230
+ 'debug',
231
+ 'ota: checking %s (running %s v%s, bytecode %d, fw %s)',
232
+ `${registryUrl}/api/v1/checkin`,
233
+ running.checksum?.slice(0, 12) ?? 'none',
234
+ running.version ?? '?',
235
+ identity.bytecode,
236
+ identity.firmware,
237
+ )
238
+
239
+ const encoded = io.encode(report)
240
+ if (!encoded.ok) {
241
+ io.log('error', 'ota: could not encode the check-in report', encoded.error)
242
+ return {status: 'failed', error: encoded.error}
243
+ }
244
+ const res = await io.request(`${registryUrl}/api/v1/checkin`, {
245
+ method: 'POST',
246
+ headers: {
247
+ 'content-type': 'application/cbor',
248
+ accept: 'application/cbor',
249
+ authorization: `Bearer ${bearer}`,
250
+ },
251
+ timeoutMs: options.checkinTimeoutMs ?? DEFAULT_CHECKIN_TIMEOUT_MS,
252
+ body: encoded.value,
253
+ })
254
+ if (!res.ok) {
255
+ io.log('error', 'ota: checkin failed', res.error)
256
+ return {status: 'failed', error: res.error}
257
+ }
258
+ const response = res.value
259
+ if (response.status === 401) {
260
+ // The update key no longer authenticates (rotated, device deleted).
261
+ // Only a 401 means this. There is no fallback secret — re-enroll over
262
+ // the cable. Release the connection before returning.
263
+ await response.close()
264
+ io.log(
265
+ 'error',
266
+ 'ota: registry rejected the update key; re-enroll with `mikro ota enroll --re-enroll`',
267
+ )
268
+ return {status: 'unauthorized'}
269
+ }
270
+ if (response.status === 415) {
271
+ // The wire is CBOR-only by design; there is no JSON fallback to hide a
272
+ // registry that predates it.
273
+ await response.close()
274
+ io.log(
275
+ 'error',
276
+ 'ota: registry does not accept CBOR check-ins; upgrade the registry to a version that supports application/cbor',
277
+ )
278
+ return {status: 'failed', error: {name: 'Status', status: 415}}
279
+ }
280
+ if (!response.ok) {
281
+ // Release the connection before the heap-sensitive image request — an
282
+ // abandoned response holds its native slot and TLS buffers until its
283
+ // timeout fires.
284
+ await response.close()
285
+ io.log('error', 'ota: checkin returned status %d', response.status)
286
+ return {status: 'failed', error: {name: 'Status', status: response.status}}
287
+ }
288
+ const raw = await response.bytes()
289
+ if (!raw.ok) {
290
+ io.log('error', 'ota: reading the checkin response failed', raw.error)
291
+ return {status: 'failed', error: raw.error}
292
+ }
293
+ const body = io.decode(raw.value)
294
+ if (!body.ok) {
295
+ io.log('error', 'ota: invalid checkin response body', body.error)
296
+ return {status: 'failed', error: body.error}
297
+ }
298
+
299
+ // The check-in completed: the cached install report is delivered.
300
+ lastInstall = undefined
301
+
302
+ // Confirm before applying: a completed check-in is the whole health signal
303
+ // requireConfirm waits for, whether or not an offer follows — and resolving
304
+ // the trial now lets an offer published during it stage in this same pass
305
+ // (applyOffer skips every offer while a trial is unresolved).
306
+ if (running.trial) {
307
+ io.log('info', 'ota: check-in completed — confirming this build as healthy')
308
+ }
309
+ io.ota.confirm()
310
+
311
+ const renamed = nameFrom(body.value)
312
+ if (renamed !== undefined) {
313
+ io.setDeviceName(renamed)
314
+ io.log('info', 'ota: registry renamed this device to %s', renamed.name ?? '(no name)')
315
+ }
316
+
317
+ const offer = io.ota.parseOffer(body.value, {allowInsecure})
318
+ if (offer === undefined) {
319
+ io.log('debug', 'ota: up to date, running the latest build')
320
+ return {status: 'up-to-date'}
321
+ }
322
+ io.log(
323
+ 'info',
324
+ 'ota: update available (%s, %d bytes); downloading',
325
+ offer.checksum.slice(0, 12),
326
+ offer.size,
327
+ )
328
+ const applied = await io.ota.applyOffer(
329
+ offer,
330
+ (update) => download(enrolled, options, offer, update),
331
+ {
332
+ requireConfirm: options.requireConfirm ?? true,
333
+ trialBoots: options.trialBoots ?? 1,
334
+ },
335
+ )
336
+ if (!applied.ok) {
337
+ const reason: DeclineReason =
338
+ applied.error.name === 'DownloadFailed' ? 'download-failed' : 'install-failed'
339
+ io.log('error', 'ota: update failed', applied.error)
340
+ return {status: 'not-staged', reason, error: applied.error}
341
+ }
342
+ if (applied.value === 'staged') {
343
+ io.log('info', 'ota: download verified and staged')
344
+ return {status: 'staged', offer}
345
+ }
346
+ // Not staged, not an error — applyOffer declined the offer:
347
+ // current already running this build
348
+ // trial-pending the running build hasn't resolved its trial (nearly
349
+ // unreachable: the confirm above resolves it first)
350
+ // abandoned the build is corrupt and won't be retried
351
+ // exhausted the retry budget for this build is spent until next boot
352
+ io.log('info', 'ota: offer not staged (%s)', applied.value)
353
+ return {status: 'not-staged', reason: applied.value}
354
+ }
355
+
356
+ /** The download pump: stream the build straight into the staging area,
357
+ * resuming an interrupted transfer with a Range request. Chunked on purpose:
358
+ * buffering the whole build would put ~2x its size on the heap. */
359
+ async function download(
360
+ enrolled: Enrollment,
361
+ options: CheckOptions,
362
+ offer: Offer,
363
+ update: Update,
364
+ ): Promise<Result<void, {message: string}>> {
365
+ const from = update.resumeOffset
366
+ // Already complete: a finish that failed transiently leaves every byte on
367
+ // flash and the offer pending, so resuming would ask for `bytes=<size>-`
368
+ // and take the 416 as a failed download forever. Hand it straight back to
369
+ // re-verify.
370
+ if (from >= offer.size) return ok()
371
+ if (from > 0) {
372
+ io.log('info', 'ota: resuming download from byte %d of %d', from, offer.size)
373
+ }
374
+ // Same-origin only. `offer.url` may legitimately point at another host (a
375
+ // CDN, an object store), so the update key goes out only when the download
376
+ // is on the registry's own origin. A build fetched elsewhere is a public
377
+ // artifact the checksum vouches for; a store that needs auth carries it in
378
+ // a signed url instead.
379
+ const headers: Record<string, string> = {}
380
+ if (from > 0) headers.range = `bytes=${from}-`
381
+ if (sameOrigin(offer.url, enrolled.registryUrl)) {
382
+ headers.authorization = `Bearer ${enrolled.bearer}`
383
+ }
384
+ const res = await io.request(offer.url, {
385
+ headers,
386
+ timeoutMs: options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS,
387
+ })
388
+ if (!res.ok) {
389
+ return err({message: `download failed: ${describeRequestError(res.error)}`})
390
+ }
391
+ const response = res.value
392
+ if (!response.ok) {
393
+ await response.close()
394
+ return err({message: `download returned status ${response.status}`})
395
+ }
396
+ // A server free to ignore Range answers 200 with the whole build. Drop the
397
+ // prefix we already hold rather than failing, which would spend a retry
398
+ // for nothing.
399
+ let skip = response.status === 206 ? 0 : from
400
+ for await (const chunk of response.body) {
401
+ if (!chunk.ok) {
402
+ await response.close()
403
+ return err({message: `reading the download failed: ${describeRequestError(chunk.error)}`})
404
+ }
405
+ let bytes = chunk.value
406
+ if (skip > 0) {
407
+ if (bytes.length <= skip) {
408
+ skip -= bytes.length
409
+ continue
410
+ }
411
+ bytes = bytes.subarray(skip)
412
+ skip = 0
413
+ }
414
+ const written = update.write(bytes)
415
+ if (!written.ok) {
416
+ await response.close()
417
+ return written
418
+ }
419
+ }
420
+ return ok()
421
+ }
422
+
423
+ function check(options: CheckOptions = {}): Promise<CheckResult> {
424
+ const enrolled = enrollment()
425
+ if (enrolled === undefined) {
426
+ return Promise.resolve({status: 'not-enrolled'})
427
+ }
428
+ return enqueue(() => runCheck(enrolled, options))
429
+ }
430
+
431
+ function watch(options: WatchOptions = {}): Watcher {
432
+ const maybeEnrolled = enrollment()
433
+ if (maybeEnrolled === undefined) {
434
+ io.log('info', 'ota: device not enrolled; run `mikro ota enroll` to enable OTA updates')
435
+ return {stop: () => undefined}
436
+ }
437
+ // Explicitly typed: the narrowing above does not reach into the hoisted
438
+ // round() declaration below.
439
+ const enrolled: Enrollment = maybeEnrolled
440
+ const intervalMs = options.checkinIntervalMs ?? DEFAULT_INTERVAL_MS
441
+ const retryMs = Math.min(options.retryAfterFailureMs ?? DEFAULT_RETRY_MS, intervalMs)
442
+
443
+ let stopped = false
444
+ let cancelSleep: (() => void) | undefined
445
+
446
+ // ±10% so a power-cut fleet doesn't stay phase-locked on the registry.
447
+ const jitterOn = options.jitter ?? true
448
+ function jitter(ms: number): number {
449
+ if (!jitterOn) return ms
450
+ return Math.round(ms * (0.9 + io.random() * 0.2))
451
+ }
452
+
453
+ function pause(ms: number): Promise<void> {
454
+ return new Promise((resolve) => {
455
+ cancelSleep = resolve
456
+ void io.sleep(jitter(ms)).then(resolve)
457
+ })
458
+ }
459
+
460
+ async function round(): Promise<NextRound> {
461
+ let teardown: Teardown | undefined
462
+ if (options.beforeCheck) {
463
+ let setup: Result<Teardown, unknown>
464
+ try {
465
+ setup = await options.beforeCheck()
466
+ } catch (e) {
467
+ setup = err(e)
468
+ }
469
+ if (!setup.ok) {
470
+ io.log('warn', 'ota: beforeCheck failed; skipping this check', setup.error)
471
+ return 'soon'
472
+ }
473
+ teardown = setup.value
474
+ }
475
+
476
+ // Contain OTA faults: an uncaught throw (e.g. an OOM from the native
477
+ // HTTP layer under a low-heap stream) must not bubble out of the
478
+ // detached loop. A crashed check is just a check that didn't complete.
479
+ let outcome: CheckResult | undefined
480
+ try {
481
+ outcome = await enqueue(() => runCheck(enrolled, options))
482
+ } catch (e) {
483
+ io.log('error', 'ota: check crashed', e)
484
+ }
485
+
486
+ // Teardown before any restart: the round is over either way, and the
487
+ // hook's invariant — teardown runs whenever setup succeeded — must not
488
+ // depend on what the check found.
489
+ if (teardown !== undefined) {
490
+ try {
491
+ await teardown()
492
+ } catch (e) {
493
+ io.log('warn', 'ota: teardown failed', e)
494
+ }
495
+ }
496
+
497
+ if (outcome === undefined) return 'soon'
498
+ if (outcome.status === 'staged') {
499
+ if (stopped) {
500
+ // stop() won the race: leave the build armed for the next natural
501
+ // reboot instead of restarting under the caller.
502
+ io.log('info', 'ota: update staged; watcher stopped, restart deferred')
503
+ return 'later'
504
+ }
505
+ io.log('info', 'ota: staged; restarting to install')
506
+ io.restart() // never returns
507
+ }
508
+ return outcome.status === 'failed' ? 'soon' : 'later'
509
+ }
510
+
511
+ async function loop(): Promise<void> {
512
+ await pause(options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS)
513
+ while (!stopped) {
514
+ const next = await round()
515
+ if (stopped) return
516
+ await pause(next === 'later' ? intervalMs : retryMs)
517
+ }
518
+ }
519
+
520
+ // Detached: never awaited by the caller, so the app keeps running
521
+ // regardless of OTA outcome. round() contains every throw, so this
522
+ // promise cannot reject.
523
+ void loop()
524
+
525
+ return {
526
+ stop: () => {
527
+ stopped = true
528
+ cancelSleep?.()
529
+ },
530
+ }
531
+ }
532
+
533
+ return {check, watch}
534
+ }
535
+
536
+ /** Render a RequestError into prose. `DownloadFn`'s contract carries only a
537
+ * `message` string forward into the `DownloadFailed` the policy reports, so
538
+ * this line is all that survives to explain a failed download on serial —
539
+ * include the underlying message, not just the variant name. */
540
+ function describeRequestError(error: RequestError): string {
541
+ return 'message' in error && error.message !== '' ? `${error.name}: ${error.message}` : error.name
542
+ }
543
+
544
+ /**
545
+ * The name pair a check-in response carries, as `[rev, name]` (or `[rev]` when
546
+ * it was cleared), or undefined when there is nothing to adopt. No `name` field
547
+ * means "no change" and never "clear it": a response lost after the registry
548
+ * adopted the device's own name looks exactly the same as one that never
549
+ * arrived, and re-sending the pair next check-in settles it.
550
+ */
551
+ export function nameFrom(body: unknown): DeviceName | undefined {
552
+ if (typeof body !== 'object' || body === null) return undefined
553
+ const pair = (body as {name?: unknown}).name
554
+ if (!Array.isArray(pair)) return undefined
555
+ const rev = pair[0]
556
+ if (typeof rev !== 'number' || !Number.isInteger(rev) || rev < 0) return undefined
557
+ const value = pair[1]
558
+ return typeof value === 'string' && value !== '' ? {rev, name: value} : {rev}
559
+ }
560
+
561
+ /** True when both urls share a scheme and authority. Deliberately literal: with
562
+ * no URL parser here, a spelled-out default port does not compare equal. Used
563
+ * to decide whether the update key may ride the download request. */
564
+ export function sameOrigin(a: string, b: string): boolean {
565
+ const origin = (url: string): string | undefined => {
566
+ const sep = url.indexOf('://')
567
+ if (sep < 0) return undefined
568
+ const start = sep + 3
569
+ const end = url.indexOf('/', start)
570
+ const authority = end < 0 ? url.slice(start) : url.slice(start, end)
571
+ return authority === '' ? undefined : (url.slice(0, start) + authority).toLowerCase()
572
+ }
573
+ const left = origin(a)
574
+ return left !== undefined && left === origin(b)
575
+ }
576
+
577
+ /** True for http:// on a LAN/loopback/mDNS host: development, not the internet.
578
+ * Anywhere else the scheme is not a judgement call — over http the offer's
579
+ * checksum is forgeable in the same response that names it. */
580
+ export function isPrivateHttp(url: string): boolean {
581
+ if (!url.startsWith('http://')) return false
582
+ const host = (url.slice(7).split('/')[0] ?? '').split(':')[0] ?? ''
583
+ if (host === 'localhost' || host.endsWith('.local')) return true
584
+ const p = host.split('.').map((s) => parseInt(s, 10))
585
+ if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false
586
+ if (p[0] === 10 || p[0] === 127) return true
587
+ if (p[0] === 192 && p[1] === 168) return true
588
+ if (p[0] === 169 && p[1] === 254) return true
589
+ return p[0] === 172 && p[1]! >= 16 && p[1]! <= 31
590
+ }
@@ -0,0 +1,77 @@
1
+ import {decode, encode} from 'mikro/cbor'
2
+ import {request} from 'mikro/http/request'
3
+ import {ota} from 'mikro/ota'
4
+ import {sleep} from 'mikro/sleep'
5
+ import {
6
+ deviceId,
7
+ deviceName,
8
+ firmware,
9
+ restart,
10
+ setDeviceName,
11
+ storageUsage,
12
+ version,
13
+ } from 'mikro/sys'
14
+
15
+ import {type ClientIo, createOtaClient, type LogLevel} from './client-impl.js'
16
+
17
+ export type {
18
+ CheckError,
19
+ CheckOptions,
20
+ CheckResult,
21
+ DeclineReason,
22
+ Teardown,
23
+ Watcher,
24
+ WatchOptions,
25
+ } from './client-impl.js'
26
+
27
+ /* eslint-disable no-console -- serial diagnostics are the client's only output channel */
28
+ const consoleFor: Record<LogLevel, (format: string, ...args: unknown[]) => void> = {
29
+ debug: (format, ...args) => console.debug(format, ...args),
30
+ info: (format, ...args) => console.log(format, ...args),
31
+ warn: (format, ...args) => console.warn(format, ...args),
32
+ error: (format, ...args) => console.error(format, ...args),
33
+ }
34
+ /* eslint-enable no-console */
35
+
36
+ /** The real device. Every runtime symbol the client uses is bound here and
37
+ * nowhere else; `ota` members go through arrows because they are methods on
38
+ * the builtin singleton. */
39
+ const deviceIo: ClientIo = {
40
+ sleep,
41
+ request,
42
+ random: Math.random,
43
+ log: (level, format, ...args) => consoleFor[level](format, ...args),
44
+ encode,
45
+ decode,
46
+ ota,
47
+ identity: () => ({
48
+ deviceId,
49
+ firmware: version,
50
+ firmwareHash: firmware.hash,
51
+ bytecode: firmware.bytecodeVersion,
52
+ }),
53
+ storageFree: () => storageUsage()?.free,
54
+ deviceName,
55
+ setDeviceName,
56
+ restart,
57
+ }
58
+
59
+ const client = createOtaClient(deviceIo)
60
+
61
+ /**
62
+ * One-shot update check for wake-cycle apps: check in with the registry,
63
+ * confirm the running trial, and download + stage any offered build. Never
64
+ * restarts — on `{status: 'staged'}` the app calls `restart()` once its
65
+ * in-flight work is done. Connectivity is the app's business: call this with
66
+ * the network already up.
67
+ */
68
+ export const check = client.check
69
+
70
+ /**
71
+ * Periodic update checks for always-on apps: a detached background loop that
72
+ * checks on a jittered cadence, retries sooner after failures, and restarts
73
+ * the device after staging a build. Use `beforeCheck` to bring the network up
74
+ * per round (its returned teardown runs after the round). Do not combine with
75
+ * `check()` — the two modes are alternatives, one per app.
76
+ */
77
+ export const watch = client.watch
@@ -44,9 +44,9 @@ const store: OtaStore = {
44
44
  }
45
45
 
46
46
  // Written as a pair to mik.sys by `mikro ota enroll`: the registry url and
47
- // the device credential that authenticates against it.
47
+ // the device update key that authenticates against it.
48
48
  function bearer(): string | undefined {
49
- const v = sysGet('ota.credential')
49
+ const v = sysGet('ota.updateKey')
50
50
  return typeof v === 'string' ? v : undefined
51
51
  }
52
52
 
@@ -62,10 +62,10 @@ interface OtaDeps {
62
62
  store: OtaStore
63
63
  /** Live app version from /app/package.json, or undefined if unreadable. */
64
64
  readAppVersion(): string | undefined
65
- /** The check-in bearer: the device credential from the system store, or
65
+ /** The check-in bearer: the device update key from the system store, or
66
66
  * undefined on an un-enrolled device. */
67
67
  bearer(): string | undefined
68
- /** The registry url the credential was minted against, or undefined. */
68
+ /** The registry url the update key was issued against, or undefined. */
69
69
  registry(): string | undefined
70
70
  }
71
71
 
@@ -116,7 +116,7 @@ export function parseOffer(raw: unknown, opts?: {allowInsecure?: boolean}): Offe
116
116
  // name where the build lives — a CDN or object store on another host, a signed
117
117
  // url with its own query. Integrity is the checksum, verified over the whole
118
118
  // download before install, so a wrong host yields a failed install, never a
119
- // bad one; and the credential is the caller's to confine (the reference client
119
+ // bad one; and the update key is the caller's to confine (the reference client
120
120
  // attaches it only when the url is same-origin with the registry).
121
121
  return {url: o.url, checksum: o.checksum, size: o.size}
122
122
  }
@@ -133,12 +133,12 @@ export interface Ota {
133
133
  confirm(): void
134
134
  /** Reinstall the previous build immediately. */
135
135
  revert(): Result<void, OtaInstallError>
136
- /** The check-in bearer: the device credential written at enrollment
136
+ /** The check-in bearer: the device update key written at enrollment
137
137
  * (`mikro ota enroll`), or `undefined` on an un-enrolled device. Read-only:
138
- * credentials are provisioned over serial, never delivered in-band. */
138
+ * update keys are provisioned over serial, never delivered in-band. */
139
139
  bearer(): string | undefined
140
140
  /** The registry url the device was enrolled against, written next to the
141
- * credential at enrollment, or `undefined` on an un-enrolled device. */
141
+ * update key at enrollment, or `undefined` on an un-enrolled device. */
142
142
  registry(): string | undefined
143
143
  }
144
144
 
package/src/mik_repl.cpp CHANGED
@@ -32,9 +32,11 @@ static bool repl_protocol_mode = false;
32
32
  static bool repl_paused = false;
33
33
  static bool repl_async_skipped = false; /* set when eval bails on paused async */
34
34
  static MIKReplTransport* repl_transport = nullptr;
35
- /* Sized to hold the base map plus the longest `[rev, name]` pair the platform
36
- * name setter accepts, so a named device never has to drop its name. */
37
- static uint8_t ready_buf[256];
35
+ /* Sized to hold the base map plus a maximum-length npm package name as the
36
+ * `fw` identity (214 chars), with room to spare for a typical `[rev, name]`
37
+ * pair. A max-length identity combined with a near-max device name can still
38
+ * exceed this; refresh_ready then drops the name (never the identity). */
39
+ static uint8_t ready_buf[384];
38
40
  static size_t ready_len = 0;
39
41
 
40
42
  /* Transient flag: set by MIK_ProtocolExit to break the current ServeLoop
@@ -879,7 +881,12 @@ static std::vector<uint8_t> proto_complete(JSContext* ctx, const char* partial,
879
881
  /* ── Protocol-mode REPL ──────────────────────────────────────────── */
880
882
 
881
883
  /* Fills ready_buf/ready_len with CBOR device info:
882
- * {"chip": tstr, "id": tstr, "v": tstr, "name": tstr (only when named)}.
884
+ * {"chip": tstr, "id": tstr, "v": tstr, "fw": tstr (when built with
885
+ * MIK_FW_NAME), "name": tstr (only when named)}.
886
+ * `fw` is the firmware identity (the firmware project's package name). The
887
+ * host only auto-flashes its bundled prebuilt over a device whose identity
888
+ * matches that prebuilt; omitting it reads as firmware predating identity
889
+ * reporting, which the host treats as its own bundled firmware.
883
890
  * `name` carries the raw `[rev, name]` pair from mik.sys, so the host reads the
884
891
  * name and its revision together and can never see them out of step. Omitted
885
892
  * entirely when the device has never been named, which the host reads as
@@ -896,16 +903,20 @@ static void refresh_ready(MIKReplTransport* transport);
896
903
  /* Encodes the MSG_READY map into buf, or measures it when buf is null. Returns
897
904
  * the length the encoding needs, which exceeds cap when it did not fit. */
898
905
  static size_t encode_ready(uint8_t* buf, size_t cap, const char* chip, const char* id,
899
- const char* version, const char* name) {
906
+ const char* version, const char* fw, const char* name) {
900
907
  nanocbor_encoder_t enc;
901
908
  nanocbor_encoder_init(&enc, buf, cap);
902
- nanocbor_fmt_map(&enc, name ? 4 : 3);
909
+ nanocbor_fmt_map(&enc, 3 + (fw ? 1 : 0) + (name ? 1 : 0));
903
910
  nanocbor_put_tstr(&enc, "chip");
904
911
  nanocbor_put_tstr(&enc, chip);
905
912
  nanocbor_put_tstr(&enc, "id");
906
913
  nanocbor_put_tstr(&enc, id);
907
914
  nanocbor_put_tstr(&enc, "v");
908
915
  nanocbor_put_tstr(&enc, version);
916
+ if (fw) {
917
+ nanocbor_put_tstr(&enc, "fw");
918
+ nanocbor_put_tstr(&enc, fw);
919
+ }
909
920
  if (name) {
910
921
  nanocbor_put_tstr(&enc, "name");
911
922
  nanocbor_put_tstr(&enc, name);
@@ -922,6 +933,12 @@ static void refresh_ready(MIKReplTransport* transport) {
922
933
  MIK_FW_VERSION;
923
934
  #else
924
935
  "0.0.0-dev";
936
+ #endif
937
+ const char* fw =
938
+ #ifdef MIK_FW_NAME
939
+ MIK_FW_NAME;
940
+ #else
941
+ nullptr;
925
942
  #endif
926
943
  const char* name =
927
944
  MIK_GetPlatform()->get_device_name ? MIK_GetPlatform()->get_device_name() : nullptr;
@@ -929,11 +946,16 @@ static void refresh_ready(MIKReplTransport* transport) {
929
946
  * end but still reports the length it would have needed, so ready_len must
930
947
  * come from a run that actually fit: sending the measured length would read
931
948
  * past ready_buf. Drop `name` rather than truncate it, since a partial pair
932
- * decodes as a different name at the host. */
933
- if (name && encode_ready(nullptr, 0, chip, id, version, name) > sizeof(ready_buf)) {
949
+ * decodes as a different name at the host. `name` goes before `fw`: a
950
+ * dropped `fw` reads as the host's own bundled firmware, re-enabling the
951
+ * auto-reflash the identity exists to prevent. */
952
+ if (name && encode_ready(nullptr, 0, chip, id, version, fw, name) > sizeof(ready_buf)) {
934
953
  name = nullptr;
935
954
  }
936
- ready_len = encode_ready(ready_buf, sizeof(ready_buf), chip, id, version, name);
955
+ if (fw && encode_ready(nullptr, 0, chip, id, version, fw, name) > sizeof(ready_buf)) {
956
+ fw = nullptr;
957
+ }
958
+ ready_len = encode_ready(ready_buf, sizeof(ready_buf), chip, id, version, fw, name);
937
959
  if (ready_len > sizeof(ready_buf)) ready_len = 0;
938
960
  }
939
961