@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 +21 -0
- package/README.md +42 -0
- package/dist/bundle.d.ts +83 -0
- package/dist/bundle.js +158 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +250 -0
- package/dist/manifest.d.ts +205 -0
- package/dist/manifest.js +152 -0
- package/dist/protocol.d.ts +77 -0
- package/dist/protocol.js +52 -0
- package/dist/values-BygiDSdW.js +60 -0
- package/dist/values.d.ts +62 -0
- package/package.json +64 -0
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.
|
package/dist/bundle.d.ts
ADDED
|
@@ -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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/manifest.js
ADDED
|
@@ -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;
|
package/dist/protocol.js
ADDED
|
@@ -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 };
|
package/dist/values.d.ts
ADDED
|
@@ -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
|
+
}
|