@kaptive/widget-api 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 state systems gmbh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @kaptive/widget-api
2
+
3
+ The runtime SDK and manifest schema for [Kaptive](https://kaptive.ch) custom widgets.
4
+
5
+ A Kaptive custom widget is a small web app — built with `@kaptive/create-widget` and `@kaptive/cli` — that a workspace admin uploads as a bundle and drops into any project. This package is what a widget's own code imports at runtime to receive its parameters and learn about the screen it's running on.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @kaptive/widget-api
11
+ ```
12
+
13
+ If you scaffolded your widget with `npm create @kaptive/widget`, this is already a dependency.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { kaptive } from "@kaptive/widget-api";
19
+
20
+ const { parameters, player } = await kaptive.ready();
21
+
22
+ document.title = parameters.title;
23
+ ```
24
+
25
+ `ready()` resolves once the widget's parameters and player information are known, whichever of three places they come from:
26
+
27
+ 1. `kaptive widget dev` — from `kaptive.dev.json`, while you're developing.
28
+ 2. A real Kaptive player — from the on-device player agent, over a local WebSocket.
29
+ 3. Anywhere else (deployed standalone, opened directly) — from `kaptive.prod.json` if you built one in, otherwise the manifest's own defaults.
30
+
31
+ Your widget code never needs to know which of the three it's in.
32
+
33
+ ## Subpaths
34
+
35
+ - `@kaptive/widget-api` — the runtime above (`kaptive.ready()`).
36
+ - `@kaptive/widget-api/manifest` — the `kaptive.manifest.json` schema and parameter-value reconciliation. Used by `@kaptive/cli` and the Kaptive manager; most widget authors never import this directly.
37
+ - `@kaptive/widget-api/bundle` — the size/entry-count/path rules a packaged bundle must satisfy, shared by the CLI, the Kaptive manager's upload validation, and the on-device player agent.
38
+ - `@kaptive/widget-api/protocol` — the wire protocol between a widget and the player agent's local WebSocket.
39
+
40
+ ## Learn more
41
+
42
+ See the [`@kaptive/create-widget`](https://www.npmjs.com/package/@kaptive/create-widget) README for how to scaffold, build, and ship a widget, including the full list of parameter types.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Rules a widget bundle (the zip a widget author uploads) has to satisfy.
3
+ *
4
+ * These are enforced three times over, on purpose: by `kaptive widget pack` so
5
+ * an author finds out at build time, by the Kaptive manager at upload so a
6
+ * hand-made zip cannot get in, and by the player agent at extraction so a
7
+ * device never trusts that the two checks upstream actually ran.
8
+ *
9
+ * Two tiers, not one:
10
+ *
11
+ * - {@link WIDGET_BUNDLE_HARD_LIMITS} is an absolute ceiling nothing may ever
12
+ * exceed, on any workspace, at any point in the pipeline. The player agent
13
+ * and `kaptive widget pack` enforce exactly this tier — a device has no
14
+ * notion of a workspace's plan, and a bundle built to ship at all must pass
15
+ * on every plan.
16
+ * - A **compressed-size cap** narrower than the hard ceiling is a per-workspace
17
+ * plan setting (`WorkspacePlan.widgetBundleLimit` in the Kaptive manager,
18
+ * defaulting to {@link DEFAULT_WIDGET_BUNDLE_COMPRESSED_BYTES}), which an
19
+ * admin can raise per workspace up to the hard ceiling. {@link
20
+ * bundleLimitsForCompressedCap} turns that one number into the full set of
21
+ * limits the manager's upload validation enforces.
22
+ */
23
+ export interface WidgetBundleLimits {
24
+ /** Size of the uploaded zip itself. */
25
+ maxCompressedBytes: number;
26
+ /** Total size of everything inside it. */
27
+ maxUncompressedBytes: number;
28
+ maxEntries: number;
29
+ /**
30
+ * Guards against zip bombs: a single entry may not expand by more than this
31
+ * factor. Text assets compress well, so the bar is deliberately generous.
32
+ */
33
+ maxCompressionRatio: number;
34
+ /** The one entry actually decompressed before the rest is even listed. */
35
+ maxManifestBytes: number;
36
+ }
37
+ export declare const WIDGET_BUNDLE_HARD_LIMITS: WidgetBundleLimits;
38
+ /** @deprecated Use {@link WIDGET_BUNDLE_HARD_LIMITS}. Kept for existing callers. */
39
+ export declare const WIDGET_BUNDLE_LIMITS: WidgetBundleLimits;
40
+ /** A workspace plan's `widgetBundleLimit` when nothing else has been set. */
41
+ export declare const DEFAULT_WIDGET_BUNDLE_COMPRESSED_BYTES: number;
42
+ /**
43
+ * Turns one workspace plan setting — the compressed bundle size it allows —
44
+ * into the full set of limits `validateWidgetBundle` checks against. Both the
45
+ * compressed cap and its derived uncompressed cap are clamped to {@link
46
+ * WIDGET_BUNDLE_HARD_LIMITS}, so a plan can never authorize more than the
47
+ * device and the CLI would accept regardless.
48
+ */
49
+ export declare function bundleLimitsForCompressedCap(compressedCap: number): WidgetBundleLimits;
50
+ /**
51
+ * File types a bundle may contain. Anything else — executables, archives,
52
+ * shell scripts — is rejected rather than silently served to the kiosk browser.
53
+ */
54
+ export declare const ALLOWED_BUNDLE_EXTENSIONS: ReadonlySet<string>;
55
+ export declare function bundleEntryExtension(path: string): string;
56
+ /**
57
+ * True for OS/archive litter: `.DS_Store` (and its AppleDouble `._*` sibling
58
+ * files), Windows' `Thumbs.db`/`desktop.ini`, KDE's `.directory`, and anything
59
+ * macOS's Archive Utility drops into a `__MACOSX/` directory when zipping.
60
+ */
61
+ export declare function isIgnoredBundleEntry(path: string): boolean;
62
+ /**
63
+ * Validates one entry path from a bundle. Returns `null` when the path is fine,
64
+ * or a human-readable reason when it is not.
65
+ */
66
+ export declare function checkBundleEntryPath(path: string): string | null;
67
+ export interface BundleEntry {
68
+ /** Bundle-relative path, `/`-separated. */
69
+ path: string;
70
+ /** Uncompressed size in bytes. */
71
+ size: number;
72
+ /** Compressed size in bytes, when the source knows it. */
73
+ compressedSize?: number;
74
+ }
75
+ /**
76
+ * Validates a whole bundle listing against `limits` (a workspace's own, via
77
+ * {@link bundleLimitsForCompressedCap}, or {@link WIDGET_BUNDLE_HARD_LIMITS}
78
+ * by default — what the CLI and the device use, since neither knows about a
79
+ * workspace plan). Returns every problem found rather than only the first, so
80
+ * an author fixing a bundle sees the full list in one go.
81
+ */
82
+ export declare function checkBundleEntries(entries: readonly BundleEntry[], limits?: WidgetBundleLimits): string[];
83
+ export declare function formatBytes(bytes: number): string;
package/dist/bundle.js ADDED
@@ -0,0 +1,158 @@
1
+ //#region src/bundle.ts
2
+ var WIDGET_BUNDLE_HARD_LIMITS = {
3
+ maxCompressedBytes: 524288e3,
4
+ maxUncompressedBytes: 1073741824,
5
+ maxEntries: 2e3,
6
+ maxCompressionRatio: 100,
7
+ maxManifestBytes: 262144
8
+ };
9
+ /** @deprecated Use {@link WIDGET_BUNDLE_HARD_LIMITS}. Kept for existing callers. */
10
+ var WIDGET_BUNDLE_LIMITS = WIDGET_BUNDLE_HARD_LIMITS;
11
+ /** A workspace plan's `widgetBundleLimit` when nothing else has been set. */
12
+ var DEFAULT_WIDGET_BUNDLE_COMPRESSED_BYTES = 52428800;
13
+ /**
14
+ * How generous the derived uncompressed cap is relative to a plan's
15
+ * compressed-size cap. A widget bundle is mostly JS/CSS/HTML plus already-
16
+ * compressed media (images, fonts, video), so real bundles land well under
17
+ * this even before the hard ceiling would clip it.
18
+ */
19
+ var UNCOMPRESSED_CAP_MULTIPLIER = 5;
20
+ /**
21
+ * Turns one workspace plan setting — the compressed bundle size it allows —
22
+ * into the full set of limits `validateWidgetBundle` checks against. Both the
23
+ * compressed cap and its derived uncompressed cap are clamped to {@link
24
+ * WIDGET_BUNDLE_HARD_LIMITS}, so a plan can never authorize more than the
25
+ * device and the CLI would accept regardless.
26
+ */
27
+ function bundleLimitsForCompressedCap(compressedCap) {
28
+ const maxCompressedBytes = Math.min(Math.max(compressedCap, 0), WIDGET_BUNDLE_HARD_LIMITS.maxCompressedBytes);
29
+ const maxUncompressedBytes = Math.min(maxCompressedBytes * UNCOMPRESSED_CAP_MULTIPLIER, WIDGET_BUNDLE_HARD_LIMITS.maxUncompressedBytes);
30
+ return {
31
+ ...WIDGET_BUNDLE_HARD_LIMITS,
32
+ maxCompressedBytes,
33
+ maxUncompressedBytes
34
+ };
35
+ }
36
+ /**
37
+ * File types a bundle may contain. Anything else — executables, archives,
38
+ * shell scripts — is rejected rather than silently served to the kiosk browser.
39
+ */
40
+ var ALLOWED_BUNDLE_EXTENSIONS = /* @__PURE__ */ new Set([
41
+ "html",
42
+ "js",
43
+ "mjs",
44
+ "cjs",
45
+ "css",
46
+ "json",
47
+ "map",
48
+ "wasm",
49
+ "txt",
50
+ "csv",
51
+ "xml",
52
+ "svg",
53
+ "png",
54
+ "jpg",
55
+ "jpeg",
56
+ "gif",
57
+ "webp",
58
+ "avif",
59
+ "ico",
60
+ "bmp",
61
+ "woff",
62
+ "woff2",
63
+ "ttf",
64
+ "otf",
65
+ "eot",
66
+ "mp4",
67
+ "webm",
68
+ "ogg",
69
+ "mp3",
70
+ "wav"
71
+ ]);
72
+ function bundleEntryExtension(path) {
73
+ const base = path.slice(path.lastIndexOf("/") + 1);
74
+ const dot = base.lastIndexOf(".");
75
+ return dot <= 0 ? "" : base.slice(dot + 1).toLowerCase();
76
+ }
77
+ /**
78
+ * Filenames that turn up in a bundle by accident of the operating system or
79
+ * archive tool, never by the widget author's intent — most commonly
80
+ * `.DS_Store`, copied in verbatim when a Vite `public/` directory is copied to
81
+ * `dist/`. These are skipped everywhere a bundle is read, rather than treated
82
+ * as a reason to reject it: `kaptive widget pack` leaves them out of a fresh
83
+ * zip, and this same check tolerates one already baked into a hand-made or
84
+ * pre-existing bundle at validate/upload time and at device extraction.
85
+ */
86
+ var IGNORED_BUNDLE_BASENAMES = /* @__PURE__ */ new Set([
87
+ ".DS_Store",
88
+ "Thumbs.db",
89
+ "desktop.ini",
90
+ ".directory"
91
+ ]);
92
+ /**
93
+ * True for OS/archive litter: `.DS_Store` (and its AppleDouble `._*` sibling
94
+ * files), Windows' `Thumbs.db`/`desktop.ini`, KDE's `.directory`, and anything
95
+ * macOS's Archive Utility drops into a `__MACOSX/` directory when zipping.
96
+ */
97
+ function isIgnoredBundleEntry(path) {
98
+ const segments = path.split("/");
99
+ const basename = segments[segments.length - 1] ?? path;
100
+ return IGNORED_BUNDLE_BASENAMES.has(basename) || basename.startsWith("._") || segments.includes("__MACOSX");
101
+ }
102
+ /** Linux (and the device's filesystem) limits a single path component to this
103
+ * many *bytes*, not characters — a distinction that matters once a segment
104
+ * holds multibyte UTF-8. */
105
+ var MAX_PATH_SEGMENT_BYTES = 255;
106
+ var textEncoder = new TextEncoder();
107
+ /**
108
+ * Validates one entry path from a bundle. Returns `null` when the path is fine,
109
+ * or a human-readable reason when it is not.
110
+ */
111
+ function checkBundleEntryPath(path) {
112
+ if (path.length === 0) return "Empty entry path";
113
+ if (path.length > 255) return `Entry path is too long: ${path}`;
114
+ if (path.includes("\\")) return `Entry path contains a backslash: ${path}`;
115
+ if (path.includes("\0")) return `Entry path contains a null byte`;
116
+ if (path.startsWith("/")) return `Entry path is absolute: ${path}`;
117
+ if (/^[A-Za-z]:/.test(path)) return `Entry path is absolute: ${path}`;
118
+ const segments = path.split("/");
119
+ for (const segment of segments) {
120
+ if (segment === "" || segment === "." || segment === "..") return `Entry path escapes the bundle: ${path}`;
121
+ if (textEncoder.encode(segment).length > MAX_PATH_SEGMENT_BYTES) return `Path segment is too long: ${segment}`;
122
+ }
123
+ const extension = bundleEntryExtension(path);
124
+ if (!ALLOWED_BUNDLE_EXTENSIONS.has(extension)) return extension ? `Unsupported file type ".${extension}": ${path}` : `File without an extension: ${path}`;
125
+ return null;
126
+ }
127
+ /**
128
+ * Validates a whole bundle listing against `limits` (a workspace's own, via
129
+ * {@link bundleLimitsForCompressedCap}, or {@link WIDGET_BUNDLE_HARD_LIMITS}
130
+ * by default — what the CLI and the device use, since neither knows about a
131
+ * workspace plan). Returns every problem found rather than only the first, so
132
+ * an author fixing a bundle sees the full list in one go.
133
+ */
134
+ function checkBundleEntries(entries, limits = WIDGET_BUNDLE_HARD_LIMITS) {
135
+ const problems = [];
136
+ const relevant = entries.filter((entry) => !isIgnoredBundleEntry(entry.path));
137
+ if (relevant.length > limits.maxEntries) problems.push(`Bundle has ${relevant.length} files, the limit is ${limits.maxEntries}`);
138
+ let total = 0;
139
+ const seenPaths = /* @__PURE__ */ new Set();
140
+ for (const entry of relevant) {
141
+ const problem = checkBundleEntryPath(entry.path);
142
+ if (problem) problems.push(problem);
143
+ if (seenPaths.has(entry.path)) problems.push(`Duplicate bundle entry: ${entry.path}`);
144
+ seenPaths.add(entry.path);
145
+ total += entry.size;
146
+ if (entry.compressedSize !== void 0 && entry.compressedSize > 0 && entry.size / entry.compressedSize > limits.maxCompressionRatio) problems.push(`"${entry.path}" expands more than ${limits.maxCompressionRatio}x and looks like a zip bomb`);
147
+ }
148
+ if (total > limits.maxUncompressedBytes) problems.push(`Bundle contents total ${formatBytes(total)}, the limit is ${formatBytes(limits.maxUncompressedBytes)}`);
149
+ return problems;
150
+ }
151
+ function formatBytes(bytes) {
152
+ if (bytes < 1024) return `${bytes} B`;
153
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
154
+ if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
155
+ return `${(bytes / 1073741824).toFixed(1)} GB`;
156
+ }
157
+ //#endregion
158
+ export { ALLOWED_BUNDLE_EXTENSIONS, DEFAULT_WIDGET_BUNDLE_COMPRESSED_BYTES, WIDGET_BUNDLE_HARD_LIMITS, WIDGET_BUNDLE_LIMITS, bundleEntryExtension, bundleLimitsForCompressedCap, checkBundleEntries, checkBundleEntryPath, formatBytes, isIgnoredBundleEntry };
@@ -0,0 +1,80 @@
1
+ import type { WidgetManifest } from "./manifest.js";
2
+ import { type WireWidgetBlockInfo, type WireWidgetPlayerInfo } from "./protocol.js";
3
+ export type { ResolvedParameterValue, ResolvedParameterValues, WidgetAssetValue, WidgetParameterValue, WidgetParameterValues, } from "./values.js";
4
+ export type { WidgetManifest, WidgetParameter } from "./manifest.js";
5
+ /**
6
+ * The parameters declared by this widget's manifest.
7
+ *
8
+ * Empty here on purpose: `kaptive widget dev` and `kaptive widget build`
9
+ * generate `src/kaptive-env.d.ts`, which augments this interface with one
10
+ * property per manifest parameter. That is what makes `parameters.city` a
11
+ * `string` rather than `unknown`.
12
+ */
13
+ export interface KaptiveParameters {
14
+ }
15
+ /** Information about the screen the widget is running on. */
16
+ export interface KaptivePlayerInfo {
17
+ deviceId: string;
18
+ deviceName: string;
19
+ orientation: 0 | 90 | 180 | 270;
20
+ /** IANA zone of the workspace, e.g. `"Europe/Zurich"`. */
21
+ timezone: string;
22
+ /** Project resolution in CSS pixels, before the page's fit-to-screen scale. */
23
+ resolution: {
24
+ width: number;
25
+ height: number;
26
+ };
27
+ }
28
+ /** The content block this widget instance was placed in. */
29
+ export interface KaptiveBlockInfo {
30
+ id: string;
31
+ width: number;
32
+ height: number;
33
+ }
34
+ export interface KaptiveContext {
35
+ parameters: KaptiveParameters;
36
+ player: KaptivePlayerInfo;
37
+ block: KaptiveBlockInfo;
38
+ }
39
+ /**
40
+ * A context built server-side by `kaptive widget` (from `kaptive.dev.json` or
41
+ * `kaptive.prod.json`) and injected into the page. Its `parameters` are
42
+ * already resolved — plain values, assets already turned into URLs — unlike
43
+ * the raw `WidgetParameterValues` the WebSocket path sends.
44
+ */
45
+ interface InjectedFileContext {
46
+ parameters?: Record<string, unknown>;
47
+ player?: Partial<WireWidgetPlayerInfo>;
48
+ block?: Partial<WireWidgetBlockInfo>;
49
+ }
50
+ /** Values injected into the page by the `kaptiveWidget()` Vite plugin. */
51
+ interface KaptiveInjected {
52
+ manifest?: WidgetManifest;
53
+ devContext?: InjectedFileContext;
54
+ prodContext?: InjectedFileContext;
55
+ }
56
+ declare global {
57
+ interface Window {
58
+ __KAPTIVE__?: KaptiveInjected;
59
+ }
60
+ }
61
+ declare class WidgetClient {
62
+ #private;
63
+ constructor();
64
+ /**
65
+ * Resolves once the widget's parameters and player information are known.
66
+ * Safe to call more than once — every caller gets the same context. Always
67
+ * resolves — a widget with nowhere to get real values from still gets its
68
+ * manifest's defaults, so it renders standalone rather than hang, and so
69
+ * does one running on a real player whose agent never answers: after
70
+ * {@link READY_TIMEOUT_MS} with no reply, `ready()` falls back to those same
71
+ * defaults rather than waiting forever.
72
+ */
73
+ ready(): Promise<KaptiveContext>;
74
+ /** The resolved context, or `null` before {@link ready} has resolved. */
75
+ getContext(): KaptiveContext | null;
76
+ /** Closes the connection to the player agent. Rarely needed. */
77
+ disconnect(): void;
78
+ }
79
+ /** The widget runtime. One instance per page. */
80
+ export declare const kaptive: WidgetClient;
package/dist/index.js ADDED
@@ -0,0 +1,250 @@
1
+ import { i as resolveParameterValues } from "./values-BygiDSdW.js";
2
+ import { BLOCK_ID_QUERY_PARAM, PLAYER_AGENT_URL, parseServerMessage } from "./protocol.js";
3
+ //#region src/index.ts
4
+ /**
5
+ * `@kaptive/widget-api` — the runtime a Kaptive custom widget uses to receive
6
+ * its parameters and learn about the screen it is running on.
7
+ *
8
+ * ```ts
9
+ * import { kaptive } from "@kaptive/widget-api";
10
+ *
11
+ * const { parameters, player } = await kaptive.ready();
12
+ * ```
13
+ *
14
+ * A widget runs in a sandboxed iframe on its own origin. Depending on where
15
+ * it's running, `ready()` gets its context from one of three places, in order:
16
+ *
17
+ * 1. `kaptive widget dev` — from `kaptive.dev.json`.
18
+ * 2. A real Kaptive player — from the player agent's local WebSocket.
19
+ * 3. Anywhere else (deployed standalone, opened directly) — from
20
+ * `kaptive.prod.json` if the build had one, else the manifest's defaults.
21
+ *
22
+ * A widget never needs to know which one it's in — `ready()` always resolves.
23
+ *
24
+ * Parameters are read once, at start-up. Editing a parameter or uploading a new
25
+ * bundle remounts the iframe, so the widget simply starts again with the new
26
+ * values — there is nothing to subscribe to.
27
+ */
28
+ var RECONNECT_DELAY_MS = 2e3;
29
+ /**
30
+ * How long to wait before asking again when the agent reports the block is not
31
+ * in the content currently on screen. The iframe only exists because the player
32
+ * rendered it, so this is a narrow race — the content changed between mount and
33
+ * the request — and one that resolves itself.
34
+ */
35
+ var RETRY_DELAY_MS = 1e3;
36
+ /**
37
+ * How long `ready()` waits for the player agent before giving up on it and
38
+ * falling back to the manifest's defaults. Without this, a widget on a device
39
+ * whose agent never answers — down, mid-restart, a bug on its end — would
40
+ * never resolve at all, contradicting {@link WidgetClient.ready}'s own
41
+ * contract that it always does. Generous: a real agent typically replies in
42
+ * well under a second, so this only ever matters when something is actually
43
+ * wrong on the other end.
44
+ */
45
+ var READY_TIMEOUT_MS = 15e3;
46
+ var DEFAULT_PLAYER = {
47
+ deviceId: "dev-device",
48
+ deviceName: "Development",
49
+ orientation: 0,
50
+ timezone: "Europe/Zurich",
51
+ resolution: {
52
+ width: 1920,
53
+ height: 1080
54
+ }
55
+ };
56
+ function toOrientation(value) {
57
+ return value === 90 || value === 180 || value === 270 ? value : 0;
58
+ }
59
+ function toPlayerInfo(wire) {
60
+ return {
61
+ deviceId: wire?.device_id ?? DEFAULT_PLAYER.deviceId,
62
+ deviceName: wire?.device_name ?? DEFAULT_PLAYER.deviceName,
63
+ orientation: toOrientation(wire?.orientation ?? 0),
64
+ timezone: wire?.timezone ?? DEFAULT_PLAYER.timezone,
65
+ resolution: wire?.resolution ?? DEFAULT_PLAYER.resolution
66
+ };
67
+ }
68
+ var WidgetClient = class {
69
+ #injected;
70
+ #socket = null;
71
+ #reconnectTimer = null;
72
+ #closed = false;
73
+ #blockId;
74
+ #context = null;
75
+ #readyPromise = null;
76
+ #resolveReady = null;
77
+ #retryTimer = null;
78
+ #readyTimeoutTimer = null;
79
+ constructor() {
80
+ this.#injected = typeof window === "undefined" ? {} : window.__KAPTIVE__ ?? {};
81
+ this.#blockId = typeof window === "undefined" ? null : new URLSearchParams(window.location.search).get(BLOCK_ID_QUERY_PARAM);
82
+ }
83
+ /**
84
+ * Resolves once the widget's parameters and player information are known.
85
+ * Safe to call more than once — every caller gets the same context. Always
86
+ * resolves — a widget with nowhere to get real values from still gets its
87
+ * manifest's defaults, so it renders standalone rather than hang, and so
88
+ * does one running on a real player whose agent never answers: after
89
+ * {@link READY_TIMEOUT_MS} with no reply, `ready()` falls back to those same
90
+ * defaults rather than waiting forever.
91
+ */
92
+ ready() {
93
+ if (this.#readyPromise) return this.#readyPromise;
94
+ this.#readyPromise = new Promise((resolve) => {
95
+ this.#resolveReady = resolve;
96
+ if (this.#injected.devContext) {
97
+ this.#applyContext(this.#fileContext(this.#injected.devContext));
98
+ return;
99
+ }
100
+ if (this.#blockId) {
101
+ this.#connect();
102
+ this.#readyTimeoutTimer = setTimeout(() => {
103
+ this.#readyTimeoutTimer = null;
104
+ if (this.#context !== null) return;
105
+ console.warn(`[kaptive] no response from the player agent after ${READY_TIMEOUT_MS}ms; falling back to the manifest's default parameters.`);
106
+ this.#applyContext(this.#defaultContext());
107
+ }, READY_TIMEOUT_MS);
108
+ return;
109
+ }
110
+ const prod = this.#injected.prodContext;
111
+ this.#applyContext(prod ? this.#fileContext(prod) : this.#defaultContext());
112
+ });
113
+ return this.#readyPromise;
114
+ }
115
+ /** The resolved context, or `null` before {@link ready} has resolved. */
116
+ getContext() {
117
+ return this.#context;
118
+ }
119
+ /** Closes the connection to the player agent. Rarely needed. */
120
+ disconnect() {
121
+ this.#closed = true;
122
+ if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
123
+ if (this.#retryTimer !== null) clearTimeout(this.#retryTimer);
124
+ if (this.#readyTimeoutTimer !== null) clearTimeout(this.#readyTimeoutTimer);
125
+ this.#reconnectTimer = null;
126
+ this.#retryTimer = null;
127
+ this.#readyTimeoutTimer = null;
128
+ this.#socket?.close();
129
+ this.#socket = null;
130
+ }
131
+ /**
132
+ * Turns an already-resolved file context (`kaptive.dev.json` /
133
+ * `kaptive.prod.json`, loaded server-side) into a `KaptiveContext`. Never
134
+ * routes through `#buildContext()`: that resolves raw values, and these are
135
+ * already resolved — doing it twice turns an asset URL back into `null`.
136
+ */
137
+ #fileContext(file) {
138
+ return {
139
+ parameters: file.parameters ?? {},
140
+ player: toPlayerInfo(file.player),
141
+ block: {
142
+ id: file.block?.id ?? "standalone",
143
+ width: file.block?.width ?? window.innerWidth,
144
+ height: file.block?.height ?? window.innerHeight
145
+ }
146
+ };
147
+ }
148
+ /** Manifest defaults only — used when there is no player and no config file. */
149
+ #defaultContext() {
150
+ const manifest = this.#injected.manifest;
151
+ return {
152
+ parameters: manifest ? resolveParameterValues(manifest, {}) : {},
153
+ player: DEFAULT_PLAYER,
154
+ block: {
155
+ id: "standalone",
156
+ width: window.innerWidth,
157
+ height: window.innerHeight
158
+ }
159
+ };
160
+ }
161
+ #buildContext(rawParameters, player, block) {
162
+ const manifest = this.#injected.manifest;
163
+ return {
164
+ parameters: manifest ? resolveParameterValues(manifest, rawParameters) : rawParameters,
165
+ player,
166
+ block
167
+ };
168
+ }
169
+ #applyContext(next) {
170
+ if (this.#readyTimeoutTimer !== null) {
171
+ clearTimeout(this.#readyTimeoutTimer);
172
+ this.#readyTimeoutTimer = null;
173
+ }
174
+ this.#context = next;
175
+ if (this.#resolveReady) {
176
+ this.#resolveReady(next);
177
+ this.#resolveReady = null;
178
+ }
179
+ this.#closed = true;
180
+ this.#socket?.close();
181
+ this.#socket = null;
182
+ }
183
+ #send(socket, message) {
184
+ socket.send(JSON.stringify(message));
185
+ }
186
+ #connect() {
187
+ if (this.#closed) return;
188
+ const socket = new WebSocket(PLAYER_AGENT_URL);
189
+ this.#socket = socket;
190
+ socket.addEventListener("open", () => {
191
+ this.#send(socket, {
192
+ type: "identify",
193
+ client_type: "widget",
194
+ version: this.#injected.manifest?.version ?? null
195
+ });
196
+ this.#requestContext(socket);
197
+ });
198
+ socket.addEventListener("message", (event) => {
199
+ const message = parseServerMessage(event.data);
200
+ if (!message) return;
201
+ switch (message.type) {
202
+ case "widget_context":
203
+ if (message.block_id !== this.#blockId) return;
204
+ this.#applyContext(this.#buildContext(message.parameters, toPlayerInfo(message.player), {
205
+ id: message.block.id,
206
+ width: message.block.width,
207
+ height: message.block.height
208
+ }));
209
+ break;
210
+ case "widget_unavailable":
211
+ this.#scheduleRetry();
212
+ break;
213
+ case "error": console.error("[kaptive] player agent error:", message.message);
214
+ }
215
+ });
216
+ socket.addEventListener("close", () => {
217
+ this.#socket = null;
218
+ this.#scheduleReconnect();
219
+ });
220
+ socket.addEventListener("error", () => {
221
+ socket.close();
222
+ });
223
+ }
224
+ #requestContext(socket) {
225
+ if (!this.#blockId) return;
226
+ this.#send(socket, {
227
+ type: "widget_request_context",
228
+ block_id: this.#blockId
229
+ });
230
+ }
231
+ #scheduleRetry() {
232
+ if (this.#closed || this.#retryTimer !== null) return;
233
+ this.#retryTimer = setTimeout(() => {
234
+ this.#retryTimer = null;
235
+ const socket = this.#socket;
236
+ if (socket && socket.readyState === WebSocket.OPEN) this.#requestContext(socket);
237
+ }, RETRY_DELAY_MS);
238
+ }
239
+ #scheduleReconnect() {
240
+ if (this.#closed || this.#reconnectTimer !== null) return;
241
+ this.#reconnectTimer = setTimeout(() => {
242
+ this.#reconnectTimer = null;
243
+ this.#connect();
244
+ }, RECONNECT_DELAY_MS);
245
+ }
246
+ };
247
+ /** The widget runtime. One instance per page. */
248
+ var kaptive = new WidgetClient();
249
+ //#endregion
250
+ export { kaptive };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The Kaptive widget manifest — the contract between a widget author, the
3
+ * Kaptive manager (which renders the parameters as a properties panel) and the
4
+ * player (which feeds the values back to the running widget).
5
+ *
6
+ * This module is the single definition of that contract. It is imported by the
7
+ * `kaptive widget` CLI commands, by the Kaptive manager's upload validation, and by the
8
+ * player's host bridge, so it must stay free of any DOM or Node dependency.
9
+ */
10
+ import { z } from "zod";
11
+ /**
12
+ * Version of the widget runtime contract. A bundle declaring a different
13
+ * `apiVersion` is rejected at upload rather than silently half-working.
14
+ */
15
+ export declare const WIDGET_API_VERSION = 1;
16
+ /** The manifest file a widget bundle must carry at its root. */
17
+ export declare const WIDGET_MANIFEST_FILENAME = "kaptive.manifest.json";
18
+ export declare const textParameterSchema: z.ZodObject<{
19
+ key: z.ZodString;
20
+ type: z.ZodLiteral<"text">;
21
+ label: z.ZodString;
22
+ description: z.ZodOptional<z.ZodString>;
23
+ default: z.ZodOptional<z.ZodString>;
24
+ required: z.ZodOptional<z.ZodBoolean>;
25
+ maxLength: z.ZodOptional<z.ZodNumber>;
26
+ multiline: z.ZodOptional<z.ZodBoolean>;
27
+ }, z.core.$strip>;
28
+ export declare const numberParameterSchema: z.ZodObject<{
29
+ key: z.ZodString;
30
+ type: z.ZodLiteral<"number">;
31
+ label: z.ZodString;
32
+ description: z.ZodOptional<z.ZodString>;
33
+ default: z.ZodOptional<z.ZodNumber>;
34
+ required: z.ZodOptional<z.ZodBoolean>;
35
+ min: z.ZodOptional<z.ZodNumber>;
36
+ max: z.ZodOptional<z.ZodNumber>;
37
+ step: z.ZodOptional<z.ZodNumber>;
38
+ }, z.core.$strip>;
39
+ export declare const booleanParameterSchema: z.ZodObject<{
40
+ key: z.ZodString;
41
+ type: z.ZodLiteral<"boolean">;
42
+ label: z.ZodString;
43
+ description: z.ZodOptional<z.ZodString>;
44
+ default: z.ZodOptional<z.ZodBoolean>;
45
+ }, z.core.$strip>;
46
+ export declare const colorParameterSchema: z.ZodObject<{
47
+ key: z.ZodString;
48
+ type: z.ZodLiteral<"color">;
49
+ label: z.ZodString;
50
+ description: z.ZodOptional<z.ZodString>;
51
+ default: z.ZodOptional<z.ZodString>;
52
+ }, z.core.$strip>;
53
+ /**
54
+ * Media kinds an asset parameter's picker will offer. These match the pages of
55
+ * the editor's media library, so the picker is the same one used everywhere
56
+ * else — full library, upload button and all.
57
+ */
58
+ export declare const assetKindSchema: z.ZodEnum<{
59
+ image: "image";
60
+ video: "video";
61
+ pdf: "pdf";
62
+ model: "model";
63
+ }>;
64
+ export type WidgetAssetKind = z.infer<typeof assetKindSchema>;
65
+ export declare const assetParameterSchema: z.ZodObject<{
66
+ key: z.ZodString;
67
+ type: z.ZodLiteral<"asset">;
68
+ label: z.ZodString;
69
+ description: z.ZodOptional<z.ZodString>;
70
+ accept: z.ZodDefault<z.ZodArray<z.ZodEnum<{
71
+ image: "image";
72
+ video: "video";
73
+ pdf: "pdf";
74
+ model: "model";
75
+ }>>>;
76
+ }, z.core.$strip>;
77
+ export declare const widgetParameterSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
78
+ key: z.ZodString;
79
+ type: z.ZodLiteral<"text">;
80
+ label: z.ZodString;
81
+ description: z.ZodOptional<z.ZodString>;
82
+ default: z.ZodOptional<z.ZodString>;
83
+ required: z.ZodOptional<z.ZodBoolean>;
84
+ maxLength: z.ZodOptional<z.ZodNumber>;
85
+ multiline: z.ZodOptional<z.ZodBoolean>;
86
+ }, z.core.$strip>, z.ZodObject<{
87
+ key: z.ZodString;
88
+ type: z.ZodLiteral<"number">;
89
+ label: z.ZodString;
90
+ description: z.ZodOptional<z.ZodString>;
91
+ default: z.ZodOptional<z.ZodNumber>;
92
+ required: z.ZodOptional<z.ZodBoolean>;
93
+ min: z.ZodOptional<z.ZodNumber>;
94
+ max: z.ZodOptional<z.ZodNumber>;
95
+ step: z.ZodOptional<z.ZodNumber>;
96
+ }, z.core.$strip>, z.ZodObject<{
97
+ key: z.ZodString;
98
+ type: z.ZodLiteral<"boolean">;
99
+ label: z.ZodString;
100
+ description: z.ZodOptional<z.ZodString>;
101
+ default: z.ZodOptional<z.ZodBoolean>;
102
+ }, z.core.$strip>, z.ZodObject<{
103
+ key: z.ZodString;
104
+ type: z.ZodLiteral<"color">;
105
+ label: z.ZodString;
106
+ description: z.ZodOptional<z.ZodString>;
107
+ default: z.ZodOptional<z.ZodString>;
108
+ }, z.core.$strip>, z.ZodObject<{
109
+ key: z.ZodString;
110
+ type: z.ZodLiteral<"asset">;
111
+ label: z.ZodString;
112
+ description: z.ZodOptional<z.ZodString>;
113
+ accept: z.ZodDefault<z.ZodArray<z.ZodEnum<{
114
+ image: "image";
115
+ video: "video";
116
+ pdf: "pdf";
117
+ model: "model";
118
+ }>>>;
119
+ }, z.core.$strip>], "type">;
120
+ /**
121
+ * Stable identity of a widget, chosen by its author. Re-uploading a bundle with
122
+ * the same id creates a new version of the same widget rather than a second
123
+ * entry, so it must never change once published.
124
+ *
125
+ * This is the manifest's own `id`, distinct from the database row's `Widget.id`
126
+ * (a cuid2) — it is that row id, not this one, that the device actually uses as
127
+ * a DNS label and filesystem directory name (`w-<rowId>.widget.kaptive.localhost`,
128
+ * see `apps/device-player/nginx.conf` and `is_safe_widget_id` in the player
129
+ * agent). The restrictive character set here is only for a stable, readable,
130
+ * URL-safe author-chosen key — dashes are fine in an `id`, unlike in the row id
131
+ * that ends up as a DNS label.
132
+ */
133
+ export declare const widgetIdSchema: z.ZodString;
134
+ export declare const widgetManifestSchema: z.ZodObject<{
135
+ id: z.ZodString;
136
+ name: z.ZodString;
137
+ version: z.ZodString;
138
+ apiVersion: z.ZodLiteral<1>;
139
+ description: z.ZodOptional<z.ZodString>;
140
+ entry: z.ZodDefault<z.ZodString>;
141
+ parameters: z.ZodDefault<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
142
+ key: z.ZodString;
143
+ type: z.ZodLiteral<"text">;
144
+ label: z.ZodString;
145
+ description: z.ZodOptional<z.ZodString>;
146
+ default: z.ZodOptional<z.ZodString>;
147
+ required: z.ZodOptional<z.ZodBoolean>;
148
+ maxLength: z.ZodOptional<z.ZodNumber>;
149
+ multiline: z.ZodOptional<z.ZodBoolean>;
150
+ }, z.core.$strip>, z.ZodObject<{
151
+ key: z.ZodString;
152
+ type: z.ZodLiteral<"number">;
153
+ label: z.ZodString;
154
+ description: z.ZodOptional<z.ZodString>;
155
+ default: z.ZodOptional<z.ZodNumber>;
156
+ required: z.ZodOptional<z.ZodBoolean>;
157
+ min: z.ZodOptional<z.ZodNumber>;
158
+ max: z.ZodOptional<z.ZodNumber>;
159
+ step: z.ZodOptional<z.ZodNumber>;
160
+ }, z.core.$strip>, z.ZodObject<{
161
+ key: z.ZodString;
162
+ type: z.ZodLiteral<"boolean">;
163
+ label: z.ZodString;
164
+ description: z.ZodOptional<z.ZodString>;
165
+ default: z.ZodOptional<z.ZodBoolean>;
166
+ }, z.core.$strip>, z.ZodObject<{
167
+ key: z.ZodString;
168
+ type: z.ZodLiteral<"color">;
169
+ label: z.ZodString;
170
+ description: z.ZodOptional<z.ZodString>;
171
+ default: z.ZodOptional<z.ZodString>;
172
+ }, z.core.$strip>, z.ZodObject<{
173
+ key: z.ZodString;
174
+ type: z.ZodLiteral<"asset">;
175
+ label: z.ZodString;
176
+ description: z.ZodOptional<z.ZodString>;
177
+ accept: z.ZodDefault<z.ZodArray<z.ZodEnum<{
178
+ image: "image";
179
+ video: "video";
180
+ pdf: "pdf";
181
+ model: "model";
182
+ }>>>;
183
+ }, z.core.$strip>], "type">>>;
184
+ }, z.core.$strip>;
185
+ export type WidgetTextParameter = z.infer<typeof textParameterSchema>;
186
+ export type WidgetNumberParameter = z.infer<typeof numberParameterSchema>;
187
+ export type WidgetBooleanParameter = z.infer<typeof booleanParameterSchema>;
188
+ export type WidgetColorParameter = z.infer<typeof colorParameterSchema>;
189
+ export type WidgetAssetParameter = z.infer<typeof assetParameterSchema>;
190
+ export type WidgetParameter = z.infer<typeof widgetParameterSchema>;
191
+ export type WidgetParameterType = WidgetParameter["type"];
192
+ export type WidgetManifest = z.infer<typeof widgetManifestSchema>;
193
+ export { LOCAL_MEDIA_BASE_URL, isWidgetAssetValue, defaultParameterValue, resolveParameterValues, type WidgetAssetValue, type WidgetParameterValue, type WidgetParameterValues, type ResolvedParameterValue, type ResolvedParameterValues, } from "./values.js";
194
+ /**
195
+ * Parses and validates an unknown value as a widget manifest.
196
+ * Returns a flat list of human-readable problems instead of a zod error tree,
197
+ * because every caller surfaces these straight to a widget author or an admin.
198
+ */
199
+ export declare function parseWidgetManifest(input: unknown): {
200
+ ok: true;
201
+ manifest: WidgetManifest;
202
+ } | {
203
+ ok: false;
204
+ errors: string[];
205
+ };
@@ -0,0 +1,152 @@
1
+ import { i as resolveParameterValues, n as defaultParameterValue, r as isWidgetAssetValue, t as LOCAL_MEDIA_BASE_URL } from "./values-BygiDSdW.js";
2
+ import { z } from "zod";
3
+ //#region src/manifest.ts
4
+ /**
5
+ * The Kaptive widget manifest — the contract between a widget author, the
6
+ * Kaptive manager (which renders the parameters as a properties panel) and the
7
+ * player (which feeds the values back to the running widget).
8
+ *
9
+ * This module is the single definition of that contract. It is imported by the
10
+ * `kaptive widget` CLI commands, by the Kaptive manager's upload validation, and by the
11
+ * player's host bridge, so it must stay free of any DOM or Node dependency.
12
+ */
13
+ /**
14
+ * Version of the widget runtime contract. A bundle declaring a different
15
+ * `apiVersion` is rejected at upload rather than silently half-working.
16
+ */
17
+ var WIDGET_API_VERSION = 1;
18
+ /** The manifest file a widget bundle must carry at its root. */
19
+ var WIDGET_MANIFEST_FILENAME = "kaptive.manifest.json";
20
+ var SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
21
+ /**
22
+ * Parameter keys double as property names on the generated `KaptiveParameters`
23
+ * interface, so they are restricted to plain JS identifiers — that keeps the
24
+ * codegen a straight `key: type` line with no quoting rules to get wrong.
25
+ */
26
+ var parameterKey = z.string().min(1).max(40).regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Parameter keys must be valid identifiers (letters, digits and underscore, not starting with a digit)");
27
+ var label = z.string().min(1).max(60);
28
+ var description = z.string().max(200).optional();
29
+ var textParameterSchema = z.object({
30
+ key: parameterKey,
31
+ type: z.literal("text"),
32
+ label,
33
+ description,
34
+ default: z.string().optional(),
35
+ required: z.boolean().optional(),
36
+ maxLength: z.number().int().positive().max(4096).optional(),
37
+ multiline: z.boolean().optional()
38
+ });
39
+ var numberParameterSchema = z.object({
40
+ key: parameterKey,
41
+ type: z.literal("number"),
42
+ label,
43
+ description,
44
+ default: z.number().finite().optional(),
45
+ required: z.boolean().optional(),
46
+ min: z.number().finite().optional(),
47
+ max: z.number().finite().optional(),
48
+ step: z.number().finite().positive().optional()
49
+ });
50
+ var booleanParameterSchema = z.object({
51
+ key: parameterKey,
52
+ type: z.literal("boolean"),
53
+ label,
54
+ description,
55
+ default: z.boolean().optional()
56
+ });
57
+ var colorParameterSchema = z.object({
58
+ key: parameterKey,
59
+ type: z.literal("color"),
60
+ label,
61
+ description,
62
+ default: z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "Default colour must be a hex value like #1f2937").optional()
63
+ });
64
+ /**
65
+ * Media kinds an asset parameter's picker will offer. These match the pages of
66
+ * the editor's media library, so the picker is the same one used everywhere
67
+ * else — full library, upload button and all.
68
+ */
69
+ var assetKindSchema = z.enum([
70
+ "image",
71
+ "video",
72
+ "pdf",
73
+ "model"
74
+ ]);
75
+ var assetParameterSchema = z.object({
76
+ key: parameterKey,
77
+ type: z.literal("asset"),
78
+ label,
79
+ description,
80
+ /**
81
+ * Which kinds of workspace media the editor offers. Defaults to images.
82
+ * Assets have no default value — a manifest cannot name a file that lives in
83
+ * someone else's workspace — so an unset asset reaches the widget as `null`.
84
+ */
85
+ accept: z.array(assetKindSchema).nonempty().default(["image"])
86
+ });
87
+ var widgetParameterSchema = z.discriminatedUnion("type", [
88
+ textParameterSchema,
89
+ numberParameterSchema,
90
+ booleanParameterSchema,
91
+ colorParameterSchema,
92
+ assetParameterSchema
93
+ ]);
94
+ /**
95
+ * Stable identity of a widget, chosen by its author. Re-uploading a bundle with
96
+ * the same id creates a new version of the same widget rather than a second
97
+ * entry, so it must never change once published.
98
+ *
99
+ * This is the manifest's own `id`, distinct from the database row's `Widget.id`
100
+ * (a cuid2) — it is that row id, not this one, that the device actually uses as
101
+ * a DNS label and filesystem directory name (`w-<rowId>.widget.kaptive.localhost`,
102
+ * see `apps/device-player/nginx.conf` and `is_safe_widget_id` in the player
103
+ * agent). The restrictive character set here is only for a stable, readable,
104
+ * URL-safe author-chosen key — dashes are fine in an `id`, unlike in the row id
105
+ * that ends up as a DNS label.
106
+ */
107
+ var widgetIdSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{2,63}$/, "Widget id must be 3-64 lowercase letters, digits or dashes and start with a letter or digit");
108
+ var widgetManifestSchema = z.object({
109
+ id: widgetIdSchema,
110
+ name: z.string().min(1).max(60),
111
+ version: z.string().regex(SEMVER, "Version must be valid semver"),
112
+ apiVersion: z.literal(1),
113
+ description: z.string().max(200).optional(),
114
+ /** Bundle-relative HTML file the player loads. */
115
+ entry: z.string().max(200).regex(/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.html$/, "Entry must be a relative path to an .html file inside the bundle").refine((entry) => entry.split("/").every((segment) => segment !== "." && segment !== ".."), "Entry must not contain '.' or '..' path segments").default("index.html"),
116
+ parameters: z.array(widgetParameterSchema).max(32).default([])
117
+ }).superRefine((manifest, ctx) => {
118
+ const seen = /* @__PURE__ */ new Set();
119
+ for (const [index, parameter] of manifest.parameters.entries()) {
120
+ if (seen.has(parameter.key)) ctx.addIssue({
121
+ code: "custom",
122
+ path: [
123
+ "parameters",
124
+ index,
125
+ "key"
126
+ ],
127
+ message: `Duplicate parameter key "${parameter.key}"`
128
+ });
129
+ seen.add(parameter.key);
130
+ }
131
+ });
132
+ /**
133
+ * Parses and validates an unknown value as a widget manifest.
134
+ * Returns a flat list of human-readable problems instead of a zod error tree,
135
+ * because every caller surfaces these straight to a widget author or an admin.
136
+ */
137
+ function parseWidgetManifest(input) {
138
+ const result = widgetManifestSchema.safeParse(input);
139
+ if (result.success) return {
140
+ ok: true,
141
+ manifest: result.data
142
+ };
143
+ return {
144
+ ok: false,
145
+ errors: result.error.issues.map((issue) => {
146
+ const path = issue.path.join(".");
147
+ return path ? `${path}: ${issue.message}` : issue.message;
148
+ })
149
+ };
150
+ }
151
+ //#endregion
152
+ export { LOCAL_MEDIA_BASE_URL, WIDGET_API_VERSION, WIDGET_MANIFEST_FILENAME, assetKindSchema, assetParameterSchema, booleanParameterSchema, colorParameterSchema, defaultParameterValue, isWidgetAssetValue, numberParameterSchema, parseWidgetManifest, resolveParameterValues, textParameterSchema, widgetIdSchema, widgetManifestSchema, widgetParameterSchema };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Wire protocol between a Kaptive widget and the player agent.
3
+ *
4
+ * A widget runs in a sandboxed iframe on its own origin and talks to the player
5
+ * agent over the same local WebSocket the device player uses
6
+ * (`ws://127.0.0.1:3511`), identifying itself as a `widget` client. The agent
7
+ * only accepts loopback peers, so this channel never leaves the device.
8
+ *
9
+ * `identify` is not what actually makes the agent treat a connection as a
10
+ * widget: the agent classifies every connection by its `Origin` header first
11
+ * — a widget's iframe origin (`w-<id>.widget.kaptive.localhost`) forces
12
+ * `Widget` regardless of what `identify` claims, since a page cannot forge
13
+ * the `Origin` its own `WebSocket` calls carry. `identify` only matters for
14
+ * connections whose origin doesn't settle it (the device player itself, the
15
+ * launcher). See `classify_origin` in
16
+ * `apps/player-agent/src/servers/player/protocol.rs`.
17
+ *
18
+ * A widget connection is deliberately starved either way: it never receives
19
+ * `Welcome` or `StateUpdate` (which carry the whole project, every other
20
+ * widget's parameters included), and the only thing it can ask for is the
21
+ * context of the one block id it already knows — which reaches it as a query
22
+ * parameter on its own iframe URL, not something it discovers over this
23
+ * socket. There is nothing to guess into and no other block's context to
24
+ * request.
25
+ *
26
+ * These types mirror `apps/player-agent/src/servers/player/protocol.rs` and use
27
+ * its snake_case field names verbatim. The camelCase shapes a widget author
28
+ * actually sees are built from these in `index.ts`.
29
+ */
30
+ /** Default address of the player agent's WebSocket server. */
31
+ export declare const PLAYER_AGENT_URL = "ws://127.0.0.1:3511";
32
+ /**
33
+ * Query parameter the player puts on a widget's iframe URL to tell it which
34
+ * content block it is rendering.
35
+ */
36
+ export declare const BLOCK_ID_QUERY_PARAM = "kaptiveBlockId";
37
+ /** Widget → agent. */
38
+ export type WidgetClientMessage = {
39
+ type: "identify";
40
+ client_type: "widget";
41
+ version: string | null;
42
+ } | {
43
+ type: "widget_request_context";
44
+ block_id: string;
45
+ };
46
+ /** Screen information, as the agent sends it. */
47
+ export interface WireWidgetPlayerInfo {
48
+ device_id: string;
49
+ device_name: string;
50
+ orientation: number;
51
+ timezone: string;
52
+ resolution: {
53
+ width: number;
54
+ height: number;
55
+ };
56
+ }
57
+ /** The content block a widget instance occupies, as the agent sends it. */
58
+ export interface WireWidgetBlockInfo {
59
+ id: string;
60
+ width: number;
61
+ height: number;
62
+ }
63
+ /** Agent → widget. */
64
+ export type WidgetServerMessage = {
65
+ type: "widget_context";
66
+ block_id: string;
67
+ parameters: Record<string, unknown>;
68
+ player: WireWidgetPlayerInfo;
69
+ block: WireWidgetBlockInfo;
70
+ } | {
71
+ type: "widget_unavailable";
72
+ block_id: string;
73
+ } | {
74
+ type: "error";
75
+ message: string;
76
+ };
77
+ export declare function parseServerMessage(data: unknown): WidgetServerMessage | null;
@@ -0,0 +1,52 @@
1
+ //#region src/protocol.ts
2
+ /**
3
+ * Wire protocol between a Kaptive widget and the player agent.
4
+ *
5
+ * A widget runs in a sandboxed iframe on its own origin and talks to the player
6
+ * agent over the same local WebSocket the device player uses
7
+ * (`ws://127.0.0.1:3511`), identifying itself as a `widget` client. The agent
8
+ * only accepts loopback peers, so this channel never leaves the device.
9
+ *
10
+ * `identify` is not what actually makes the agent treat a connection as a
11
+ * widget: the agent classifies every connection by its `Origin` header first
12
+ * — a widget's iframe origin (`w-<id>.widget.kaptive.localhost`) forces
13
+ * `Widget` regardless of what `identify` claims, since a page cannot forge
14
+ * the `Origin` its own `WebSocket` calls carry. `identify` only matters for
15
+ * connections whose origin doesn't settle it (the device player itself, the
16
+ * launcher). See `classify_origin` in
17
+ * `apps/player-agent/src/servers/player/protocol.rs`.
18
+ *
19
+ * A widget connection is deliberately starved either way: it never receives
20
+ * `Welcome` or `StateUpdate` (which carry the whole project, every other
21
+ * widget's parameters included), and the only thing it can ask for is the
22
+ * context of the one block id it already knows — which reaches it as a query
23
+ * parameter on its own iframe URL, not something it discovers over this
24
+ * socket. There is nothing to guess into and no other block's context to
25
+ * request.
26
+ *
27
+ * These types mirror `apps/player-agent/src/servers/player/protocol.rs` and use
28
+ * its snake_case field names verbatim. The camelCase shapes a widget author
29
+ * actually sees are built from these in `index.ts`.
30
+ */
31
+ /** Default address of the player agent's WebSocket server. */
32
+ var PLAYER_AGENT_URL = "ws://127.0.0.1:3511";
33
+ /**
34
+ * Query parameter the player puts on a widget's iframe URL to tell it which
35
+ * content block it is rendering.
36
+ */
37
+ var BLOCK_ID_QUERY_PARAM = "kaptiveBlockId";
38
+ function parseServerMessage(data) {
39
+ if (typeof data !== "string") return null;
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(data);
43
+ } catch {
44
+ return null;
45
+ }
46
+ if (typeof parsed !== "object" || parsed === null || !("type" in parsed)) return null;
47
+ const { type } = parsed;
48
+ if (type === "widget_context" || type === "widget_unavailable" || type === "error") return parsed;
49
+ return null;
50
+ }
51
+ //#endregion
52
+ export { BLOCK_ID_QUERY_PARAM, PLAYER_AGENT_URL, parseServerMessage };
@@ -0,0 +1,60 @@
1
+ //#region src/values.ts
2
+ /**
3
+ * Where a player reads media that lives on its own attached storage rather than
4
+ * in the workspace library. Matches the `source: "local"` video block, which is
5
+ * the only other thing that plays files off a stick.
6
+ */
7
+ var LOCAL_MEDIA_BASE_URL = "http://127.0.0.1:8000";
8
+ function isWidgetAssetValue(value) {
9
+ if (typeof value !== "object" || value === null) return false;
10
+ const candidate = value;
11
+ return candidate.source === "local" ? typeof candidate.localFilePath === "string" && candidate.localFilePath.length > 0 : typeof candidate.assetId === "string" && candidate.assetId.length > 0;
12
+ }
13
+ function defaultParameterValue(parameter) {
14
+ if (parameter.type === "asset") return void 0;
15
+ if (parameter.default !== void 0) return parameter.default;
16
+ switch (parameter.type) {
17
+ case "text": return "";
18
+ case "number": return parameter.min ?? 0;
19
+ case "boolean": return false;
20
+ case "color": return "#000000";
21
+ }
22
+ }
23
+ function coerce(parameter, value) {
24
+ if (parameter.type === "asset") {
25
+ if (!isWidgetAssetValue(value)) return null;
26
+ return value.source === "local" ? `${LOCAL_MEDIA_BASE_URL}/${value.localFilePath}` : value.assetUrl ?? null;
27
+ }
28
+ const fallback = defaultParameterValue(parameter);
29
+ if (value === void 0) return fallback;
30
+ switch (parameter.type) {
31
+ case "text":
32
+ if (typeof value !== "string") return fallback;
33
+ return parameter.maxLength !== void 0 ? value.slice(0, parameter.maxLength) : value;
34
+ case "number": {
35
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
36
+ let next = value;
37
+ if (parameter.min !== void 0) next = Math.max(parameter.min, next);
38
+ if (parameter.max !== void 0) next = Math.min(parameter.max, next);
39
+ return next;
40
+ }
41
+ case "boolean": return typeof value === "boolean" ? value : fallback;
42
+ case "color": return typeof value === "string" ? value : fallback;
43
+ }
44
+ }
45
+ /**
46
+ * Reconciles the values stored on a content block against the manifest of the
47
+ * version that is about to run: fills in defaults, clamps out-of-range values
48
+ * and drops keys the manifest no longer declares.
49
+ *
50
+ * This is what makes promoting a widget version safe — a version that adds,
51
+ * removes or retypes a parameter can never hand a widget a value it did not
52
+ * declare, and never leaves a declared parameter undefined.
53
+ */
54
+ function resolveParameterValues(manifest, stored) {
55
+ const resolved = {};
56
+ for (const parameter of manifest.parameters) resolved[parameter.key] = coerce(parameter, stored?.[parameter.key]);
57
+ return resolved;
58
+ }
59
+ //#endregion
60
+ export { resolveParameterValues as i, defaultParameterValue as n, isWidgetAssetValue as r, LOCAL_MEDIA_BASE_URL as t };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Reconciling a widget's stored parameter values against its manifest.
3
+ *
4
+ * Split out of `manifest.ts` on purpose: this file has no zod dependency and
5
+ * none of its own imports are runtime ones — `manifest.ts`'s types are
6
+ * referenced only via `import type`, which TypeScript erases entirely. That
7
+ * is what lets `index.ts` (the runtime a widget actually ships) import from
8
+ * here instead of from `manifest.ts` and never pull zod into a widget's own
9
+ * bundle, while `@kaptive/widget-api/manifest` (validation, used server-side
10
+ * and by the CLI, where zod is already a cost being paid) re-exports
11
+ * everything below unchanged.
12
+ */
13
+ import type { WidgetAssetParameter, WidgetManifest, WidgetParameter } from "./manifest.js";
14
+ /**
15
+ * Where a player reads media that lives on its own attached storage rather than
16
+ * in the workspace library. Matches the `source: "local"` video block, which is
17
+ * the only other thing that plays files off a stick.
18
+ */
19
+ export declare const LOCAL_MEDIA_BASE_URL = "http://127.0.0.1:8000";
20
+ /**
21
+ * An asset parameter as stored on a content block.
22
+ *
23
+ * Either a workspace library asset — where only `assetId` is persisted and
24
+ * `assetUrl` is filled in on the way to a screen, signed by the API and then
25
+ * rewritten to the device's local cache — or a file on the player's attached
26
+ * storage, named by a path relative to its media root.
27
+ */
28
+ export type WidgetAssetValue = {
29
+ source?: "upload";
30
+ assetId: string;
31
+ assetUrl?: string;
32
+ } | {
33
+ source: "local";
34
+ localFilePath: string;
35
+ };
36
+ export declare function isWidgetAssetValue(value: unknown): value is WidgetAssetValue;
37
+ /** A parameter value as stored on a content block. */
38
+ export type WidgetParameterValue = string | number | boolean | WidgetAssetValue;
39
+ export type WidgetParameterValues = Record<string, WidgetParameterValue>;
40
+ /**
41
+ * A parameter value as a widget receives it. Assets arrive as a URL the widget
42
+ * can put straight in an `<img>` or `fetch`, or `null` when none is chosen.
43
+ */
44
+ export type ResolvedParameterValue = string | number | boolean | null;
45
+ export type ResolvedParameterValues = Record<string, ResolvedParameterValue>;
46
+ /**
47
+ * The value a parameter takes when the project has not set one, or `undefined`
48
+ * for an asset — there is nothing sensible to point at until someone picks a
49
+ * file, so the key is simply left off the block.
50
+ */
51
+ export declare function defaultParameterValue(parameter: Exclude<WidgetParameter, WidgetAssetParameter>): Exclude<WidgetParameterValue, WidgetAssetValue>;
52
+ export declare function defaultParameterValue(parameter: WidgetParameter): WidgetParameterValue | undefined;
53
+ /**
54
+ * Reconciles the values stored on a content block against the manifest of the
55
+ * version that is about to run: fills in defaults, clamps out-of-range values
56
+ * and drops keys the manifest no longer declares.
57
+ *
58
+ * This is what makes promoting a widget version safe — a version that adds,
59
+ * removes or retypes a parameter can never hand a widget a value it did not
60
+ * declare, and never leaves a declared parameter undefined.
61
+ */
62
+ export declare function resolveParameterValues(manifest: Pick<WidgetManifest, "parameters">, stored: Readonly<Partial<WidgetParameterValues>> | undefined): ResolvedParameterValues;
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@kaptive/widget-api",
3
+ "version": "0.1.0",
4
+ "description": "Runtime SDK and manifest schema for Kaptive custom widgets.",
5
+ "keywords": [
6
+ "kaptive",
7
+ "widget",
8
+ "digital-signage"
9
+ ],
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "homepage": "https://kaptive.ch",
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "types": "./dist/index.d.ts",
24
+ "main": "./dist/index.js",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ },
30
+ "./manifest": {
31
+ "types": "./dist/manifest.d.ts",
32
+ "import": "./dist/manifest.js"
33
+ },
34
+ "./bundle": {
35
+ "types": "./dist/bundle.d.ts",
36
+ "import": "./dist/bundle.js"
37
+ },
38
+ "./protocol": {
39
+ "types": "./dist/protocol.d.ts",
40
+ "import": "./dist/protocol.js"
41
+ }
42
+ },
43
+ "dependencies": {
44
+ "zod": "^4.3.6"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^24.0.0",
48
+ "eslint": "^10.0.0",
49
+ "prettier": "^3.8.1",
50
+ "typescript": "6.0.3",
51
+ "vite": "^8.0.0",
52
+ "vitest": "^4.0.0",
53
+ "@repo/eslint-config": "0.0.0",
54
+ "@repo/typescript-config": "0.0.0"
55
+ },
56
+ "scripts": {
57
+ "build": "vite build && tsc -p tsconfig.build.json",
58
+ "lint": "eslint . --max-warnings 0 --cache --cache-location node_modules/.cache/eslint/",
59
+ "format": "prettier --write .",
60
+ "check-format": "prettier --check .",
61
+ "check-types": "tsc --noEmit",
62
+ "test": "vitest run"
63
+ }
64
+ }