@flow-like/widget-sdk 0.1.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/README.md ADDED
@@ -0,0 +1,258 @@
1
+ # @flow-like/widget-sdk
2
+
3
+ SDK for Flow-Like micro widgets: typed contracts, the `flw/1` host bridge, and
4
+ framework adapters. Widgets run inside an opaque-origin sandboxed iframe and
5
+ talk to the host exclusively via `postMessage`; this SDK implements the widget
6
+ side of that protocol, plus a zero-config standalone mode for plain `vite dev`.
7
+
8
+ ## Define a widget
9
+
10
+ Authors write ordinary TypeScript interfaces — `@flow-like/widget-bundler`
11
+ derives `contract.json` from the type arguments and injects it into the built
12
+ HTML as `globalThis.__FLW_CONTRACT__`.
13
+
14
+ ```ts
15
+ // widget.config.ts
16
+ import { defineWidget } from "@flow-like/widget-sdk";
17
+
18
+ interface SalesRow {
19
+ x: string;
20
+ y: number;
21
+ }
22
+
23
+ interface Inputs {
24
+ /** Chart headline @default "Sales" */
25
+ title: string;
26
+ /** @default "bar" */
27
+ variant: "bar" | "line";
28
+ /** @minimum 1 @maximum 500 @default 50 */
29
+ limit: number;
30
+ /** @default [] */
31
+ rows: SalesRow[];
32
+ }
33
+
34
+ interface Events {
35
+ pointSelected: SalesRow;
36
+ refreshRequested: void;
37
+ }
38
+
39
+ interface Queries {
40
+ getSelection: { args: void; returns: { rows: SalesRow[] } };
41
+ getValue: { args: void; returns: string };
42
+ }
43
+
44
+ export default defineWidget<Inputs, Events, Queries>({
45
+ id: "sales-chart",
46
+ name: "Sales Chart",
47
+ description: "Interactive bar/line chart",
48
+ sizing: { defaultHeight: 320, resizable: true },
49
+ dev: {
50
+ fixtures: {
51
+ empty: { rows: [] },
52
+ loaded: { title: "Q3 Sales", rows: [{ x: "Q1", y: 12 }] },
53
+ },
54
+ },
55
+ });
56
+ ```
57
+
58
+ ## Geometry contracts
59
+
60
+ Use the geometry types exported by `@flow-like/widget-sdk` when a widget
61
+ exchanges geometry with a workflow. They describe two-dimensional WGS 84
62
+ GeoJSON geometry objects. Every position uses `[longitude, latitude]` order.
63
+
64
+ ```ts
65
+ import {
66
+ defineWidget,
67
+ type GeoGeometry,
68
+ type GeoLineString,
69
+ type GeoPoint,
70
+ type GeoPolygon,
71
+ } from "@flow-like/widget-sdk";
72
+
73
+ interface Inputs {
74
+ focus?: GeoPoint;
75
+ /** @default [] */
76
+ routes: GeoLineString[];
77
+ /** @uniqueItems true @default [] */
78
+ waypoints: GeoPoint[];
79
+ regionsById?: Record<string, GeoPolygon>;
80
+ }
81
+
82
+ interface Events {
83
+ pointSelected: GeoPoint;
84
+ }
85
+
86
+ interface Queries {
87
+ contains: {
88
+ args: { point: GeoPoint; area: GeoPolygon };
89
+ returns: boolean;
90
+ };
91
+ getGeometry: { args: void; returns: GeoGeometry };
92
+ }
93
+
94
+ export default defineWidget<Inputs, Events, Queries>({
95
+ id: "route-map",
96
+ name: "Route Map",
97
+ description: "Routes and selectable map points",
98
+ });
99
+ ```
100
+
101
+ `GeoPoint`, `GeoLineString`, `GeoPolygon`, `GeoMultiPoint`,
102
+ `GeoMultiLineString`, `GeoMultiPolygon`, `GeoGeometryCollection`, and
103
+ `GeoGeometry` carry the schema metadata that creates native Geometry pins.
104
+ `GeoPosition` is the `[longitude, latitude]` tuple used by their coordinates.
105
+ Arrays retain array shape, `@uniqueItems true` makes a set-shaped pin, and
106
+ `Record<string, GeoPoint>` makes a map-shaped pin.
107
+
108
+ For a compatible type declared in the widget, annotate the scalar type or
109
+ scalar property with `@geometry Point`, `LineString`, `Polygon`, `MultiPoint`,
110
+ `MultiLineString`, `MultiPolygon`, `GeometryCollection`, or `Any`. Container
111
+ properties get their geometry metadata from the annotated element type.
112
+
113
+ `validateSchema` and `validateInputValue` enforce the geometry profile. The
114
+ hosted bridge uses those checks for incoming `props:update` values and emitted
115
+ event payloads. Use GeoJSON geometry objects directly. GeoJSON Feature and
116
+ FeatureCollection wrappers are outside this contract. Geometry values cannot
117
+ be `null`; an explicit nullable union remains JSON-shaped rather than
118
+ producing a native Geometry pin.
119
+
120
+ ## LLM contracts
121
+
122
+ Use `LlmHistory`, `LlmResponse` and `LlmResponseChunk` when a widget exchanges
123
+ chat history or model output with model and agent nodes. They follow the
124
+ serialization of Flow-Like's `History`, `Response` and `ResponseChunk`.
125
+
126
+ ```ts
127
+ import {
128
+ defineWidget,
129
+ type LlmHistory,
130
+ type LlmResponse,
131
+ type LlmResponseChunk,
132
+ } from "@flow-like/widget-sdk";
133
+
134
+ interface Events {
135
+ asked: { requestId: string; history: LlmHistory };
136
+ }
137
+
138
+ interface Queries {
139
+ /** @mutation */
140
+ pushChunk: { args: { requestId: string; chunk: LlmResponseChunk }; returns: void };
141
+ /** @mutation */
142
+ pushResponse: { args: { requestId: string; response: LlmResponse }; returns: void };
143
+ }
144
+
145
+ export default defineWidget<{}, Events, Queries>({ id: "chat", name: "Chat" });
146
+ ```
147
+
148
+ The bundler writes these types into the contract as
149
+ `{"type":"object","x-flow-like-type":"llm","x-llm":"Response"}`. A Query
150
+ Widget or Update Widget Inputs pin for that marker carries exactly the schema
151
+ of the native Rust type, so outputs of model and agent nodes connect directly.
152
+ Query Widget enforces that schema on its argument and result pins. A widget
153
+ action payload is a single struct: read a `history` field with **Get Field**
154
+ and connect it to a History input.
155
+
156
+ `validateSchema` checks marked values against the native schemas in
157
+ `src/llm-schemas.ts`, which are copies of `packages/schema/llm`. After
158
+ `cargo run -p schema-gen` changes those files, copy them again; a test fails
159
+ until they match. Annotate your own object type with `@llm History`,
160
+ `Response` or `ResponseChunk` only if it serializes exactly like that type.
161
+ Like geometry, a nullable union stays JSON-shaped instead of producing a
162
+ native pin.
163
+
164
+ ## Mount (hosted)
165
+
166
+ `mountFlowWidget` registers the message listener, performs the `flw/1`
167
+ handshake (`hello` → `init` → `ready`), applies the host theme as CSS custom
168
+ properties (and a `dark` class) on `document.documentElement`, and keeps the
169
+ nanostores in sync with `props:update` / `theme:change`. Auto-height is
170
+ reported via a coalesced `resize` message unless the contract sets
171
+ `sizing.resizable: false`.
172
+
173
+ ```ts
174
+ import { mountFlowWidget } from "@flow-like/widget-sdk";
175
+ import widget from "./widget.config";
176
+
177
+ const bridge = mountFlowWidget(widget);
178
+
179
+ bridge.$props.subscribe((props) => render(props));
180
+ bridge.emit("pointSelected", { x: "Q1", y: 12 });
181
+ bridge.onQuery("getSelection", () => ({ rows: currentSelection() }));
182
+ bridge.setValues({ value: currentSelection() });
183
+ ```
184
+
185
+ ## Standalone mode
186
+
187
+ When no host answers within 300 ms (or the widget is opened top-level, e.g.
188
+ plain `vite dev`), the bridge boots standalone:
189
+
190
+ - `$props` is filled from the contract's input defaults,
191
+ - the bundled Flow-Like theme tokens are applied, following
192
+ `prefers-color-scheme` live,
193
+ - `emit`/`setValues` log structured events to the console,
194
+ - queries are invokable from devtools via `window.__flw.query(name, args)`,
195
+ - a small "standalone" badge marks the mode.
196
+
197
+ ## React
198
+
199
+ ```tsx
200
+ import { useWidgetProps, useWidgetTheme } from "@flow-like/widget-sdk/react";
201
+ import { bridge } from "./main";
202
+
203
+ export function App() {
204
+ const props = useWidgetProps(bridge);
205
+ const theme = useWidgetTheme(bridge);
206
+ return <h1 data-mode={theme.mode}>{props.title}</h1>;
207
+ }
208
+ ```
209
+
210
+ Other frameworks use the official `@nanostores/*` bindings on `bridge.$props`
211
+ / `bridge.$theme` / `bridge.$mode` directly (Svelte needs no adapter);
212
+ `@flow-like/widget-sdk/vanilla` re-exports the core for subscription-based
213
+ usage.
214
+
215
+ ## Validation
216
+
217
+ `validateSchema` is a dependency-free JSON Schema subset validator (the repo
218
+ pins `ajv` too old to use here). Supported keywords: `type` (incl. `integer`
219
+ and type arrays), `enum`, `const`, `properties` / `required` /
220
+ `additionalProperties`, `items`, numeric bounds, string and array length,
221
+ `pattern`, `anyOf` / `oneOf` / `allOf`, and the Flow-Like geometry and LLM
222
+ markers.
223
+ Schemas must be pre-inlined by the bundler. `$ref` cannot be resolved at
224
+ runtime and is treated as valid.
225
+
226
+ ## Host media and microphone
227
+
228
+ Widgets can declare `capabilities` in `defineWidget`: `workers`, `wasm`,
229
+ `media`, `microphone`, and `downloads`. Each is opt-in. Workers, media, and
230
+ WebAssembly access stay restricted to bundled assets and local Blob URLs. The
231
+ iframe retains its opaque origin. Downloads add only `allow-downloads` to its
232
+ sandbox policy.
233
+
234
+ Widgets cannot start workflows directly. To request bytes owned by a workflow,
235
+ emit a declared contract event and let a page-defined action handle it. That
236
+ workflow returns the bytes with Query Widget on a declared contract query.
237
+ Declare that query `@mutation` so each call needs a live acknowledgement.
238
+
239
+ `bridge.captureAudio({ maxDurationMs, signal })` records through the host and
240
+ returns audio bytes and a MIME type. `bridge.stopAudioCapture()` finishes early.
241
+ A host-owned stop button stays visible during capture. The recording lasts at
242
+ most 60 seconds and ends on teardown. Send its result to an appropriate workflow
243
+ audio node; this API does not create a WebRTC session.
244
+
245
+
246
+ For public radio streams, expose `publicMediaGrants: [{ id, url }]` alongside the
247
+ `media` capability. A source node must resolve and vet these credential-free
248
+ HTTPS broadcaster URLs. The host strips the grants before delivering props and
249
+ only announces their IDs in `$capabilities.mediaIds`. `playMedia(id)` returns a
250
+ promise; `pauseMedia()` and `stopMedia()` control the host player. `$media` reports
251
+ its current ID and state. The host always shows Play/Pause/Stop controls, including
252
+ a Play button when browser autoplay policy requires a fresh user gesture.
253
+
254
+ The host rejects literal private destinations, localhost, URL userinfo, and
255
+ credential query fields. Broadcaster DNS and redirects must also be vetted by
256
+ the source workflow. Native HTML audio uses anonymous CORS; MP3/AAC broadcasts
257
+ are supported when the broadcaster permits it. Widget teardown and grant
258
+ revocation stop playback.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@flow-like/widget-sdk",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Flow-Like micro widget SDK: typed contracts, flw/1 host bridge, and framework adapters.",
6
+ "license": "MIT",
7
+ "files": ["src"],
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./react": "./src/react.ts",
14
+ "./vanilla": "./src/vanilla.ts",
15
+ "./protocol": "./src/protocol.ts",
16
+ "./validate": "./src/validate.ts",
17
+ "./llm": "./src/llm.ts"
18
+ },
19
+ "scripts": {
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "test": "bun test"
22
+ },
23
+ "dependencies": {
24
+ "nanostores": "^1.0.1"
25
+ },
26
+ "devDependencies": {
27
+ "@nanostores/react": "^1.0.0",
28
+ "typescript": "^5.7.3"
29
+ },
30
+ "peerDependencies": {
31
+ "@nanostores/react": ">=0.8.0",
32
+ "react": ">=18"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "@nanostores/react": {
36
+ "optional": true
37
+ },
38
+ "react": {
39
+ "optional": true
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,121 @@
1
+ export const CONTRACT_VERSION = 1;
2
+
3
+ export type JsonSchema = Record<string, unknown>;
4
+
5
+ export type ContractInputType =
6
+ | "string"
7
+ | "number"
8
+ | "integer"
9
+ | "boolean"
10
+ | "enum"
11
+ | "json";
12
+
13
+ export interface ContractInput {
14
+ type: ContractInputType;
15
+ description?: string;
16
+ default?: unknown;
17
+ choices?: string[];
18
+ min?: number;
19
+ max?: number;
20
+ schema?: JsonSchema;
21
+ optional?: boolean;
22
+ }
23
+
24
+ export interface ContractEvent {
25
+ payloadSchema?: JsonSchema | null;
26
+ description?: string;
27
+ }
28
+
29
+ export interface ContractQuery {
30
+ argsSchema?: JsonSchema | null;
31
+ resultSchema?: JsonSchema | null;
32
+ description?: string;
33
+ /** Mutations change widget state and require a live acknowledgement. */
34
+ mutation?: boolean;
35
+ }
36
+
37
+ export interface WidgetSizing {
38
+ defaultHeight?: number;
39
+ resizable?: boolean;
40
+ maxHeight?: number;
41
+ }
42
+
43
+ export interface WidgetCapabilities {
44
+ workers?: boolean;
45
+ media?: boolean;
46
+ microphone?: boolean;
47
+ wasm?: boolean;
48
+ downloads?: boolean;
49
+ }
50
+
51
+ /** CSP fetch directives a widget may extend, in canonical order. */
52
+ export type WidgetCspDirective =
53
+ | "connectSrc"
54
+ | "imgSrc"
55
+ | "fontSrc"
56
+ | "mediaSrc"
57
+ | "styleSrc";
58
+
59
+ /**
60
+ * Network sources per CSP fetch directive. Each entry is `scheme://host` or
61
+ * `scheme://*.host` (`https`, plus `wss` for `connectSrc`) without port or
62
+ * path.
63
+ */
64
+ export interface WidgetCsp {
65
+ connectSrc?: string[];
66
+ imgSrc?: string[];
67
+ fontSrc?: string[];
68
+ mediaSrc?: string[];
69
+ styleSrc?: string[];
70
+ }
71
+
72
+ /** Explicit expansion of `{s}` placeholders in runtime URL hosts. */
73
+ export interface WidgetUrlTemplate {
74
+ /** Lowercase DNS labels substituted for `{s}` */
75
+ subdomains?: string[];
76
+ /** A string or json input whose value lists the labels for `{s}` */
77
+ subdomainsInput?: string;
78
+ }
79
+
80
+ /**
81
+ * A widget input whose string values carry URLs. The host takes only
82
+ * `scheme://host` from each value and asks the viewer to approve it.
83
+ */
84
+ export interface WidgetNetworkInput {
85
+ /** `root *( "." key / "[]" / ".*" )`, e.g. `tileUrl` or `layers[].url` */
86
+ path: string;
87
+ directives: WidgetCspDirective[];
88
+ template?: WidgetUrlTemplate;
89
+ }
90
+
91
+ /**
92
+ * One purpose group: a reason the viewer reads next to the sources, plus
93
+ * static sources and/or network inputs.
94
+ */
95
+ export interface WidgetCspPurpose extends WidgetCsp {
96
+ reason: string;
97
+ inputs?: WidgetNetworkInput[];
98
+ }
99
+
100
+ export interface WidgetContract {
101
+ contractVersion: number;
102
+ id: string;
103
+ inputs?: Record<string, ContractInput>;
104
+ events?: Record<string, ContractEvent>;
105
+ queries?: Record<string, ContractQuery>;
106
+ sizing?: WidgetSizing;
107
+ capabilities?: WidgetCapabilities;
108
+ /** Present only with `contractVersion` 2 */
109
+ csp?: WidgetCspPurpose[];
110
+ }
111
+
112
+ export function contractDefaults(
113
+ contract: WidgetContract | null | undefined,
114
+ ): Record<string, unknown> {
115
+ const defaults: Record<string, unknown> = {};
116
+ if (!contract?.inputs) return defaults;
117
+ for (const [key, input] of Object.entries(contract.inputs)) {
118
+ if (input.default !== undefined) defaults[key] = input.default;
119
+ }
120
+ return defaults;
121
+ }
package/src/define.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type {
2
+ WidgetCapabilities,
3
+ WidgetCspPurpose,
4
+ WidgetSizing,
5
+ } from "./contract";
6
+
7
+ export type WidgetInputsShape = object;
8
+ export type WidgetEventsShape = object;
9
+ export type WidgetQueriesShape = object;
10
+
11
+ export type QueryArgs<Q, Name extends keyof Q> = Q[Name] extends {
12
+ args: infer A;
13
+ }
14
+ ? A
15
+ : unknown;
16
+
17
+ export type QueryReturns<Q, Name extends keyof Q> = Q[Name] extends {
18
+ returns: infer R;
19
+ }
20
+ ? R
21
+ : unknown;
22
+
23
+ export interface WidgetDevConfig<Inputs extends WidgetInputsShape> {
24
+ fixtures?: Record<string, Partial<Inputs>>;
25
+ }
26
+
27
+ export interface WidgetConfig<Inputs extends WidgetInputsShape> {
28
+ id: string;
29
+ name: string;
30
+ description: string;
31
+ sizing?: WidgetSizing;
32
+ capabilities?: WidgetCapabilities;
33
+ /**
34
+ * Purpose groups of network sources the viewer must approve before the
35
+ * widget can reach them. Extracted statically: use string literals only.
36
+ */
37
+ csp?: WidgetCspPurpose[];
38
+ dev?: WidgetDevConfig<Inputs>;
39
+ }
40
+
41
+ export interface WidgetDefinition<
42
+ Inputs extends WidgetInputsShape = Record<string, unknown>,
43
+ Events extends WidgetEventsShape = Record<string, unknown>,
44
+ Queries extends WidgetQueriesShape = Record<
45
+ string,
46
+ { args: unknown; returns: unknown }
47
+ >,
48
+ > extends WidgetConfig<Inputs> {
49
+ // Phantom marker so the Events/Queries type arguments survive structural
50
+ // typing; never present at runtime.
51
+ readonly __types?: {
52
+ inputs: Inputs;
53
+ events: Events;
54
+ queries: Queries;
55
+ };
56
+ }
57
+
58
+ export type WidgetInputsOf<D> = D extends WidgetDefinition<
59
+ infer Inputs,
60
+ WidgetEventsShape,
61
+ WidgetQueriesShape
62
+ >
63
+ ? Inputs
64
+ : never;
65
+
66
+ export type WidgetEventsOf<D> = D extends WidgetDefinition<
67
+ WidgetInputsShape,
68
+ infer Events,
69
+ WidgetQueriesShape
70
+ >
71
+ ? Events
72
+ : never;
73
+
74
+ export type WidgetQueriesOf<D> = D extends WidgetDefinition<
75
+ WidgetInputsShape,
76
+ WidgetEventsShape,
77
+ infer Queries
78
+ >
79
+ ? Queries
80
+ : never;
81
+
82
+ export function defineWidget<
83
+ Inputs extends WidgetInputsShape = Record<string, unknown>,
84
+ Events extends WidgetEventsShape = Record<string, unknown>,
85
+ Queries extends WidgetQueriesShape = Record<
86
+ string,
87
+ { args: unknown; returns: unknown }
88
+ >,
89
+ >(config: WidgetConfig<Inputs>): WidgetDefinition<Inputs, Events, Queries> {
90
+ return config;
91
+ }