@mikrojs/native 0.18.0 → 0.18.1
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 +62 -1
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
- package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
- package/dist/runtime/result/native-result.node-shim.js +41 -0
- package/dist/runtime/result/native-result.node-shim.js.map +1 -0
- package/dist/runtime/result/types.d.ts +55 -0
- package/dist/runtime/result/types.d.ts.map +1 -0
- package/dist/runtime/result/types.js +2 -0
- package/dist/runtime/result/types.js.map +1 -0
- package/dist/runtime/schema/core.d.ts +115 -0
- package/dist/runtime/schema/core.d.ts.map +1 -0
- package/dist/runtime/schema/core.js +259 -0
- package/dist/runtime/schema/core.js.map +1 -0
- package/dist/runtime/schema/shared.d.ts +54 -0
- package/dist/runtime/schema/shared.d.ts.map +1 -0
- package/dist/runtime/schema/shared.js +489 -0
- package/dist/runtime/schema/shared.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/include/mikrojs/cbor_helpers.h +20 -0
- package/include/mikrojs/mem.h +11 -0
- package/include/mikrojs/mikrojs.h +2 -1
- package/include/mikrojs/ota_client.h +342 -0
- package/include/mikrojs/ota_config.h +100 -0
- package/include/mikrojs/ota_env.h +192 -0
- package/include/mikrojs/ota_js_hooks.h +71 -0
- package/include/mikrojs/ota_policy.h +131 -0
- package/include/mikrojs/ota_slots.h +47 -0
- package/include/mikrojs/sys_codec.h +61 -0
- package/package.json +7 -5
- package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
- package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
- package/runtime/internal.d.ts +22 -16
- package/runtime/kv/shared.ts +11 -5
- package/runtime/kv/types.ts +4 -4
- package/runtime/ota/client.ts +12 -51
- package/runtime/ota/config.ts +18 -0
- package/runtime/ota/ota.ts +28 -70
- package/runtime/ota/types.ts +220 -2
- package/runtime/schema/core.ts +539 -0
- package/runtime/schema/schema.ts +36 -314
- package/runtime/schema/shared.ts +494 -0
- package/runtime/schema/types.ts +84 -12
- package/scripts/bundle-runtime.js +33 -0
- package/scripts/gen-checkin-fixtures.js +323 -0
- package/src/builtins.cpp +7 -8
- package/src/fs.cpp +3 -0
- package/src/mem.cpp +38 -0
- package/src/mik_abort.cpp +8 -1
- package/src/mik_cbor.cpp +43 -5
- package/src/mik_inspect.cpp +128 -22
- package/src/mik_ota_client.cpp +1230 -0
- package/src/mik_ota_config.cpp +296 -0
- package/src/mik_ota_js_hooks.cpp +190 -0
- package/src/mik_ota_policy.cpp +419 -0
- package/src/mik_ota_slots.cpp +249 -0
- package/src/mik_repl.cpp +9 -3
- package/src/mik_result.cpp +3 -1
- package/src/mik_sys_codec.cpp +167 -0
- package/src/mikrojs.cpp +15 -0
- package/src/modules.cpp +32 -13
- package/runtime/ota/client-impl.ts +0 -590
- package/runtime/ota/policy.ts +0 -299
package/runtime/ota/policy.ts
DELETED
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
import {err, ok} from 'mikro/result'
|
|
2
|
-
|
|
3
|
-
import type {Result} from '../result/types.js'
|
|
4
|
-
import type {
|
|
5
|
-
ApplyOutcome,
|
|
6
|
-
DownloadFn,
|
|
7
|
-
InstallOptions,
|
|
8
|
-
InstallOutcome,
|
|
9
|
-
Offer,
|
|
10
|
-
Ota,
|
|
11
|
-
OtaDownloadError,
|
|
12
|
-
OtaError,
|
|
13
|
-
OtaInstallError,
|
|
14
|
-
RunningBuild,
|
|
15
|
-
Update,
|
|
16
|
-
} from './types.js'
|
|
17
|
-
|
|
18
|
-
/** The native:mikro/ota contract (ESP-only C module; stubbed on host). */
|
|
19
|
-
export interface NativeOta {
|
|
20
|
-
stageBegin(
|
|
21
|
-
checksum: string,
|
|
22
|
-
size: number,
|
|
23
|
-
): {ok: true; resumeOffset: number} | {ok: false; error: string}
|
|
24
|
-
// No `kind` here, unlike stageFinish: every write failure is a storage
|
|
25
|
-
// problem and maps to StagingFull, so there is nothing to discriminate on.
|
|
26
|
-
stageWrite(bytes: Uint8Array): {ok: true} | {ok: false; error: string}
|
|
27
|
-
stageFinish(
|
|
28
|
-
trialBoots: number,
|
|
29
|
-
requireConfirm: boolean,
|
|
30
|
-
installNow: boolean,
|
|
31
|
-
): {ok: true} | {ok: false; error: string; kind: 'corrupt' | 'transient' | 'oom'}
|
|
32
|
-
stageAbort(): void
|
|
33
|
-
markValid(): void
|
|
34
|
-
revert(): {ok: true} | {ok: false; error: string}
|
|
35
|
-
running(): {checksum?: string; trial: boolean}
|
|
36
|
-
reconcile(): {
|
|
37
|
-
installed?: string
|
|
38
|
-
reverted: boolean
|
|
39
|
-
diagnostic?: {reason: string; detail?: string}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Crash-loop-safe policy state, persisted to NVS with short keys. */
|
|
44
|
-
export interface OtaStore {
|
|
45
|
-
getUrl(): string | undefined
|
|
46
|
-
setUrl(url: string): void
|
|
47
|
-
/** Checksum the retry budget is currently counting against. */
|
|
48
|
-
getAttempt(): string | undefined
|
|
49
|
-
setAttempt(checksum: string): void
|
|
50
|
-
getTries(): number
|
|
51
|
-
setTries(n: number): void
|
|
52
|
-
getBad(): string | undefined
|
|
53
|
-
setBad(checksum: string): void
|
|
54
|
-
/** True while an install attempt is running. Still true at boot means the
|
|
55
|
-
* last one never returned, i.e. it crashed the device. */
|
|
56
|
-
getInFlight(): boolean
|
|
57
|
-
setInFlight(value: boolean): void
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface OtaDeps {
|
|
61
|
-
native: NativeOta
|
|
62
|
-
store: OtaStore
|
|
63
|
-
/** Live app version from /app/package.json, or undefined if unreadable. */
|
|
64
|
-
readAppVersion(): string | undefined
|
|
65
|
-
/** The check-in bearer: the device update key from the system store, or
|
|
66
|
-
* undefined on an un-enrolled device. */
|
|
67
|
-
bearer(): string | undefined
|
|
68
|
-
/** The registry url the update key was issued against, or undefined. */
|
|
69
|
-
registry(): string | undefined
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Number of staging attempts for one url before a checksum is abandoned. */
|
|
73
|
-
const MAX_TRIES = 3
|
|
74
|
-
|
|
75
|
-
/** Validate an untrusted registry value into an `Offer`, or `undefined`. */
|
|
76
|
-
export function parseOffer(raw: unknown, opts?: {allowInsecure?: boolean}): Offer | undefined {
|
|
77
|
-
// null/undefined is the registry's "no update available" signal, not a
|
|
78
|
-
// malformed offer, so return quietly without a warning.
|
|
79
|
-
if (raw === null || raw === undefined) return undefined
|
|
80
|
-
const reject = (reason: string): undefined => {
|
|
81
|
-
// eslint-disable-next-line no-console
|
|
82
|
-
console.warn(`ota: ignoring offer (${reason})`)
|
|
83
|
-
return undefined
|
|
84
|
-
}
|
|
85
|
-
if (typeof raw !== 'object') return reject('not an object')
|
|
86
|
-
const o = raw as Record<string, unknown>
|
|
87
|
-
// No url is "no update", not a malformed offer: a check-in with nothing newer
|
|
88
|
-
// still returns a body when the registry has something else to say — a name to
|
|
89
|
-
// adopt, say — and the reference client reads that before parseOffer. Warn
|
|
90
|
-
// only when a url is present but unusable, so a dashboard rename that coincides
|
|
91
|
-
// with "up to date" does not log a misleading warning on every device.
|
|
92
|
-
if (o.url === undefined) return undefined
|
|
93
|
-
if (typeof o.url !== 'string') return reject('missing url')
|
|
94
|
-
// https is required so the device never downloads executable bytecode over
|
|
95
|
-
// plaintext; allowInsecure (dev only) also permits http for local registries.
|
|
96
|
-
const schemeOk =
|
|
97
|
-
o.url.startsWith('https://') || (opts?.allowInsecure === true && o.url.startsWith('http://'))
|
|
98
|
-
// Only the path has to name a .tgz. A registry is free to hang a query on the
|
|
99
|
-
// url — a signed-url registry puts an expiry and signature there — and the
|
|
100
|
-
// device treats the whole string as opaque, so rejecting on the query would
|
|
101
|
-
// forbid that for no gain.
|
|
102
|
-
const pathEnd = o.url.search(/[?#]/)
|
|
103
|
-
const urlPath = pathEnd < 0 ? o.url : o.url.slice(0, pathEnd)
|
|
104
|
-
if (!schemeOk || !urlPath.endsWith('.tgz')) {
|
|
105
|
-
return reject(opts?.allowInsecure ? 'url must be an http(s) .tgz' : 'url must be an https .tgz')
|
|
106
|
-
}
|
|
107
|
-
if (typeof o.checksum !== 'string' || o.checksum.length === 0) return reject('missing checksum')
|
|
108
|
-
// Must be a positive integer: a negative size fails the first write with
|
|
109
|
-
// TooLarge, and 0 disables the cap on both sides, leaving the download
|
|
110
|
-
// unbounded.
|
|
111
|
-
if (typeof o.size !== 'number' || !Number.isInteger(o.size) || o.size <= 0) {
|
|
112
|
-
return reject('invalid size')
|
|
113
|
-
}
|
|
114
|
-
// The url's host is not checked. The offer arrives in an authenticated
|
|
115
|
-
// check-in response from the enrolled registry, so the registry is trusted to
|
|
116
|
-
// name where the build lives — a CDN or object store on another host, a signed
|
|
117
|
-
// url with its own query. Integrity is the checksum, verified over the whole
|
|
118
|
-
// download before install, so a wrong host yields a failed install, never a
|
|
119
|
-
// bad one; and the update key is the caller's to confine (the reference client
|
|
120
|
-
// attaches it only when the url is same-origin with the registry).
|
|
121
|
-
return {url: o.url, checksum: o.checksum, size: o.size}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function makeUpdate(native: NativeOta, size: number | undefined, resumeOffset: number): Update {
|
|
125
|
-
let written = resumeOffset
|
|
126
|
-
return {
|
|
127
|
-
resumeOffset,
|
|
128
|
-
write(bytes) {
|
|
129
|
-
if (size !== undefined && written + bytes.length > size) {
|
|
130
|
-
return err({name: 'TooLarge' as const, message: `build exceeds offered size ${size}`})
|
|
131
|
-
}
|
|
132
|
-
const r = native.stageWrite(bytes)
|
|
133
|
-
if (!r.ok) return err({name: 'StagingFull' as const, message: r.error})
|
|
134
|
-
written += bytes.length
|
|
135
|
-
return ok()
|
|
136
|
-
},
|
|
137
|
-
finish(options) {
|
|
138
|
-
const r = native.stageFinish(
|
|
139
|
-
options?.trialBoots ?? 1,
|
|
140
|
-
options?.requireConfirm ?? false,
|
|
141
|
-
options?.install === 'now',
|
|
142
|
-
)
|
|
143
|
-
if (r.ok) return ok()
|
|
144
|
-
return err({name: 'InstallFailed' as const, kind: r.kind, message: r.error})
|
|
145
|
-
},
|
|
146
|
-
abort() {
|
|
147
|
-
native.stageAbort()
|
|
148
|
-
},
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** True if the failure means the bytes are bad and must not be retried. */
|
|
153
|
-
function isAbandon(e: OtaError): boolean {
|
|
154
|
-
return e.name === 'InstallFailed' && e.kind === 'corrupt'
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function createOta(deps: OtaDeps): Ota {
|
|
158
|
-
const {native, store, readAppVersion} = deps
|
|
159
|
-
|
|
160
|
-
function beginUpdate(options: {checksum: string; size: number}) {
|
|
161
|
-
const r = native.stageBegin(options.checksum, options.size)
|
|
162
|
-
if (!r.ok) return err({name: 'StagingFailed' as const, message: r.error})
|
|
163
|
-
return ok(makeUpdate(native, options.size, r.resumeOffset))
|
|
164
|
-
}
|
|
165
|
-
// beginUpdate stays internal: staging is only ever driven through the policy.
|
|
166
|
-
|
|
167
|
-
function reconcile(): InstallOutcome {
|
|
168
|
-
// Give the budget back once per boot. Everything it counts is transient
|
|
169
|
-
// (OOM, a truncated download), and a reboot is the one signal available
|
|
170
|
-
// here that conditions may have changed — there is no clock to back off
|
|
171
|
-
// against. Without this the budget is a permanent latch: three OOM
|
|
172
|
-
// failures would strand the device on the old build forever, even once
|
|
173
|
-
// free heap recovered.
|
|
174
|
-
//
|
|
175
|
-
// Unless the last attempt crashed. A native OOM panics into a restart, so
|
|
176
|
-
// an unconditional reset zeroes the count on exactly the failure the budget
|
|
177
|
-
// exists to bound, and the device reboots into the same attempt forever.
|
|
178
|
-
// The in-flight flag is still set only in that case, so the crashed attempt
|
|
179
|
-
// keeps its bump and MAX_TRIES eventually binds.
|
|
180
|
-
if (store.getInFlight()) store.setInFlight(false)
|
|
181
|
-
else store.setTries(0)
|
|
182
|
-
const r = native.reconcile()
|
|
183
|
-
const out: InstallOutcome = {reverted: r.reverted}
|
|
184
|
-
if (r.installed !== undefined) out.installed = r.installed
|
|
185
|
-
if (r.diagnostic !== undefined) out.lastInstall = r.diagnostic
|
|
186
|
-
return out
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function running(): RunningBuild {
|
|
190
|
-
const r = native.running()
|
|
191
|
-
const out: RunningBuild = {trial: r.trial}
|
|
192
|
-
if (r.checksum !== undefined) out.checksum = r.checksum
|
|
193
|
-
const v = readAppVersion()
|
|
194
|
-
if (v !== undefined) out.version = v
|
|
195
|
-
return out
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function revert(): Result<void, OtaInstallError> {
|
|
199
|
-
const r = native.revert()
|
|
200
|
-
if (r.ok) return ok()
|
|
201
|
-
return err({name: 'InstallFailed' as const, kind: 'transient' as const, message: r.error})
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async function applyOffer(
|
|
205
|
-
offer: Offer,
|
|
206
|
-
download: DownloadFn,
|
|
207
|
-
options?: InstallOptions,
|
|
208
|
-
): Promise<Result<ApplyOutcome, OtaError>> {
|
|
209
|
-
const run = native.running()
|
|
210
|
-
// (a) a trial is unresolved. Reported distinctly because the caller's
|
|
211
|
-
// response differs from every other skip: the build on trial still needs
|
|
212
|
-
// confirming, and treating this like "nothing to do" lets the trial lapse
|
|
213
|
-
// and roll back a healthy build just because a newer one was published.
|
|
214
|
-
if (run.trial) return ok('trial-pending')
|
|
215
|
-
// (b) already running this build
|
|
216
|
-
if (offer.checksum === run.checksum) return ok('current')
|
|
217
|
-
// (d) device has given up on this build
|
|
218
|
-
if (offer.checksum === store.getBad()) return ok('abandoned')
|
|
219
|
-
// (e) retry budget, keyed on url *and* checksum. Exhausting it only stops
|
|
220
|
-
// attempts against this exact build at this exact url; the checksum is not
|
|
221
|
-
// abandoned, because everything counted here is transient by construction
|
|
222
|
-
// (a corrupt build is abandoned at (h) instead). Marking it bad would be
|
|
223
|
-
// unrecoverable: check (d) runs before this, so re-publishing the same
|
|
224
|
-
// build at a fresh url could never revive it, and nothing clears `bad`. A
|
|
225
|
-
// device on flaky wifi would permanently lose a good build after three
|
|
226
|
-
// failed downloads.
|
|
227
|
-
//
|
|
228
|
-
// The checksum has to be part of the key because a registry that serves a
|
|
229
|
-
// stable url ("/latest.tgz", re-pointed on publish) would otherwise never
|
|
230
|
-
// reset: three failures against the old build would skip every future one
|
|
231
|
-
// at that url, permanently, since the reset on success is unreachable.
|
|
232
|
-
if (offer.url !== store.getUrl() || offer.checksum !== store.getAttempt()) {
|
|
233
|
-
store.setUrl(offer.url)
|
|
234
|
-
store.setAttempt(offer.checksum)
|
|
235
|
-
store.setTries(0)
|
|
236
|
-
}
|
|
237
|
-
const tries = store.getTries()
|
|
238
|
-
if (tries >= MAX_TRIES) return ok('exhausted')
|
|
239
|
-
// (f) bump before the attempt, so a crash mid-attempt still counts — the
|
|
240
|
-
// flag is what makes that true across a reboot (see reconcile). The finally
|
|
241
|
-
// clears it on every ordinary exit, and deliberately does not run when the
|
|
242
|
-
// attempt takes the device down with it.
|
|
243
|
-
store.setTries(tries + 1)
|
|
244
|
-
store.setInFlight(true)
|
|
245
|
-
try {
|
|
246
|
-
return await attempt(offer, download, options)
|
|
247
|
-
} finally {
|
|
248
|
-
store.setInFlight(false)
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async function attempt(
|
|
253
|
-
offer: Offer,
|
|
254
|
-
download: DownloadFn,
|
|
255
|
-
options?: InstallOptions,
|
|
256
|
-
): Promise<Result<ApplyOutcome, OtaError>> {
|
|
257
|
-
// (g) stage, download, verify
|
|
258
|
-
const begun = beginUpdate({checksum: offer.checksum, size: offer.size})
|
|
259
|
-
if (!begun.ok) return err(begun.error)
|
|
260
|
-
const update = begun.value
|
|
261
|
-
// The download callback is the app's transport code. A failure there (network,
|
|
262
|
-
// a write rejection) is transient: keep the bumped tries so it retries next time,
|
|
263
|
-
// and do NOT abandon the checksum (that is only for a corrupt build).
|
|
264
|
-
const downloaded = await download(update)
|
|
265
|
-
if (!downloaded.ok) {
|
|
266
|
-
update.abort()
|
|
267
|
-
const downloadError: OtaDownloadError = {
|
|
268
|
-
name: 'DownloadFailed',
|
|
269
|
-
message: downloaded.error.message,
|
|
270
|
-
}
|
|
271
|
-
return err(downloadError)
|
|
272
|
-
}
|
|
273
|
-
// (h) a corrupt build is the one thing worth abandoning: the same bytes will
|
|
274
|
-
// fail identically forever. Abort first, or the verified-bad staging file
|
|
275
|
-
// (up to the whole build) sits on the app partition until some later offer
|
|
276
|
-
// happens to reclaim it.
|
|
277
|
-
const finished = update.finish(options)
|
|
278
|
-
if (!finished.ok) {
|
|
279
|
-
if (isAbandon(finished.error)) {
|
|
280
|
-
update.abort()
|
|
281
|
-
store.setBad(offer.checksum)
|
|
282
|
-
}
|
|
283
|
-
return err(finished.error)
|
|
284
|
-
}
|
|
285
|
-
store.setTries(0)
|
|
286
|
-
return ok('staged')
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return {
|
|
290
|
-
reconcile,
|
|
291
|
-
running,
|
|
292
|
-
parseOffer,
|
|
293
|
-
applyOffer,
|
|
294
|
-
confirm: () => native.markValid(),
|
|
295
|
-
revert,
|
|
296
|
-
bearer: deps.bearer,
|
|
297
|
-
registry: deps.registry,
|
|
298
|
-
}
|
|
299
|
-
}
|