@discord-player/plugin-host 8.0.0-dev.1 → 8.0.0-dev.3

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.
Files changed (56) hide show
  1. package/README.md +92 -28
  2. package/dist/compat/base-extractor.d.ts +6 -0
  3. package/dist/compat/base-extractor.js.map +1 -1
  4. package/dist/compat/extractor-execution-context.js.map +1 -1
  5. package/dist/compat/extractor-plugin.js +107 -75
  6. package/dist/compat/extractor-plugin.js.map +1 -1
  7. package/dist/compat/utils.js.map +1 -1
  8. package/dist/errors.d.ts +15 -2
  9. package/dist/errors.js +17 -2
  10. package/dist/errors.js.map +1 -1
  11. package/dist/host.js +81 -20
  12. package/dist/host.js.map +1 -1
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.js +2 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/legacy-player.d.ts +1 -1
  17. package/dist/lyrics.d.ts +56 -0
  18. package/dist/lyrics.js +110 -0
  19. package/dist/lyrics.js.map +1 -0
  20. package/dist/media.js.map +1 -1
  21. package/dist/plugins/execution-options.d.ts +4 -0
  22. package/dist/plugins/operation-dispatch.js +43 -6
  23. package/dist/plugins/operation-dispatch.js.map +1 -1
  24. package/dist/plugins/plugin-error.js.map +1 -1
  25. package/dist/plugins/registry-internals.d.ts +5 -3
  26. package/dist/plugins/registry.d.ts +103 -5
  27. package/dist/plugins/registry.js +403 -24
  28. package/dist/plugins/registry.js.map +1 -1
  29. package/dist/plugins/stream-resolver.js +39 -3
  30. package/dist/plugins/stream-resolver.js.map +1 -1
  31. package/dist/plugins/types.d.ts +62 -25
  32. package/dist/plugins/types.js.map +1 -1
  33. package/dist/protocol.d.ts +35 -0
  34. package/dist/protocol.js.map +1 -1
  35. package/dist/providers/lrclib.d.ts +120 -0
  36. package/dist/providers/lrclib.js +183 -0
  37. package/dist/providers/lrclib.js.map +1 -0
  38. package/dist/remote.d.ts +29 -0
  39. package/dist/remote.js +142 -0
  40. package/dist/remote.js.map +1 -0
  41. package/dist/runtime.d.ts +0 -8
  42. package/dist/runtime.js +55 -44
  43. package/dist/runtime.js.map +1 -1
  44. package/dist/structures/search-result.js.map +1 -1
  45. package/dist/structures/track-types.d.ts +7 -3
  46. package/dist/structures/track.d.ts +4 -11
  47. package/dist/structures/track.js +8 -10
  48. package/dist/structures/track.js.map +1 -1
  49. package/dist/types.d.ts +2 -4
  50. package/dist/utils/legacy-serialization-schemas.d.ts +10 -0
  51. package/dist/utils/serialization-schemas.d.ts +15 -0
  52. package/dist/utils/serialization-schemas.js +1 -0
  53. package/dist/utils/serialization-schemas.js.map +1 -1
  54. package/dist/utils/serialization.js.map +1 -1
  55. package/docs/remedia-contract.md +9 -8
  56. package/package.json +3 -3
@@ -1,17 +1,58 @@
1
+ import { serialize, deserialize } from '../utils/serde.js';
2
+ import { parseEntity } from '../utils/parse-serialized-entity.js';
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ import { registerRemote, discoverRemote } from '../remote.js';
1
5
  import { PluginError } from './plugin-error.js';
2
6
  import { dispatchOperation, selectHandlers } from './operation-dispatch.js';
3
7
  import { resolveTrackStream } from './stream-resolver.js';
4
8
  export { PluginError } from './plugin-error.js';
5
- /** Context-owned source execution registry. Removal aborts and drains calls before teardown. */
9
+ /** Context-owned capability registry. Removal aborts and drains calls before teardown. */
6
10
  export class PluginRegistry {
7
11
  player;
8
12
  blocked;
9
13
  hostId;
14
+ providerNames = new Set();
15
+ sealedProviders = new Set();
16
+ providersIndex = new Map();
17
+ /** Enable a local singleton capability before setup. Source hosts do not enable playback providers.
18
+ * @param name Provider capability name.
19
+ */
20
+ defineProvider(name) {
21
+ this.providerNames.add(name);
22
+ }
23
+ /** Read the selected provider registration lifetime.
24
+ * @param name Provider capability name.
25
+ * @returns Registration cancellation signal, absent for the default implementation.
26
+ */
27
+ providerSignal(name) {
28
+ return this.providersIndex.get(name)?.entry.controller.signal;
29
+ }
30
+ /** Select and seal a provider before resource initialization. Later replacement registrations fail.
31
+ * @param name Provider capability name.
32
+ * @param dispose Release owned framework resources before plugin teardown.
33
+ * @returns The active implementation, or undefined for the framework default.
34
+ */
35
+ selectProvider(name, dispose) {
36
+ if (this.sealedProviders.has(name))
37
+ throw new Error(`Provider already selected: ${String(name)}`);
38
+ this.sealedProviders.add(name);
39
+ const provider = this.providersIndex.get(name);
40
+ if (!provider?.entry.active)
41
+ return undefined;
42
+ provider.dispose = dispose;
43
+ return provider.value;
44
+ }
45
+ advertisements = new WeakMap();
10
46
  entries = new Map();
11
- handlers = [];
47
+ handlers = new Map();
48
+ synchronous = new Set();
49
+ dispatching = false;
50
+ calls = new AsyncLocalStorage();
51
+ initializing = new Set();
12
52
  order = 0;
13
53
  resultOwners = new WeakMap();
14
54
  destroyed = false;
55
+ lifetime = new AbortController();
15
56
  /** Create an independent host.
16
57
  * @param player Structural source context.
17
58
  * @param blocked Default disabled source IDs supplied by the owning Player.
@@ -23,6 +64,14 @@ export class PluginRegistry {
23
64
  this.hostId = hostId;
24
65
  }
25
66
  definitions = new Map([
67
+ ['serialization.beforeRestore', 'pipe'],
68
+ ['serialization.afterSerialize', 'pipe'],
69
+ ['media.beforeResolve', 'pipe'],
70
+ ['search.beforeQuery', 'pipe'],
71
+ ['search.afterResults', 'pipe'],
72
+ ['media.descriptor', 'pipe'],
73
+ ['media.transform', 'pipe'],
74
+ ['lyrics.search', 'collect'],
26
75
  ['search.query.match', 'collect'],
27
76
  ['search.query.resolve', 'first'],
28
77
  ['track.stream', 'first'],
@@ -34,18 +83,26 @@ export class PluginRegistry {
34
83
  /** Define a declaration-merged operation before registering handlers.
35
84
  * @param name Operation name.
36
85
  * @param policy Dispatch policy matching its contract.
86
+ * @param synchronous Whether callbacks must complete before returning; required for interception.
37
87
  */
38
- defineOperation(name, policy) {
88
+ defineOperation(name, policy, synchronous = false) {
89
+ if (policy === 'intercept' && !synchronous)
90
+ throw new TypeError('Interception requires synchronous dispatch.');
91
+ if (synchronous && policy === 'collect')
92
+ throw new TypeError('Synchronous collection dispatch is unsupported.');
39
93
  if (this.definitions.has(name)) {
40
94
  throw new Error(`Operation already defined: ${name}`);
41
95
  }
42
96
  this.definitions.set(name, policy);
97
+ if (synchronous)
98
+ this.synchronous.add(name);
43
99
  }
44
100
  /** Whether an ID is reserved or active.
45
101
  * @param id Plugin ID.
102
+ * @param hostId Optional deployment constraint.
46
103
  */
47
- has(id) {
48
- return this.entries.has(id);
104
+ has(id, hostId) {
105
+ return [...this.entries.values()].some((entry) => entry.id === id && (!hostId || entry.registration.definition.remoteHost?.hostId === hostId));
49
106
  }
50
107
  /** Registration lifetime for transport-owned ephemeral state.
51
108
  * @internal
@@ -55,18 +112,142 @@ export class PluginRegistry {
55
112
  registrationSignal(id) {
56
113
  return this.entries.get(id)?.controller.signal;
57
114
  }
115
+ /** List active provider/deployment references in priority and registration order.
116
+ * @param operation Capability to inspect.
117
+ * @returns Unique eligible provider/deployment references.
118
+ */
119
+ providers(operation) {
120
+ const seen = new Set();
121
+ return selectHandlers(operation, {}, {}, {
122
+ handlers: this.handlers,
123
+ definitions: this.definitions,
124
+ blocked: () => this.blocked(),
125
+ resultOwners: this.resultOwners,
126
+ }).flatMap((handler) => {
127
+ const pluginId = handler.entry.id;
128
+ const hostId = handler.entry.registration.definition.remoteHost?.hostId;
129
+ const key = `${hostId ?? ''}::${pluginId}`;
130
+ if (seen.has(key))
131
+ return [];
132
+ seen.add(key);
133
+ return [{ pluginId, ...(hostId ? { hostId } : {}), data: null }];
134
+ });
135
+ }
136
+ /** Check whether a deployment has an owned registration.
137
+ * @param hostId Deployment identity.
138
+ */
139
+ hasHost(hostId) {
140
+ return [...this.entries.values()].some((entry) => entry.registration.definition.remoteHost?.hostId === hostId);
141
+ }
142
+ /** Advertise only active local operation handlers and supplied provider capabilities.
143
+ * @returns Serializable provider capability descriptions.
144
+ */
145
+ describe() {
146
+ return [...this.entries.values()]
147
+ .filter((entry) => entry.active)
148
+ .map((entry) => {
149
+ const handlers = [...entry.handlers];
150
+ const operations = [...new Set(handlers.map((handler) => handler.operation))];
151
+ const provides = [...this.providersIndex]
152
+ .filter(([, provider]) => provider.entry === entry)
153
+ .map(([name]) => name);
154
+ return {
155
+ id: entry.id,
156
+ provides,
157
+ operations,
158
+ protocols: [
159
+ ...new Set(handlers
160
+ .filter((handler) => handler.operation.startsWith('search.query.'))
161
+ .flatMap((handler) => handler.protocols ?? [])),
162
+ ],
163
+ priorities: Object.fromEntries(operations.map((operation) => [
164
+ operation,
165
+ Math.max(...handlers
166
+ .filter((handler) => handler.operation === operation)
167
+ .map((handler) => handler.priority ?? 0)),
168
+ ])),
169
+ subscriptions: handlers.map((handler) => ({
170
+ operation: handler.operation,
171
+ priority: handler.priority ?? 0,
172
+ guildIds: handler.guildIds,
173
+ order: handler.order,
174
+ })),
175
+ };
176
+ });
177
+ }
178
+ /** Register all advertised providers from an existing HTTP deployment.
179
+ * @param host Existing deployment and authentication.
180
+ * @param signal Caller lifetime.
181
+ */
182
+ async registerHost(host, signal = this.lifetime.signal) {
183
+ signal = AbortSignal.any([signal, this.lifetime.signal]);
184
+ signal.throwIfAborted();
185
+ const providers = await discoverRemote(host, signal);
186
+ const owned = [];
187
+ try {
188
+ for (const provider of providers) {
189
+ signal.throwIfAborted();
190
+ if (this.has(provider.id, host.hostId))
191
+ continue;
192
+ const registration = { definition: { id: provider.id, remoteHost: host }, options: {} };
193
+ this.advertisements.set(registration, providers);
194
+ await this.register(registration);
195
+ owned.push(provider.id);
196
+ }
197
+ await this.ready();
198
+ signal.throwIfAborted();
199
+ }
200
+ catch (error) {
201
+ const results = await Promise.allSettled(owned.map((id) => this.unregister(id, host.hostId)));
202
+ const cleanup = results.filter((result) => result.status === 'rejected').map((result) => result.reason);
203
+ if (cleanup.length)
204
+ throw new AggregateError([error, ...cleanup], 'Remote provider registration and cleanup failed', {
205
+ cause: error,
206
+ });
207
+ throw error;
208
+ }
209
+ }
58
210
  /** Whether an active matching handler explicitly accepts a protocol.
59
211
  * @param protocol Query prefix without punctuation.
60
212
  * @param options Source targeting and block list.
61
213
  */
62
214
  supportsProtocol(protocol, options = {}) {
63
- return this.handlers.some((handler) => handler.operation === 'search.query.match' &&
215
+ return ['search.query.match', 'search.query.resolve']
216
+ .flatMap((name) => this.handlers.get(name) ?? [])
217
+ .some((handler) => (handler.operation === 'search.query.match' || handler.operation === 'search.query.resolve') &&
64
218
  handler.entry.active &&
65
219
  (!options.pluginId || handler.entry.id === options.pluginId) &&
220
+ (options.hostId === undefined ||
221
+ (handler.entry.registration.definition.remoteHost?.hostId ?? null) === options.hostId) &&
66
222
  !this.blocked().includes(handler.entry.id) &&
67
223
  !options.blocked?.includes(handler.entry.id) &&
68
224
  handler.protocols?.includes(protocol));
69
225
  }
226
+ /** Ordered owners of a custom query protocol.
227
+ * @param protocol Registered protocol prefix.
228
+ * @param options Targeting and block list.
229
+ * @returns Eligible provider identities, in dispatch order.
230
+ */
231
+ protocolMatches(protocol, options = {}) {
232
+ const handlers = ['search.query.match', 'search.query.resolve']
233
+ .flatMap((operation) => selectHandlers(operation, { protocol }, options, {
234
+ handlers: this.handlers,
235
+ definitions: this.definitions,
236
+ blocked: () => this.blocked(),
237
+ resultOwners: this.resultOwners,
238
+ }).filter((handler) => handler.protocols?.includes(protocol)))
239
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.order - b.order);
240
+ const seen = new Set();
241
+ return handlers.flatMap((handler) => {
242
+ const pluginId = handler.entry.id;
243
+ const hostId = handler.entry.registration.definition.remoteHost?.hostId;
244
+ const key = `${hostId ?? ''}::${pluginId}`;
245
+ if (seen.has(key))
246
+ return [];
247
+ seen.add(key);
248
+ return [{ pluginId, hostId, score: 0 }];
249
+ });
250
+ }
70
251
  /** Register and initialize a source descriptor, reserving its ID synchronously.
71
252
  * @param registration Descriptor produced by definePlugin.
72
253
  */
@@ -75,10 +256,14 @@ export class PluginRegistry {
75
256
  if (this.destroyed) {
76
257
  throw new Error('Plugin host has been destroyed.');
77
258
  }
78
- if (!definition || definition.kind !== 'source' || !definition.id || typeof definition.setup !== 'function') {
79
- throw new TypeError('Expected a source plugin registration descriptor.');
259
+ if (!definition ||
260
+ 'kind' in definition ||
261
+ !definition.id ||
262
+ (typeof definition.setup !== 'function' && !definition.remoteHost)) {
263
+ throw new TypeError('Expected a plugin registration with setup() or remoteHost.');
80
264
  }
81
- if (this.entries.has(definition.id)) {
265
+ const key = definition.remoteHost ? `${definition.remoteHost.hostId}::${definition.id}` : definition.id;
266
+ if (this.entries.has(key)) {
82
267
  throw new Error(`Duplicate plugin ID: ${definition.id}`);
83
268
  }
84
269
  const controller = new AbortController();
@@ -87,6 +272,7 @@ export class PluginRegistry {
87
272
  registration,
88
273
  controller,
89
274
  cleanups: [],
275
+ handlers: new Set(),
90
276
  pending: new Set(),
91
277
  active: false,
92
278
  ready: Promise.resolve(),
@@ -96,21 +282,47 @@ export class PluginRegistry {
96
282
  player: this.player,
97
283
  options: registration.options,
98
284
  signal: controller.signal,
285
+ provide: (name, value) => {
286
+ controller.signal.throwIfAborted();
287
+ if (!this.providerNames.has(name))
288
+ throw new Error(`Unsupported local provider: ${String(name)}`);
289
+ if (this.sealedProviders.has(name))
290
+ throw new Error(`Provider selection is sealed: ${String(name)}`);
291
+ if (this.providersIndex.has(name))
292
+ throw new Error(`Duplicate provider: ${String(name)}`);
293
+ if (!value)
294
+ throw new TypeError(`Provider implementation is required: ${String(name)}`);
295
+ this.providersIndex.set(name, { entry, value });
296
+ },
99
297
  on: (operation, fn, options = {}) => {
100
298
  controller.signal.throwIfAborted();
101
299
  if (!this.definitions.has(operation)) {
102
300
  throw new Error(`Undefined operation: ${operation}`);
103
301
  }
302
+ if (this.synchronous.has(operation) && fn.constructor.name === 'AsyncFunction')
303
+ throw new TypeError(`Capability ${operation} requires a synchronous callback.`);
304
+ if (!Number.isFinite(options.priority ?? definition.priority ?? 0))
305
+ throw new TypeError('Handler priority must be finite.');
104
306
  const handler = {
105
307
  entry,
106
308
  operation,
107
309
  fn,
310
+ priority: definition.priority,
108
311
  ...options,
312
+ ...(definition.remoteHost && definition.priority !== undefined ? { priority: definition.priority } : {}),
313
+ ...(options.guildIds ? { guildIds: Object.freeze([...options.guildIds]) } : {}),
109
314
  order: this.order++,
110
315
  };
111
- this.handlers.push(handler);
316
+ entry.handlers.add(handler);
317
+ const list = this.handlers.get(operation) ?? [];
318
+ this.handlers.set(operation, [...list, handler].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.order - b.order));
112
319
  return () => {
113
- this.handlers = this.handlers.filter((item) => item !== handler);
320
+ entry.handlers.delete(handler);
321
+ const remaining = this.handlers.get(operation)?.filter((item) => item !== handler) ?? [];
322
+ if (remaining.length)
323
+ this.handlers.set(operation, remaining);
324
+ else
325
+ this.handlers.delete(operation);
114
326
  };
115
327
  },
116
328
  onCleanup: (callback) => {
@@ -118,10 +330,12 @@ export class PluginRegistry {
118
330
  entry.cleanups.push(callback);
119
331
  },
120
332
  };
121
- this.entries.set(entry.id, entry);
333
+ this.entries.set(key, entry);
122
334
  entry.ready = Promise.resolve().then(async () => {
123
335
  try {
124
- await definition.setup(entry.context);
336
+ if (definition.remoteHost)
337
+ await registerRemote(entry.context, definition.id, definition.remoteHost, this.advertisements.get(registration));
338
+ await definition.setup?.(entry.context);
125
339
  entry.active = !controller.signal.aborted;
126
340
  }
127
341
  catch (cause) {
@@ -135,11 +349,35 @@ export class PluginRegistry {
135
349
  throw new PluginError(entry.id, 'setup', cause);
136
350
  }
137
351
  });
138
- await entry.ready;
352
+ this.initializing.add(entry.ready);
353
+ try {
354
+ await entry.ready;
355
+ }
356
+ finally {
357
+ this.initializing.delete(entry.ready);
358
+ }
139
359
  }
140
360
  async cleanup(entry) {
141
- this.handlers = this.handlers.filter((handler) => handler.entry !== entry);
361
+ for (const operation of new Set([...entry.handlers].map((handler) => handler.operation))) {
362
+ const remaining = this.handlers.get(operation)?.filter((handler) => handler.entry !== entry) ?? [];
363
+ if (remaining.length)
364
+ this.handlers.set(operation, remaining);
365
+ else
366
+ this.handlers.delete(operation);
367
+ }
368
+ entry.handlers.clear();
142
369
  const errors = [];
370
+ for (const [name, provider] of this.providersIndex) {
371
+ if (provider.entry !== entry)
372
+ continue;
373
+ this.providersIndex.delete(name);
374
+ try {
375
+ await provider.dispose?.();
376
+ }
377
+ catch (error) {
378
+ errors.push(new PluginError(entry.id, String(name), error));
379
+ }
380
+ }
143
381
  try {
144
382
  await entry.registration.definition.destroy?.(entry.context);
145
383
  }
@@ -154,19 +392,23 @@ export class PluginRegistry {
154
392
  errors.push(new PluginError(entry.id, 'cleanup', error));
155
393
  }
156
394
  }
157
- if (this.entries.get(entry.id) === entry) {
158
- this.entries.delete(entry.id);
159
- }
395
+ for (const [key, value] of this.entries)
396
+ if (value === entry)
397
+ this.entries.delete(key);
160
398
  if (errors.length) {
161
399
  throw new AggregateError(errors, `Plugin ${entry.id} cleanup failed`);
162
400
  }
163
401
  }
164
402
  /** Abort new and active work, drain handlers, and release resources. Handlers must cooperate with cancellation.
165
403
  * @param id Plugin ID.
404
+ * @param hostId Optional deployment identity when the provider exists on multiple hosts.
166
405
  * @returns Whether the registration existed.
167
406
  */
168
- async unregister(id) {
169
- const entry = this.entries.get(id);
407
+ async unregister(id, hostId) {
408
+ const candidates = [...this.entries.values()].filter((item) => item.id === id);
409
+ if (!hostId && !this.entries.has(id) && candidates.length > 1)
410
+ throw new Error(`Plugin ${id} has multiple deployments; supply hostId to unregister one`);
411
+ const entry = this.entries.get(hostId ? `${hostId}::${id}` : id) ?? (!hostId ? candidates[0] : undefined);
170
412
  if (!entry) {
171
413
  return false;
172
414
  }
@@ -190,17 +432,103 @@ export class PluginRegistry {
190
432
  }
191
433
  /** Initialize pending registrations before dispatch. */
192
434
  async ready() {
193
- await Promise.all([...this.entries.values()].map((entry) => entry.ready));
435
+ if (this.initializing.size)
436
+ await Promise.all(this.initializing);
194
437
  }
195
438
  /** Destroy all registrations, attempting every cleanup even when one fails. */
196
439
  async destroy() {
197
440
  this.destroyed = true;
441
+ this.lifetime.abort();
198
442
  const results = await Promise.allSettled([...this.entries.keys()].map((id) => this.unregister(id)));
199
443
  const errors = results.filter((result) => result.status === 'rejected').map((result) => result.reason);
200
444
  if (errors.length) {
201
445
  throw new AggregateError(errors, 'Plugin cleanup failed');
202
446
  }
203
447
  }
448
+ /** Whether an operation has active subscribers. Does not scan unrelated registrations.
449
+ * @param operation Capability name.
450
+ * @returns Whether at least one active handler exists.
451
+ */
452
+ hasCapability(operation) {
453
+ return this.handlers.get(operation)?.some((handler) => handler.entry.active) ?? false;
454
+ }
455
+ /** Reject reentrant application operations from synchronous capability handlers. */
456
+ assertNotDispatching() {
457
+ if (this.dispatching)
458
+ throw new Error('Reentrant application operations inside a capability handler are unsupported.');
459
+ }
460
+ /** Dispatch a synchronous local capability without scheduling promises.
461
+ * @param operation Capability defined with synchronous execution.
462
+ * @param input Validated operation payload.
463
+ * @param options Scope and cancellation constraints.
464
+ * @returns The selected result, or transformed input for pipe operations.
465
+ */
466
+ executeSync(operation, input, options = {}) {
467
+ options.signal?.throwIfAborted();
468
+ if (!this.synchronous.has(operation))
469
+ throw new Error(`Operation is not synchronous: ${operation}`);
470
+ const policy = this.definitions.get(operation);
471
+ const list = this.handlers.get(operation);
472
+ if (!list?.length)
473
+ return (policy === 'pipe' ? input : undefined);
474
+ this.assertNotDispatching();
475
+ this.dispatching = true;
476
+ let value = input;
477
+ const errors = [];
478
+ try {
479
+ for (const handler of selectHandlers(operation, input, options, {
480
+ handlers: this.handlers,
481
+ definitions: this.definitions,
482
+ blocked: this.blocked,
483
+ resultOwners: this.resultOwners,
484
+ })) {
485
+ const signal = options.signal
486
+ ? AbortSignal.any([options.signal, handler.entry.controller.signal])
487
+ : handler.entry.controller.signal;
488
+ signal.throwIfAborted();
489
+ try {
490
+ const result = handler.fn(policy === 'pipe' || policy === 'intercept' ? value : input, { signal });
491
+ if (result && typeof result === 'object' && 'then' in result) {
492
+ const pending = Promise.resolve(result);
493
+ handler.entry.pending.add(pending);
494
+ void pending.then(() => handler.entry.pending.delete(pending), () => handler.entry.pending.delete(pending));
495
+ throw new TypeError('Synchronous capability handlers must not return promises.');
496
+ }
497
+ signal.throwIfAborted();
498
+ if (result !== undefined && policy === 'intercept') {
499
+ if (!result || typeof result !== 'object' || !('action' in result))
500
+ throw new TypeError('Expected an explicit capability decision.');
501
+ if (result.action === 'cancel') {
502
+ if (!('reason' in result) || typeof result.reason !== 'string')
503
+ throw new TypeError('Cancellation requires a reason.');
504
+ return result;
505
+ }
506
+ if (result.action !== 'replace' || !('value' in result))
507
+ throw new TypeError('Expected cancel or replace.');
508
+ value = result.value;
509
+ }
510
+ if (result !== undefined && policy === 'first')
511
+ return result;
512
+ if (result !== undefined && policy === 'pipe')
513
+ value = result;
514
+ }
515
+ catch (cause) {
516
+ const error = new PluginError(handler.entry.id, operation, cause);
517
+ if (policy !== 'broadcast')
518
+ throw error;
519
+ errors.push(error);
520
+ }
521
+ }
522
+ if (errors.length === 1)
523
+ throw errors[0];
524
+ if (errors.length)
525
+ throw new AggregateError(errors, `Observers failed during ${operation}`);
526
+ return (policy === 'pipe' ? value : policy === 'intercept' ? { action: 'replace', value } : undefined);
527
+ }
528
+ finally {
529
+ this.dispatching = false;
530
+ }
531
+ }
204
532
  /** Execute using the operation's declared policy. Genuine failures reject immediately.
205
533
  * @param operation Typed operation name.
206
534
  * @param input Operation payload.
@@ -208,11 +536,26 @@ export class PluginRegistry {
208
536
  * @returns Result inferred from the operation policy.
209
537
  */
210
538
  async execute(operation, input, options = {}) {
211
- return this.run(operation, input, options);
539
+ if (this.synchronous.has(operation))
540
+ return this.executeSync(operation, input, options);
541
+ const guarded = operation.startsWith('media.') ||
542
+ operation.startsWith('serialization.') ||
543
+ operation === 'search.beforeQuery' ||
544
+ operation === 'search.afterResults';
545
+ if (!guarded)
546
+ return this.run(operation, input, options);
547
+ const active = this.calls.getStore();
548
+ if (active?.has(operation))
549
+ throw new Error(`Recursive capability dispatch: ${operation}`);
550
+ return this.calls.run(new Set([...(active ?? []), operation]), () => this.run(operation, input, options));
212
551
  }
213
552
  async run(operation, input, options, handled) {
214
553
  const track = input && typeof input === 'object' && 'track' in input ? input.track : undefined;
215
- if (track?.sourceReference?.hostId && track.sourceReference.hostId !== this.hostId) {
554
+ if (track?.sourceReference?.hostId &&
555
+ track.sourceReference.hostId !== this.hostId &&
556
+ !(this.handlers.get(operation) ?? []).some((handler) => handler.operation === operation &&
557
+ handler.entry.active &&
558
+ handler.entry.registration.definition.remoteHost?.hostId === track.sourceReference?.hostId)) {
216
559
  throw new Error(`Source host ${track.sourceReference.hostId} is unavailable in this runtime; execute this Track on its owning source host`);
217
560
  }
218
561
  options.signal?.throwIfAborted();
@@ -239,6 +582,8 @@ export class PluginRegistry {
239
582
  * @returns Whether a byte transformation would be invoked.
240
583
  */
241
584
  hasStreamTransforms(pluginId, options = {}) {
585
+ if (this.hasCapability('media.transform'))
586
+ return true;
242
587
  return (selectHandlers('stream.transform', {}, { ...options, pluginId }, {
243
588
  handlers: this.handlers,
244
589
  definitions: this.definitions,
@@ -246,12 +591,46 @@ export class PluginRegistry {
246
591
  resultOwners: this.resultOwners,
247
592
  }).length > 0);
248
593
  }
594
+ /** Serialize using modern application-metadata transformations. Plain model serialization remains synchronous.
595
+ * @param entity Track or playlist to persist.
596
+ * @param options Cancellation and provider scope.
597
+ * @returns A validated JSON persistence payload, without playback grants or live streams.
598
+ */
599
+ async serialize(entity, options = {}) {
600
+ const input = serialize(entity);
601
+ const result = this.hasCapability('serialization.afterSerialize')
602
+ ? await this.execute('serialization.afterSerialize', input, options)
603
+ : input;
604
+ return parseEntity(result);
605
+ }
606
+ /** Restore through modern metadata transformations and the existing schema validator.
607
+ * @param payload Untrusted persistence payload.
608
+ * @param options Cancellation and provider scope.
609
+ * @returns A restored entity without ephemeral playback state.
610
+ */
611
+ async restore(payload, options = {}) {
612
+ const input = parseEntity(payload);
613
+ const result = this.hasCapability('serialization.beforeRestore')
614
+ ? await this.execute('serialization.beforeRestore', input, options)
615
+ : input;
616
+ return deserialize(parseEntity(result));
617
+ }
249
618
  /** Resolve and normalize a track for the already selected Remedia node.
250
619
  * @param track Data model with a portable provider reference.
251
620
  * @param options Target node mode and cancellation.
252
621
  */
253
622
  async resolveStream(track, options = {}) {
254
- return resolveTrackStream(track, options, (operation, input, execution, handled) => this.run(operation, input, execution, handled), (owner) => this.hasStreamTransforms(owner, options));
623
+ if (this.hasCapability('media.beforeResolve')) {
624
+ const originalPluginId = track.sourceReference?.pluginId;
625
+ const originalHostId = track.sourceReference?.hostId;
626
+ const prepared = await this.execute('media.beforeResolve', { track }, { signal: options.signal });
627
+ if (!prepared ||
628
+ prepared.track.sourceReference?.pluginId !== originalPluginId ||
629
+ prepared.track.sourceReference?.hostId !== originalHostId)
630
+ throw new TypeError('media.beforeResolve must preserve provider and deployment identity.');
631
+ track = prepared.track;
632
+ }
633
+ return resolveTrackStream(track, options, (operation, input, execution, handled) => this.run(operation, input, execution, handled), (owner) => this.hasStreamTransforms(owner, options), (operation) => this.hasCapability(operation));
255
634
  }
256
635
  }
257
636
  //# sourceMappingURL=registry.js.map