@longstoryshort/vtt-sdk 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 tldrpg
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 all
13
+ 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,102 @@
1
+ # @longstoryshort/vtt-sdk
2
+
3
+ Embed a [longstoryshort.app](https://longstoryshort.app) character sheet in any virtual tabletop (VTT) via iframe and postMessage.
4
+
5
+ ## How it works
6
+
7
+ The SDK implements a postMessage-based protocol that lets a VTT extension (the "bridge") embed the LSS sheet in a nested iframe and receive dice rolls, manifests, and other events — without ever reading the sheet's DOM, cookies, or auth token.
8
+
9
+ ```
10
+ OBR / Foundry / … ──► [bridge page, FOREIGN origin]
11
+ │ embeds iframe ↓ ← trust boundary (postMessage only)
12
+ └► [LSS sheet, lss origin]
13
+ ```
14
+
15
+ ## Installation
16
+
17
+ ```sh
18
+ npm install @longstoryshort/vtt-sdk
19
+ ```
20
+
21
+ The Owlbear adapter is an optional peer dependency:
22
+
23
+ ```sh
24
+ npm install @owlbear-rodeo/sdk
25
+ ```
26
+
27
+ ## Packages
28
+
29
+ | Import | Contents |
30
+ |--------|----------|
31
+ | `@longstoryshort/vtt-sdk` | Core: types, `createSheetBridge`, `createSheetClient`, `createBridgeSheetSource`, `formatRollMessage` |
32
+ | `@longstoryshort/vtt-sdk/owlbear` | `OwlbearAdapter`, `syncObrref`, constants |
33
+
34
+ ## Quick start — bridge side
35
+
36
+ ```ts
37
+ import { createSheetBridge, createBridgeSheetSource } from '@longstoryshort/vtt-sdk';
38
+ import { OwlbearAdapter } from '@longstoryshort/vtt-sdk/owlbear';
39
+
40
+ const adapter = new OwlbearAdapter();
41
+ const source = createBridgeSheetSource({
42
+ iframe: document.getElementById('sheet-frame') as HTMLIFrameElement,
43
+ allowedOrigins: ['https://longstoryshort.app'],
44
+ });
45
+
46
+ const dispose = createSheetBridge(source, adapter, {
47
+ messages: {
48
+ connected: '🎲 Sheet connected',
49
+ labelHint: 'Select exactly one token to place a roll label',
50
+ },
51
+ });
52
+
53
+ // later:
54
+ dispose();
55
+ source.dispose();
56
+ ```
57
+
58
+ ## Quick start — sheet side
59
+
60
+ ```ts
61
+ import { createSheetClient } from '@longstoryshort/vtt-sdk';
62
+
63
+ const client = createSheetClient();
64
+
65
+ // emit a roll to the bridge
66
+ client.send({ type: 'DICE_ROLL', payload: { ... } });
67
+
68
+ // receive inbound commands from the bridge
69
+ const unsub = client.onEvent((event) => {
70
+ if (event.type === 'CAPABILITY_COMMAND') { /* handle damage, conditions, … */ }
71
+ });
72
+
73
+ // cleanup
74
+ client.dispose();
75
+ ```
76
+
77
+ ## iframe sandbox requirements
78
+
79
+ The bridge must embed the sheet iframe with at least these sandbox tokens:
80
+
81
+ ```
82
+ sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals"
83
+ ```
84
+
85
+ `allow-same-origin` is required so the sheet can read its auth cookie and access localStorage. Without it the sheet gets an opaque origin and auth breaks.
86
+
87
+ ## Reference bridge
88
+
89
+ A ready-to-adapt React bridge page for Owlbear Rodeo lives in [`bridges/owlbear/index.tsx`](bridges/owlbear/index.tsx). It is NOT published to npm — deploy it as a separate page from your own infrastructure.
90
+
91
+ ## Protocol events
92
+
93
+ | Type | Direction | Description |
94
+ |------|-----------|-------------|
95
+ | `DICE_ROLL` | sheet → bridge | A roll result |
96
+ | `MANIFEST` | sheet → bridge | Sheet capabilities at handshake |
97
+ | `HEALTH_CHANGED` | sheet → bridge | HP after an adjust/set |
98
+ | `CAPABILITY_COMMAND` | bridge → sheet | Narrow inbound ops (adjust HP, toggle condition, …) |
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,51 @@
1
+ import { V as VTTAdapter, a as VTTUser, S as SheetEvent, N as NotifyVariant } from '../../types-CS2uTxCW.js';
2
+
3
+ /**
4
+ * Owlbear Rodeo implementation of {@link VTTAdapter}.
5
+ *
6
+ * Owlbear exposes no public dice API (its 3D roller is first-party and closed),
7
+ * so rendering a roll is our job: we broadcast a result for toasts/logs and add
8
+ * a transient label item over the roller's token — scene items are shared, so
9
+ * everyone at the table sees the floating number. All scene work is best-effort
10
+ * and degrades silently; the broadcast/notification path is the guaranteed core.
11
+ */
12
+ declare class OwlbearAdapter implements VTTAdapter {
13
+ private sdk;
14
+ private obr;
15
+ private user;
16
+ private sessionId;
17
+ private readyPromise;
18
+ private disposed;
19
+ get isAvailable(): boolean;
20
+ ready(): Promise<boolean>;
21
+ private init;
22
+ private loadSdk;
23
+ getSessionId(): string | undefined;
24
+ getCurrentUser(): VTTUser | undefined;
25
+ broadcast(event: SheetEvent): void;
26
+ onEvent(handler: (event: SheetEvent) => void): () => void;
27
+ notify(message: string, variant?: NotifyVariant): void;
28
+ labelOverSelection(text: string, ttlMs?: number): Promise<boolean>;
29
+ dispose(): void;
30
+ }
31
+
32
+ /**
33
+ * Keep the Owlbear handshake (`obrref`) reachable by the SDK across client-side
34
+ * navigation.
35
+ *
36
+ * The OBR SDK reads `obrref` from the URL once, at module-import time, and
37
+ * freezes `isAvailable` from it. But in-app links drop the param, and the host
38
+ * app may load a fresh SDK copy on the next page. So: when we see the param
39
+ * (entry page) we stash it; when it's missing (after navigation) we put it back
40
+ * via `replaceState` before the SDK loads.
41
+ */
42
+ declare function syncObrref(): void;
43
+
44
+ /** Room-wide pub/sub channel for sheet events. Namespaced to avoid collisions. */
45
+ declare const BROADCAST_CHANNEL = "rodeo.lss/sheet-events";
46
+ /** Marks scene items we create (transient roll labels) so we own/clean them. */
47
+ declare const LABEL_METADATA_KEY = "rodeo.lss/label";
48
+ /** How long a roll label floats over a token before it is removed. */
49
+ declare const DEFAULT_LABEL_TTL_MS = 4000;
50
+
51
+ export { BROADCAST_CHANNEL, DEFAULT_LABEL_TTL_MS, LABEL_METADATA_KEY, OwlbearAdapter, syncObrref };
@@ -0,0 +1,202 @@
1
+ // src/adapters/owlbear/constants.ts
2
+ var BROADCAST_CHANNEL = "rodeo.lss/sheet-events";
3
+ var LABEL_METADATA_KEY = "rodeo.lss/label";
4
+ var DEFAULT_LABEL_TTL_MS = 4e3;
5
+
6
+ // src/adapters/owlbear/obrref.ts
7
+ var PARAM = "obrref";
8
+ var STORAGE_KEY = "lss-obrref";
9
+ var DEV = process.env["NODE_ENV"] !== "production";
10
+ function syncObrref() {
11
+ if (typeof window === "undefined") {
12
+ return;
13
+ }
14
+ let url;
15
+ try {
16
+ url = new URL(window.location.href);
17
+ } catch {
18
+ return;
19
+ }
20
+ const current = url.searchParams.get(PARAM);
21
+ if (current) {
22
+ try {
23
+ window.sessionStorage.setItem(STORAGE_KEY, current);
24
+ if (DEV) {
25
+ console.info("[LSS/OBR] obrref captured from URL");
26
+ }
27
+ } catch {
28
+ }
29
+ return;
30
+ }
31
+ let stashed = null;
32
+ try {
33
+ stashed = window.sessionStorage.getItem(STORAGE_KEY);
34
+ } catch {
35
+ }
36
+ if (stashed) {
37
+ url.searchParams.set(PARAM, stashed);
38
+ window.history.replaceState(window.history.state, "", url.toString());
39
+ if (DEV) {
40
+ console.info("[LSS/OBR] obrref restored from stash");
41
+ }
42
+ } else if (DEV) {
43
+ console.warn("[LSS/OBR] obrref missing and no stash to restore");
44
+ }
45
+ }
46
+
47
+ // src/adapters/owlbear/OwlbearAdapter.ts
48
+ var DEV2 = process.env["NODE_ENV"] !== "production";
49
+ var NOTIFY_VARIANT = {
50
+ info: "INFO",
51
+ success: "SUCCESS",
52
+ warning: "WARNING",
53
+ error: "ERROR"
54
+ };
55
+ var OwlbearAdapter = class {
56
+ constructor() {
57
+ this.sdk = null;
58
+ this.obr = null;
59
+ this.readyPromise = null;
60
+ this.disposed = false;
61
+ }
62
+ get isAvailable() {
63
+ return this.obr?.isAvailable ?? false;
64
+ }
65
+ ready() {
66
+ if (!this.readyPromise) {
67
+ this.readyPromise = this.init();
68
+ }
69
+ return this.readyPromise;
70
+ }
71
+ async init() {
72
+ if (typeof window === "undefined") {
73
+ return false;
74
+ }
75
+ syncObrref();
76
+ const sdk = await this.loadSdk();
77
+ if (!sdk) {
78
+ return false;
79
+ }
80
+ this.sdk = sdk;
81
+ this.obr = sdk.default;
82
+ if (DEV2) {
83
+ console.info(
84
+ "[LSS/OBR] init \u2014 isAvailable:",
85
+ this.obr.isAvailable,
86
+ "| isReady:",
87
+ this.obr.isReady
88
+ );
89
+ }
90
+ if (!this.obr.isAvailable) {
91
+ if (DEV2) {
92
+ console.warn("[LSS/OBR] not embedded (origin empty) \u2014 obrref missing at SDK load.");
93
+ }
94
+ return false;
95
+ }
96
+ if (DEV2) {
97
+ console.info("[LSS/OBR] awaiting onReady (isReady =", this.obr.isReady, ")");
98
+ }
99
+ await new Promise((resolve) => {
100
+ this.obr.onReady(resolve);
101
+ });
102
+ if (DEV2) {
103
+ console.info("[LSS/OBR] onReady fired \u2014 fetching player\u2026");
104
+ }
105
+ if (this.disposed) {
106
+ return false;
107
+ }
108
+ this.sessionId = this.obr.room.id;
109
+ const [id, name, role] = await Promise.all([
110
+ this.obr.player.getId(),
111
+ this.obr.player.getName(),
112
+ this.obr.player.getRole()
113
+ ]);
114
+ this.user = { id, name, role: role === "GM" ? "gm" : "player" };
115
+ if (DEV2) {
116
+ console.info("[LSS/OBR] ready \u2014 room:", this.sessionId, "user:", this.user);
117
+ }
118
+ return true;
119
+ }
120
+ async loadSdk() {
121
+ const host = window;
122
+ for (let i = 0; i < 10 && !host.__lssObrSdk; i += 1) {
123
+ await new Promise((resolve) => {
124
+ window.setTimeout(resolve, 50);
125
+ });
126
+ }
127
+ if (host.__lssObrSdk) {
128
+ return host.__lssObrSdk;
129
+ }
130
+ if (DEV2) {
131
+ console.warn("[LSS/OBR] no preloaded SDK \u2014 importing now (may miss OBR_READY)");
132
+ }
133
+ try {
134
+ return await import('@owlbear-rodeo/sdk');
135
+ } catch (error) {
136
+ console.error("[LSS/OBR] SDK import failed:", error);
137
+ return null;
138
+ }
139
+ }
140
+ getSessionId() {
141
+ return this.sessionId;
142
+ }
143
+ getCurrentUser() {
144
+ return this.user;
145
+ }
146
+ broadcast(event) {
147
+ void this.obr?.broadcast.sendMessage(BROADCAST_CHANNEL, event).catch(() => {
148
+ });
149
+ }
150
+ onEvent(handler) {
151
+ if (!this.obr) {
152
+ return () => {
153
+ };
154
+ }
155
+ return this.obr.broadcast.onMessage(BROADCAST_CHANNEL, (message) => {
156
+ handler(message.data);
157
+ });
158
+ }
159
+ notify(message, variant = "info") {
160
+ void this.obr?.notification.show(message, NOTIFY_VARIANT[variant]).catch(() => {
161
+ });
162
+ }
163
+ async labelOverSelection(text, ttlMs = DEFAULT_LABEL_TTL_MS) {
164
+ const { obr, sdk } = this;
165
+ if (!obr || !sdk) {
166
+ return false;
167
+ }
168
+ try {
169
+ const selection = await obr.player.getSelection();
170
+ if (!selection || selection.length !== 1) {
171
+ if (DEV2) {
172
+ console.warn("[OwlbearAdapter] label skipped \u2014 selected tokens:", selection?.length ?? 0);
173
+ }
174
+ return false;
175
+ }
176
+ const [token] = await obr.scene.items.getItems(selection);
177
+ if (!token) {
178
+ if (DEV2) {
179
+ console.warn("[OwlbearAdapter] label skipped \u2014 selected item not found in scene");
180
+ }
181
+ return false;
182
+ }
183
+ const label = sdk.buildLabel().plainText(text).position(token.position).attachedTo(token.id).pointerHeight(0).disableHit(true).locked(true).layer("TEXT").metadata({ [LABEL_METADATA_KEY]: true }).build();
184
+ await obr.scene.items.addItems([label]);
185
+ window.setTimeout(() => {
186
+ void obr.scene.items.deleteItems([label.id]).catch(() => {
187
+ });
188
+ }, ttlMs);
189
+ return true;
190
+ } catch (error) {
191
+ if (DEV2) {
192
+ console.warn("[OwlbearAdapter] label error:", error);
193
+ }
194
+ return false;
195
+ }
196
+ }
197
+ dispose() {
198
+ this.disposed = true;
199
+ }
200
+ };
201
+
202
+ export { BROADCAST_CHANNEL, DEFAULT_LABEL_TTL_MS, LABEL_METADATA_KEY, OwlbearAdapter, syncObrref };
@@ -0,0 +1,72 @@
1
+ import { b as SheetSource, V as VTTAdapter, c as SheetBridgeOptions, d as SheetClientOptions, e as SheetClient, C as CapabilityManifest, S as SheetEvent, M as MessageHost, D as DiceRollPayload, N as NotifyVariant } from './types-CS2uTxCW.js';
2
+ export { f as CapabilityDescriptor, g as CapabilityOpAddTag, h as CapabilityOpAdjust, i as CapabilityOpName, j as CapabilityOpRemoveTag, k as CapabilityOpRequestRoll, l as CapabilityOpSet, m as CapabilityOpToggle, n as CapabilityOperation, H as HealthChangedPayload, o as MessageTarget, p as SheetBridgeMessages, a as VTTUser, q as VTTUserRole } from './types-CS2uTxCW.js';
3
+
4
+ /**
5
+ * Wires a host {@link SheetSource} to a {@link VTTAdapter}. This is the whole
6
+ * integration in one place, and it knows nothing about any specific sheet app or
7
+ * any specific VTT:
8
+ *
9
+ * - a roll on the sheet → local toast for the roller + broadcast to other
10
+ * clients + a transient label over the roller's selected token;
11
+ * - a roll broadcast by another client → local toast.
12
+ *
13
+ * Returns a dispose fn that tears down every subscription it created. The
14
+ * adapter is left untouched — it may be shared and longer-lived than the bridge.
15
+ */
16
+ declare function createSheetBridge(source: SheetSource, adapter: VTTAdapter, options?: SheetBridgeOptions): () => void;
17
+
18
+ /**
19
+ * Sheet-side half of the postMessage transport. The character sheet (running in
20
+ * its own origin, embedded by a VTT bridge in a foreign origin) uses this to push
21
+ * outbound events (rolls) to the bridge and to receive inbound commands — the
22
+ * frame boundary keeps the sheet's DOM, cookies, and token unreadable to the
23
+ * bridge, so only the explicit protocol crosses.
24
+ *
25
+ * Inbound is honored only from the same window we post to (the bridge) and,
26
+ * optionally, an origin allowlist. Returns a safe no-op client during SSR / when
27
+ * no message host is available. The counterpart on the bridge side is
28
+ * `createBridgeSheetSource`.
29
+ */
30
+ declare function createSheetClient(options?: SheetClientOptions): SheetClient;
31
+
32
+ /** A reference to the embedded sheet iframe — only `contentWindow` is read (live). */
33
+ interface SheetFrameRef {
34
+ contentWindow: Window | null;
35
+ }
36
+ interface BridgeSheetSourceOptions {
37
+ /** The embedded sheet iframe. Its *live* `contentWindow` is the message peer. */
38
+ iframe: SheetFrameRef;
39
+ /** Window to listen on. Default: `window`. */
40
+ host?: MessageHost;
41
+ /** Honor inbound only from these origins (the sheet's origin). */
42
+ allowedOrigins?: string[];
43
+ /** Origin to post inbound commands to. Default `'*'`. */
44
+ targetOrigin?: string;
45
+ }
46
+ /** A `SheetSource` (for `createSheetBridge`) plus raw access and inbound `send`. */
47
+ interface BridgeSheetSource extends SheetSource {
48
+ /** Subscribe to the sheet's capability manifest (sent once at handshake). Returns an unsubscribe fn. */
49
+ onManifest(handler: (manifest: CapabilityManifest) => void): () => void;
50
+ /** Subscribe to every event coming from the sheet (not only rolls). */
51
+ onEvent(handler: (event: SheetEvent) => void): () => void;
52
+ /** Post an inbound command to the sheet (e.g. `CAPABILITY_COMMAND`). */
53
+ send(event: SheetEvent): void;
54
+ dispose(): void;
55
+ }
56
+ /**
57
+ * Bridge-side half of the postMessage transport. Runs in the bridge frame (the
58
+ * VTT extension), listens to the embedded sheet iframe, and exposes a
59
+ * `SheetSource` so `createSheetBridge` can drive the VTT adapter — without the
60
+ * bridge ever touching the sheet's internals.
61
+ *
62
+ * `contentWindow` is read live on every message/send, so it survives the sheet
63
+ * iframe navigating or reloading (e.g. Gatsby dev's full-reload on navigation).
64
+ */
65
+ declare function createBridgeSheetSource(options: BridgeSheetSourceOptions): BridgeSheetSource;
66
+
67
+ /** Toast text for a roll, e.g. "🎲 Alice: Longsword Attack — 18 💥". */
68
+ declare function formatRollMessage(payload: DiceRollPayload): string;
69
+ /** Maps a roll's crit state onto a toast variant. */
70
+ declare function rollVariant(payload: DiceRollPayload): NotifyVariant;
71
+
72
+ export { type BridgeSheetSource, type BridgeSheetSourceOptions, CapabilityManifest, DiceRollPayload, MessageHost, NotifyVariant, SheetBridgeOptions, SheetClient, SheetClientOptions, SheetEvent, type SheetFrameRef, SheetSource, VTTAdapter, createBridgeSheetSource, createSheetBridge, createSheetClient, formatRollMessage, rollVariant };
package/dist/index.js ADDED
@@ -0,0 +1,188 @@
1
+ // src/formatRoll.ts
2
+ function formatRollMessage(payload) {
3
+ let crit = "";
4
+ if (payload.isCrit) {
5
+ if (payload.critKind === "success") {
6
+ crit = " \u{1F4A5}";
7
+ } else if (payload.critKind === "failure") {
8
+ crit = " \u{1F480}";
9
+ }
10
+ }
11
+ return `\u{1F3B2} ${payload.characterName}: ${payload.title} \u2014 ${payload.total}${crit}`;
12
+ }
13
+ function rollVariant(payload) {
14
+ if (payload.isCrit && payload.critKind === "success") {
15
+ return "success";
16
+ }
17
+ if (payload.isCrit && payload.critKind === "failure") {
18
+ return "warning";
19
+ }
20
+ return "info";
21
+ }
22
+
23
+ // src/createSheetBridge.ts
24
+ var DEFAULT_MESSAGES = {
25
+ connected: "\u{1F3B2} Sheet connected to the table",
26
+ labelHint: "No label placed \u2014 select exactly one of your tokens on the map"
27
+ };
28
+ function createSheetBridge(source, adapter, options = {}) {
29
+ const messages = { ...DEFAULT_MESSAGES, ...options.messages };
30
+ const cleanups = [];
31
+ let cancelled = false;
32
+ cleanups.push(source.onRoll((roll) => {
33
+ if (!adapter.isAvailable) {
34
+ return;
35
+ }
36
+ adapter.notify(formatRollMessage(roll), rollVariant(roll));
37
+ adapter.broadcast({ type: "DICE_ROLL", payload: roll });
38
+ void adapter.labelOverSelection(roll.total).then((placed) => {
39
+ if (!placed) {
40
+ adapter.notify(messages.labelHint, "warning");
41
+ }
42
+ });
43
+ }));
44
+ void adapter.ready().then((available) => {
45
+ if (cancelled || !available) {
46
+ return;
47
+ }
48
+ adapter.notify(messages.connected, "success");
49
+ cleanups.push(adapter.onEvent((event) => {
50
+ if (event.type !== "DICE_ROLL") {
51
+ return;
52
+ }
53
+ adapter.notify(formatRollMessage(event.payload), rollVariant(event.payload));
54
+ }));
55
+ });
56
+ return () => {
57
+ cancelled = true;
58
+ cleanups.forEach((cleanup) => cleanup());
59
+ };
60
+ }
61
+
62
+ // src/postMessageProtocol.ts
63
+ var MARKER = "__lssSheetSdk";
64
+ var VERSION = 1;
65
+ function wrapEnvelope(event) {
66
+ return { __lssSheetSdk: VERSION, event };
67
+ }
68
+ function readEnvelope(data) {
69
+ if (typeof data === "object" && data !== null) {
70
+ const record = data;
71
+ if (record[MARKER] === VERSION && "event" in record) {
72
+ return record.event;
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+
78
+ // src/createSheetClient.ts
79
+ function createSheetClient(options = {}) {
80
+ const host = options.host ?? (typeof window !== "undefined" ? window : void 0);
81
+ const target = options.target ?? (typeof window !== "undefined" ? window.parent : void 0);
82
+ const targetOrigin = options.targetOrigin ?? "*";
83
+ const { allowedOrigins } = options;
84
+ if (!host) {
85
+ return { send: () => {
86
+ }, onEvent: () => () => {
87
+ }, dispose: () => {
88
+ } };
89
+ }
90
+ const handlers = /* @__PURE__ */ new Set();
91
+ const listener = (event) => {
92
+ if (target && event.source !== target) {
93
+ return;
94
+ }
95
+ if (allowedOrigins && !allowedOrigins.includes(event.origin)) {
96
+ return;
97
+ }
98
+ const sheetEvent = readEnvelope(event.data);
99
+ if (!sheetEvent) {
100
+ return;
101
+ }
102
+ handlers.forEach((handler) => handler(sheetEvent));
103
+ };
104
+ host.addEventListener("message", listener);
105
+ return {
106
+ send(event) {
107
+ if (!target) {
108
+ return;
109
+ }
110
+ target.postMessage(wrapEnvelope(event), targetOrigin);
111
+ },
112
+ onEvent(handler) {
113
+ handlers.add(handler);
114
+ return () => {
115
+ handlers.delete(handler);
116
+ };
117
+ },
118
+ dispose() {
119
+ handlers.clear();
120
+ host.removeEventListener("message", listener);
121
+ }
122
+ };
123
+ }
124
+
125
+ // src/createBridgeSheetSource.ts
126
+ function createBridgeSheetSource(options) {
127
+ const host = options.host ?? (typeof window !== "undefined" ? window : void 0);
128
+ const targetOrigin = options.targetOrigin ?? "*";
129
+ const { iframe, allowedOrigins } = options;
130
+ const handlers = /* @__PURE__ */ new Set();
131
+ const listener = (event) => {
132
+ if (event.source !== iframe.contentWindow) {
133
+ return;
134
+ }
135
+ if (allowedOrigins && !allowedOrigins.includes(event.origin)) {
136
+ return;
137
+ }
138
+ const sheetEvent = readEnvelope(event.data);
139
+ if (!sheetEvent) {
140
+ return;
141
+ }
142
+ handlers.forEach((handler) => handler(sheetEvent));
143
+ };
144
+ if (host) {
145
+ host.addEventListener("message", listener);
146
+ }
147
+ return {
148
+ onRoll(handler) {
149
+ const wrapped = (event) => {
150
+ if (event.type === "DICE_ROLL") {
151
+ handler(event.payload);
152
+ }
153
+ };
154
+ handlers.add(wrapped);
155
+ return () => {
156
+ handlers.delete(wrapped);
157
+ };
158
+ },
159
+ onManifest(handler) {
160
+ const wrapped = (event) => {
161
+ if (event.type === "MANIFEST") {
162
+ handler(event.payload);
163
+ }
164
+ };
165
+ handlers.add(wrapped);
166
+ return () => {
167
+ handlers.delete(wrapped);
168
+ };
169
+ },
170
+ onEvent(handler) {
171
+ handlers.add(handler);
172
+ return () => {
173
+ handlers.delete(handler);
174
+ };
175
+ },
176
+ send(event) {
177
+ iframe.contentWindow?.postMessage(wrapEnvelope(event), targetOrigin);
178
+ },
179
+ dispose() {
180
+ handlers.clear();
181
+ if (host) {
182
+ host.removeEventListener("message", listener);
183
+ }
184
+ }
185
+ };
186
+ }
187
+
188
+ export { createBridgeSheetSource, createSheetBridge, createSheetClient, formatRollMessage, rollVariant };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * LSS Sheet SDK — public types.
3
+ *
4
+ * This file must not import from any project outside this SDK directory —
5
+ * only from other SDK files and external dependencies.
6
+ */
7
+ type VTTUserRole = 'gm' | 'player';
8
+ interface VTTUser {
9
+ id: string;
10
+ name: string;
11
+ role: VTTUserRole;
12
+ }
13
+ type NotifyVariant = 'info' | 'success' | 'warning' | 'error';
14
+ /** A single dice roll made on a character sheet, normalized for any VTT. */
15
+ interface DiceRollPayload {
16
+ characterId: string;
17
+ characterName: string;
18
+ /** Human label, e.g. "Атака Длинный меч" or "Спасбросок Ловкость". */
19
+ title: string;
20
+ /** Dice notation as shown, e.g. "(1к20) + 5". */
21
+ formula: string;
22
+ /** Rolled breakdown, e.g. "(15) + 5". */
23
+ breakdown: string;
24
+ /** Final total as a string — supports advantage pairs like "18 | 7". */
25
+ total: string;
26
+ isCrit: boolean;
27
+ critKind?: 'success' | 'failure';
28
+ timestamp: number;
29
+ }
30
+ type CapabilityOpName = 'adjust' | 'set' | 'toggle' | 'add-tag' | 'remove-tag' | 'request-roll';
31
+ /**
32
+ * adjust: apply a signed delta to a numeric capability.
33
+ * Sign convention: negative delta = subtract (damage), positive delta = add (heal/gain).
34
+ * The sheet applies its own rules (temp HP absorption, resistances, clamping).
35
+ */
36
+ interface CapabilityOpAdjust {
37
+ op: 'adjust';
38
+ capabilityId: string;
39
+ delta: number;
40
+ }
41
+ /** set: overwrite a capability value outright. */
42
+ interface CapabilityOpSet {
43
+ op: 'set';
44
+ capabilityId: string;
45
+ value: number | boolean | string;
46
+ }
47
+ /** toggle: flip a boolean capability. */
48
+ interface CapabilityOpToggle {
49
+ op: 'toggle';
50
+ capabilityId: string;
51
+ }
52
+ /**
53
+ * add-tag: append a label to a tag-track capability (e.g. a condition).
54
+ * Idempotent — if the value is already present, the sheet must ignore the command.
55
+ */
56
+ interface CapabilityOpAddTag {
57
+ op: 'add-tag';
58
+ capabilityId: string;
59
+ value: string;
60
+ }
61
+ /**
62
+ * remove-tag: remove a label from a tag-track capability.
63
+ * Removes the first occurrence. Assumes a well-behaved set (no duplicates in DnD5e conditions).
64
+ */
65
+ interface CapabilityOpRemoveTag {
66
+ op: 'remove-tag';
67
+ capabilityId: string;
68
+ value: string;
69
+ }
70
+ /** request-roll: ask the sheet to roll a d20 check against an optional DC. */
71
+ interface CapabilityOpRequestRoll {
72
+ op: 'request-roll';
73
+ capabilityId: string;
74
+ rollType: string;
75
+ dc?: number;
76
+ }
77
+ type CapabilityOperation = CapabilityOpAdjust | CapabilityOpSet | CapabilityOpToggle | CapabilityOpAddTag | CapabilityOpRemoveTag | CapabilityOpRequestRoll;
78
+ /** One capability entry in the sheet manifest. */
79
+ interface CapabilityDescriptor {
80
+ id: string;
81
+ operations: CapabilityOpName[];
82
+ }
83
+ /**
84
+ * Capability manifest — the sheet's public declaration of what the host may do.
85
+ * Sent outbound by the sheet at handshake time; the host reads it to know which
86
+ * CAPABILITY_COMMAND operations are valid for this member.
87
+ */
88
+ interface CapabilityManifest {
89
+ version: '1';
90
+ /** Short identifier for the sheet system, e.g. 'dnd5e' or 'anima'. */
91
+ sheetSystem: string;
92
+ capabilities: CapabilityDescriptor[];
93
+ }
94
+ /** Outbound fact: HP values after the sheet has applied an adjust/set command. */
95
+ interface HealthChangedPayload {
96
+ characterId: string;
97
+ current: number;
98
+ max: number;
99
+ temp: number;
100
+ }
101
+ /**
102
+ * Everything that crosses the sheet↔VTT boundary.
103
+ *
104
+ * Outbound (sheet → host): DICE_ROLL · MANIFEST · HEALTH_CHANGED
105
+ * Inbound (host → sheet): CAPABILITY_COMMAND
106
+ */
107
+ type SheetEvent = {
108
+ type: 'DICE_ROLL';
109
+ payload: DiceRollPayload;
110
+ } | {
111
+ type: 'MANIFEST';
112
+ payload: CapabilityManifest;
113
+ } | {
114
+ type: 'HEALTH_CHANGED';
115
+ payload: HealthChangedPayload;
116
+ } | {
117
+ type: 'CAPABILITY_COMMAND';
118
+ payload: CapabilityOperation;
119
+ };
120
+ /**
121
+ * Implemented by the host (the character-sheet app) so the bridge stays
122
+ * agnostic of any specific app. The host normalizes its own roll representation
123
+ * into a {@link DiceRollPayload} and pushes it through `onRoll`.
124
+ */
125
+ interface SheetSource {
126
+ /** Subscribe to rolls made on the sheet. Returns an unsubscribe fn. */
127
+ onRoll(handler: (roll: DiceRollPayload) => void): () => void;
128
+ }
129
+ /** Human-facing strings surfaced by the bridge — override to localize. */
130
+ interface SheetBridgeMessages {
131
+ /** Toast shown once the sheet connects to the table. */
132
+ connected: string;
133
+ /** Toast shown to the roller when a token label could not be placed. */
134
+ labelHint: string;
135
+ }
136
+ interface SheetBridgeOptions {
137
+ messages?: Partial<SheetBridgeMessages>;
138
+ }
139
+ /**
140
+ * The seam every VTT implements. The sheet talks only to this interface; each
141
+ * table (Owlbear, Foundry, …) ships a thin adapter that maps its own SDK onto
142
+ * these methods.
143
+ */
144
+ interface VTTAdapter {
145
+ /** True only when the page actually runs inside this VTT. */
146
+ readonly isAvailable: boolean;
147
+ /**
148
+ * Loads and handshakes with the VTT SDK. Resolves `true` once ready, or
149
+ * `false` if the page is not running inside this VTT. Safe to call multiple
150
+ * times — the work happens once.
151
+ */
152
+ ready(): Promise<boolean>;
153
+ getSessionId(): string | undefined;
154
+ getCurrentUser(): VTTUser | undefined;
155
+ /** Send an event to every other client in the room (sender excluded). */
156
+ broadcast(event: SheetEvent): void;
157
+ /** Subscribe to events broadcast by other clients. Returns an unsubscribe fn. */
158
+ onEvent(handler: (event: SheetEvent) => void): () => void;
159
+ /** Local toast on this client. */
160
+ notify(message: string, variant?: NotifyVariant): void;
161
+ /**
162
+ * Float a transient text label over the player's currently selected token.
163
+ * Scene items are shared, so the label is visible to everyone at the table.
164
+ * Resolves `true` if a label was placed, `false` when there isn't exactly
165
+ * one token selected or the scene write was rejected (e.g. no permission).
166
+ */
167
+ labelOverSelection(text: string, ttlMs?: number): Promise<boolean>;
168
+ /** Tear down listeners / SDK handlers. */
169
+ dispose(): void;
170
+ }
171
+ /** Minimal "listen for messages" surface — the real `window` satisfies it. */
172
+ interface MessageHost {
173
+ addEventListener(type: 'message', listener: (event: MessageEvent) => void): void;
174
+ removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void;
175
+ }
176
+ /** Minimal "post a message" surface — a real `Window` (e.g. `window.parent`) satisfies it. */
177
+ interface MessageTarget {
178
+ postMessage(message: unknown, targetOrigin: string): void;
179
+ }
180
+ interface SheetClientOptions {
181
+ /** Window to post outbound events to (the embedding bridge). Default: `window.parent`. */
182
+ target?: MessageTarget;
183
+ /** Window to listen on for inbound events. Default: `window`. */
184
+ host?: MessageHost;
185
+ /** If set, inbound events are honored only from these origins. */
186
+ allowedOrigins?: string[];
187
+ /**
188
+ * Origin to post outbound events to. Default `'*'`. Override with the bridge's
189
+ * origin once known — outbound carries only non-sensitive data, never the token.
190
+ */
191
+ targetOrigin?: string;
192
+ }
193
+ /**
194
+ * Sheet-side half of the postMessage transport. The sheet (its own origin,
195
+ * embedded by a VTT bridge) emits outbound events and receives inbound ones
196
+ * without exposing DOM/cookies/token across the frame boundary.
197
+ */
198
+ interface SheetClient {
199
+ /** Send an outbound event (e.g. a dice roll or manifest) to the embedding bridge. */
200
+ send(event: SheetEvent): void;
201
+ /** Subscribe to inbound events/commands from the bridge. Returns an unsubscribe fn. */
202
+ onEvent(handler: (event: SheetEvent) => void): () => void;
203
+ /** Remove the inbound listener. */
204
+ dispose(): void;
205
+ }
206
+
207
+ export type { CapabilityManifest as C, DiceRollPayload as D, HealthChangedPayload as H, MessageHost as M, NotifyVariant as N, SheetEvent as S, VTTAdapter as V, VTTUser as a, SheetSource as b, SheetBridgeOptions as c, SheetClientOptions as d, SheetClient as e, CapabilityDescriptor as f, CapabilityOpAddTag as g, CapabilityOpAdjust as h, CapabilityOpName as i, CapabilityOpRemoveTag as j, CapabilityOpRequestRoll as k, CapabilityOpSet as l, CapabilityOpToggle as m, CapabilityOperation as n, MessageTarget as o, SheetBridgeMessages as p, VTTUserRole as q };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@longstoryshort/vtt-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Embed a longstoryshort.app character sheet in any virtual tabletop via iframe and postMessage",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./owlbear": {
15
+ "import": "./dist/adapters/owlbear/index.js",
16
+ "types": "./dist/adapters/owlbear/index.d.ts"
17
+ }
18
+ },
19
+ "typesVersions": {
20
+ "*": {
21
+ "owlbear": ["./dist/adapters/owlbear/index.d.ts"]
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "test": "vitest run",
30
+ "typecheck": "tsc --noEmit",
31
+ "lint": "eslint src"
32
+ },
33
+ "peerDependencies": {
34
+ "@owlbear-rodeo/sdk": ">=3.0.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "@owlbear-rodeo/sdk": {
38
+ "optional": true
39
+ }
40
+ },
41
+ "devDependencies": {
42
+ "@owlbear-rodeo/sdk": "^3.1.0",
43
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
44
+ "@typescript-eslint/parser": "^8.0.0",
45
+ "eslint": "^9.0.0",
46
+ "tsup": "^8.0.0",
47
+ "typescript": "^5.5.0",
48
+ "vitest": "^2.0.0"
49
+ }
50
+ }