@vestra-player/sdk 1.5.24 → 1.5.26

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.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The capability rule, as data: which manifest capability a request needs
3
+ * (the player's table, `HOST_CAPABILITIES`, synced from its
4
+ * `src/host/capabilities.json`) and which requests each public member of the
5
+ * plugin API sends (`API_REQUESTS`). `CAPABILITY_INFO`, `vestra-plugin check`
6
+ * and the TypeScript language-service plugin are all built from these two
7
+ * tables, so they and the player cannot disagree.
8
+ *
9
+ * @internal
10
+ */
11
+ import { API_REQUESTS } from "./api-requests.js";
12
+ import { HOST_CAPABILITIES } from "./host-capabilities.js";
13
+ /** @internal The SDK's rules: the synced player table and `API_REQUESTS`. */
14
+ export const RULES = {
15
+ host: HOST_CAPABILITIES,
16
+ apis: API_REQUESTS,
17
+ };
18
+ /**
19
+ * The player's entry for a method, as the player routes it: the exact name
20
+ * first, then the first `prefix.*` entry whose prefix it starts with.
21
+ * `undefined`: the player does not know the method.
22
+ *
23
+ * @internal
24
+ */
25
+ export function hostMethod(method, host = RULES.host) {
26
+ const all = [
27
+ ...host.capabilities.flatMap((capability) => capability.methods),
28
+ ...host.uncapable,
29
+ ];
30
+ return (all.find((entry) => entry.name === method) ??
31
+ all.find((entry) => entry.name.endsWith(".*") && method.startsWith(entry.name.slice(0, -1))));
32
+ }
33
+ /**
34
+ * The capability one request needs, as the player decides it
35
+ * (`capabilities::Route::needs`): `null` for none, `undefined` for a method
36
+ * the player does not know.
37
+ *
38
+ * @param method - The wire method.
39
+ * @param params - Its string parameters that a `Needs::Param` rule reads.
40
+ * @param declared - Whether the plugin declared a capability.
41
+ *
42
+ * @internal
43
+ */
44
+ export function requestNeeds(method, params, declared, host = RULES.host) {
45
+ const entry = hostMethod(method, host);
46
+ if (!entry)
47
+ return undefined;
48
+ const needs = entry.needs;
49
+ if (needs === null || typeof needs === "string")
50
+ return needs;
51
+ if ("param" in needs) {
52
+ const value = params?.[needs.param];
53
+ return value !== undefined && Object.hasOwn(needs.cases, value)
54
+ ? needs.cases[value]
55
+ : needs.otherwise;
56
+ }
57
+ return declared(needs.ifDeclared) ? needs.then : needs.otherwise;
58
+ }
59
+ /**
60
+ * Every capability a member of the plugin API needs with the given manifest:
61
+ * those its requests need and, through `requires`, those they need in turn.
62
+ * `undefined`: the member is not in the table (it needs none).
63
+ *
64
+ * @param api - The member as `Interface.member` (`Player.seek`).
65
+ * @param capabilities - The manifest's capabilities.
66
+ *
67
+ * @internal
68
+ */
69
+ export function memberNeeds(api, capabilities, rules = RULES) {
70
+ const requests = rules.apis[api];
71
+ if (!requests)
72
+ return undefined;
73
+ // An `ifDeclared` rule is decided last, as if the plugin had declared what
74
+ // the member's other requests need: `Player.seek` needs `playback.control`
75
+ // for `source.submit`, and with it `source.operation` needs nothing more.
76
+ const declared = (name) => capabilities.includes(name) ||
77
+ needs.some((need) => need.capability === name);
78
+ const conditional = (request) => {
79
+ const rule = hostMethod(request.method, rules.host)?.needs;
80
+ return typeof rule === "object" && rule !== null && "ifDeclared" in rule;
81
+ };
82
+ const needs = [];
83
+ const add = (need) => {
84
+ if (needs.some((each) => each.capability === need.capability))
85
+ return;
86
+ needs.push(need);
87
+ const entry = rules.host.capabilities.find((capability) => capability.name === need.capability);
88
+ for (const required of entry?.requires ?? [])
89
+ add({
90
+ capability: required,
91
+ method: need.method,
92
+ requiredBy: need.capability,
93
+ });
94
+ };
95
+ for (const request of [
96
+ ...requests.filter((request) => !conditional(request)),
97
+ ...requests.filter(conditional),
98
+ ]) {
99
+ const capability = requestNeeds(request.method, request.params, declared, rules.host);
100
+ if (capability)
101
+ add({ capability, method: request.method });
102
+ }
103
+ return needs;
104
+ }
105
+ /**
106
+ * `CAPABILITY_INFO` from the tables: the label, highlight and requirements
107
+ * from the player's table; the members from `API_REQUESTS` (every member a
108
+ * request of which needs the capability, in the table's order; requests
109
+ * marked `also` are left out, as they are documented at the member).
110
+ *
111
+ * @internal
112
+ */
113
+ export function capabilityInfo(rules = RULES) {
114
+ const info = {};
115
+ for (const capability of rules.host.capabilities)
116
+ info[capability.name] = {
117
+ apis: [],
118
+ requires: [...capability.requires],
119
+ label: capability.label,
120
+ risky: capability.risky,
121
+ };
122
+ for (const [api, requests] of Object.entries(rules.apis))
123
+ for (const request of requests) {
124
+ if (request.also)
125
+ continue;
126
+ const capability = requestNeeds(request.method, request.params, () => true, rules.host);
127
+ const entry = capability ? info[capability] : undefined;
128
+ if (entry && !entry.apis.includes(api))
129
+ entry.apis.push(api);
130
+ }
131
+ return info;
132
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The player's capability table, copied from its `src/host/capabilities.json`
3
+ * by `npm run capabilities:sync -- <player repository>`. Do not edit: change
4
+ * the player's capability modules, regenerate its JSON and sync again.
5
+ *
6
+ * @internal
7
+ */
8
+ import type { HostCapabilities } from "./capability-rules.ts";
9
+ /** @internal */
10
+ export declare const HOST_CAPABILITIES: HostCapabilities;
@@ -0,0 +1,360 @@
1
+ // "then" is a field of the player's IfDeclared rule, not a thenable.
2
+ /* oxlint-disable unicorn/no-thenable */
3
+ /** @internal */
4
+ export const HOST_CAPABILITIES = {
5
+ about: "Generated from src/host/src/capabilities/ (VESTRA_UPDATE_CAPABILITIES=1 cargo test capabilities); do not edit. A method's needs: null (none), a capability name, {param, cases, otherwise} (by a string parameter) or {ifDeclared, then, otherwise}. A method name ending in .* covers the prefix's other methods.",
6
+ apiVersion: "vestra/1",
7
+ capabilities: [
8
+ {
9
+ label: "Provide audio and video it generates itself",
10
+ methods: [
11
+ { name: "producer.prepare", needs: "producer.av" },
12
+ { name: "producer.state", needs: "producer.av" },
13
+ { name: "producer.release", needs: "producer.av" },
14
+ { name: "producer.ack", needs: "producer.av" },
15
+ { name: "producer.declareInput", needs: "producer.av" },
16
+ { name: "producer.ackInput", needs: "producer.av" },
17
+ ],
18
+ name: "producer.av",
19
+ requires: ["playback.control"],
20
+ residency: "live",
21
+ risky: false,
22
+ },
23
+ {
24
+ label: "Read what is playing and the position",
25
+ methods: [
26
+ { name: "source.state", needs: "playback.read" },
27
+ { name: "processing.state", needs: "playback.read" },
28
+ { name: "playback.stats", needs: "playback.read" },
29
+ { name: "player.chapters", needs: "playback.read" },
30
+ { name: "player.audioDevices", needs: "playback.read" },
31
+ { name: "timeline.marks", needs: "playback.read" },
32
+ ],
33
+ name: "playback.read",
34
+ requires: [],
35
+ residency: "none",
36
+ risky: false,
37
+ },
38
+ {
39
+ label: "Open media, control playback, keep the computer awake",
40
+ methods: [
41
+ { name: "playback.set", needs: "playback.control" },
42
+ { name: "source.submit", needs: "playback.control" },
43
+ {
44
+ name: "source.operation",
45
+ needs: {
46
+ ifDeclared: "playback.control",
47
+ otherwise: "playback.read",
48
+ then: "playback.control",
49
+ },
50
+ },
51
+ { name: "audio.addTrack", needs: "playback.control" },
52
+ { name: "audio.selectTrack", needs: "playback.control" },
53
+ { name: "player.setLoop", needs: "playback.control" },
54
+ { name: "player.setAudioDevice", needs: "playback.control" },
55
+ { name: "player.step", needs: "playback.control" },
56
+ { name: "player.setStepSeek", needs: "playback.control" },
57
+ { name: "power.preventSleep", needs: "playback.control" },
58
+ { name: "sequence.set", needs: "playback.control" },
59
+ { name: "sequence.step", needs: "playback.control" },
60
+ ],
61
+ name: "playback.control",
62
+ requires: [],
63
+ residency: "live",
64
+ risky: false,
65
+ },
66
+ {
67
+ label: "Resolve online video addresses",
68
+ methods: [
69
+ { name: "sourceResolvers.register", needs: "source.resolve" },
70
+ { name: "sourceResolvers.unregister", needs: "source.resolve" },
71
+ { name: "sourceResolvers.respond", needs: "source.resolve" },
72
+ { name: "sourceResolvers.*", needs: "source.resolve" },
73
+ ],
74
+ name: "source.resolve",
75
+ requires: [],
76
+ residency: "recordable",
77
+ risky: false,
78
+ },
79
+ {
80
+ label: "Receive playback changes as they happen",
81
+ methods: [
82
+ {
83
+ name: "events.subscribe",
84
+ needs: {
85
+ cases: {
86
+ "bindings.changed": "bindings.manage",
87
+ "packages.changed": null,
88
+ },
89
+ otherwise: "events",
90
+ param: "event",
91
+ },
92
+ },
93
+ {
94
+ name: "events.unsubscribe",
95
+ needs: {
96
+ cases: {
97
+ "bindings.changed": "bindings.manage",
98
+ "packages.changed": null,
99
+ },
100
+ otherwise: "events",
101
+ param: "event",
102
+ },
103
+ },
104
+ ],
105
+ name: "events",
106
+ requires: [],
107
+ residency: "live",
108
+ risky: false,
109
+ },
110
+ {
111
+ label: "Add menu commands and shortcuts",
112
+ methods: [
113
+ { name: "commands.register", needs: "commands" },
114
+ { name: "commands.update", needs: "commands" },
115
+ { name: "commands.unregister", needs: "commands" },
116
+ { name: "host.run", needs: "commands" },
117
+ ],
118
+ name: "commands",
119
+ requires: [],
120
+ residency: "recordable",
121
+ risky: false,
122
+ },
123
+ {
124
+ label: "Open tool windows",
125
+ methods: [
126
+ { name: "ui.open", needs: "ui.window" },
127
+ { name: "ui.set", needs: "ui.window" },
128
+ { name: "ui.show", needs: "ui.window" },
129
+ { name: "ui.close", needs: "ui.window" },
130
+ ],
131
+ name: "ui.window",
132
+ requires: [],
133
+ residency: "live",
134
+ risky: false,
135
+ },
136
+ {
137
+ label: "Show panels in the bottom bar, the sidebar or behind the video",
138
+ methods: [
139
+ { name: "ui.mount", needs: "ui.mount" },
140
+ { name: "ui.setMount", needs: "ui.mount" },
141
+ { name: "ui.unmount", needs: "ui.mount" },
142
+ { name: "ui.interaction", needs: "ui.mount" },
143
+ { name: "ui.menu", needs: "ui.mount" },
144
+ { name: "layout.state", needs: "ui.mount" },
145
+ { name: "layout.set", needs: "ui.mount" },
146
+ ],
147
+ name: "ui.mount",
148
+ requires: [],
149
+ residency: "live",
150
+ risky: false,
151
+ },
152
+ {
153
+ label: "Show file pickers and confirmations",
154
+ methods: [
155
+ { name: "dialogs.open", needs: "ui.dialog" },
156
+ { name: "dialogs.confirm", needs: "ui.dialog" },
157
+ ],
158
+ name: "ui.dialog",
159
+ requires: [],
160
+ residency: "none",
161
+ risky: false,
162
+ },
163
+ {
164
+ label: "Install, uninstall and disable other plugins",
165
+ methods: [{ name: "packages.*", needs: "plugins.manage" }],
166
+ name: "plugins.manage",
167
+ requires: [],
168
+ residency: "none",
169
+ risky: true,
170
+ },
171
+ {
172
+ label: "Process the picture (filters, shaders)",
173
+ methods: [
174
+ {
175
+ name: "processing.replace",
176
+ needs: {
177
+ cases: { video: "processing.video" },
178
+ otherwise: "processing.audio",
179
+ param: "stage",
180
+ },
181
+ },
182
+ {
183
+ name: "processing.operation",
184
+ needs: {
185
+ cases: { video: "processing.video" },
186
+ otherwise: "processing.audio",
187
+ param: "stage",
188
+ },
189
+ },
190
+ { name: "video.view.set", needs: "processing.video" },
191
+ { name: "video.view.release", needs: "processing.video" },
192
+ { name: "video.render.set", needs: "processing.video" },
193
+ { name: "video.render.release", needs: "processing.video" },
194
+ ],
195
+ name: "processing.video",
196
+ requires: ["playback.read"],
197
+ residency: "live",
198
+ risky: false,
199
+ },
200
+ {
201
+ label: "Process the sound",
202
+ methods: [],
203
+ name: "processing.audio",
204
+ requires: ["playback.read", "events"],
205
+ residency: "live",
206
+ risky: false,
207
+ },
208
+ {
209
+ label: "Read subtitles",
210
+ methods: [{ name: "subtitles.state", needs: "subtitles.read" }],
211
+ name: "subtitles.read",
212
+ requires: [],
213
+ residency: "none",
214
+ risky: false,
215
+ },
216
+ {
217
+ label: "Load and change subtitles",
218
+ methods: [{ name: "subtitles.*", needs: "subtitles.control" }],
219
+ name: "subtitles.control",
220
+ requires: [],
221
+ residency: "live",
222
+ risky: false,
223
+ },
224
+ {
225
+ label: "Take snapshots and export clips",
226
+ methods: [
227
+ { name: "snapshot.state", needs: "media.read" },
228
+ { name: "jobs.state", needs: "media.read" },
229
+ { name: "jobs.operation", needs: "media.read" },
230
+ ],
231
+ name: "media.read",
232
+ requires: ["media.control"],
233
+ residency: "none",
234
+ risky: false,
235
+ },
236
+ {
237
+ label: "Take snapshots and export clips",
238
+ methods: [
239
+ { name: "snapshot.*", needs: "media.control" },
240
+ { name: "jobs.*", needs: "media.control" },
241
+ ],
242
+ name: "media.control",
243
+ requires: ["media.read"],
244
+ residency: "live",
245
+ risky: false,
246
+ },
247
+ {
248
+ label: "Show messages on the video",
249
+ methods: [
250
+ { name: "osd.show", needs: "ui.osd" },
251
+ { name: "osd.panel", needs: "ui.osd" },
252
+ ],
253
+ name: "ui.osd",
254
+ requires: [],
255
+ residency: "live",
256
+ risky: false,
257
+ },
258
+ {
259
+ label: "Read and write the clipboard",
260
+ methods: [
261
+ { name: "clipboard.readText", needs: "clipboard" },
262
+ { name: "clipboard.writeText", needs: "clipboard" },
263
+ { name: "clipboard.writeImage", needs: "clipboard" },
264
+ ],
265
+ name: "clipboard",
266
+ requires: [],
267
+ residency: "none",
268
+ risky: true,
269
+ },
270
+ {
271
+ label: "Adjust the main window (size, on top, picture-in-picture)",
272
+ methods: [
273
+ { name: "window.setVideoArea", needs: "window.control" },
274
+ { name: "window.setAlwaysOnTop", needs: "window.control" },
275
+ { name: "window.setCursorAutoHide", needs: "window.control" },
276
+ { name: "window.setPictureInPicture", needs: "window.control" },
277
+ ],
278
+ name: "window.control",
279
+ requires: [],
280
+ residency: "live",
281
+ risky: false,
282
+ },
283
+ {
284
+ label: "Change all shortcuts",
285
+ methods: [
286
+ { name: "bindings.list", needs: "bindings.manage" },
287
+ { name: "bindings.set", needs: "bindings.manage" },
288
+ { name: "bindings.reset", needs: "bindings.manage" },
289
+ ],
290
+ name: "bindings.manage",
291
+ requires: [],
292
+ residency: "none",
293
+ risky: true,
294
+ },
295
+ {
296
+ label: "Show marks on the progress bar",
297
+ methods: [{ name: "timeline.set", needs: "timeline.marks" }],
298
+ name: "timeline.marks",
299
+ requires: [],
300
+ residency: "live",
301
+ risky: false,
302
+ },
303
+ {
304
+ label: "Receive files dropped on the player",
305
+ methods: [
306
+ { name: "drops.provide", needs: "ui.drop" },
307
+ { name: "drops.withdraw", needs: "ui.drop" },
308
+ { name: "drops.listen", needs: "ui.drop" },
309
+ ],
310
+ name: "ui.drop",
311
+ requires: [],
312
+ residency: "recordable",
313
+ risky: false,
314
+ },
315
+ {
316
+ label: "Add pages to the Options window",
317
+ methods: [{ name: "settings.*", needs: "ui.settings" }],
318
+ name: "ui.settings",
319
+ requires: [],
320
+ residency: "recordable",
321
+ risky: false,
322
+ },
323
+ {
324
+ label: "Change the player's palette",
325
+ methods: [
326
+ { name: "appearance.set", needs: "ui.theme" },
327
+ { name: "appearance.setLanguage", needs: "ui.theme" },
328
+ ],
329
+ name: "ui.theme",
330
+ requires: [],
331
+ residency: "none",
332
+ risky: false,
333
+ },
334
+ {
335
+ label: "Store sign-in details in the system keychain",
336
+ methods: [
337
+ { name: "secrets.get", needs: "secrets" },
338
+ { name: "secrets.set", needs: "secrets" },
339
+ { name: "secrets.delete", needs: "secrets" },
340
+ ],
341
+ name: "secrets",
342
+ requires: [],
343
+ residency: "none",
344
+ risky: true,
345
+ },
346
+ ],
347
+ uncapable: [
348
+ { name: "packages.list", needs: null },
349
+ { name: "window.state", needs: null },
350
+ { name: "system.reveal", needs: null },
351
+ { name: "system.openPath", needs: null },
352
+ { name: "system.openUrl", needs: null },
353
+ { name: "app.system", needs: null },
354
+ { name: "ui.markdownScan", needs: null },
355
+ { name: "storage.session.get", needs: null },
356
+ { name: "storage.session.set", needs: null },
357
+ { name: "storage.session.delete", needs: null },
358
+ { name: "storage.session.keys", needs: null },
359
+ ],
360
+ };
@@ -12,10 +12,11 @@ export type { PluginInstance, PluginManifest } from "./plugin/index.ts";
12
12
  export { HostError } from "./wire.ts";
13
13
  export type { Json, Transport } from "./wire.ts";
14
14
  export { API_VERSION, CAPABILITIES, CAPABILITY_INFO, MESSAGE_LIMIT, } from "./capabilities.ts";
15
+ export { HOST_CAPABILITIES } from "./host-capabilities.ts";
15
16
  export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.ts";
16
17
  export { BINDING_INPUTS } from "./plugin/bindings.ts";
17
18
  export { OSD_LIMITS } from "./plugin/osd.ts";
18
- export { SECRET_LIMITS } from "./plugin/services.ts";
19
+ export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.ts";
19
20
  export { THEME_PALETTES } from "./palettes.ts";
20
21
  export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.ts";
21
22
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.ts";
@@ -10,10 +10,11 @@
10
10
  export { createPlugin } from "./plugin/index.js";
11
11
  export { HostError } from "./wire.js";
12
12
  export { API_VERSION, CAPABILITIES, CAPABILITY_INFO, MESSAGE_LIMIT, } from "./capabilities.js";
13
+ export { HOST_CAPABILITIES } from "./host-capabilities.js";
13
14
  export { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "./commands.js";
14
15
  export { BINDING_INPUTS } from "./plugin/bindings.js";
15
16
  export { OSD_LIMITS } from "./plugin/osd.js";
16
- export { SECRET_LIMITS } from "./plugin/services.js";
17
+ export { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./plugin/services.js";
17
18
  export { THEME_PALETTES } from "./palettes.js";
18
19
  export { Catalog, HOST_ZH_HANS, LIST_SEPARATORS, LOCALES, MENU_KEYS, matchLocale, parsePo, rewriteSlint, renderText, scanSlint, TEXT_LIMITS, textKey, textProblem, } from "./i18n.js";
19
20
  export { MANIFEST_FIELDS, MANIFEST_LIMITS } from "./manifest.js";
@@ -4,6 +4,41 @@ export declare const clipboard: (scope: ScopeImpl) => Clipboard;
4
4
  export declare const host: (scope: ScopeImpl) => Host;
5
5
  export declare const power: (scope: ScopeImpl) => Power;
6
6
  export declare function tools(scope: ScopeImpl): Tools;
7
+ /**
8
+ * The player's limits on {@link SessionStorage}. A key outside them fails
9
+ * with code `invalid`; a `set` that would make the plugin's entries larger
10
+ * than `bytes` fails with `limit` and changes nothing. A single `set` must
11
+ * also fit in one message to the player
12
+ * ({@link "@vestra-player/sdk/testing"!MESSAGE_LIMIT | MESSAGE_LIMIT}, 1 MiB
13
+ * with the key and the request around the value). The player's tests check
14
+ * that these agree with the player.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import assert from "node:assert/strict";
19
+ * import { SESSION_STORAGE_LIMITS } from "@vestra-player/sdk/testing";
20
+ *
21
+ * const key = "https://example.com/a.mkv";
22
+ * assert.ok(Buffer.byteLength(key) <= SESSION_STORAGE_LIMITS.keyBytes);
23
+ * ```
24
+ *
25
+ * @since 1.5.25
26
+ */
27
+ export declare const SESSION_STORAGE_LIMITS: Readonly<{
28
+ /** UTF-8 bytes of a key (1024, at least 1; no NUL character). */
29
+ readonly keyBytes: 1024;
30
+ /**
31
+ * Bytes one plugin may hold at the same time: for every key, its UTF-8
32
+ * bytes, its value written as compact JSON (`JSON.stringify(value)`) and
33
+ * `entryBytes`.
34
+ */
35
+ readonly bytes: number;
36
+ /**
37
+ * What each key costs on top of its text and value (128): the player's
38
+ * own bookkeeping, so that many tiny entries stay within `bytes` of memory.
39
+ */
40
+ readonly entryBytes: 128;
41
+ }>;
7
42
  export declare const storage: (scope: ScopeImpl) => Storage;
8
43
  export declare function system(scope: ScopeImpl): System;
9
44
  /**
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared services: the clipboard, sleep prevention, showing and opening files
3
- * and links, and secrets go to the host; external tools and settings files
4
- * run in this plugin's own process.
3
+ * and links, secrets and session storage go to the host; external tools and
4
+ * settings files run in this plugin's own process.
5
5
  */
6
6
  import { existsSync, readFileSync, renameSync } from "node:fs";
7
7
  import { mkdir, open, rename, rm, stat } from "node:fs/promises";
@@ -238,6 +238,90 @@ class Store {
238
238
  this.#scope.forget(this);
239
239
  }
240
240
  }
241
+ /**
242
+ * The player's limits on {@link SessionStorage}. A key outside them fails
243
+ * with code `invalid`; a `set` that would make the plugin's entries larger
244
+ * than `bytes` fails with `limit` and changes nothing. A single `set` must
245
+ * also fit in one message to the player
246
+ * ({@link "@vestra-player/sdk/testing"!MESSAGE_LIMIT | MESSAGE_LIMIT}, 1 MiB
247
+ * with the key and the request around the value). The player's tests check
248
+ * that these agree with the player.
249
+ *
250
+ * @example
251
+ * ```ts
252
+ * import assert from "node:assert/strict";
253
+ * import { SESSION_STORAGE_LIMITS } from "@vestra-player/sdk/testing";
254
+ *
255
+ * const key = "https://example.com/a.mkv";
256
+ * assert.ok(Buffer.byteLength(key) <= SESSION_STORAGE_LIMITS.keyBytes);
257
+ * ```
258
+ *
259
+ * @since 1.5.25
260
+ */
261
+ export const SESSION_STORAGE_LIMITS = Object.freeze({
262
+ /** UTF-8 bytes of a key (1024, at least 1; no NUL character). */
263
+ keyBytes: 1024,
264
+ /**
265
+ * Bytes one plugin may hold at the same time: for every key, its UTF-8
266
+ * bytes, its value written as compact JSON (`JSON.stringify(value)`) and
267
+ * `entryBytes`.
268
+ */
269
+ bytes: 1024 * 1024,
270
+ /**
271
+ * What each key costs on top of its text and value (128): the player's
272
+ * own bookkeeping, so that many tiny entries stay within `bytes` of memory.
273
+ */
274
+ entryBytes: 128,
275
+ });
276
+ /** A lone surrogate: the player's JSON parser refuses the whole message. */
277
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
278
+ /** Whether a parsed JSON value has a string or a property name with a lone surrogate. */
279
+ function hasLoneSurrogate(value) {
280
+ if (typeof value === "string")
281
+ return LONE_SURROGATE.test(value);
282
+ if (Array.isArray(value))
283
+ return value.some(hasLoneSurrogate);
284
+ if (value && typeof value === "object")
285
+ return Object.entries(value).some(([key, item]) => LONE_SURROGATE.test(key) || hasLoneSurrogate(item));
286
+ return false;
287
+ }
288
+ function sessionStorage(scope) {
289
+ const keyed = (key) => {
290
+ if (typeof key !== "string" ||
291
+ !key ||
292
+ key.includes("\0") ||
293
+ LONE_SURROGATE.test(key) ||
294
+ Buffer.byteLength(key) > SESSION_STORAGE_LIMITS.keyBytes)
295
+ throw failure("invalid", `session storage keys are 1–${SESSION_STORAGE_LIMITS.keyBytes} bytes of well-formed text without NUL`);
296
+ return key;
297
+ };
298
+ const host = () => scope.runtime.host.storageSession;
299
+ return {
300
+ async get(key) {
301
+ const found = await host().get(keyed(key));
302
+ return "value" in found ? found.value : undefined;
303
+ },
304
+ async set(key, value) {
305
+ const name = keyed(key);
306
+ let text;
307
+ try {
308
+ text = JSON.stringify(value);
309
+ }
310
+ catch {
311
+ text = undefined; // a cycle or a BigInt
312
+ }
313
+ if (text === undefined)
314
+ throw failure("invalid", "session storage values must be JSON; delete() removes one");
315
+ // What the player stores, and what `get` will return.
316
+ const json = JSON.parse(text);
317
+ if (hasLoneSurrogate(json))
318
+ throw failure("invalid", "session storage values must be well-formed text: a string has a lone surrogate");
319
+ await host().set(name, json);
320
+ },
321
+ delete: async (key) => host().delete(keyed(key)),
322
+ keys: async () => host().keys(),
323
+ };
324
+ }
241
325
  export const storage = (scope) => ({
242
326
  json(name, options) {
243
327
  if (typeof name !== "string" || !NAME.test(name))
@@ -247,6 +331,7 @@ export const storage = (scope) => ({
247
331
  throw failure("invalid", "debounceMs must be a non-negative number");
248
332
  return new Store(scope, scope.dataDirectory, name, options);
249
333
  },
334
+ session: sessionStorage(scope),
250
335
  });
251
336
  export function system(scope) {
252
337
  const exists = async (path) => {