@magelight/sdk 0.28.6

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 Severause
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,32 @@
1
+ # @magelight/sdk
2
+
3
+ Typed page bridge for [Magelight UI](../../README.md), the Ultralight-based web UI host for
4
+ Skyrim SE/AE. Framework-agnostic; `@magelight/react` adds hooks on top.
5
+
6
+ ```ts
7
+ import { host, bridge, channel } from '@magelight/sdk';
8
+
9
+ if (host.present) console.log(`running on Magelight ${host.version} as ${host.modId}/${host.viewName}`);
10
+
11
+ // host → page: whatever the mod sends with InteropCall(view, 'state', json)
12
+ const state = channel<{ volume: number }>('state');
13
+ state.on((s) => render(s)); // replayed if it arrived before this line
14
+
15
+ // page → host: reaches the listener the mod registered as 'save'
16
+ bridge.send('save', { volume: 70 }); // objects are JSON-encoded, strings pass through
17
+
18
+ bridge.onUIMode((on) => on ? input.focus() : input.blur());
19
+ ```
20
+
21
+ - **`host`** — `present`, `version`, `versionNumber`, `modId`, `viewName`, `viewId`, `dev`,
22
+ `can('textureImage')`, `atLeast(0, 15)`, `imageUrl(name)`.
23
+ - **`bridge.on / send / onRaw / pending / onUIMode`**, **`channel<T>(name)`**, **`ready()`**.
24
+ - **Pre-mount replay** — the host buffers any channel payload until someone subscribes, so a
25
+ payload sent from `ViewDomReady` is never lost to a late React mount.
26
+ - **Browser mock** — with no host present the SDK installs `window.magelight` itself and a
27
+ floating panel to dispatch payloads / watch sends, so `vite dev` works on the page alone.
28
+ Opt out with `window.__MAGELIGHT_NO_MOCK__ = true` before the SDK loads.
29
+
30
+ Host side: any channel with no `window.<channel>` shim routes into the page core, so a mod
31
+ needs no page-side registration for host → page traffic; page → host still needs
32
+ `RegisterJSListenerEx(view, channel, …)` (or Papyrus `RegisterListener`).
@@ -0,0 +1,37 @@
1
+ import { host } from './host';
2
+ import type { Handler, PageCore, Unsubscribe } from './types';
3
+ /**
4
+ * The page core: the host's `window.magelight` in the game, a mock in a
5
+ * plain browser (set `window.__MAGELIGHT_NO_MOCK__ = true` before the SDK
6
+ * loads to opt out, e.g. under another host that has its own bridge).
7
+ */
8
+ export declare function ensureCore(): PageCore;
9
+ /**
10
+ * Parse what the host sent on a channel. Objects arrive as JSON text
11
+ * (`InteropCall(view, channel, json)`); plain strings pass through when
12
+ * they are not JSON. Mirrors what SeverActions' bridge learned in the
13
+ * field: never throw on a payload, log and return the raw text.
14
+ */
15
+ export declare function parsePayload<T = unknown>(raw: string): T;
16
+ export declare const bridge: {
17
+ /** page → host. Objects are JSON-encoded; strings pass through. */
18
+ send(channel: string, payload?: unknown): void;
19
+ /** host → page. Payloads that arrived before this call are replayed at once. */
20
+ on<T = unknown>(channel: string, fn: Handler<T>): Unsubscribe;
21
+ /** raw text variant of `on` */
22
+ onRaw(channel: string, fn: Handler<string>): Unsubscribe;
23
+ /** how many payloads wait on a channel nobody subscribed to yet */
24
+ pending(channel: string): number;
25
+ /** true while this view holds UI mode (cursor up, keyboard to the page) */
26
+ onUIMode(fn: Handler<boolean>): Unsubscribe;
27
+ };
28
+ /** A typed channel handle: `const state = channel<State>('state'); state.on(s => …); state.send(...)` */
29
+ export declare function channel<T = unknown>(name: string): {
30
+ name: string;
31
+ on(fn: Handler<T>): Unsubscribe;
32
+ send(payload?: unknown): void;
33
+ readonly pending: number;
34
+ };
35
+ /** Tell the host-side mod the page is mounted (a plain send on `ready`; register a listener for it). */
36
+ export declare function ready(detail?: unknown): void;
37
+ export { host };
package/dist/bridge.js ADDED
@@ -0,0 +1,82 @@
1
+ import { host } from './host';
2
+ import { installMock } from './mock';
3
+ let core;
4
+ /**
5
+ * The page core: the host's `window.magelight` in the game, a mock in a
6
+ * plain browser (set `window.__MAGELIGHT_NO_MOCK__ = true` before the SDK
7
+ * loads to opt out, e.g. under another host that has its own bridge).
8
+ */
9
+ export function ensureCore() {
10
+ if (core)
11
+ return core;
12
+ if (typeof window === 'undefined')
13
+ throw new Error('@magelight/sdk needs a window');
14
+ if (window.magelight)
15
+ return (core = window.magelight);
16
+ if (window.__MAGELIGHT_NO_MOCK__) {
17
+ throw new Error('@magelight/sdk: no host and the mock is disabled');
18
+ }
19
+ return (core = installMock());
20
+ }
21
+ /**
22
+ * Parse what the host sent on a channel. Objects arrive as JSON text
23
+ * (`InteropCall(view, channel, json)`); plain strings pass through when
24
+ * they are not JSON. Mirrors what SeverActions' bridge learned in the
25
+ * field: never throw on a payload, log and return the raw text.
26
+ */
27
+ export function parsePayload(raw) {
28
+ if (raw === '')
29
+ return '';
30
+ const c = raw[0];
31
+ if (c === '{' || c === '[' || c === '"' || c === '-' || (c >= '0' && c <= '9') || raw === 'true' || raw === 'false' || raw === 'null') {
32
+ try {
33
+ return JSON.parse(raw);
34
+ }
35
+ catch {
36
+ /* not JSON after all */
37
+ }
38
+ }
39
+ return raw;
40
+ }
41
+ export const bridge = {
42
+ /** page → host. Objects are JSON-encoded; strings pass through. */
43
+ send(channel, payload) {
44
+ ensureCore().send(channel, payload);
45
+ },
46
+ /** host → page. Payloads that arrived before this call are replayed at once. */
47
+ on(channel, fn) {
48
+ return ensureCore().on(channel, (raw) => fn(parsePayload(raw)));
49
+ },
50
+ /** raw text variant of `on` */
51
+ onRaw(channel, fn) {
52
+ return ensureCore().on(channel, fn);
53
+ },
54
+ /** how many payloads wait on a channel nobody subscribed to yet */
55
+ pending(channel) {
56
+ return ensureCore().pending(channel);
57
+ },
58
+ /** true while this view holds UI mode (cursor up, keyboard to the page) */
59
+ onUIMode(fn) {
60
+ return ensureCore().on('__uimode', (raw) => fn(raw === '1'));
61
+ },
62
+ };
63
+ /** A typed channel handle: `const state = channel<State>('state'); state.on(s => …); state.send(...)` */
64
+ export function channel(name) {
65
+ return {
66
+ name,
67
+ on(fn) {
68
+ return bridge.on(name, fn);
69
+ },
70
+ send(payload) {
71
+ bridge.send(name, payload);
72
+ },
73
+ get pending() {
74
+ return bridge.pending(name);
75
+ },
76
+ };
77
+ }
78
+ /** Tell the host-side mod the page is mounted (a plain send on `ready`; register a listener for it). */
79
+ export function ready(detail) {
80
+ bridge.send('ready', detail);
81
+ }
82
+ export { host };
package/dist/host.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { HostInfo } from './types';
2
+ /**
3
+ * Who is hosting this page. Read lazily: the host installs its globals
4
+ * before any page script runs, but a module evaluated in a plain browser
5
+ * (or under PrismaUI) sees `present === false`.
6
+ */
7
+ export declare const host: {
8
+ readonly present: boolean;
9
+ readonly info: HostInfo;
10
+ readonly version: string;
11
+ readonly versionNumber: number;
12
+ readonly modId: string;
13
+ readonly viewName: string;
14
+ readonly viewId: number;
15
+ readonly dev: boolean;
16
+ /** `host.can('textureImage')` — 0/absent = no */
17
+ can(capability: string): boolean;
18
+ /** at least `major.minor.patch` */
19
+ atLeast(major: number, minor: number, patch?: number): boolean;
20
+ /** URL of a host-registered texture image (`RegisterTextureImage` name) */
21
+ imageUrl(name: string): string;
22
+ };
23
+ /** Convenience: `if (isMagelight()) document.documentElement.classList.add('host-magelight')` */
24
+ export declare function isMagelight(): boolean;
package/dist/host.js ADDED
@@ -0,0 +1,62 @@
1
+ const absent = {
2
+ version: '',
3
+ versionNumber: 0,
4
+ viewId: 0,
5
+ modId: '',
6
+ viewName: '',
7
+ capabilities: {},
8
+ dev: false,
9
+ runtimeUrl: '',
10
+ };
11
+ function read() {
12
+ return typeof window !== 'undefined' ? window.__MAGELIGHT__ : undefined;
13
+ }
14
+ /**
15
+ * Who is hosting this page. Read lazily: the host installs its globals
16
+ * before any page script runs, but a module evaluated in a plain browser
17
+ * (or under PrismaUI) sees `present === false`.
18
+ */
19
+ export const host = {
20
+ get present() {
21
+ return read() !== undefined;
22
+ },
23
+ get info() {
24
+ var _a;
25
+ return (_a = read()) !== null && _a !== void 0 ? _a : absent;
26
+ },
27
+ get version() {
28
+ return this.info.version;
29
+ },
30
+ get versionNumber() {
31
+ return this.info.versionNumber;
32
+ },
33
+ get modId() {
34
+ return this.info.modId;
35
+ },
36
+ get viewName() {
37
+ return this.info.viewName;
38
+ },
39
+ get viewId() {
40
+ return this.info.viewId;
41
+ },
42
+ get dev() {
43
+ return this.info.dev;
44
+ },
45
+ /** `host.can('textureImage')` — 0/absent = no */
46
+ can(capability) {
47
+ var _a;
48
+ return ((_a = this.info.capabilities[capability]) !== null && _a !== void 0 ? _a : 0) > 0;
49
+ },
50
+ /** at least `major.minor.patch` */
51
+ atLeast(major, minor, patch = 0) {
52
+ return this.versionNumber >= major * 10000 + minor * 100 + patch;
53
+ },
54
+ /** URL of a host-registered texture image (`RegisterTextureImage` name) */
55
+ imageUrl(name) {
56
+ return this.info.runtimeUrl ? `${this.info.runtimeUrl}images/${name}.imgsrc` : '';
57
+ },
58
+ };
59
+ /** Convenience: `if (isMagelight()) document.documentElement.classList.add('host-magelight')` */
60
+ export function isMagelight() {
61
+ return host.present;
62
+ }
@@ -0,0 +1,5 @@
1
+ export { host, isMagelight } from './host';
2
+ export { bridge, channel, ready, parsePayload, ensureCore } from './bridge';
3
+ export { installMock } from './mock';
4
+ export type { HostInfo, PageCore, Handler, Unsubscribe } from './types';
5
+ export type { MockOptions } from './mock';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { host, isMagelight } from './host';
2
+ export { bridge, channel, ready, parsePayload, ensureCore } from './bridge';
3
+ export { installMock } from './mock';
package/dist/mock.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { HostInfo, PageCore } from './types';
2
+ /**
3
+ * A stand-in `window.magelight` for a plain browser: lets you develop a
4
+ * page with `vite dev`, inject payloads on any channel from a floating
5
+ * panel, and see what the page sent. Installed by `ensureCore()` when no
6
+ * host is present; never installed in the game.
7
+ */
8
+ export interface MockOptions {
9
+ /** show the floating panel (default: true when a document exists) */
10
+ panel?: boolean;
11
+ /** pretend to be this host (default: a dev host with every capability) */
12
+ info?: Partial<HostInfo>;
13
+ /** called for every page → host send */
14
+ onSend?: (channel: string, payload: string) => void;
15
+ }
16
+ export declare function installMock(opts?: MockOptions): PageCore;
package/dist/mock.js ADDED
@@ -0,0 +1,134 @@
1
+ export function installMock(opts = {}) {
2
+ var _a;
3
+ const subs = {};
4
+ const buf = {};
5
+ const sent = [];
6
+ const info = {
7
+ // Pretend to be a current host: version gates (host.atLeast, HostGate
8
+ // minVersion) must pass so version-gated pages can be iterated in the
9
+ // browser. Override with opts.info to test the gate UI itself.
10
+ version: '99.0.0-mock',
11
+ versionNumber: 990000,
12
+ viewId: 1,
13
+ modId: 'Mock',
14
+ viewName: 'dev',
15
+ // The canonical capability set (matches the host's kNames list, 0.28.4).
16
+ // Values are the browser-mock truth: no GPU/VR/native powers, the
17
+ // always-on page/logic capabilities on, the browser's own network open
18
+ // (loopback / networkPolicy). `mock` flags the mock itself.
19
+ capabilities: { gpu: 0, textureImage: 0, clipPathHole: 0, pause: 1, events: 1, clipboard: 1,
20
+ networkDeny: 0, sessions: 1, manifest: 1, http: 0, vr: 0, hotkeys: 1, evaljs: 1,
21
+ pagebridge: 1, cutout: 0, hibernate: 0, inspector: 0, ime: 0, loopback: 1,
22
+ escapeCapture: 0, viewOrder: 0, scrollStep: 0, networkPolicy: 1, mock: 1 },
23
+ dev: true,
24
+ runtimeUrl: '',
25
+ ...opts.info,
26
+ };
27
+ const core = {
28
+ send(channel, payload) {
29
+ var _a;
30
+ const a = payload == null ? '' : typeof payload === 'string' ? payload : JSON.stringify(payload);
31
+ sent.push({ channel, payload: a });
32
+ (_a = opts.onSend) === null || _a === void 0 ? void 0 : _a.call(opts, channel, a);
33
+ log(`→ ${channel}(${a.length > 120 ? a.slice(0, 120) + '…' : a})`);
34
+ },
35
+ on(channel, fn) {
36
+ var _a;
37
+ ((_a = subs[channel]) !== null && _a !== void 0 ? _a : (subs[channel] = [])).push(fn);
38
+ const b = buf[channel];
39
+ if (b) {
40
+ delete buf[channel];
41
+ // Host parity: one throwing handler does not kill the rest of the
42
+ // buffer or propagate into the subscribe call site.
43
+ for (const arg of b) {
44
+ try {
45
+ fn(arg);
46
+ }
47
+ catch (e) {
48
+ console.error(`[magelight mock] replay for ${channel} threw`, e);
49
+ }
50
+ }
51
+ }
52
+ return () => core.off(channel, fn);
53
+ },
54
+ off(channel, fn) {
55
+ const s = subs[channel];
56
+ if (!s)
57
+ return;
58
+ const i = s.indexOf(fn);
59
+ if (i >= 0)
60
+ s.splice(i, 1);
61
+ },
62
+ _dispatch(channel, arg) {
63
+ var _a;
64
+ const s = subs[channel];
65
+ if (!s || !s.length) {
66
+ ((_a = buf[channel]) !== null && _a !== void 0 ? _a : (buf[channel] = [])).push(arg);
67
+ return;
68
+ }
69
+ for (const fn of s) {
70
+ try {
71
+ fn(arg);
72
+ }
73
+ catch (e) {
74
+ console.error(`[magelight mock] handler for ${channel} threw`, e);
75
+ }
76
+ }
77
+ },
78
+ pending(channel) {
79
+ var _a;
80
+ return ((_a = buf[channel]) !== null && _a !== void 0 ? _a : []).length;
81
+ },
82
+ };
83
+ window.__MAGELIGHT__ = info;
84
+ window.magelight = core;
85
+ window.__MAGELIGHT_MOCK__ = { sent, core };
86
+ let logEl = null;
87
+ function log(line) {
88
+ var _a;
89
+ if (logEl) {
90
+ const d = document.createElement('div');
91
+ d.textContent = line;
92
+ logEl.prepend(d);
93
+ while (logEl.childElementCount > 40)
94
+ (_a = logEl.lastElementChild) === null || _a === void 0 ? void 0 : _a.remove();
95
+ }
96
+ }
97
+ const wantPanel = (_a = opts.panel) !== null && _a !== void 0 ? _a : typeof document !== 'undefined';
98
+ if (wantPanel) {
99
+ const mount = () => {
100
+ if (!document.body || document.getElementById('__ml_mock'))
101
+ return;
102
+ const root = document.createElement('div');
103
+ root.id = '__ml_mock';
104
+ root.innerHTML =
105
+ `<style>#__ml_mock{position:fixed;right:8px;bottom:8px;z-index:2147483000;width:340px;font:12px/1.4 system-ui,sans-serif;` +
106
+ `background:#1e1e1e;color:#ddd;border:1px solid #555;border-radius:6px;box-shadow:0 4px 18px #0008}` +
107
+ `#__ml_mock header{padding:6px 10px;background:#2c2c2c;border-radius:6px 6px 0 0;cursor:pointer;display:flex;justify-content:space-between}` +
108
+ `#__ml_mock .b{padding:8px 10px;display:none}#__ml_mock.open .b{display:block}` +
109
+ `#__ml_mock input,#__ml_mock textarea{width:100%;box-sizing:border-box;background:#111;color:#eee;border:1px solid #444;border-radius:3px;padding:4px;font:12px monospace;margin:2px 0}` +
110
+ `#__ml_mock textarea{height:70px}#__ml_mock button{margin-top:4px;padding:4px 10px;background:#3a5;color:#fff;border:0;border-radius:3px;cursor:pointer}` +
111
+ `#__ml_mock .log{margin-top:8px;max-height:140px;overflow:auto;font:11px monospace;color:#9c9;border-top:1px solid #333;padding-top:4px}</style>` +
112
+ `<header><span>Magelight mock host</span><span>▴</span></header><div class="b">` +
113
+ `<input placeholder="channel (e.g. pageData)"><textarea placeholder='payload (JSON or text)'></textarea>` +
114
+ `<button>Dispatch to page</button><div class="log"></div></div>`;
115
+ document.body.appendChild(root);
116
+ const header = root.querySelector('header');
117
+ header.addEventListener('click', () => root.classList.toggle('open'));
118
+ const [chan] = Array.from(root.querySelectorAll('input'));
119
+ const ta = root.querySelector('textarea');
120
+ root.querySelector('button').addEventListener('click', () => {
121
+ if (!chan.value)
122
+ return;
123
+ core._dispatch(chan.value, ta.value);
124
+ log(`← ${chan.value}(${ta.value.length} chars)`);
125
+ });
126
+ logEl = root.querySelector('.log');
127
+ };
128
+ if (document.readyState === 'loading')
129
+ document.addEventListener('DOMContentLoaded', mount);
130
+ else
131
+ mount();
132
+ }
133
+ return core;
134
+ }
@@ -0,0 +1,34 @@
1
+ /** What the host installs as `window.__MAGELIGHT__` at window-object-ready. */
2
+ export interface HostInfo {
3
+ /** "0.15.0" */
4
+ version: string;
5
+ /** MAJOR*10000 + MINOR*100 + PATCH — compare against this, never parse the string */
6
+ versionNumber: number;
7
+ viewId: number;
8
+ /** "" for views created through the v1-v3 API */
9
+ modId: string;
10
+ viewName: string;
11
+ /** 1 = supported, 0 = not (see docs/PUBLIC_FRAMEWORK_PLAN.md §6 for the names) */
12
+ capabilities: Record<string, number>;
13
+ /** Magelight.json devMode: hot reload, F5, F12 inspector, error overlay */
14
+ dev: boolean;
15
+ /** file:///.../Data/SKSE/Plugins/Magelight/ — images live under images/<name>.imgsrc */
16
+ runtimeUrl: string;
17
+ }
18
+ /** The two verbs the host installs as `window.magelight` (the page core). */
19
+ export interface PageCore {
20
+ send(channel: string, payload?: unknown): void;
21
+ on(channel: string, fn: (arg: string) => void): () => void;
22
+ off(channel: string, fn: (arg: string) => void): void;
23
+ _dispatch(channel: string, arg: string): void;
24
+ pending(channel: string): number;
25
+ }
26
+ declare global {
27
+ interface Window {
28
+ __MAGELIGHT__?: HostInfo;
29
+ magelight?: PageCore;
30
+ __mlNative?: (viewId: string, channel: string, arg: string) => void;
31
+ }
32
+ }
33
+ export type Handler<T> = (value: T) => void;
34
+ export type Unsubscribe = () => void;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@magelight/sdk",
3
+ "version": "0.28.6",
4
+ "description": "Typed page bridge for Magelight UI (Skyrim SE/AE web UI host): host detection, channels with pre-mount replay, capabilities, browser mock.",
5
+ "license": "MIT",
6
+ "repository": { "type": "git", "url": "git+https://github.com/Severause/MagelightUI.git", "directory": "packages/sdk" },
7
+ "publishConfig": { "access": "public" },
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }
13
+ },
14
+ "files": ["dist", "README.md"],
15
+ "sideEffects": false,
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "prepack": "npm run build"
19
+ },
20
+ "keywords": ["skyrim", "skse", "ultralight", "magelight", "ui"],
21
+ "devDependencies": {
22
+ "typescript": "~5.9.3"
23
+ }
24
+ }