@3sln/trove 0.0.16 → 0.0.18

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 (29) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/index.js +1 -1
  3. package/packages/core/src/indexers/registry.js +11 -0
  4. package/packages/core/src/plugins/index.js +44 -6
  5. package/packages/core/src/plugins/indexers.js +52 -0
  6. package/packages/core/src/plugins/runtime.js +101 -4
  7. package/packages/core/src/plugins/workerLoaderRuntime.js +160 -0
  8. package/packages/core/src/sidecar/document.js +60 -2
  9. package/packages/core/src/sidecar/index.js +28 -1
  10. package/packages/core/src/vfs.js +14 -3
  11. package/packages/plugin-sdk/src/browser.js +257 -26
  12. package/packages/plugin-sdk/src/protocol.js +9 -0
  13. package/packages/server/src/adapters/worker-tasks.js +11 -0
  14. package/packages/server/src/engine/providers/core.js +12 -5
  15. package/packages/server/src/index.js +90 -0
  16. package/packages/server/src/routes.js +92 -2
  17. package/packages/web/dist/assets/main-b9jd0fyt.js +742 -0
  18. package/packages/web/dist/assets/{main-wpfmmbfd.js.map → main-b9jd0fyt.js.map} +13 -12
  19. package/packages/web/dist/index.html +1 -1
  20. package/packages/web/dist/sw.js +33 -3
  21. package/packages/web/src/bl/fileType.js +8 -0
  22. package/packages/web/src/bl/launcher.js +15 -0
  23. package/packages/web/src/platform/index.js +8 -0
  24. package/packages/web/src/platform/itemData.js +161 -0
  25. package/packages/web/src/platform/navigation.js +16 -2
  26. package/packages/web/src/platform/pluginRpc.js +55 -0
  27. package/packages/web/src/ui/components/launcher.js +1 -1
  28. package/packages/web/src/ui/components/views/index.js +14 -1
  29. package/packages/web/dist/assets/main-wpfmmbfd.js +0 -511
@@ -14,12 +14,140 @@
14
14
  // protocol.js — this file is injected as text and cannot import it, so
15
15
  // protocol.test.js asserts the two stay in step.
16
16
  const SDK_PROTOCOL_VERSION = '1.0';
17
+
18
+ // --- events -----------------------------------------------------------------
19
+ //
20
+ // Every hook here used to be one assignment — `onDeactivate = fn`, `onDock = fn`,
21
+ // `mediaHandlers[action] = fn`. Register twice and the first silently vanished, with
22
+ // nothing to report it: the symptom is a timer or an object URL outliving its viewer,
23
+ // noticed much later as a leak. The audiobook player was losing one of two teardowns
24
+ // exactly that way.
25
+ //
26
+ // So the SDK's nodes are EventTargets and handlers are listeners. Registrations
27
+ // accumulate, removal is `removeEventListener`, and a plugin can compose without
28
+ // knowing what else is listening.
29
+ //
30
+ // Inlined rather than imported because this file is injected as TEXT into a sandboxed
31
+ // frame and has no module loader — see the header.
32
+ //
33
+ // Two things a plain CustomEvent cannot express, both needed:
34
+ //
35
+ // waitUntil(p) The host awaits some of these. `opener:open` must not resolve until
36
+ // the viewer has drawn, or "Opening…" is hidden over a blank frame.
37
+ // respondWith(v) A command has a return value that crosses back over the port. First
38
+ // answer wins; a second is a bug in the plugin, not something to pick
39
+ // between silently.
40
+ class TroveEvent extends Event {
41
+ constructor(type, detail) {
42
+ super(type, { cancelable: true });
43
+ this.detail = detail;
44
+ this._waits = [];
45
+ this._answered = false;
46
+ this._answer = undefined;
47
+ }
48
+ waitUntil(p) { this._waits.push(Promise.resolve(p)); }
49
+ respondWith(value) {
50
+ if (this._answered) return;
51
+ this._answered = true;
52
+ this._answer = value;
53
+ if (value && typeof value.then === 'function') this._waits.push(Promise.resolve(value).then((v) => { this._answer = v; }));
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Dispatch, then wait for every `waitUntil`, then answer.
59
+ *
60
+ * A listener that throws is reported and does not stop the others — dispatch is where
61
+ * resources are released, and one bad handler must not strand the rest.
62
+ */
63
+ async function fire(target, type, detail) {
64
+ const ev = new TroveEvent(type, detail);
65
+ target.dispatchEvent(ev);
66
+ for (const r of await Promise.allSettled(ev._waits)) {
67
+ if (r.status === 'rejected') console.error('[trove] a "' + type + '" handler failed', r.reason);
68
+ }
69
+ return ev._answer;
70
+ }
71
+
72
+ /**
73
+ * An EventTarget that knows what is listening.
74
+ *
75
+ * The host needs that: it is how the manifest reports which commands a plugin actually
76
+ * implements, and how media claims an OS control on the first listener and releases it
77
+ * on the last.
78
+ */
79
+ function makeTarget(onFirst, onLast) {
80
+ const counts = new Map();
81
+ const target = new EventTarget();
82
+ const add = target.addEventListener.bind(target);
83
+ const remove = target.removeEventListener.bind(target);
84
+ target.types = () => [...counts.entries()].filter(([, n]) => n > 0).map(([t]) => t);
85
+ target.listening = (type) => (counts.get(type) || 0) > 0;
86
+ target.addEventListener = (type, fn, opts) => {
87
+ const n = (counts.get(type) || 0) + 1;
88
+ counts.set(type, n);
89
+ if (n === 1 && onFirst) onFirst(type);
90
+ return add(type, fn, opts);
91
+ };
92
+ target.removeEventListener = (type, fn, opts) => {
93
+ const n = Math.max(0, (counts.get(type) || 0) - 1);
94
+ counts.set(type, n);
95
+ if (n === 0 && onLast) onLast(type);
96
+ return remove(type, fn, opts);
97
+ };
98
+ return target;
99
+ }
100
+
101
+ /**
102
+ * `onThing(fn)` written over `addEventListener`.
103
+ *
104
+ * Kept because it reads well for the common case and is what every existing plugin
105
+ * says. What changed is that it no longer REPLACES: two calls mean two listeners, and
106
+ * it returns a disposer. A handler that returns a promise keeps the host waiting; one
107
+ * that returns a value answers.
108
+ */
109
+ function hook(target, type, args = (e) => [e.detail]) {
110
+ return (fn) => {
111
+ if (typeof fn !== 'function') return () => {};
112
+ const listener = (e) => {
113
+ // GUARDED here rather than relying on the host's EventTarget to isolate a throw.
114
+ // Browsers do report an uncaught listener error and carry on, but that is not
115
+ // uniform — Bun stops dispatch — and teardown is the one place where "the rest
116
+ // still ran" has to be a guarantee rather than a hope.
117
+ let out;
118
+ try {
119
+ out = fn(...args(e));
120
+ } catch (err) {
121
+ console.error('[trove] a "' + e.type + '" handler threw', err);
122
+ return;
123
+ }
124
+ if (out && typeof out.then === 'function') e.waitUntil(out);
125
+ if (out !== undefined) e.respondWith(out);
126
+ };
127
+ target.addEventListener(type, listener);
128
+ return () => target.removeEventListener(type, listener);
129
+ };
130
+ }
17
131
  let port = null, manifest = null, capabilities = [], storageScopes = {}, online = true, seq = 0, role = 'primary';
18
132
  const pending = new Map();
19
- const commandHandlers = new Map();
20
- const openerHandlers = new Map();
21
- let onConnectivity = null, onDeactivate = null, onSettingsChange = null, onDock = null;
22
- const mediaHandlers = {}; // action -> handler, for OS media-session controls
133
+ // EVERY hook is an EventTarget registration — see above for why. `ctx` itself is
134
+ // the target for lifecycle and connectivity; commands, openers and media each get their
135
+ // own so an event type can be a command name or an action without colliding.
136
+ const bus = makeTarget(); // deactivate | connectivity | settingschange | dockchange
137
+ const commandTarget = makeTarget(); // type = the command's short name
138
+ const openerTarget = makeTarget(); // type = the opener's name, or '*'
139
+ // Media tells the HOST which actions to claim from the OS, so registration is observed:
140
+ // the first listener for an action claims it, the last one to go releases it.
141
+ const mediaTarget = makeTarget(
142
+ (action) => { call('media:action', { action, on: true }).catch(() => {}); },
143
+ (action) => { call('media:action', { action, on: false }).catch(() => {}); },
144
+ );
145
+ // A LIST. It was one slot — `onDeactivate = fn` — so a second registration silently
146
+ // discarded the first, and the plugin that lost one had no way to notice: the symptom
147
+ // is a timer or an object URL that outlives the viewer. The audiobook player has two
148
+ // teardowns (a download poller and the transport), which is not exotic; anything with
149
+ // more than one resource has more than one.
150
+
23
151
 
24
152
  const now = () => { try { return Date.now(); } catch { return 0; } };
25
153
 
@@ -65,7 +193,13 @@
65
193
  constructor(id, { size = 0, type = '', etag = null, start = 0, end = null } = {}) {
66
194
  super();
67
195
  this.id = id;
68
- this.type = type;
196
+ // `_type`, and a getter below. `Blob.prototype.type` is an accessor with no setter,
197
+ // so `this.type = …` THROWS in strict mode — which every module is. That one line
198
+ // meant `ctx.files.blob()` rejected on construction for every plugin that ever
199
+ // called it: no cover art, no container parsing, no streaming, and an error message
200
+ // ("Cannot set property type of #<Blob> which has only a getter") that never left
201
+ // the sandboxed frame it was thrown in.
202
+ this._type = type;
69
203
  this.etag = etag;
70
204
  // A window on the source. `size` is this window's length, which is what makes
71
205
  // `slice()` of a slice behave the way a caller expects.
@@ -74,6 +208,7 @@
74
208
  }
75
209
 
76
210
  get size() { return Math.max(0, this._end - this._start); }
211
+ get type() { return this._type; }
77
212
 
78
213
  /**
79
214
  * A window on the same source. No bytes move and none need to exist yet.
@@ -160,7 +295,7 @@
160
295
  return {
161
296
  domain: manifest && manifest.domain, name: manifest && manifest.name,
162
297
  online, role, ts: now(),
163
- handlers: [...commandHandlers.keys()],
298
+ handlers: commandTarget.types(),
164
299
  };
165
300
  }
166
301
  const announce = () => port && emit('manifest', buildManifest());
@@ -185,30 +320,32 @@
185
320
 
186
321
  function dispatch(method, params) {
187
322
  if (method === 'command:execute') {
188
- const h = commandHandlers.get(params.id);
189
- // Throw, like `opener:open` two lines down. Resolving `undefined` for an id with
190
- // no handler told the host the command had RUN when nothing had.
191
- if (!h) throw new Error(`No handler registered for command "${params.id}"`);
192
- return h(...(params.args || []));
323
+ // Throw rather than resolve `undefined` for an id nobody implements: that told the
324
+ // host the command had RUN when nothing had.
325
+ if (!commandTarget.listening(params.id)) throw new Error(`No handler registered for command "${params.id}"`);
326
+ return fire(commandTarget, params.id, { args: params.args || [] });
193
327
  }
194
328
  if (method === 'opener:open') {
195
329
  // An opener frame boots at that opener's entry module and runs exactly one
196
330
  // opener, so an unkeyed onOpen(fn) handler is the normal case.
197
- const f = openerHandlers.get(params.openerId) || openerHandlers.get('*');
198
- if (!f) throw new Error('no opener ' + params.openerId);
199
- return f(params.file, params.context);
331
+ const type = openerTarget.listening(params.openerId) ? params.openerId
332
+ : openerTarget.listening('*') ? '*' : null;
333
+ if (!type) throw new Error('no opener ' + params.openerId);
334
+ // AWAITED, and that is what `waitUntil` is for: `opener:open` must not resolve until
335
+ // the viewer has drawn, or the host hides "Opening…" over a blank frame.
336
+ return fire(openerTarget, type, { file: params.file, context: params.context, openerId: params.openerId });
200
337
  }
201
338
  if (method === 'manifest') return buildManifest();
202
339
  throw new Error('Unknown host call ' + method);
203
340
  }
204
341
  async function dispatchEvent(method, params) {
205
- if (method === 'deactivate') return onDeactivate && onDeactivate();
206
- if (method === 'connectivity') { online = !!params.online; try { onConnectivity && (await onConnectivity({ online })); } catch (e) { console.error(e); } announce(); }
207
- if (method === 'settings:changed') { try { onSettingsChange && onSettingsChange(params.key, params.value); } catch (e) { console.error(e); } }
342
+ if (method === 'deactivate') return fire(bus, 'deactivate', {});
343
+ if (method === 'connectivity') { online = !!params.online; await fire(bus, 'connectivity', { online }); announce(); }
344
+ if (method === 'settings:changed') return fire(bus, 'settingschange', { key: params.key, value: params.value });
208
345
  // The OS/host fired a media transport control (play/pause/next/seek…).
209
- if (method === 'media:action') { try { mediaHandlers[params.action] && mediaHandlers[params.action](params); } catch (e) { console.error(e); } }
346
+ if (method === 'media:action') return fire(mediaTarget, params.action, params);
210
347
  // The host docked or undocked this viewer (see ctx.dock).
211
- if (method === 'dock:state') { try { onDock && onDock(params); } catch (e) { console.error(e); } }
348
+ if (method === 'dock:state') return fire(bus, 'dockchange', params);
212
349
  }
213
350
 
214
351
  function requireCap(cap) { if (!capabilities.includes(cap)) throw new Error('Plugin lacks capability "' + cap + '"'); }
@@ -263,7 +400,10 @@
263
400
  // for what it declared, addressed by id.
264
401
  commands: {
265
402
  /** Implement a command this plugin's manifest declares, by its short name. */
266
- handle(name, handler) { commandHandlers.set(name, handler); return this; },
403
+ /** Implement a command this plugin's manifest declares, by its short name. */
404
+ handle(name, handler) { hook(commandTarget, name, (e) => e.detail.args)(handler); return this; },
405
+ addEventListener: (...a) => commandTarget.addEventListener(...a),
406
+ removeEventListener: (...a) => commandTarget.removeEventListener(...a),
267
407
  /**
268
408
  * Run a command. Its OWN commands by short name; anyone else's by full address
269
409
  * (a built-in like 'explorer.download', or a `trove+contrib:` URI) — and only
@@ -275,9 +415,13 @@
275
415
  * optional — an opener frame runs exactly one opener. */
276
416
  onOpen(idOrHandler, maybeHandler) {
277
417
  const [id, handler] = typeof idOrHandler === 'function' ? ['*', idOrHandler] : [idOrHandler, maybeHandler];
278
- openerHandlers.set(id, handler);
418
+ // A handler returning a promise keeps the host waiting — which is what holds
419
+ // "Opening…" on screen until the viewer has actually drawn. See `hook`.
420
+ hook(openerTarget, id, (e) => [e.detail.file, e.detail.context])(handler);
279
421
  return this;
280
422
  },
423
+ /** The opener target, for `addEventListener('*', e => e.waitUntil(…))`. */
424
+ openers: openerTarget,
281
425
  // NOTE: there is no onIndex(). Indexers run on the SERVER (in its isolate
282
426
  // runtime), not in this sandbox — indexing has to happen once per upload for the
283
427
  // drive, not in whichever tab is open. An indexer's entry module is plain ESM
@@ -328,7 +472,21 @@
328
472
  setMetadata: (m) => (requireCap('media'), call('media:metadata', m || {})),
329
473
  setPlaybackState: (state) => (requireCap('media'), call('media:playbackState', { state })),
330
474
  setPositionState: (p) => (requireCap('media'), call('media:position', p || {})),
331
- setActionHandler: (action, handler) => { requireCap('media'); if (handler) mediaHandlers[action] = handler; else delete mediaHandlers[action]; return call('media:action', { action, on: !!handler }); },
475
+ /**
476
+ * @deprecated use `ctx.media.addEventListener(action, …)`.
477
+ *
478
+ * Additive now, and the RPC that claims the OS control moves with it: the first
479
+ * listener for an action claims it, the last one to go releases it. Passing null
480
+ * removes nothing in particular, so it is a no-op rather than a silent surprise.
481
+ */
482
+ setActionHandler: (action, handler) => {
483
+ requireCap('media');
484
+ if (!handler) return Promise.resolve({ ok: true });
485
+ hook(mediaTarget, action)(handler);
486
+ return Promise.resolve({ ok: true });
487
+ },
488
+ addEventListener: (...a) => (requireCap('media'), mediaTarget.addEventListener(...a)),
489
+ removeEventListener: (...a) => mediaTarget.removeEventListener(...a),
332
490
  clear: () => call('media:clear', {}),
333
491
  },
334
492
  // Dock — register this viewer to persist as a small floating frame when the
@@ -339,7 +497,10 @@
339
497
  enable: (opts) => (requireCap('dock'), call('dock:enable', opts || {})),
340
498
  disable: () => (requireCap('dock'), call('dock:disable', {})),
341
499
  close: () => call('dock:close', {}),
342
- onChange: (fn) => { onDock = fn; },
500
+ /** @deprecated use `ctx.addEventListener('dockchange', …)`. Now additive. */
501
+ onChange: hook(bus, 'dockchange'),
502
+ addEventListener: (...a) => bus.addEventListener(...a),
503
+ removeEventListener: (...a) => bus.removeEventListener(...a),
343
504
  },
344
505
  // Network — there is no direct fetch in the sandbox; the host performs the
345
506
  // request, but ONLY to endpoints declared in the manifest's `network` list
@@ -394,6 +555,61 @@
394
555
  */
395
556
  mediaUrl: (id, opts) => (requireCap('files'), call('files:mediaUrl', Object.assign({ id }, opts))),
396
557
 
558
+ /**
559
+ * A file's own key/value data, for THIS plugin.
560
+ *
561
+ * The third kind of state a viewer has, and the one there was nowhere to put. A
562
+ * contribution is derived from the file and is rewritten whenever it is
563
+ * re-indexed. A setting is per-device, so a listening position kept there does
564
+ * not follow you to your phone — which is the moment people notice. This is the
565
+ * other thing: state about a user's use of an item.
566
+ *
567
+ * ONE INTERFACE, and it deliberately does not say where the bytes are. A write
568
+ * lands locally at once and reaches the server when there is a server to reach;
569
+ * a read is the merge of both. Two devices that each wrote while apart converge,
570
+ * later write winning, because the sidecar underneath is a CRDT — see
571
+ * core/sidecar/document.js.
572
+ *
573
+ * Scoped to this plugin by the HOST, from the plugin it authenticated. There is
574
+ * no parameter for the scope because there is no version of this where a plugin
575
+ * chooses whose data it is writing.
576
+ */
577
+ data: (id) => ({
578
+ all: () => (requireCap('files'), call('items:data:get', { id })),
579
+ get: async (key) => (requireCap('files'), (await call('items:data:get', { id }))[key]),
580
+ set: (key, value) => (requireCap('files'), call('items:data:set', { id, key, value })),
581
+ remove: (key) => (requireCap('files'), call('items:data:remove', { id, key })),
582
+ }),
583
+
584
+ /**
585
+ * Is the whole file already in this browser?
586
+ *
587
+ * Cheap — it reads bookkeeping, not bytes — so ask it before deciding what to
588
+ * draw. `{ local, loaded, total, ratio, filling }`: enough to show a progress
589
+ * bar for a download already under way instead of offering to start it again.
590
+ */
591
+ hasLocal: (id) => (requireCap('files'), call('files:hasLocal', { id })),
592
+
593
+ /**
594
+ * The whole file as a Blob, or null when it is not stored locally.
595
+ *
596
+ * THE WAY MEDIA WORKS IN HERE. This frame's CSP is `connect-src 'none'` and
597
+ * `media-src blob: data:` — deliberately, so a plugin cannot reach the network
598
+ * and cannot be an exfiltration side-channel. An `<audio src="https://…">` is
599
+ * therefore blocked outright, and no amount of URL minting changes that. A Blob
600
+ * is allowed, so a downloaded file plays by being handed over.
601
+ *
602
+ * Null when the file is incomplete rather than a partial Blob: half an MP4 is
603
+ * not a shorter MP4, and a caller handed one would fail like a decoder bug
604
+ * instead of like a missing download. Pair it with `offline.start` and
605
+ * `hasLocal` to get from "not here" to "here".
606
+ */
607
+ localBlob: async (id) => {
608
+ requireCap('files');
609
+ const { blob } = await call('files:localBlob', { id });
610
+ return blob || null;
611
+ },
612
+
397
613
  /**
398
614
  * Keeping a file, which is a DIFFERENT act from reading one.
399
615
  *
@@ -434,11 +650,26 @@
434
650
  get: (key) => call('settings:get', { key }),
435
651
  getSecret: (key) => call('settings:getSecret', { key }),
436
652
  set: (key, value) => call('settings:set', { key, value }),
437
- onChange: (fn) => { onSettingsChange = fn; },
653
+ /** @deprecated use `ctx.addEventListener('settingschange', …)`. Now additive. */
654
+ onChange: hook(bus, 'settingschange', (e) => [e.detail.key, e.detail.value]),
438
655
  },
439
- onConnectivity: (fn) => { onConnectivity = fn; },
656
+ /** @deprecated use `ctx.addEventListener('connectivity', …)`. Now additive. */
657
+ onConnectivity: hook(bus, 'connectivity'),
440
658
  announce,
441
- onDeactivate: (fn) => { onDeactivate = fn; },
659
+ /**
660
+ * @deprecated use `ctx.addEventListener('deactivate', …)`.
661
+ *
662
+ * Kept, and now additive: registering twice used to discard the first, which is
663
+ * how a viewer ended up leaking one of its two teardowns. Returns a disposer.
664
+ */
665
+ onDeactivate: hook(bus, 'deactivate'),
666
+
667
+ // The bus itself. `deactivate`, `connectivity`, `settingschange`, `dockchange` —
668
+ // and every one of them composes, which the `onX` forms above now do too because
669
+ // they are written over this.
670
+ addEventListener: (...a) => bus.addEventListener(...a),
671
+ removeEventListener: (...a) => bus.removeEventListener(...a),
672
+ dispatchEvent: (...a) => bus.dispatchEvent(...a),
442
673
  };
443
674
  }
444
675
 
@@ -49,6 +49,15 @@ export const METHODS = {
49
49
  bytes: 'files:bytes',
50
50
  // A minted URL for a media element. See pluginRpc.js for why this one host URL crosses.
51
51
  mediaUrl: 'files:mediaUrl',
52
+ // Is the whole file here, and can I have it? The pair a viewer needs because the
53
+ // frame's CSP forbids loading media from a URL — see pluginFrames.js.
54
+ // A file's own key/value data for this plugin — local-first, merged with the
55
+ // server opportunistically. See platform/itemData.js.
56
+ dataGet: 'items:data:get',
57
+ dataSet: 'items:data:set',
58
+ dataRemove: 'items:data:remove',
59
+ hasLocal: 'files:hasLocal',
60
+ localBlob: 'files:localBlob',
52
61
  offline: {
53
62
  start: 'files:offline:start', status: 'files:offline:status',
54
63
  cancel: 'files:offline:cancel', remove: 'files:offline:remove',
@@ -85,6 +85,15 @@ export function createTaskHost(getServer) {
85
85
  return { task, alreadyRunning };
86
86
  }
87
87
 
88
+ async #beginBackfill(indexerIds, reason) {
89
+ const server = await this.#boot();
90
+ const { task, alreadyRunning, done } = await server.beginBackfill({ indexerIds, reason });
91
+ // Same shape as a reindex: nothing to resume from, so it either finishes in this
92
+ // object's lifetime or the files stay unindexed until something asks again.
93
+ if (!alreadyRunning) this.state.waitUntil?.(done.catch(() => null));
94
+ return { task, alreadyRunning };
95
+ }
96
+
88
97
  async #beginReindex(reason) {
89
98
  const server = await this.#boot();
90
99
  const { task, alreadyRunning, done } = await server.beginReindex({ reason });
@@ -102,6 +111,7 @@ export function createTaskHost(getServer) {
102
111
  const server = await this.#boot();
103
112
  switch (url.pathname) {
104
113
  case '/begin':
114
+ if (body.kind === 'backfill') return json(await this.#beginBackfill(body.indexerIds, body.reason));
105
115
  return json(body.kind === 'index'
106
116
  ? await this.#beginReindex(body.reason)
107
117
  : await this.#beginScan(body.collectionId || 'default', body.reason));
@@ -204,6 +214,7 @@ export function remoteBackground(namespace) {
204
214
  background: {
205
215
  beginScan: (collectionId, { reason } = {}) => begin({ kind: 'scan', collectionId, reason }),
206
216
  beginReindex: ({ reason } = {}) => begin({ kind: 'index', reason }),
217
+ beginBackfill: ({ indexerIds, reason } = {}) => begin({ kind: 'backfill', indexerIds, reason }),
207
218
  },
208
219
  maintain: (budgetMs) => stub()
209
220
  .fetch('https://trove.tasks/maintain', {
@@ -39,7 +39,7 @@ import {
39
39
  ApiKeyService, CapabilityProvider, ApiKeyCapabilityProvider,
40
40
  CollectionService,
41
41
  PluginService, PackageStore, StoragePackageStore, SqlitePluginInstallStore,
42
- IndexerRuntime, InProcessIndexerRuntime, PluginIndexers,
42
+ IndexerRuntime, InProcessIndexerRuntime, WorkerLoaderIndexerRuntime, PluginIndexers,
43
43
  TaskRegistry, IssueRegistry,
44
44
  Vfs, TroveError,
45
45
  resolveAuthDiscovery,
@@ -225,6 +225,7 @@ export function coreProviders(config, lifecycleState) {
225
225
  backgroundWork: Provider.fromSingleton({
226
226
  beginScan: (collectionId, opts) => lifecycleState.background.beginScan(collectionId, opts),
227
227
  beginReindex: (opts) => lifecycleState.background.beginReindex(opts),
228
+ beginBackfill: (opts) => lifecycleState.background.beginBackfill(opts),
228
229
  }),
229
230
 
230
231
  /**
@@ -611,10 +612,16 @@ export function coreProviders(config, lifecycleState) {
611
612
 
612
613
  // Server indexer sub-packages run through a pluggable runtime. The default is
613
614
  // the in-process (trusted) runner; a deployment swaps in an isolate runtime.
614
- indexerRuntime: Provider.fromLazySingleton(() =>
615
- (config.serverIndexers === false
616
- ? null
617
- : resolve(config.indexerRuntime, IndexerRuntime, () => new InProcessIndexerRuntime()))),
615
+ indexerRuntime: Provider.fromLazySingleton(() => {
616
+ if (config.serverIndexers === false) return null;
617
+ // `{ loader }` — a Worker Loader binding, which configFromEnv sets when the
618
+ // deployment declares one. Built here rather than there so core stays the only
619
+ // place that knows what a runtime is.
620
+ if (config.indexerRuntime?.loader && !(config.indexerRuntime instanceof IndexerRuntime)) {
621
+ return new WorkerLoaderIndexerRuntime(config.indexerRuntime);
622
+ }
623
+ return resolve(config.indexerRuntime, IndexerRuntime, () => new InProcessIndexerRuntime());
624
+ }),
618
625
 
619
626
  plugins: Provider.fromLazySingleton(
620
627
  async (deps) => {
@@ -140,6 +140,47 @@ export async function createServer(config = {}) {
140
140
  }
141
141
  };
142
142
  const startReindex = async (opts) => (await beginReindex(opts)).done;
143
+
144
+ /**
145
+ * Re-run named indexers over the files they match — what a freshly installed plugin
146
+ * needs, and the reason it is a TASK rather than part of the install.
147
+ *
148
+ * Scoped to `indexerIds` rather than rebuilding the drive: installing one plugin should
149
+ * not re-embed every file for every other indexer. That is also why it does not take
150
+ * the drive-wide `reindex` claim a full rebuild takes — two different plugins
151
+ * backfilling at once are doing disjoint work.
152
+ */
153
+ const beginBackfill = async ({ indexerIds = [], reason, title } = {}) => {
154
+ const wanted = indexerIds.filter(Boolean);
155
+ if (!wanted.length) {
156
+ return { task: null, alreadyRunning: false, done: Promise.resolve({ indexed: 0 }) };
157
+ }
158
+ const begun = tasks.begin(
159
+ {
160
+ kind: 'index',
161
+ title: title || (wanted.length === 1 ? 'Indexing existing files' : `Indexing existing files for ${wanted.length} indexers`),
162
+ detail: reason || null,
163
+ unit: 'items',
164
+ cancellable: true,
165
+ },
166
+ async (task) => {
167
+ let indexed = 0;
168
+ for (const id of wanted) {
169
+ const indexer = vfs.indexers.get(id);
170
+ // A named indexer that is not registered is not an error worth failing the
171
+ // task over: the usual cause is a deployment that cannot run it, which has
172
+ // already said so through the plugin-indexers issue.
173
+ if (!indexer?.match) continue;
174
+ const r = await vfs.backfillIndexer(indexer, { shouldStop: () => task.cancelled || closing() });
175
+ indexed += r?.indexed ?? 0;
176
+ task.progress?.({ done: indexed });
177
+ if (task.cancelled || closing()) break;
178
+ }
179
+ return { indexed };
180
+ },
181
+ );
182
+ return { task: begun.task, alreadyRunning: false, done: begun.done };
183
+ };
143
184
  // Retrying an issue runs the same work as everything else, and reports it the same
144
185
  // way. The issue is not cleared here — it is cleared by the indexing that succeeds,
145
186
  // so a retry can't report success over a problem that is still there.
@@ -165,10 +206,25 @@ export async function createServer(config = {}) {
165
206
  lifecycleState.background = {
166
207
  beginScan: config.background?.beginScan || beginScan,
167
208
  beginReindex: config.background?.beginReindex || beginReindex,
209
+ beginBackfill: config.background?.beginBackfill || beginBackfill,
168
210
  };
169
211
  const routeBeginScan = lifecycleState.background.beginScan;
170
212
  const routeBeginReindex = lifecycleState.background.beginReindex;
213
+ const routeBeginBackfill = lifecycleState.background.beginBackfill;
171
214
  issues.handle('scan-collection', (issue) => startScan(issue.retry.collectionId, { reason: 'Retrying after a failed scan' }));
215
+ // "I have added the binding — try again." Re-activating re-probes, which is the only
216
+ // honest way to answer: a button that merely dismissed the diagnostic would leave the
217
+ // drive exactly as unable to index as it was, with nothing saying so.
218
+ issues.handle('reactivate-indexers', async (issue) => {
219
+ const record = (await plugins?.installs?.all?.() ?? []).find((r) => r.pluginId === issue.retry.pluginId);
220
+ if (!record) return { ok: false };
221
+ await plugins.indexers?.activate(record, { backfill: false });
222
+ // Registered again? Then catch the existing files up, which is what the install
223
+ // would have scheduled had the deployment been able to run them at the time.
224
+ const ids = (record.indexers || []).map((i) => i.id).filter((id) => vfs.indexers.get(id));
225
+ if (ids.length) await routeBeginBackfill({ indexerIds: ids, reason: 'Retrying after the indexer runtime became available' });
226
+ return { ok: true };
227
+ });
172
228
  issues.handle('storage-check', (issue) => storageCheck.run({ origin: issue.retry?.origin || config.publicUrl || null }));
173
229
  // The one retry that matters most: the user has been told a comment saved and it exists
174
230
  // only in memory. The op was raised for years with no handler registered for it — and in
@@ -529,6 +585,7 @@ export async function createServer(config = {}) {
529
585
  // inside a Durable Object want. `begin*` goes wherever `config.background` says,
530
586
  // which for a front-line Worker isolate is the object rather than itself.
531
587
  startScan, startReindex, beginScan: routeBeginScan, beginReindex: routeBeginReindex,
588
+ beginBackfill: routeBeginBackfill,
532
589
  runMaintenance, checkStorage: (opts) => storageCheck.run(opts), rotation, mcp, auth, close };
533
590
  }
534
591
 
@@ -902,6 +959,17 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
902
959
  // TROVE_SERVER_INDEXERS=0/false refuses server-indexer plugins on this deployment.
903
960
  if (env.TROVE_SERVER_INDEXERS === '0' || env.TROVE_SERVER_INDEXERS === 'false') config.serverIndexers = false;
904
961
 
962
+ // A `worker_loaders` binding means this is a Worker that CAN run plugin code, in a
963
+ // real isolate — so it does, rather than falling back to the in-process runner, which
964
+ // cannot import a `data:` URL on workerd and fails on every file. Discovered from the
965
+ // binding rather than configured: a deployment that declared the binding wants it, and
966
+ // one that did not gets the honest install-time refusal from the probe.
967
+ //
968
+ // Any binding name is accepted so the wrangler config can call it what it likes;
969
+ // TROVE_WORKER_LOADER names it explicitly when there is more than one.
970
+ const loader = env[env.TROVE_WORKER_LOADER || 'LOADER'] ?? findWorkerLoader(env);
971
+ if (loader && config.serverIndexers !== false) config.indexerRuntime = { loader };
972
+
905
973
  // Plugin package blob store: defaults to the primary storage backend (prefixed).
906
974
  // Point it at a separate bucket/root with TROVE_PACKAGE_STORE (+ its own settings).
907
975
  if (env.TROVE_PACKAGE_STORE) {
@@ -955,3 +1023,25 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
955
1023
  }
956
1024
 
957
1025
  export { createRouter };
1026
+
1027
+ /**
1028
+ * A `worker_loaders` binding, found by shape.
1029
+ *
1030
+ * Bindings arrive as an untyped bag and a loader is only identifiable by having `get`
1031
+ * and nothing else — deliberately narrow, because a false positive here would hand
1032
+ * plugin code to whatever object happened to match. Skips the names of bindings that
1033
+ * also expose `get` and are emphatically not loaders (KV, R2, D1, Durable Objects are
1034
+ * declared under their own config keys and reached by name, so a drive that wants this
1035
+ * can always be explicit with TROVE_WORKER_LOADER).
1036
+ */
1037
+ function findWorkerLoader(env) {
1038
+ for (const [name, value] of Object.entries(env || {})) {
1039
+ if (!value || typeof value !== 'object') continue;
1040
+ if (typeof value.get !== 'function') continue;
1041
+ // A loader has exactly one method. KV has `put`/`list`, R2 has `head`/`delete`,
1042
+ // a DO namespace has `idFromName`, D1 has `prepare` — all disqualifying.
1043
+ if (value.put || value.list || value.head || value.delete || value.idFromName || value.prepare) continue;
1044
+ if (/loader/i.test(name)) return value;
1045
+ }
1046
+ return null;
1047
+ }