@mica-js/sdk 0.0.0-stage → 0.2.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -2
  3. package/dist/build/cli.d.ts +22 -0
  4. package/dist/build/cli.js +211 -0
  5. package/dist/build/config.d.ts +16 -0
  6. package/dist/build/config.js +92 -0
  7. package/dist/build/content.d.ts +14 -0
  8. package/dist/build/content.js +159 -0
  9. package/dist/build/prepare.d.ts +51 -0
  10. package/dist/build/prepare.js +306 -0
  11. package/dist/build/project.d.ts +4 -0
  12. package/dist/build/project.js +30 -0
  13. package/dist/build/vite.d.ts +7 -0
  14. package/dist/build/vite.js +60 -0
  15. package/dist/ddl.d.ts +2 -0
  16. package/dist/ddl.js +1 -0
  17. package/dist/device.d.ts +1 -0
  18. package/dist/device.js +1 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +1 -0
  21. package/dist/metadata/index.d.ts +5 -0
  22. package/dist/metadata/index.js +2 -0
  23. package/dist/metadata/schema.d.ts +10 -0
  24. package/dist/metadata/schema.js +365 -0
  25. package/dist/metadata/types.d.ts +185 -0
  26. package/dist/metadata/types.js +7 -0
  27. package/dist/metadata/validate.d.ts +25 -0
  28. package/dist/metadata/validate.js +283 -0
  29. package/dist/model.d.ts +1 -0
  30. package/dist/model.js +1 -0
  31. package/dist/widget.d.ts +1 -0
  32. package/dist/widget.js +1 -0
  33. package/package.json +69 -3
  34. package/templates/basic/mica.config.ts +3 -0
  35. package/templates/basic/package.json +14 -0
  36. package/templates/basic/src/devices/resistor/device.json +30 -0
  37. package/templates/basic/src/devices/resistor/index.ts +8 -0
  38. package/templates/basic/src/devices/resistor/model.ddl +6 -0
  39. package/templates/basic/src/widgets/lamp/index.ts +22 -0
  40. package/templates/basic/src/widgets/lamp/widget.json +9 -0
  41. package/templates/basic/tsconfig.json +14 -0
@@ -0,0 +1,51 @@
1
+ /** Device definition file expected inside every device directory. */
2
+ export declare const DEVICE_SOURCE_FILE = "model.ddl";
3
+ /** TypeScript declaration generated next to {@link DEVICE_SOURCE_FILE}. */
4
+ export declare const DEVICE_DECLARATION_FILE = "model.d.ddl.ts";
5
+ export interface PrepareOptions {
6
+ /** Package root containing the source directory; defaults to `process.cwd()`. */
7
+ readonly root?: string;
8
+ /** Source directory inside the root that holds `devices/`; defaults to `src`. */
9
+ readonly sourceDir?: string;
10
+ }
11
+ export interface WatchOptions extends PrepareOptions {
12
+ /** Coalescing window between a file event and the regeneration; defaults to 50 ms. */
13
+ readonly debounceMs?: number;
14
+ /** Receives the declaration paths after every successful regeneration. */
15
+ readonly onPrepare?: (files: readonly string[]) => void;
16
+ /** Receives every failed regeneration; watching continues afterwards. */
17
+ readonly onError?: (error: unknown) => void;
18
+ }
19
+ export interface PrepareWatcher {
20
+ /** Declaration paths from the initial scan; resolves empty when that scan failed. */
21
+ readonly ready: Promise<readonly string[]>;
22
+ /** Stops watching and resolves once the regeneration in flight has finished. */
23
+ dispose(): Promise<void>;
24
+ }
25
+ /**
26
+ * Compiles `<root>/<sourceDir>/devices/<name>/model.ddl` into the source-adjacent
27
+ * `model.d.ddl.ts` declaration module of every device directory.
28
+ *
29
+ * Every device compiles before the first write, so a failing definition never leaves the package
30
+ * with a partly updated set of declarations. A declaration whose content is unchanged keeps its
31
+ * previous file and timestamp. The previous declaration of a device is removed when its
32
+ * definition fails to compile or no longer exists; only files carrying the generated marker are
33
+ * ever removed.
34
+ *
35
+ * @returns Absolute paths of the declaration files that the current definitions describe, in
36
+ * device directory order.
37
+ * @throws The original `BuildError` of a failed compilation, with the device file path prefixed
38
+ * to its message.
39
+ */
40
+ export declare function prepare(options?: PrepareOptions): Promise<readonly string[]>;
41
+ /**
42
+ * Regenerates declarations whenever a device definition below `<root>/<sourceDir>` appears,
43
+ * changes or disappears; output writes never feed back into the watch loop.
44
+ *
45
+ * Events are coalesced within {@link WatchOptions.debounceMs} and runs are serialized, so a
46
+ * save burst produces a single regeneration. Failures reach {@link WatchOptions.onError} and
47
+ * never stop the watcher.
48
+ *
49
+ * @throws When the source directory cannot be watched; the message names that directory.
50
+ */
51
+ export declare function watchDevices(options?: WatchOptions): PrepareWatcher;
@@ -0,0 +1,306 @@
1
+ import { watch } from 'node:fs';
2
+ import { readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { compileDDL, generateDeviceTypeScript } from '@mica-js/ddl';
6
+ /** Device definition file expected inside every device directory. */
7
+ export const DEVICE_SOURCE_FILE = 'model.ddl';
8
+ /** TypeScript declaration generated next to {@link DEVICE_SOURCE_FILE}. */
9
+ export const DEVICE_DECLARATION_FILE = 'model.d.ddl.ts';
10
+ /** Runtime surface every generated declaration imports its boundary types from. */
11
+ const DEVICE_MODULE = '@mica-js/sdk/device';
12
+ /** Text marking a file as a `mica prepare` output, so only generated files are removed. */
13
+ const GENERATED_MARKER = '@mica-js/sdk prepare';
14
+ const DEVICE_DIRECTORY = 'devices';
15
+ const DEFAULT_SOURCE_DIR = 'src';
16
+ const DEFAULT_DEBOUNCE_MS = 50;
17
+ const DEVICE_ALIASES = [
18
+ 'Props',
19
+ 'PropertyInput',
20
+ 'Inputs',
21
+ 'Memory',
22
+ 'Outputs',
23
+ 'Context',
24
+ 'Batch',
25
+ 'Adapter',
26
+ 'Factory',
27
+ ];
28
+ /**
29
+ * Compiles `<root>/<sourceDir>/devices/<name>/model.ddl` into the source-adjacent
30
+ * `model.d.ddl.ts` declaration module of every device directory.
31
+ *
32
+ * Every device compiles before the first write, so a failing definition never leaves the package
33
+ * with a partly updated set of declarations. A declaration whose content is unchanged keeps its
34
+ * previous file and timestamp. The previous declaration of a device is removed when its
35
+ * definition fails to compile or no longer exists; only files carrying the generated marker are
36
+ * ever removed.
37
+ *
38
+ * @returns Absolute paths of the declaration files that the current definitions describe, in
39
+ * device directory order.
40
+ * @throws The original `BuildError` of a failed compilation, with the device file path prefixed
41
+ * to its message.
42
+ */
43
+ export async function prepare(options = {}) {
44
+ const root = resolve(options.root ?? process.cwd());
45
+ const sourceDir = options.sourceDir ?? DEFAULT_SOURCE_DIR;
46
+ const devicesDir = resolve(root, sourceDir, DEVICE_DIRECTORY);
47
+ const directories = await listDeviceDirectories(devicesDir);
48
+ const devices = [];
49
+ const orphaned = [];
50
+ for (const directory of directories) {
51
+ const deviceDir = join(devicesDir, directory);
52
+ const sourceFile = join(deviceDir, DEVICE_SOURCE_FILE);
53
+ const declarationFile = join(deviceDir, DEVICE_DECLARATION_FILE);
54
+ if (await isFile(sourceFile))
55
+ devices.push({ sourceFile, declarationFile });
56
+ else
57
+ orphaned.push(declarationFile);
58
+ }
59
+ const outputs = [];
60
+ for (const device of devices)
61
+ outputs.push({
62
+ file: device.declarationFile,
63
+ content: await compileDeclaration(device.sourceFile, device.declarationFile),
64
+ });
65
+ for (const file of orphaned)
66
+ await removeGenerated(file);
67
+ for (const output of outputs)
68
+ await writeIfChanged(output.file, output.content);
69
+ return outputs.map((output) => output.file);
70
+ }
71
+ /**
72
+ * Regenerates declarations whenever a device definition below `<root>/<sourceDir>` appears,
73
+ * changes or disappears; output writes never feed back into the watch loop.
74
+ *
75
+ * Events are coalesced within {@link WatchOptions.debounceMs} and runs are serialized, so a
76
+ * save burst produces a single regeneration. Failures reach {@link WatchOptions.onError} and
77
+ * never stop the watcher.
78
+ *
79
+ * @throws When the source directory cannot be watched; the message names that directory.
80
+ */
81
+ export function watchDevices(options = {}) {
82
+ const root = resolve(options.root ?? process.cwd());
83
+ const sourceDir = options.sourceDir ?? DEFAULT_SOURCE_DIR;
84
+ const sourceRoot = resolve(root, sourceDir);
85
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
86
+ let disposed = false;
87
+ let queued = false;
88
+ let timer;
89
+ let current;
90
+ const report = (error) => {
91
+ try {
92
+ options.onError?.(error);
93
+ }
94
+ catch {
95
+ // A failing reporter must not end the watch loop.
96
+ }
97
+ };
98
+ const scan = async () => {
99
+ try {
100
+ const files = await prepare({ root, sourceDir });
101
+ options.onPrepare?.(files);
102
+ return files;
103
+ }
104
+ catch (error) {
105
+ if (!disposed)
106
+ report(error);
107
+ return [];
108
+ }
109
+ };
110
+ const drain = async () => {
111
+ for (;;) {
112
+ queued = false;
113
+ const files = await scan();
114
+ if (!queued || disposed)
115
+ return files;
116
+ }
117
+ };
118
+ const schedule = () => {
119
+ if (disposed)
120
+ return;
121
+ if (timer !== undefined)
122
+ clearTimeout(timer);
123
+ timer = setTimeout(() => {
124
+ timer = undefined;
125
+ run();
126
+ }, debounceMs);
127
+ };
128
+ const start = () => {
129
+ const task = drain();
130
+ current = task;
131
+ void task.finally(() => {
132
+ if (current === task)
133
+ current = undefined;
134
+ if (queued && !disposed)
135
+ schedule();
136
+ });
137
+ return task;
138
+ };
139
+ const run = () => {
140
+ if (disposed)
141
+ return;
142
+ if (current)
143
+ queued = true;
144
+ else
145
+ start();
146
+ };
147
+ let watcher;
148
+ try {
149
+ watcher = watch(sourceRoot, { recursive: true }, (_event, filename) => {
150
+ if (disposed)
151
+ return;
152
+ void isRelevantEvent(sourceRoot, filename).then((relevant) => {
153
+ if (relevant && !disposed)
154
+ schedule();
155
+ }, () => {
156
+ // An unreadable path may still be the device directory that changed.
157
+ if (!disposed)
158
+ schedule();
159
+ });
160
+ });
161
+ }
162
+ catch (error) {
163
+ throw new Error(`Cannot watch ${sourceRoot} for ${DEVICE_SOURCE_FILE} changes: ${messageOf(error)}`, { cause: error });
164
+ }
165
+ watcher.on('error', (error) => {
166
+ if (!disposed)
167
+ report(error);
168
+ });
169
+ const ready = start();
170
+ return {
171
+ ready,
172
+ async dispose() {
173
+ if (disposed)
174
+ return;
175
+ disposed = true;
176
+ if (timer !== undefined) {
177
+ clearTimeout(timer);
178
+ timer = undefined;
179
+ }
180
+ watcher.close();
181
+ await current;
182
+ },
183
+ };
184
+ }
185
+ async function compileDeclaration(sourceFile, declarationFile) {
186
+ try {
187
+ const source = await readFile(sourceFile, 'utf8');
188
+ const model = await compileDDL(source, { uri: pathToFileURL(sourceFile).href });
189
+ return renderDeclaration(model);
190
+ }
191
+ catch (error) {
192
+ // The editor must never keep declarations that a failed definition contradicts.
193
+ await removeGenerated(declarationFile).catch(() => undefined);
194
+ throw annotate(error, sourceFile);
195
+ }
196
+ }
197
+ function renderDeclaration(model) {
198
+ const prefix = model.name;
199
+ const generated = generateDeviceTypeScript(model, {
200
+ deviceModule: DEVICE_MODULE,
201
+ name: prefix,
202
+ });
203
+ const aliases = DEVICE_ALIASES.map((alias) => `export type ${alias} = ${prefix}${alias};`);
204
+ return [
205
+ `// Generated by ${GENERATED_MARKER} from ${DEVICE_SOURCE_FILE}; do not edit.`,
206
+ '',
207
+ generated.trimEnd(),
208
+ '',
209
+ ...aliases,
210
+ '',
211
+ ].join('\n');
212
+ }
213
+ /** A `model.ddl` edit or a device directory appearing, disappearing or being renamed. */
214
+ async function isRelevantEvent(sourceRoot, filename) {
215
+ if (filename === null)
216
+ return true;
217
+ const relativePath = filename.toString();
218
+ if (basename(relativePath) === DEVICE_SOURCE_FILE)
219
+ return true;
220
+ return isDirectory(join(sourceRoot, relativePath));
221
+ }
222
+ async function listDeviceDirectories(devicesDir) {
223
+ let entries;
224
+ try {
225
+ entries = await readdir(devicesDir, { withFileTypes: true });
226
+ }
227
+ catch (error) {
228
+ if (errorCode(error) === 'ENOENT')
229
+ return [];
230
+ throw error;
231
+ }
232
+ const directories = [];
233
+ for (const entry of entries) {
234
+ if (entry.isDirectory())
235
+ directories.push(entry.name);
236
+ else if (entry.isSymbolicLink() && (await isDirectory(join(devicesDir, entry.name))))
237
+ directories.push(entry.name);
238
+ }
239
+ return directories.sort();
240
+ }
241
+ async function removeGenerated(file) {
242
+ let content;
243
+ try {
244
+ content = await readFile(file, 'utf8');
245
+ }
246
+ catch (error) {
247
+ if (errorCode(error) === 'ENOENT')
248
+ return;
249
+ throw error;
250
+ }
251
+ if (!content.includes(GENERATED_MARKER))
252
+ return;
253
+ await unlink(file).catch((error) => {
254
+ if (errorCode(error) !== 'ENOENT')
255
+ throw error;
256
+ });
257
+ }
258
+ async function writeIfChanged(file, content) {
259
+ try {
260
+ if ((await readFile(file, 'utf8')) === content)
261
+ return;
262
+ }
263
+ catch (error) {
264
+ if (errorCode(error) !== 'ENOENT')
265
+ throw error;
266
+ }
267
+ await writeFile(file, content, 'utf8');
268
+ }
269
+ async function isFile(path) {
270
+ try {
271
+ return (await stat(path)).isFile();
272
+ }
273
+ catch (error) {
274
+ const code = errorCode(error);
275
+ if (code === 'ENOENT' || code === 'ENOTDIR')
276
+ return false;
277
+ throw error;
278
+ }
279
+ }
280
+ async function isDirectory(path) {
281
+ try {
282
+ return (await stat(path)).isDirectory();
283
+ }
284
+ catch (error) {
285
+ const code = errorCode(error);
286
+ if (code === 'ENOENT' || code === 'ENOTDIR')
287
+ return false;
288
+ throw error;
289
+ }
290
+ }
291
+ function annotate(error, sourceFile) {
292
+ if (error instanceof Error) {
293
+ error.message = `${sourceFile}: ${error.message}`;
294
+ return error;
295
+ }
296
+ return new Error(`${sourceFile}: ${String(error)}`);
297
+ }
298
+ function messageOf(error) {
299
+ return error instanceof Error ? error.message : String(error);
300
+ }
301
+ function errorCode(error) {
302
+ if (typeof error !== 'object' || error === null || !('code' in error))
303
+ return undefined;
304
+ const code = error.code;
305
+ return typeof code === 'string' ? code : undefined;
306
+ }
@@ -0,0 +1,4 @@
1
+ import type { loadProjectConfig } from './config.js';
2
+ type Project = Awaited<ReturnType<typeof loadProjectConfig>>;
3
+ export declare function buildProject(project: Project): Promise<void>;
4
+ export {};
@@ -0,0 +1,30 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { build } from 'vite';
3
+ import { micaSdk } from './vite.js';
4
+ function viteConfig(project) {
5
+ const { root, config } = project;
6
+ const source = resolve(root, config.sourceDir ?? 'src');
7
+ const output = resolve(root, config.outDir ?? 'dist');
8
+ const contains = (parent, child) => {
9
+ const path = relative(parent, child);
10
+ return (path === '' ||
11
+ (!isAbsolute(path) && path !== '..' && !path.startsWith('..\\') && !path.startsWith('../')));
12
+ };
13
+ if (contains(output, root) || contains(output, source) || contains(source, output))
14
+ throw new Error('outDir must be separate from the project root and sourceDir.');
15
+ return {
16
+ configFile: false,
17
+ root,
18
+ base: config.base ?? '/',
19
+ publicDir: false,
20
+ appType: 'custom',
21
+ resolve: {
22
+ alias: Object.fromEntries(Object.entries(config.alias ?? {}).map(([key, path]) => [key, resolve(root, path)])),
23
+ },
24
+ plugins: [micaSdk({ sourceDir: config.sourceDir ?? 'src' })],
25
+ build: { outDir: config.outDir ?? 'dist' },
26
+ };
27
+ }
28
+ export async function buildProject(project) {
29
+ await build(viteConfig(project));
30
+ }
@@ -0,0 +1,7 @@
1
+ import type { Plugin } from 'vite';
2
+ export interface MicaSdkOptions {
3
+ /** Source directory relative to the Vite root; defaults to src. */
4
+ readonly sourceDir?: string;
5
+ }
6
+ /** Builds independent two-file devices/widgets for the SDK CLI. */
7
+ export declare function micaSdk(options?: MicaSdkOptions): Plugin;
@@ -0,0 +1,60 @@
1
+ import { buildContent } from './content.js';
2
+ import { prepare } from './prepare.js';
3
+ const entryId = 'virtual:mica-sdk-empty';
4
+ /** Builds independent two-file devices/widgets for the SDK CLI. */
5
+ export function micaSdk(options = {}) {
6
+ let config;
7
+ const compile = async () => {
8
+ await prepare({ root: config.root, ...options });
9
+ return buildContent({
10
+ root: config.root,
11
+ ...options,
12
+ aliases: config.resolve.alias,
13
+ minify: config.build.minify !== false,
14
+ });
15
+ };
16
+ return {
17
+ name: 'mica:sdk',
18
+ config(_config, environment) {
19
+ if (environment.command !== 'build')
20
+ return;
21
+ return {
22
+ publicDir: false,
23
+ build: {
24
+ sourcemap: false,
25
+ copyPublicDir: false,
26
+ lib: { entry: entryId, formats: ['es'], fileName: () => '__mica_sdk__.js' },
27
+ rolldownOptions: { input: entryId },
28
+ },
29
+ };
30
+ },
31
+ configResolved(resolved) {
32
+ config = resolved;
33
+ },
34
+ resolveId(id) {
35
+ if (id === entryId)
36
+ return '\0' + entryId;
37
+ },
38
+ load(id) {
39
+ if (id === '\0' + entryId)
40
+ return 'export {};';
41
+ },
42
+ async buildStart() {
43
+ if (config.command !== 'build')
44
+ return;
45
+ for (const file of await compile())
46
+ this.emitFile({ type: 'asset', ...file });
47
+ },
48
+ generateBundle(_options, bundle) {
49
+ if (config.command !== 'build')
50
+ return;
51
+ for (const [name, output] of Object.entries(bundle)) {
52
+ if (output.type === 'chunk' && output.facadeModuleId === '\0' + entryId)
53
+ delete bundle[name];
54
+ else if (output.type === 'chunk' ||
55
+ !/^(devices\/[A-Za-z0-9-]+\/device|widgets\/[A-Za-z0-9-]+\/widget)\.(js|json)$/.test(name))
56
+ this.error(`Unexpected content artifact: ${name}`);
57
+ }
58
+ },
59
+ };
60
+ }
package/dist/ddl.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { compileDDL, generateDeviceTypes, generateDeviceTypeScript } from '@mica-js/ddl';
2
+ export type { CompileDDLOptions, DeviceTypeScriptOptions, DeviceIR } from '@mica-js/ddl';
package/dist/ddl.js ADDED
@@ -0,0 +1 @@
1
+ export { compileDDL, generateDeviceTypes, generateDeviceTypeScript } from '@mica-js/ddl';
@@ -0,0 +1 @@
1
+ export type { BorrowedBytes, ReadonlyBytes, ChannelMessage, Notification, DeviceAdapter, DeviceBatch, DeviceContext, DeviceFactory, InputChannel, InputNotification, TopicBridge, TopicMessage, JitHost, JitOptions, JitTranslate, JitTranslateInput, InstructionHook, Disposable, Event, } from '@mica-js/simulator';
package/dist/device.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export { createDeviceLibrary, createBoard, SimulationError } from '@mica-js/simulator';
2
+ export type { Board, BoardDescription, BoardOptions, DeviceLibrary, DeviceLibraryEntry, } from '@mica-js/simulator';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createDeviceLibrary, createBoard, SimulationError } from '@mica-js/simulator';
@@ -0,0 +1,5 @@
1
+ export { deviceMetadataSchema, widgetMetadataSchema } from './schema.js';
2
+ export type { MetadataJsonSchema } from './schema.js';
3
+ export { MetadataValidationError, validateMetadata } from './validate.js';
4
+ export type { MetadataIssue } from './validate.js';
5
+ export type { ArcGraphic, BooleanProperty, BytesProperty, CheckboxEditor, CircleGraphic, DesignatorAnnotationMetadata, DeviceMetadata, EnumProperty, FileEditor, Graphic, GraphicFill, LineGraphic, Metadata, MetadataKind, NumberEditor, NumberProperty, PinDirection, PinLabelMetadata, PinMetadata, Point, PolylineGraphic, PortDelivery, PortDirection, PortMetadata, PortPayloadType, PropertyEditor, PropertyMetadata, PropertyType, RectGraphic, SelectEditor, StringProperty, SymbolAnnotationsMetadata, SymbolBounds, SymbolMetadata, SymbolPinMetadata, TextAlign, TextareaEditor, TextEditor, TextGraphic, WidgetMetadata, } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { deviceMetadataSchema, widgetMetadataSchema } from './schema.js';
2
+ export { MetadataValidationError, validateMetadata } from './validate.js';
@@ -0,0 +1,10 @@
1
+ import type { MetadataKind } from './types.js';
2
+ export type { MetadataKind };
3
+ /** A JSON Schema node. The exported schemas are plain data and can be serialized as-is. */
4
+ export type MetadataJsonSchema = {
5
+ readonly [keyword: string]: unknown;
6
+ };
7
+ /** Metadata of a single device, independent of any package manifest or DDL source. */
8
+ export declare const deviceMetadataSchema: MetadataJsonSchema;
9
+ /** Metadata of a single widget, independent of any package manifest or DDL source. */
10
+ export declare const widgetMetadataSchema: MetadataJsonSchema;