@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.
- package/package.json +1 -1
- package/packages/core/src/index.js +1 -1
- package/packages/core/src/indexers/registry.js +11 -0
- package/packages/core/src/plugins/index.js +44 -6
- package/packages/core/src/plugins/indexers.js +52 -0
- package/packages/core/src/plugins/runtime.js +101 -4
- package/packages/core/src/plugins/workerLoaderRuntime.js +160 -0
- package/packages/core/src/sidecar/document.js +60 -2
- package/packages/core/src/sidecar/index.js +28 -1
- package/packages/core/src/vfs.js +14 -3
- package/packages/plugin-sdk/src/browser.js +257 -26
- package/packages/plugin-sdk/src/protocol.js +9 -0
- package/packages/server/src/adapters/worker-tasks.js +11 -0
- package/packages/server/src/engine/providers/core.js +12 -5
- package/packages/server/src/index.js +90 -0
- package/packages/server/src/routes.js +92 -2
- package/packages/web/dist/assets/main-b9jd0fyt.js +742 -0
- package/packages/web/dist/assets/{main-wpfmmbfd.js.map → main-b9jd0fyt.js.map} +13 -12
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +33 -3
- package/packages/web/src/bl/fileType.js +8 -0
- package/packages/web/src/bl/launcher.js +15 -0
- package/packages/web/src/platform/index.js +8 -0
- package/packages/web/src/platform/itemData.js +161 -0
- package/packages/web/src/platform/navigation.js +16 -2
- package/packages/web/src/platform/pluginRpc.js +55 -0
- package/packages/web/src/ui/components/launcher.js +1 -1
- package/packages/web/src/ui/components/views/index.js +14 -1
- 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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
206
|
-
if (method === 'connectivity') { online = !!params.online;
|
|
207
|
-
if (method === 'settings:changed')
|
|
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')
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
653
|
+
/** @deprecated use `ctx.addEventListener('settingschange', …)`. Now additive. */
|
|
654
|
+
onChange: hook(bus, 'settingschange', (e) => [e.detail.key, e.detail.value]),
|
|
438
655
|
},
|
|
439
|
-
|
|
656
|
+
/** @deprecated use `ctx.addEventListener('connectivity', …)`. Now additive. */
|
|
657
|
+
onConnectivity: hook(bus, 'connectivity'),
|
|
440
658
|
announce,
|
|
441
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
+
}
|