@simantic/cloud-sim 0.1.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/dist/runs.js ADDED
@@ -0,0 +1,284 @@
1
+ // `client.runs`: make runs, read them, follow them, and compare engines.
2
+ import { CloudSimError, CompareError } from './errors.js';
3
+ import { follow } from './follow.js';
4
+ import { segment, sleep } from './http.js';
5
+ import { isFinished, } from './types.js';
6
+ const WAIT_INTERVAL_MS = 1_000;
7
+ export class Runs {
8
+ http;
9
+ constructor(http) {
10
+ this.http = http;
11
+ }
12
+ /**
13
+ * Makes a run. Answers with the run whether it was made now (201) or by an
14
+ * earlier request with the same idempotency key (200).
15
+ */
16
+ async create(params) {
17
+ return this.make(params, firmwareBody(params.firmware));
18
+ }
19
+ /** The run. */
20
+ get(id, options = {}) {
21
+ return this.http.json({
22
+ method: 'GET',
23
+ path: `/v1/runs/${segment(id)}`,
24
+ signal: options.signal,
25
+ retry: true,
26
+ });
27
+ }
28
+ /**
29
+ * The caller's runs, newest first. Awaited, it is the first page
30
+ * (`{ runs, next }`); iterated with `for await`, it is every run, a page at
31
+ * a time.
32
+ */
33
+ list(params = {}) {
34
+ return new RunListing(this.http, params);
35
+ }
36
+ /** What a finished run produced. A run that has not finished is 409 `run_not_finished`. */
37
+ result(id, options = {}) {
38
+ return this.http.json({
39
+ method: 'GET',
40
+ path: `/v1/runs/${segment(id)}/result`,
41
+ signal: options.signal,
42
+ retry: true,
43
+ });
44
+ }
45
+ /** A finished run's UART output, as text. */
46
+ output(id, options = {}) {
47
+ return this.http.text({
48
+ method: 'GET',
49
+ path: `/v1/runs/${segment(id)}/output`,
50
+ signal: options.signal,
51
+ retry: true,
52
+ });
53
+ }
54
+ /** A finished run's console, as text. */
55
+ log(id, options = {}) {
56
+ return this.http.text({
57
+ method: 'GET',
58
+ path: `/v1/runs/${segment(id)}/log`,
59
+ signal: options.signal,
60
+ retry: true,
61
+ });
62
+ }
63
+ /**
64
+ * Cancels a run: a queued one at once, a running one within seconds. A run
65
+ * that already succeeded or failed is 409 `run_finished`. Retried like a
66
+ * read: the server answers a repeated cancel with the run as it is, and a
67
+ * run that finished meanwhile gets the 409, which is not retried.
68
+ */
69
+ cancel(id, options = {}) {
70
+ return this.http.json({
71
+ method: 'POST',
72
+ path: `/v1/runs/${segment(id)}/cancel`,
73
+ signal: options.signal,
74
+ retry: true,
75
+ });
76
+ }
77
+ /** The run once it has finished: succeeded, failed or canceled. */
78
+ async wait(id, options = {}) {
79
+ const { signal, intervalMs = WAIT_INTERVAL_MS } = options;
80
+ for (;;) {
81
+ const run = await this.get(id, { signal });
82
+ if (isFinished(run))
83
+ return run;
84
+ await sleep(intervalMs, signal);
85
+ }
86
+ }
87
+ /**
88
+ * The run as events, from wherever it is (or from `from`) to its end:
89
+ * `status` when its status changes, `log` and `output` text as it prints,
90
+ * `reset` when a stream starts over, `reconnecting` while a failure is
91
+ * being retried, and `end`. See `FollowEvent`.
92
+ */
93
+ follow(id, options = {}) {
94
+ return follow(this.http, id, options);
95
+ }
96
+ /**
97
+ * Runs the same firmware, MCU and options once on each engine, in
98
+ * parallel, and answers with the runs. It reads the version first (the
99
+ * call's, the client's, or the server's default), and an engine the
100
+ * version does not have fails the whole comparison
101
+ * (`engine_not_in_version`) before anything is uploaded. Every run is made
102
+ * on that version, whatever the server's default becomes meanwhile. Each
103
+ * run counts against the caller's limit of unfinished runs.
104
+ *
105
+ * The engines are checked before anything is asked of the server: none,
106
+ * or one named twice, is 400 `invalid_request` (`param: 'engines'`).
107
+ *
108
+ * Every engine's run is asked for even when another is refused. When
109
+ * some runs are made and others are not, it fails with a `CompareError`
110
+ * that carries the runs made, by engine, to follow or cancel.
111
+ */
112
+ async compare(params) {
113
+ const { engines, firmware, signal, version: asked, ...options } = params;
114
+ checkEngines(engines);
115
+ // Encoded once, sent once per engine.
116
+ const body = firmwareBody(firmware);
117
+ const version = await this.readVersion(asked, signal);
118
+ const have = version.engines.map((e) => e.name);
119
+ const missing = engines.filter((e) => !have.includes(e));
120
+ if (missing.length > 0) {
121
+ throw new CloudSimError(`API version ${version.id} has no ${missing.join(' or ')} engine: it has ${have.join(', ')}`, { status: 400, code: 'engine_not_in_version', param: 'engines' });
122
+ }
123
+ const made = await Promise.allSettled(engines.map((engine) => this.make({ ...options, engine, signal, version: version.id }, body)));
124
+ if (signal?.aborted)
125
+ throw signal.reason;
126
+ const runs = {};
127
+ const errors = {};
128
+ made.forEach((m, i) => {
129
+ const engine = engines[i];
130
+ if (m.status === 'fulfilled')
131
+ runs[engine] = m.value;
132
+ else
133
+ errors[engine] = m.reason;
134
+ });
135
+ if (Object.keys(errors).length > 0)
136
+ throw new CompareError(version, runs, errors);
137
+ return { version, runs: Object.values(runs) };
138
+ }
139
+ make(params, firmware) {
140
+ const key = params.idempotencyKey ?? newIdempotencyKey();
141
+ const fields = optionFields(params);
142
+ const call = {
143
+ method: 'POST',
144
+ path: '/v1/runs',
145
+ headers: { 'Idempotency-Key': key },
146
+ version: params.version,
147
+ signal: params.signal,
148
+ timeoutMs: params.timeoutMs,
149
+ retry: true,
150
+ };
151
+ if (firmware.kind === 'json') {
152
+ return this.http.json({ ...call, json: { ...fields, firmware: firmware.json } });
153
+ }
154
+ const form = new FormData();
155
+ for (const [name, value] of Object.entries(fields)) {
156
+ for (const one of Array.isArray(value) ? value : [value])
157
+ form.append(name, String(one));
158
+ }
159
+ form.append('firmware', firmware.blob, firmware.name);
160
+ return this.http.json({ ...call, form });
161
+ }
162
+ /** Version `asked`, else the client's, else the server's default. */
163
+ async readVersion(asked, signal) {
164
+ const id = asked ?? this.http.version;
165
+ if (id) {
166
+ return this.http.json({
167
+ method: 'GET',
168
+ path: `/v1/versions/${segment(id)}`,
169
+ signal,
170
+ retry: true,
171
+ });
172
+ }
173
+ const list = await this.http.json({
174
+ method: 'GET',
175
+ path: '/v1/versions',
176
+ signal,
177
+ retry: true,
178
+ });
179
+ const version = list.versions.find((v) => v.id === list.default);
180
+ if (!version) {
181
+ throw new CloudSimError(`The Cloud Sim API lists no default version ${list.default}.`, {
182
+ status: 200,
183
+ code: 'invalid_response',
184
+ });
185
+ }
186
+ return version;
187
+ }
188
+ }
189
+ /**
190
+ * The caller's runs: a promise of the first page, and an async iterable of
191
+ * every run. Nothing is read until it is awaited or iterated.
192
+ */
193
+ export class RunListing {
194
+ http;
195
+ params;
196
+ first;
197
+ constructor(http, params) {
198
+ this.http = http;
199
+ this.params = params;
200
+ }
201
+ /** The first page. */
202
+ firstPage() {
203
+ this.first ??= this.page(this.params.before);
204
+ return this.first;
205
+ }
206
+ then(onFulfilled, onRejected) {
207
+ return this.firstPage().then(onFulfilled, onRejected);
208
+ }
209
+ async *[Symbol.asyncIterator]() {
210
+ let page = await this.firstPage();
211
+ for (;;) {
212
+ yield* page.runs;
213
+ if (!page.next)
214
+ return;
215
+ page = await this.page(page.next);
216
+ }
217
+ }
218
+ page(before) {
219
+ return this.http.json({
220
+ method: 'GET',
221
+ path: '/v1/runs',
222
+ query: { limit: this.params.limit, before, engine: this.params.engine },
223
+ signal: this.params.signal,
224
+ retry: true,
225
+ });
226
+ }
227
+ }
228
+ /** Refuses a comparison of no engines, or of one engine twice, as the Python SDK does. */
229
+ function checkEngines(engines) {
230
+ const refuse = (message) => new CloudSimError(message, { status: 400, code: 'invalid_request', param: 'engines' });
231
+ if (!Array.isArray(engines) || engines.length === 0) {
232
+ throw refuse('compare needs at least one engine');
233
+ }
234
+ if (new Set(engines).size !== engines.length) {
235
+ throw refuse(`each engine may be named once: ${engines.join(', ')}`);
236
+ }
237
+ }
238
+ function firmwareBody(firmware) {
239
+ if (typeof Blob !== 'undefined' && firmware instanceof Blob) {
240
+ const name = 'name' in firmware && typeof firmware.name === 'string' ? firmware.name : '';
241
+ return { kind: 'form', blob: firmware, name: name || 'firmware.elf' };
242
+ }
243
+ if (firmware instanceof Uint8Array || firmware instanceof ArrayBuffer) {
244
+ return { kind: 'form', blob: new Blob([firmware]), name: 'firmware.elf' };
245
+ }
246
+ if (typeof firmware === 'object' && firmware !== null) {
247
+ if ('url' in firmware && typeof firmware.url === 'string') {
248
+ return { kind: 'json', json: { url: firmware.url } };
249
+ }
250
+ if ('base64' in firmware && typeof firmware.base64 === 'string') {
251
+ return { kind: 'json', json: { base64: firmware.base64 } };
252
+ }
253
+ }
254
+ throw new TypeError('firmware must be a Blob, a Uint8Array, an ArrayBuffer, { url } or { base64 }');
255
+ }
256
+ /** The run's fields other than its firmware, as the API names them. */
257
+ function optionFields(params) {
258
+ const fields = {
259
+ mcu: params.mcu,
260
+ engine: params.engine,
261
+ simulated_seconds: params.simulatedSeconds,
262
+ uart_encoding: params.uartEncoding,
263
+ uart_messages_only: params.uartMessagesOnly,
264
+ trap_unmapped: params.trapUnmapped,
265
+ usb_cdc_consoles: params.usbCdcConsoles,
266
+ };
267
+ const set = {};
268
+ for (const [name, value] of Object.entries(fields))
269
+ if (value !== undefined)
270
+ set[name] = value;
271
+ return set;
272
+ }
273
+ /** A new Idempotency-Key. */
274
+ function newIdempotencyKey() {
275
+ const c = globalThis.crypto;
276
+ if (c?.randomUUID)
277
+ return c.randomUUID();
278
+ // Node 18 has no global Web Crypto unless started with
279
+ // --experimental-global-webcrypto. The key only has to be unique among
280
+ // the caller's runs, which 122 random bits are, from any generator.
281
+ const hex = (n) => Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join('');
282
+ const variant = '89ab'[Math.floor(Math.random() * 4)];
283
+ return `${hex(8)}-${hex(4)}-4${hex(3)}-${variant}${hex(3)}-${hex(12)}`;
284
+ }
package/dist/sse.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export interface SseEvent {
2
+ /** The event's name; `message` when it named none. */
3
+ event: string;
4
+ data: string;
5
+ /** The last event id the stream set, at or before this event. */
6
+ id: string | undefined;
7
+ }
8
+ /** Turns text, fed in pieces as it arrives, into events. */
9
+ export declare class SseParser {
10
+ private buffer;
11
+ private data;
12
+ private event;
13
+ /**
14
+ * The id of the last event dispatched: what a reconnect resumes from. An
15
+ * `id` line takes effect when its event is complete, so an event cut off
16
+ * by a dropped connection is asked for again.
17
+ */
18
+ lastId: string | undefined;
19
+ private idBuffer;
20
+ /** The reconnection delay the server last asked for, in milliseconds. */
21
+ retry: number | undefined;
22
+ private pendingCr;
23
+ constructor(lastId?: string);
24
+ /** The events completed by `text`. */
25
+ push(text: string): SseEvent[];
26
+ private line;
27
+ private dispatch;
28
+ }
29
+ /** The events of a response's body, as they arrive. */
30
+ export declare function readEvents(body: ReadableStream<Uint8Array>, parser: SseParser): AsyncGenerator<SseEvent>;
package/dist/sse.js ADDED
@@ -0,0 +1,109 @@
1
+ // A server-sent event stream, parsed from fetch's body. EventSource would
2
+ // do this, but it cannot send Authorization, so the SDK reads the stream
3
+ // itself (the WHATWG HTML "event stream interpretation", less what the API
4
+ // never sends).
5
+ /** Turns text, fed in pieces as it arrives, into events. */
6
+ export class SseParser {
7
+ buffer = '';
8
+ data = [];
9
+ event = '';
10
+ /**
11
+ * The id of the last event dispatched: what a reconnect resumes from. An
12
+ * `id` line takes effect when its event is complete, so an event cut off
13
+ * by a dropped connection is asked for again.
14
+ */
15
+ lastId;
16
+ idBuffer;
17
+ /** The reconnection delay the server last asked for, in milliseconds. */
18
+ retry;
19
+ // A `\r` that ended the last piece: if the next starts with `\n`, the
20
+ // two are one line break.
21
+ pendingCr = false;
22
+ constructor(lastId) {
23
+ this.lastId = lastId;
24
+ this.idBuffer = lastId;
25
+ }
26
+ /** The events completed by `text`. */
27
+ push(text) {
28
+ if (this.pendingCr && text.startsWith('\n'))
29
+ text = text.slice(1);
30
+ this.pendingCr = false;
31
+ this.buffer += text;
32
+ const events = [];
33
+ for (;;) {
34
+ const at = this.buffer.search(/[\r\n]/);
35
+ if (at < 0)
36
+ break;
37
+ const line = this.buffer.slice(0, at);
38
+ let skip = 1;
39
+ if (this.buffer[at] === '\r') {
40
+ if (at + 1 === this.buffer.length)
41
+ this.pendingCr = true;
42
+ else if (this.buffer[at + 1] === '\n')
43
+ skip = 2;
44
+ }
45
+ this.buffer = this.buffer.slice(at + skip);
46
+ const event = this.line(line);
47
+ if (event)
48
+ events.push(event);
49
+ }
50
+ return events;
51
+ }
52
+ line(line) {
53
+ if (line === '')
54
+ return this.dispatch();
55
+ if (line.startsWith(':'))
56
+ return undefined;
57
+ const colon = line.indexOf(':');
58
+ const field = colon < 0 ? line : line.slice(0, colon);
59
+ let value = colon < 0 ? '' : line.slice(colon + 1);
60
+ if (value.startsWith(' '))
61
+ value = value.slice(1);
62
+ switch (field) {
63
+ case 'event':
64
+ this.event = value;
65
+ break;
66
+ case 'data':
67
+ this.data.push(value);
68
+ break;
69
+ case 'id':
70
+ if (!value.includes('\0'))
71
+ this.idBuffer = value;
72
+ break;
73
+ case 'retry':
74
+ if (/^\d+$/.test(value))
75
+ this.retry = Number(value);
76
+ break;
77
+ }
78
+ return undefined;
79
+ }
80
+ dispatch() {
81
+ const data = this.data;
82
+ const event = this.event || 'message';
83
+ this.data = [];
84
+ this.event = '';
85
+ this.lastId = this.idBuffer;
86
+ // A block without data dispatches nothing (but its id stands).
87
+ if (data.length === 0)
88
+ return undefined;
89
+ return { event, data: data.join('\n'), id: this.lastId };
90
+ }
91
+ }
92
+ /** The events of a response's body, as they arrive. */
93
+ export async function* readEvents(body, parser) {
94
+ const reader = body.getReader();
95
+ const decoder = new TextDecoder();
96
+ try {
97
+ for (;;) {
98
+ const { done, value } = await reader.read();
99
+ if (done)
100
+ break;
101
+ yield* parser.push(decoder.decode(value, { stream: true }));
102
+ }
103
+ yield* parser.push(decoder.decode());
104
+ }
105
+ finally {
106
+ // The consumer stopped early, or the read failed: let the connection go.
107
+ reader.cancel().catch(() => { });
108
+ }
109
+ }
@@ -0,0 +1,47 @@
1
+ import type { components } from './generated/v1.js';
2
+ type Schemas = components['schemas'];
3
+ /** A simulation run. */
4
+ export type Run = Schemas['Run'];
5
+ /** Where a run is. `succeeded`, `failed` and `canceled` are final. */
6
+ export type RunStatus = Schemas['RunStatus'];
7
+ /** The engine a run is bound to. */
8
+ export type RunEngine = Schemas['RunEngine'];
9
+ /** A run's options as the server recorded them, every default filled in. */
10
+ export type RunOptions = Schemas['RunOptions'];
11
+ /** What a finished run produced: its exit code, UART output and console. */
12
+ export type RunResult = Schemas['RunResult'];
13
+ /** A page of the caller's runs, newest first. */
14
+ export type RunList = Schemas['RunList'];
15
+ /** The firmware a run simulates, as the server received it. */
16
+ export type Firmware = Schemas['Firmware'];
17
+ /** A run and a page of each of its streams (`GET /v1/runs/{id}/live`). */
18
+ export type Live = Schemas['Live'];
19
+ /** A page of one stream. */
20
+ export type LiveStream = Schemas['LiveStream'];
21
+ /** How UART bytes are written: hex rows or text. */
22
+ export type UartEncoding = Schemas['UartEncoding'];
23
+ /** Where a version is in its life. */
24
+ export type Lifecycle = Schemas['Lifecycle'];
25
+ /** A version's MCU catalog. */
26
+ export type McuList = Schemas['McuList'];
27
+ /** One MCU, and what each of the version's engines makes of it. */
28
+ export type Mcu = Schemas['McuView'];
29
+ /** One engine's view of an MCU. */
30
+ export type McuEngine = Schemas['McuEngineView'];
31
+ /** What a version changed against the one before it. */
32
+ export type Changes = Schemas['Changes'];
33
+ /** The body of every error the server answers with. */
34
+ export type ErrorBody = Schemas['Error'];
35
+ /** One run option an engine takes, with its default and bounds. */
36
+ export type EngineOption = Schemas['OptionView'];
37
+ /** An engine as a version pins it. */
38
+ export type Engine = Schemas['EngineView'];
39
+ /** One API version: its lifecycle and the engines it binds. */
40
+ export type Version = Schemas['VersionView'];
41
+ /** Every version the server serves. */
42
+ export type VersionList = Schemas['VersionList'];
43
+ /** The statuses a run ends in. */
44
+ export declare const FINISHED: readonly RunStatus[];
45
+ /** Whether a run has ended (succeeded, failed or was canceled). */
46
+ export declare function isFinished(run: Pick<Run, 'status'>): boolean;
47
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,11 @@
1
+ // The wire types this SDK hands back, named for use. They come from
2
+ // src/generated/v1.ts, which `npm run generate` writes from the server's
3
+ // openapi/v1.json, so they change only when the contract does. Readers
4
+ // ignore fields they do not know: the server adds fields within a major
5
+ // version, and never removes or changes one.
6
+ /** The statuses a run ends in. */
7
+ export const FINISHED = ['succeeded', 'failed', 'canceled'];
8
+ /** Whether a run has ended (succeeded, failed or was canceled). */
9
+ export function isFinished(run) {
10
+ return FINISHED.includes(run.status);
11
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@simantic/cloud-sim",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript client for the Cloud Sim API: run MCU firmware in the cloud, follow its output live, and compare simulation engines.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ },
11
+ "./node": {
12
+ "types": "./dist/node.d.ts",
13
+ "default": "./dist/node.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "types": "./dist/index.d.ts",
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "sideEffects": false,
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "license": "UNLICENSED",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/simantic-dev/cloud-sim.git",
30
+ "directory": "sdk/typescript"
31
+ },
32
+ "homepage": "https://github.com/simantic-dev/cloud-sim/tree/main/sdk/typescript#readme",
33
+ "keywords": [
34
+ "simantic",
35
+ "cloud-sim",
36
+ "firmware",
37
+ "simulator",
38
+ "mcu"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsc -p tsconfig.build.json",
42
+ "typecheck": "tsc -p tsconfig.json --noEmit",
43
+ "test": "vitest run",
44
+ "generate": "openapi-typescript ../../openapi/v1.json -o src/generated/v1.ts",
45
+ "check:generated": "node scripts/check-generated.mjs",
46
+ "prepublishOnly": "npm run build"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.20.5",
50
+ "ajv": "^8.20.0",
51
+ "ajv-formats": "^3.0.1",
52
+ "openapi-typescript": "^7.13.0",
53
+ "typescript": "~5.9.3",
54
+ "vitest": "^4.1.11"
55
+ },
56
+ "publishConfig": {
57
+ "access": "restricted"
58
+ }
59
+ }