@medeo-plugin/sdk 0.7.0 → 0.8.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.
@@ -0,0 +1,161 @@
1
+ import { METHODS } from "./registry.js";
2
+ const installations = [];
3
+ function installAPI(api) {
4
+ const entry = { api, previous: Object.getOwnPropertyDescriptor(globalThis, 'medeoPlugin'), removed: false };
5
+ Object.defineProperty(globalThis, 'medeoPlugin', { configurable: true, writable: true, value: api });
6
+ installations.push(entry);
7
+ return () => {
8
+ if (entry.removed)
9
+ return;
10
+ entry.removed = true;
11
+ while (installations.at(-1)?.removed) {
12
+ const top = installations.pop();
13
+ // Never overwrite a bridge installed by unrelated test code.
14
+ if (Object.getOwnPropertyDescriptor(globalThis, 'medeoPlugin')?.value !== top.api)
15
+ continue;
16
+ if (top.previous)
17
+ Object.defineProperty(globalThis, 'medeoPlugin', top.previous);
18
+ else
19
+ Reflect.deleteProperty(globalThis, 'medeoPlugin');
20
+ }
21
+ };
22
+ }
23
+ /** Offline bridge for real SDK calls. No OS, permissions, persistence or manifest
24
+ * validation is simulated. Channel messages loop back to this host's listeners.
25
+ * Install only within a serial test scope (globals cannot isolate concurrent tests).
26
+ * Every unimplemented capability fails unless supplied in `handlers`.
27
+ */
28
+ export function createFakeHost(options = {}) {
29
+ const clone = (value) => structuredClone(value);
30
+ const storage = new Map(Object.entries(clone(options.storage ?? {})));
31
+ const config = new Map(Object.entries(clone(options.config ?? {})));
32
+ const handlers = new Map(Object.entries(options.handlers ?? {}));
33
+ const commands = new Map();
34
+ const messages = new Set();
35
+ const events = new Set();
36
+ const locales = new Set();
37
+ const restores = new Set();
38
+ let disposed = false;
39
+ const assertActive = () => { if (disposed)
40
+ throw new Error('fake_host_disposed'); };
41
+ const subscribe = (listeners, callback) => {
42
+ assertActive();
43
+ listeners.add(callback);
44
+ return () => { listeners.delete(callback); };
45
+ };
46
+ const emit = (listeners, value) => {
47
+ assertActive();
48
+ for (const listener of [...listeners])
49
+ listener(clone(value));
50
+ };
51
+ const key = (args) => {
52
+ if (typeof args.key !== 'string')
53
+ throw new Error('invalid_key');
54
+ return args.key;
55
+ };
56
+ const builtins = {
57
+ 'storage.get': args => clone(storage.get(key(args)) ?? null),
58
+ 'storage.set': args => { storage.set(key(args), clone(args.value)); return null; },
59
+ 'storage.remove': args => { storage.delete(key(args)); return null; },
60
+ 'config.get': args => {
61
+ if (args.key === undefined)
62
+ return Object.fromEntries(config);
63
+ const name = key(args);
64
+ if (!config.has(name))
65
+ throw new Error(`unknown_configuration: ${name}`);
66
+ return config.get(name);
67
+ },
68
+ 'config.set': args => {
69
+ const name = key(args);
70
+ if (!config.has(name))
71
+ throw new Error(`unknown_configuration: ${name}`);
72
+ config.set(name, clone(args.value));
73
+ emit(events, { type: 'signal', channel: 'config' });
74
+ return null;
75
+ },
76
+ };
77
+ const api = {
78
+ async invoke(method, args = {}) {
79
+ assertActive();
80
+ const handler = handlers.get(method) ?? (Object.hasOwn(builtins, method) ? builtins[method] : undefined);
81
+ if (!handler)
82
+ throw new Error(`${Object.hasOwn(METHODS, method) ? 'unimplemented_method' : 'unknown_method'}: ${method}`);
83
+ return clone(await handler(clone(args)));
84
+ },
85
+ async run(command, args) {
86
+ assertActive();
87
+ const handler = commands.get(command);
88
+ if (!handler)
89
+ throw new Error(`unregistered_command: ${command}`);
90
+ return clone(await handler(clone(args)));
91
+ },
92
+ async post(message) { emit(messages, message); },
93
+ onMessage: callback => subscribe(messages, callback),
94
+ onEvent: callback => subscribe(events, callback),
95
+ onLocaleChanged: callback => subscribe(locales, callback),
96
+ };
97
+ const context = {
98
+ subscriptions: [],
99
+ commands: {
100
+ register(id, handler) {
101
+ assertActive();
102
+ if (commands.has(id))
103
+ throw new Error(`duplicate_command: ${id}`);
104
+ commands.set(id, handler);
105
+ const disposable = { dispose() { if (commands.get(id) === handler)
106
+ commands.delete(id); } };
107
+ context.subscriptions.push(disposable);
108
+ return disposable;
109
+ },
110
+ },
111
+ };
112
+ for (const [id, handler] of Object.entries(options.commands ?? {}))
113
+ context.commands.register(id, handler);
114
+ return {
115
+ api, context,
116
+ /** Explicit installation: importing this module never changes the global. */
117
+ install() {
118
+ assertActive();
119
+ const restore = installAPI(api);
120
+ const off = () => { restore(); restores.delete(off); };
121
+ restores.add(off);
122
+ return off;
123
+ },
124
+ emitEvent: (event) => emit(events, event),
125
+ emitMessage: (message) => emit(messages, message),
126
+ emitLocaleChanged: (state) => emit(locales, state),
127
+ async dispose() {
128
+ if (disposed)
129
+ return;
130
+ disposed = true;
131
+ for (const restore of [...restores].reverse())
132
+ restore();
133
+ messages.clear();
134
+ events.clear();
135
+ locales.clear();
136
+ commands.clear();
137
+ const errors = [];
138
+ for (const subscription of context.subscriptions.splice(0).reverse()) {
139
+ try {
140
+ await subscription.dispose();
141
+ }
142
+ catch (error) {
143
+ errors.push(error);
144
+ }
145
+ }
146
+ if (errors.length)
147
+ throw new AggregateError(errors, 'fake_host_cleanup_failed');
148
+ },
149
+ };
150
+ }
151
+ /** Installs a bridge for a callback and restores it even when the callback throws. */
152
+ export async function withFakeHost(options, run) {
153
+ const host = createFakeHost(options);
154
+ host.install();
155
+ try {
156
+ return await run(host);
157
+ }
158
+ finally {
159
+ await host.dispose();
160
+ }
161
+ }
@@ -0,0 +1,4 @@
1
+ /** Layout only: colors, type and dimensions remain owned by /_medeo/theme.css. */
2
+ export declare const uiStyles = "\n.medeo-ui{font-family:var(--font-family-plain);color:var(--color-surface-on-surface);font-size:var(--type-label-large-size);line-height:var(--type-label-large-leading)}\n.medeo-card{padding:var(--space-s5);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-16);background:var(--color-surface-surface-container-lowest);display:grid;gap:var(--space-s3)}\n.medeo-card h2,.medeo-empty h2{margin:0;font-size:var(--type-title-medium-size);line-height:var(--type-title-medium-leading);font-weight:var(--type-title-medium-weight);letter-spacing:var(--type-title-medium-tracking)}\n.medeo-description{margin:0;color:var(--color-surface-on-surface-variant)}\n.medeo-pill{display:inline-flex;align-items:center;justify-self:start;padding:var(--space-s1) var(--space-s3);border-radius:var(--shape-radius-full);background:var(--medeo-selected);font-size:var(--type-label-medium-size);line-height:var(--type-label-medium-leading);font-weight:var(--type-label-medium-prominent-weight)}\n.medeo-pill[data-tone=error]{color:var(--color-schemes-error);background:var(--medeo-error-wash)}\n.medeo-pill[data-tone=neutral]{background:var(--medeo-hover)}\n.medeo-toggle{display:inline-flex;align-items:center;gap:var(--space-s2);min-height:var(--space-s8);cursor:pointer}\n.medeo-toggle input{width:var(--space-s4);height:var(--space-s4);accent-color:var(--color-schemes-primary)}\n.medeo-toggle:has(input:disabled){opacity:.45;cursor:default}\n.medeo-segmented{display:flex;flex-wrap:wrap;gap:var(--space-s1);padding:var(--space-s1);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-12)}\n.medeo-segmented button{font:inherit;color:inherit;border:0;border-radius:var(--shape-radius-8);background:transparent;min-height:var(--space-s8);padding:var(--space-s2) var(--space-s3);cursor:pointer}\n.medeo-segmented button[aria-checked=true]{background:var(--color-schemes-primary);color:var(--color-schemes-on-primary)}\n.medeo-segmented button:hover:not(:disabled):not([aria-checked=true]){background:var(--medeo-hover)}\n.medeo-segmented button:disabled{opacity:.45;cursor:default}\n.medeo-ui :focus-visible{outline:2px solid var(--color-schemes-primary);outline-offset:2px}\n.medeo-list{list-style:none;margin:0;padding:0;display:grid;gap:var(--space-s2)}\n.medeo-list>li{padding:var(--space-s3);border-bottom:1px solid var(--color-surface-outline-variant)}\n.medeo-empty{display:grid;gap:var(--space-s2);padding:var(--space-s7);text-align:center;border:1px dashed var(--color-surface-outline);border-radius:var(--shape-radius-12)}\n";
3
+ /** Explicit and idempotent; importing this module has no DOM side effects. */
4
+ export declare function installStyles(doc?: Document): void;
@@ -0,0 +1,31 @@
1
+ /** Layout only: colors, type and dimensions remain owned by /_medeo/theme.css. */
2
+ export const uiStyles = `
3
+ .medeo-ui{font-family:var(--font-family-plain);color:var(--color-surface-on-surface);font-size:var(--type-label-large-size);line-height:var(--type-label-large-leading)}
4
+ .medeo-card{padding:var(--space-s5);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-16);background:var(--color-surface-surface-container-lowest);display:grid;gap:var(--space-s3)}
5
+ .medeo-card h2,.medeo-empty h2{margin:0;font-size:var(--type-title-medium-size);line-height:var(--type-title-medium-leading);font-weight:var(--type-title-medium-weight);letter-spacing:var(--type-title-medium-tracking)}
6
+ .medeo-description{margin:0;color:var(--color-surface-on-surface-variant)}
7
+ .medeo-pill{display:inline-flex;align-items:center;justify-self:start;padding:var(--space-s1) var(--space-s3);border-radius:var(--shape-radius-full);background:var(--medeo-selected);font-size:var(--type-label-medium-size);line-height:var(--type-label-medium-leading);font-weight:var(--type-label-medium-prominent-weight)}
8
+ .medeo-pill[data-tone=error]{color:var(--color-schemes-error);background:var(--medeo-error-wash)}
9
+ .medeo-pill[data-tone=neutral]{background:var(--medeo-hover)}
10
+ .medeo-toggle{display:inline-flex;align-items:center;gap:var(--space-s2);min-height:var(--space-s8);cursor:pointer}
11
+ .medeo-toggle input{width:var(--space-s4);height:var(--space-s4);accent-color:var(--color-schemes-primary)}
12
+ .medeo-toggle:has(input:disabled){opacity:.45;cursor:default}
13
+ .medeo-segmented{display:flex;flex-wrap:wrap;gap:var(--space-s1);padding:var(--space-s1);border:1px solid var(--color-surface-outline-variant);border-radius:var(--shape-radius-12)}
14
+ .medeo-segmented button{font:inherit;color:inherit;border:0;border-radius:var(--shape-radius-8);background:transparent;min-height:var(--space-s8);padding:var(--space-s2) var(--space-s3);cursor:pointer}
15
+ .medeo-segmented button[aria-checked=true]{background:var(--color-schemes-primary);color:var(--color-schemes-on-primary)}
16
+ .medeo-segmented button:hover:not(:disabled):not([aria-checked=true]){background:var(--medeo-hover)}
17
+ .medeo-segmented button:disabled{opacity:.45;cursor:default}
18
+ .medeo-ui :focus-visible{outline:2px solid var(--color-schemes-primary);outline-offset:2px}
19
+ .medeo-list{list-style:none;margin:0;padding:0;display:grid;gap:var(--space-s2)}
20
+ .medeo-list>li{padding:var(--space-s3);border-bottom:1px solid var(--color-surface-outline-variant)}
21
+ .medeo-empty{display:grid;gap:var(--space-s2);padding:var(--space-s7);text-align:center;border:1px dashed var(--color-surface-outline);border-radius:var(--shape-radius-12)}
22
+ `;
23
+ /** Explicit and idempotent; importing this module has no DOM side effects. */
24
+ export function installStyles(doc = document) {
25
+ if (doc.querySelector('style[data-medeo-ui]'))
26
+ return;
27
+ const style = doc.createElement('style');
28
+ style.dataset.medeoUi = '';
29
+ style.textContent = uiStyles;
30
+ doc.head.append(style);
31
+ }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ export { installStyles, uiStyles } from './ui/styles.js';
2
+ export interface Identified {
3
+ testid: string;
4
+ }
5
+ export interface CardOptions extends Identified {
6
+ title: string;
7
+ description?: string;
8
+ }
9
+ export interface PillOptions extends Identified {
10
+ label: string;
11
+ tone?: 'neutral' | 'info' | 'error';
12
+ }
13
+ export interface ToggleOptions extends Identified {
14
+ label: string;
15
+ checked: boolean;
16
+ disabled?: boolean;
17
+ onChange: (checked: boolean) => void;
18
+ }
19
+ export interface Segment<T extends string = string> {
20
+ value: T;
21
+ label: string;
22
+ disabled?: boolean;
23
+ }
24
+ export interface SegmentedOptions<T extends string = string> extends Identified {
25
+ label: string;
26
+ options: readonly Segment<T>[];
27
+ value: T;
28
+ disabled?: boolean;
29
+ onChange: (value: T) => void;
30
+ }
31
+ export interface ListOptions extends Identified {
32
+ label: string;
33
+ }
34
+ export type Child = Node | string | number | null | undefined | false;
35
+ export declare function card(options: CardOptions, ...children: Child[]): HTMLElement;
36
+ export declare function pill(options: PillOptions): HTMLSpanElement;
37
+ /** Native checkbox keyboard behavior (Space), with switch semantics. */
38
+ export declare function toggle(options: ToggleOptions): HTMLLabelElement & {
39
+ input: HTMLInputElement;
40
+ };
41
+ /** Shared radio keyboard navigation; disabled choices are skipped, edges wrap. */
42
+ export declare function segmentTarget<T extends string>(options: readonly Segment<T>[], index: number, key: string, rtl?: boolean): number | undefined;
43
+ export declare function segmentTabIndex<T extends string>(options: readonly Segment<T>[], value: T, index: number): number;
44
+ export declare function segmented<T extends string>(options: SegmentedOptions<T>): HTMLDivElement & {
45
+ setValue(value: T): void;
46
+ setDisabled(disabled: boolean): void;
47
+ };
48
+ /** Each child becomes one semantic list item; caller owns empty-state wording. */
49
+ export declare function list(options: ListOptions, ...items: Child[]): HTMLUListElement;
50
+ export declare function emptyState(options: CardOptions): HTMLElement;
package/dist/ui.js ADDED
@@ -0,0 +1,115 @@
1
+ export { installStyles, uiStyles } from './ui/styles.js';
2
+ function node(tag, className, testid, text) {
3
+ const el = document.createElement(tag);
4
+ el.className = className;
5
+ if (testid !== undefined)
6
+ el.dataset.testid = testid;
7
+ if (text !== undefined)
8
+ el.textContent = text;
9
+ return el;
10
+ }
11
+ function append(el, children) {
12
+ for (const child of children)
13
+ if (child !== null && child !== undefined && child !== false)
14
+ el.append(typeof child === 'number' ? String(child) : child);
15
+ }
16
+ export function card(options, ...children) {
17
+ const root = node('section', 'medeo-ui medeo-card', options.testid);
18
+ root.setAttribute('aria-label', options.title);
19
+ root.append(node('h2', '', `${options.testid}.title`, options.title));
20
+ if (options.description !== undefined)
21
+ root.append(node('p', 'medeo-description', undefined, options.description));
22
+ append(root, children);
23
+ return root;
24
+ }
25
+ export function pill(options) {
26
+ const root = node('span', 'medeo-ui medeo-pill', options.testid, options.label);
27
+ root.dataset.tone = options.tone ?? 'info';
28
+ return root;
29
+ }
30
+ /** Native checkbox keyboard behavior (Space), with switch semantics. */
31
+ export function toggle(options) {
32
+ const root = node('label', 'medeo-ui medeo-toggle', `${options.testid}.root`);
33
+ const input = node('input', '', options.testid);
34
+ input.type = 'checkbox';
35
+ input.setAttribute('role', 'switch');
36
+ input.checked = options.checked;
37
+ input.disabled = options.disabled ?? false;
38
+ input.addEventListener('change', () => options.onChange(input.checked));
39
+ root.append(input, node('span', '', undefined, options.label));
40
+ return Object.assign(root, { input });
41
+ }
42
+ /** Shared radio keyboard navigation; disabled choices are skipped, edges wrap. */
43
+ export function segmentTarget(options, index, key, rtl = false) {
44
+ const enabled = options.map((o, i) => o.disabled ? -1 : i).filter(i => i >= 0);
45
+ if (!enabled.length)
46
+ return undefined;
47
+ if (key === 'Home')
48
+ return enabled[0];
49
+ if (key === 'End')
50
+ return enabled[enabled.length - 1];
51
+ let direction = key === 'ArrowDown' ? 1 : key === 'ArrowUp' ? -1 : key === 'ArrowRight' ? (rtl ? -1 : 1) : key === 'ArrowLeft' ? (rtl ? 1 : -1) : 0;
52
+ if (!direction)
53
+ return undefined;
54
+ return enabled[(enabled.indexOf(index) + direction + enabled.length) % enabled.length];
55
+ }
56
+ export function segmentTabIndex(options, value, index) {
57
+ let active = options.findIndex(o => o.value === value && !o.disabled);
58
+ if (active < 0)
59
+ active = options.findIndex(o => !o.disabled);
60
+ return index === active ? 0 : -1;
61
+ }
62
+ export function segmented(options) {
63
+ const root = node('div', 'medeo-ui medeo-segmented', options.testid);
64
+ root.setAttribute('role', 'radiogroup');
65
+ root.setAttribute('aria-label', options.label);
66
+ let value = options.value, disabled = options.disabled ?? false;
67
+ const buttons = options.options.map((option, index) => {
68
+ const button = node('button', '', `${options.testid}.${index}`, option.label);
69
+ button.type = 'button';
70
+ button.setAttribute('role', 'radio');
71
+ button.addEventListener('click', () => {
72
+ if (disabled || option.disabled)
73
+ return;
74
+ value = option.value;
75
+ render();
76
+ options.onChange(value);
77
+ });
78
+ button.addEventListener('keydown', event => {
79
+ if (disabled)
80
+ return;
81
+ const target = segmentTarget(options.options, index, event.key, getComputedStyle(root).direction === 'rtl');
82
+ if (target === undefined)
83
+ return;
84
+ event.preventDefault();
85
+ buttons[target].focus();
86
+ buttons[target].click();
87
+ });
88
+ return button;
89
+ });
90
+ function render() {
91
+ buttons.forEach((button, index) => {
92
+ button.disabled = disabled || !!options.options[index].disabled;
93
+ button.tabIndex = disabled ? -1 : segmentTabIndex(options.options, value, index);
94
+ button.setAttribute('aria-checked', String(options.options[index].value === value));
95
+ });
96
+ }
97
+ root.append(...buttons);
98
+ render();
99
+ return Object.assign(root, { setValue(next) { value = next; render(); }, setDisabled(next) { disabled = next; render(); } });
100
+ }
101
+ /** Each child becomes one semantic list item; caller owns empty-state wording. */
102
+ export function list(options, ...items) {
103
+ const root = node('ul', 'medeo-ui medeo-list', options.testid);
104
+ root.setAttribute('aria-label', options.label);
105
+ items.forEach((item, index) => { const li = node('li', '', `${options.testid}.${index}`); append(li, [item]); root.append(li); });
106
+ return root;
107
+ }
108
+ export function emptyState(options) {
109
+ const root = node('section', 'medeo-ui medeo-empty', options.testid);
110
+ root.setAttribute('aria-label', options.title);
111
+ root.append(node('h2', '', undefined, options.title));
112
+ if (options.description !== undefined)
113
+ root.append(node('p', 'medeo-description', undefined, options.description));
114
+ return root;
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medeo-plugin/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "TypeScript SDK for Medeo desktop plugins: one import for the sandboxed worker, its windows and Node plugins.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,26 @@
22
22
  "import": "./dist/index.js",
23
23
  "default": "./dist/index.js"
24
24
  },
25
+ "./rpc": {
26
+ "types": "./dist/rpc.d.ts",
27
+ "import": "./dist/rpc.js",
28
+ "default": "./dist/rpc.js"
29
+ },
30
+ "./testing": {
31
+ "types": "./dist/testing.d.ts",
32
+ "import": "./dist/testing.js",
33
+ "default": "./dist/testing.js"
34
+ },
35
+ "./ui": {
36
+ "types": "./dist/ui.d.ts",
37
+ "import": "./dist/ui.js",
38
+ "default": "./dist/ui.js"
39
+ },
40
+ "./ui/react": {
41
+ "types": "./dist/react.d.ts",
42
+ "import": "./dist/react.js",
43
+ "default": "./dist/react.js"
44
+ },
25
45
  "./package.json": "./package.json"
26
46
  },
27
47
  "types": "./dist/index.d.ts",
@@ -34,6 +54,14 @@
34
54
  "build": "tsc -p tsconfig.build.json",
35
55
  "prepack": "npm run build"
36
56
  },
57
+ "peerDependencies": {
58
+ "react": "^18.0.0 || ^19.0.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "react": {
62
+ "optional": true
63
+ }
64
+ },
37
65
  "engines": {
38
66
  "node": ">=22"
39
67
  }
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ export interface PluginAPI {
13
13
  post(message:unknown):Promise<void>;
14
14
  onMessage(callback:(message:unknown)=>void):()=>void;
15
15
  invoke(method: MethodName, args?: Record<string, unknown>): Promise<unknown>;
16
- run(command: string): Promise<unknown>;
16
+ run(command: string, args?: JsonValue): Promise<unknown>;
17
17
  }
18
18
  declare global { var medeoPlugin: PluginAPI; interface Window { medeoPlugin: PluginAPI } }
19
19
 
@@ -55,14 +55,14 @@ export const storage = {
55
55
  set: (key: string, value: unknown) => globalThis.medeoPlugin.invoke('storage.set', { key, value }),
56
56
  };
57
57
  export const creation = { open: () => globalThis.medeoPlugin.invoke('creation.open') };
58
- export const commands = { run: (command: string) => globalThis.medeoPlugin.run(command) };
58
+ export const commands = { run: <T = unknown>(command: string, args?: JsonValue) => globalThis.medeoPlugin.run(command, args) as Promise<T> };
59
59
  export interface Disposable { dispose(): unknown }
60
60
  /** Passed to `activate` (VS Code style), in both runtimes. */
61
61
  export interface PluginContext {
62
62
  /** Disposed, in reverse order, when the plugin stops. */
63
63
  subscriptions: Disposable[];
64
64
  /** Register a handler for each command the manifest declares; all must be registered when `activate` returns. */
65
- commands: { register(id: string, handler: () => unknown | Promise<unknown>): Disposable };
65
+ commands: { register(id: string, handler: (args?: JsonValue) => unknown | Promise<unknown>): Disposable };
66
66
  /** `runtime: "node"` only: the installed, verified package directory (read-only). */
67
67
  extensionPath?: string;
68
68
  /** `runtime: "node"` only: a private writable directory that survives updates. */
@@ -229,10 +229,11 @@ export const inputMethod={
229
229
  * Closing UI preserves jobs; disable/revoke cancels. Mutations are never replayed.
230
230
  * Release completed results to return the per-instance memory quota.
231
231
  */
232
- export interface NetworkRequest {url:string;method?:'GET'|'HEAD'|'POST'|'PUT'|'PATCH'|'DELETE'|'OPTIONS';headers?:Record<string,string>;body?:string;body_base64?:string;timeout_ms?:number}
233
- export interface NetworkJob {id:string;status:'running'|'completed'|'failed'|'cancelled';received?:number;http_status?:number|null;headers?:[string,string][];error?:string|null}
232
+ export interface NetworkRequest {url:string;method?:'GET'|'HEAD'|'POST'|'PUT'|'PATCH'|'DELETE'|'OPTIONS';headers?:Record<string,string>;body?:string;body_base64?:string;timeout_ms?:number;stream?:boolean}
233
+ export interface NetworkJob {id:string;status:'running'|'completed'|'failed'|'cancelled';received?:number;http_status?:number|null;headers?:[string,string][];error?:string|null;stream?:boolean}
234
234
  export interface NetworkChunk {data_base64:string;next_offset:number;eof:boolean}
235
235
  export interface NetworkOrigin {origin:string;declared:boolean;allowed:boolean}
236
+ export interface NetworkServerEvent {data:string;event:string;id:string;retry?:number}
236
237
  export const network = {
237
238
  origins:()=>capabilities.invoke<NetworkOrigin[]>('network.origins'),
238
239
  requestOrigin:(origin:string,reason:string)=>capabilities.invoke<{origin:string;granted:boolean}>('network.request_origin',{origin,reason}),
@@ -244,6 +245,80 @@ export const network = {
244
245
  release:(id:string)=>operations.release(id) as Promise<{released:boolean}>,
245
246
  /** Resolves once the response has fully arrived; the job stays readable until released. */
246
247
  async settled(id:string):Promise<NetworkJob>{for(;;){const change=nextChange(id,2000);const state=await network.state(id);if(state.status!=='running'){change.off();return state}await change.promise;change.off()}},
248
+ /** Single consumer for a stream:true job. Break/throw/abort cancels and releases it. */
249
+ async *bytes(id:string,signal?:AbortSignal):AsyncGenerator<Uint8Array>{
250
+ let offset=0;
251
+ let abortCleanup:Promise<void>|undefined;
252
+ const abort=()=>{abortCleanup=network.dispose(id);void abortCleanup.catch(()=>{})};
253
+ signal?.addEventListener('abort',abort,{once:true});
254
+ try {
255
+ for(;;){
256
+ let wake!:()=>void;
257
+ const changed=new Promise<void>(resolve=>{wake=resolve});
258
+ const off=events.on(event=>{if(event.type==='operation'&&event.id===id)wake()});
259
+ signal?.addEventListener('abort',wake,{once:true});
260
+ try {
261
+ if(signal?.aborted)throw new Error('network_cancelled');
262
+ const state=await network.state(id);
263
+ if(state.status==='failed'||state.status==='cancelled')throw new Error(state.error??state.status);
264
+ const part=await network.read(id,offset);
265
+ offset=part.next_offset;
266
+ if(signal?.aborted)throw new Error('network_cancelled');
267
+ if(part.data_base64)yield Uint8Array.from(atob(part.data_base64),c=>c.charCodeAt(0));
268
+ if(part.eof)return;
269
+ if(!part.data_base64)await changed;
270
+ } finally {off();signal?.removeEventListener('abort',wake)}
271
+ }
272
+ } finally {
273
+ signal?.removeEventListener('abort',abort);
274
+ await (abortCleanup??network.dispose(id));
275
+ }
276
+ },
277
+ /** Incremental UTF-8 decoder, including code points split across HTTP chunks. */
278
+ async *textStream(id:string,signal?:AbortSignal):AsyncGenerator<string>{
279
+ const decoder=new TextDecoder();
280
+ for await(const bytes of network.bytes(id,signal)){
281
+ const text=decoder.decode(bytes,{stream:true});if(text)yield text;
282
+ }
283
+ const tail=decoder.decode();if(tail)yield tail;
284
+ },
285
+ /** SSE framing only: no automatic reconnect/retry or Last-Event-ID requests. */
286
+ async *sse(id:string,signal?:AbortSignal):AsyncGenerator<NetworkServerEvent>{
287
+ let pending='',data:string[]=[],event='',lastId='',retry:number|undefined,size=0,skipLf=false;
288
+ for await(const text of network.textStream(id,signal)){
289
+ pending+=skipLf&&text.startsWith('\n')?text.slice(1):text;
290
+ skipLf=false;
291
+ for(;;){
292
+ const at=pending.search(/[\r\n]/);
293
+ if(at<0)break;
294
+ skipLf=pending[at]==='\r'&&at===pending.length-1;
295
+ const line=pending.slice(0,at);
296
+ pending=pending.slice(at+(pending[at]==='\r'&&pending[at+1]==='\n'?2:1));
297
+ size+=line.length;
298
+ if(size>262144)throw new Error('network_sse_event_too_large');
299
+ if(!line){
300
+ if(data.length)yield {data:data.join('\n'),event:event||'message',id:lastId,...(retry===undefined?{}:{retry})};
301
+ data=[];event='';size=0;continue;
302
+ }
303
+ if(line.startsWith(':'))continue;
304
+ const colon=line.indexOf(':');
305
+ const field=colon<0?line:line.slice(0,colon);
306
+ let value=colon<0?'':line.slice(colon+1);if(value.startsWith(' '))value=value.slice(1);
307
+ if(field==='data')data.push(value);
308
+ else if(field==='event')event=value;
309
+ else if(field==='id'&&!value.includes('\0'))lastId=value;
310
+ else if(field==='retry'&&/^\d+$/.test(value)&&Number.isSafeInteger(Number(value)))retry=Number(value);
311
+ }
312
+ if(size+pending.length>262144)throw new Error('network_sse_event_too_large');
313
+ }
314
+ // SSE requires a blank line to dispatch; incomplete final events are discarded.
315
+ },
316
+ /** Explicitly dispose a stream that was never iterated (or has another owner). */
317
+ async dispose(id:string):Promise<void>{
318
+ const ignoreMissing=(error:unknown)=>{if(!/^(operation_missing|network_job_missing|permission_denied)$/.test(error instanceof Error?error.message:String(error)))throw error};
319
+ try{await network.cancel(id)}catch(error){ignoreMissing(error)}
320
+ try{await network.release(id)}catch(error){ignoreMissing(error)}
321
+ },
247
322
  async text(id:string):Promise<string>{
248
323
  const decoder=new TextDecoder();let offset=0,text='';
249
324
  for(;;){const part=await network.read(id,offset);const bytes=Uint8Array.from(atob(part.data_base64),c=>c.charCodeAt(0));text+=decoder.decode(bytes,{stream:!part.eof});if(part.eof)return text;offset=part.next_offset;}
package/src/react.ts ADDED
@@ -0,0 +1,50 @@
1
+ /** React is only imported by the optional @medeo-plugin/sdk/ui/react entry. */
2
+ import { createElement as h, useEffect, type ReactNode, type KeyboardEvent } from 'react';
3
+ import { segmentTarget, segmentTabIndex, type CardOptions, type PillOptions, type ToggleOptions, type SegmentedOptions, type ListOptions } from './ui.js';
4
+ import { installStyles } from './ui/styles.js';
5
+ export { uiStyles, installStyles } from './ui/styles.js';
6
+ export type { CardOptions, PillOptions, ToggleOptions, SegmentedOptions, ListOptions, Segment } from './ui.js';
7
+
8
+ /** Call once near your root. SSR-safe; style installation is idempotent. */
9
+ export function useUIStyles(): void { useEffect(() => { installStyles(); }, []); }
10
+ export function Card({ title, description, testid, children }: CardOptions & { children?: ReactNode }) {
11
+ return h('section', { className: 'medeo-ui medeo-card', 'data-testid': testid, 'aria-label': title },
12
+ h('h2', { 'data-testid': `${testid}.title` }, title),
13
+ description !== undefined && h('p', { className: 'medeo-description' }, description), children);
14
+ }
15
+ export function Pill({ label, tone = 'info', testid }: PillOptions) {
16
+ return h('span', { className: 'medeo-ui medeo-pill', 'data-testid': testid, 'data-tone': tone }, label);
17
+ }
18
+ /** Controlled: update checked in onChange. Native input supplies name and Space handling. */
19
+ export function Toggle({ label, checked, disabled, onChange, testid }: ToggleOptions) {
20
+ return h('label', { className: 'medeo-ui medeo-toggle', 'data-testid': `${testid}.root` },
21
+ h('input', { type: 'checkbox', role: 'switch', checked, disabled, 'data-testid': testid, onChange: (event: React.ChangeEvent<HTMLInputElement>) => onChange(event.currentTarget.checked) }),
22
+ h('span', null, label));
23
+ }
24
+ /** Controlled radio group with a single tab stop and arrow/Home/End navigation. */
25
+ export function Segmented<T extends string>({ label, value, options, disabled, onChange, testid }: SegmentedOptions<T>) {
26
+ return h('div', { className: 'medeo-ui medeo-segmented', role: 'radiogroup', 'aria-label': label, 'data-testid': testid },
27
+ options.map((option, index) => h('button', {
28
+ key: option.value, type: 'button', role: 'radio', 'aria-checked': option.value === value,
29
+ 'data-testid': `${testid}.${index}`, disabled: disabled || option.disabled,
30
+ tabIndex: disabled ? -1 : segmentTabIndex(options, value, index),
31
+ onClick: () => { if (!disabled && !option.disabled) onChange(option.value); },
32
+ onKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => {
33
+ if (disabled) return;
34
+ const group = event.currentTarget.parentElement!;
35
+ const target = segmentTarget(options, index, event.key, getComputedStyle(group).direction === 'rtl');
36
+ if (target === undefined) return;
37
+ event.preventDefault();
38
+ (group.children[target] as HTMLButtonElement).focus();
39
+ onChange(options[target].value);
40
+ },
41
+ }, option.label)));
42
+ }
43
+ export function List({ label, testid, items }: ListOptions & { items: readonly { key: string; content: ReactNode }[] }) {
44
+ return h('ul', { className: 'medeo-ui medeo-list', 'data-testid': testid, 'aria-label': label },
45
+ items.map(item => h('li', { key: item.key, 'data-testid': `${testid}.${item.key}` }, item.content)));
46
+ }
47
+ export function EmptyState({ title, description, testid }: CardOptions) {
48
+ return h('section', { className: 'medeo-ui medeo-empty', 'data-testid': testid, 'aria-label': title },
49
+ h('h2', null, title), description !== undefined && h('p', { className: 'medeo-description' }, description));
50
+ }