@3sln/trove 0.0.16 → 0.0.17
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 +41 -0
- package/packages/core/src/plugins/workerLoaderRuntime.js +160 -0
- package/packages/core/src/vfs.js +14 -3
- 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 +18 -2
- package/packages/web/dist/assets/main-wpfmmbfd.js.map +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@3sln/trove",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
|
|
6
6
|
"repository": {
|
|
@@ -36,7 +36,7 @@ export { EmbeddingProvider, LocalHashEmbedding, HttpEmbedding } from './search/e
|
|
|
36
36
|
export { SqliteVectorStore, SqliteKeywordStore, SEARCH_DB_KEY } from './search/sqliteStores.js';
|
|
37
37
|
|
|
38
38
|
export { IndexerRegistry, textIndexer, chunkText } from './indexers/registry.js';
|
|
39
|
-
export { PluginService, PackageStore, StoragePackageStore, PluginInstallStore, SqlitePluginInstallStore, MemoryPluginInstallStore, parsePluginPackage, capabilityList, ALL_CAPABILITIES, IndexerRuntime, InProcessIndexerRuntime, PluginIndexers, matchFromSelector } from './plugins/index.js';
|
|
39
|
+
export { PluginService, PackageStore, StoragePackageStore, PluginInstallStore, SqlitePluginInstallStore, MemoryPluginInstallStore, parsePluginPackage, capabilityList, ALL_CAPABILITIES, IndexerRuntime, InProcessIndexerRuntime, WorkerLoaderIndexerRuntime, PluginIndexers, matchFromSelector } from './plugins/index.js';
|
|
40
40
|
export { UploadManager, KvSessionStore, DEFAULT_PART_SIZE } from './uploads.js';
|
|
41
41
|
// Encryption at rest: the bucket holds ciphertext, the drive holds the key. Protects
|
|
42
42
|
// against the STORAGE host (a leaked bucket credential, a storage vendor who is not the
|
|
@@ -64,9 +64,20 @@ export class IndexerRegistry {
|
|
|
64
64
|
unregister(id) {
|
|
65
65
|
this.indexers.delete(id);
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The redacted view — what `/api/indexers` answers with. Deliberately not the indexer
|
|
69
|
+
* itself: an indexer holds a `match` and an `index` that runs plugin code, and neither
|
|
70
|
+
* belongs in a response.
|
|
71
|
+
*/
|
|
67
72
|
list() {
|
|
68
73
|
return [...this.indexers.values()].map((i) => ({ id: i.id, displayName: i.displayName || i.id }));
|
|
69
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The LIVE indexer, for a caller that means to run it — `backfillIndexer` after an
|
|
78
|
+
* install, which needs the real `match` and `index` that `list()` strips.
|
|
79
|
+
*/
|
|
80
|
+
get(id) { return this.indexers.get(id) || null; }
|
|
70
81
|
matching(node) {
|
|
71
82
|
return [...this.indexers.values()].filter((i) => {
|
|
72
83
|
try {
|
|
@@ -15,6 +15,7 @@ export { parsePluginPackage, capabilityList, ALL_CAPABILITIES, serverIndexers, d
|
|
|
15
15
|
export * from './identity.js';
|
|
16
16
|
export { CONTRIBUTION_TYPES, contributionsOfType } from './contributions.js';
|
|
17
17
|
export { IndexerRuntime, InProcessIndexerRuntime } from './runtime.js';
|
|
18
|
+
export { WorkerLoaderIndexerRuntime } from './workerLoaderRuntime.js';
|
|
18
19
|
export { clampContribution, DEFAULT_CAPS } from '../indexers/contribution.js';
|
|
19
20
|
export { PluginIndexers, matchFromSelector } from './indexers.js';
|
|
20
21
|
|
|
@@ -79,6 +80,16 @@ export class PluginService {
|
|
|
79
80
|
if (pkg.indexers.length && !this.indexers) {
|
|
80
81
|
throw TroveError.unsupported('Server indexers are disabled on this deployment');
|
|
81
82
|
}
|
|
83
|
+
// ...or when it HAS one that cannot run here — the in-process runner on workerd,
|
|
84
|
+
// which cannot import a `data:` URL. That case DEGRADES rather than refusing: a
|
|
85
|
+
// plugin is more than its indexer, and refusing the install would take the viewers,
|
|
86
|
+
// commands and everything else away over one part the deployment cannot host.
|
|
87
|
+
//
|
|
88
|
+
// What it must not do is what it used to: run anyway and fail once per file forever,
|
|
89
|
+
// with nothing said. So the reason is recorded ON the record — `indexersSkipped` —
|
|
90
|
+
// and travels to the client, which is what turns "my search is empty" into a sentence
|
|
91
|
+
// someone can act on. Asked once per install, not per node.
|
|
92
|
+
const probe = pkg.indexers.length ? (await this.indexers?.probe?.() ?? { ok: true }) : { ok: true };
|
|
82
93
|
|
|
83
94
|
const version = pkg.manifest.version || '0';
|
|
84
95
|
// CONTENT-addressed. The ref used to be `<account>/<pluginId>/<version>.zip`, written
|
|
@@ -112,21 +123,48 @@ export class PluginService {
|
|
|
112
123
|
if (prev?.packageRef && prev.packageRef !== ref && !(await this.#refIsShared(prev.packageRef, account))) {
|
|
113
124
|
await this.packages.delete(prev.packageRef).catch(() => {});
|
|
114
125
|
}
|
|
115
|
-
// Register + backfill any server indexers this package ships
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
// Register + backfill any server indexers this package ships — unless the probe
|
|
127
|
+
// just said this deployment cannot run them, in which case registering would only
|
|
128
|
+
// queue up a failure per file.
|
|
129
|
+
if (this.indexers && pkg.indexers.length && probe.ok) {
|
|
130
|
+
// REGISTER ONLY. The backfill used to run here, inline and awaited inside the
|
|
131
|
+
// install request, which is the same mistake `completeUpload` made at a larger
|
|
132
|
+
// scale: re-reading every matching file in a drive is not work a request can
|
|
133
|
+
// finish, and on Workers the isolate goes before it gets far. The caller schedules
|
|
134
|
+
// it instead — `beginBackfill`, which lands in the same Durable Object that owns
|
|
135
|
+
// scans and reindexes, and reports through the same task record.
|
|
136
|
+
try { await this.indexers.activate(record, { backfill: false }); }
|
|
118
137
|
catch (err) { console.error(`activating indexers for ${record.pluginId} failed:`, err.message); }
|
|
138
|
+
} else if (!probe.ok) {
|
|
139
|
+
console.warn(`server indexers for ${record.pluginId} are not running: ${probe.reason}`);
|
|
119
140
|
}
|
|
120
|
-
return this.#publicRecord(record);
|
|
141
|
+
return this.#annotate(this.#publicRecord(record));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Why this record's indexers are not running, or null when they are.
|
|
146
|
+
*
|
|
147
|
+
* DERIVED, not stored. The reason is a property of the DEPLOYMENT, not of the install:
|
|
148
|
+
* a drive that gains a Worker Loader binding should stop reporting "skipped" the moment
|
|
149
|
+
* it restarts, without anyone rewriting rows — and a drive that loses one should start.
|
|
150
|
+
* Storing it at install would freeze an answer that is only true until the next deploy.
|
|
151
|
+
*
|
|
152
|
+
* The probe caches its own result, so this costs nothing after the first call.
|
|
153
|
+
*/
|
|
154
|
+
async #annotate(record) {
|
|
155
|
+
if (!record?.indexers?.length) return record;
|
|
156
|
+
const probe = await this.indexers?.probe?.() ?? { ok: true };
|
|
157
|
+
return probe.ok ? record : { ...record, indexersSkipped: probe.reason };
|
|
121
158
|
}
|
|
122
159
|
|
|
123
160
|
/** An account's installed plugins (secrets stripped). */
|
|
124
161
|
async list(principal) {
|
|
125
|
-
|
|
162
|
+
const rows = (await this.installs.list(accountOf(principal))).map((r) => this.#publicRecord(r));
|
|
163
|
+
return Promise.all(rows.map((r) => this.#annotate(r)));
|
|
126
164
|
}
|
|
127
165
|
async get(principal, pluginId) {
|
|
128
166
|
const r = await this.installs.get(accountOf(principal), pluginId);
|
|
129
|
-
return r ? this.#publicRecord(r) : null;
|
|
167
|
+
return r ? this.#annotate(this.#publicRecord(r)) : null;
|
|
130
168
|
}
|
|
131
169
|
|
|
132
170
|
/** Download the package blob (for a device to sync + enable). */
|
|
@@ -34,6 +34,14 @@ export class PluginIndexers {
|
|
|
34
34
|
*/
|
|
35
35
|
async activate(record, { backfill = true } = {}) {
|
|
36
36
|
const specs = record.indexers || [];
|
|
37
|
+
// The probe first, so a deployment that cannot run this plugin's indexers says so
|
|
38
|
+
// once, here, instead of failing per file — and so the saying of it is undone the
|
|
39
|
+
// moment the deployment can. A record with no indexers has nothing to diagnose.
|
|
40
|
+
if (specs.length) {
|
|
41
|
+
const probe = await this.probe();
|
|
42
|
+
await this.#diagnose(record, probe);
|
|
43
|
+
if (!probe.ok) return 0;
|
|
44
|
+
}
|
|
37
45
|
// Retire anything this plugin used to declare and no longer does. Both this and
|
|
38
46
|
// deactivate() iterated only the record in hand, so an indexer dropped by an
|
|
39
47
|
// upgrade stayed registered — running code the user upgraded away from on every
|
|
@@ -66,6 +74,50 @@ export class PluginIndexers {
|
|
|
66
74
|
return specs.length;
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Can indexers actually run on this deployment? Passed straight through to the
|
|
79
|
+
* runtime — PluginIndexers is the coordinator, not the thing that knows.
|
|
80
|
+
*/
|
|
81
|
+
async probe() { return this.runtime?.probe?.() ?? { ok: true }; }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Say — or stop saying — that this plugin's indexers are not running.
|
|
85
|
+
*
|
|
86
|
+
* A standing DIAGNOSTIC rather than a field nobody draws, because the symptom is an
|
|
87
|
+
* absence: a drive whose plugin indexers never ran looks exactly like a drive with
|
|
88
|
+
* nothing to index. The issues list is where the drive already admits to things it
|
|
89
|
+
* cannot do, and it carries a retry, so the answer to "why is my search empty" arrives
|
|
90
|
+
* in the same place as every other one.
|
|
91
|
+
*
|
|
92
|
+
* Raised and cleared at ACTIVATION, which happens on install and again at every boot —
|
|
93
|
+
* so a deployment that gains a Worker Loader binding drops the diagnostic on its next
|
|
94
|
+
* start, and one that loses it picks the diagnostic back up, with nobody rewriting a
|
|
95
|
+
* stored answer.
|
|
96
|
+
*/
|
|
97
|
+
async #diagnose(record, probe) {
|
|
98
|
+
const issues = this.vfs?.issues;
|
|
99
|
+
if (!issues) return;
|
|
100
|
+
try {
|
|
101
|
+
if (probe.ok) {
|
|
102
|
+
await issues.clear('plugin-indexers', record.pluginId);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
await issues.raise({
|
|
106
|
+
kind: 'plugin-indexers',
|
|
107
|
+
subject: record.pluginId,
|
|
108
|
+
severity: 'warning',
|
|
109
|
+
title: `“${record.pluginId}” cannot index on this deployment — what it would add to search is missing`,
|
|
110
|
+
detail: probe.reason,
|
|
111
|
+
remedy: 'Bind a Worker Loader (`[[worker_loaders]]` in wrangler.toml) so plugin indexers can run in their own isolate.',
|
|
112
|
+
// Retrying re-activates, which re-probes: the honest way to ask again after the
|
|
113
|
+
// binding has been added, rather than a button that only clears the message.
|
|
114
|
+
retry: { op: 'reactivate-indexers', pluginId: record.pluginId },
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('could not record the plugin-indexer diagnostic:', err.message);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
69
121
|
/** Unregister + purge every indexer a record declared. */
|
|
70
122
|
async deactivate(record) {
|
|
71
123
|
for (const spec of record.indexers || []) {
|
|
@@ -33,6 +33,27 @@ export class IndexerRuntime {
|
|
|
33
33
|
*/
|
|
34
34
|
async run(spec, node, ctx) { throw TroveError.unsupported('IndexerRuntime.run'); }
|
|
35
35
|
async close() {}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Can this runtime actually execute an indexer HERE, on this deployment?
|
|
39
|
+
*
|
|
40
|
+
* Asked once, at install, because the alternative is what shipped: a runtime that
|
|
41
|
+
* installs happily and then fails on every single file, silently, for the life of the
|
|
42
|
+
* deployment. The in-process runner loads code by importing a `data:` URL — which
|
|
43
|
+
* Node and Bun allow and **workerd does not** — so on Cloudflare every plugin indexer
|
|
44
|
+
* failed with `No such module "data:text/javascript;base64,…"`, once per node, with
|
|
45
|
+
* nothing to see at install time and nothing in the UI to explain the empty index.
|
|
46
|
+
*
|
|
47
|
+
* The design doc's provider matrix already called for this (§7, the `CF plain / none`
|
|
48
|
+
* row: *"install-scope check refuses server-indexer plugins on this deployment, with a
|
|
49
|
+
* clear message"*). It could not fire, because it keyed on the runtime being ABSENT
|
|
50
|
+
* and the broken runtime is present — it just cannot run.
|
|
51
|
+
*
|
|
52
|
+
* Default true: a runtime that does not implement a probe is one that works.
|
|
53
|
+
*
|
|
54
|
+
* @returns {Promise<{ok: true} | {ok: false, reason: string}>}
|
|
55
|
+
*/
|
|
56
|
+
async probe() { return { ok: true }; }
|
|
36
57
|
}
|
|
37
58
|
|
|
38
59
|
/**
|
|
@@ -72,6 +93,26 @@ export class InProcessIndexerRuntime extends IndexerRuntime {
|
|
|
72
93
|
return p;
|
|
73
94
|
}
|
|
74
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Try the loader itself, once, on a module that does nothing.
|
|
98
|
+
*
|
|
99
|
+
* The probe imports a real `data:` URL rather than sniffing for a runtime name.
|
|
100
|
+
* Feature-detection over branding: workerd is the case that prompted this, but the
|
|
101
|
+
* question is "does dynamic import of a data: URL work here", and only doing it
|
|
102
|
+
* answers that. Cached — including the failure, which is a property of the runtime
|
|
103
|
+
* and will not change while the process lives.
|
|
104
|
+
*/
|
|
105
|
+
async probe() {
|
|
106
|
+
this._probe ||= import(/* @vite-ignore */ 'data:text/javascript;base64,' + btoa('export default 1'))
|
|
107
|
+
.then(() => ({ ok: true }))
|
|
108
|
+
.catch((err) => ({
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: `this deployment's JavaScript runtime cannot load plugin code dynamically (${err?.message || err}). `
|
|
111
|
+
+ 'Server indexers need an isolate runtime — on Cloudflare, a Worker Loader binding.',
|
|
112
|
+
}));
|
|
113
|
+
return this._probe;
|
|
114
|
+
}
|
|
115
|
+
|
|
75
116
|
async run(spec, node, ctx) {
|
|
76
117
|
const fn = await this.#load(spec);
|
|
77
118
|
const result = await withTimeout(
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// WorkerLoaderIndexerRuntime — the Cloudflare arm of the provider matrix.
|
|
2
|
+
//
|
|
3
|
+
// This is step 4 of docs/design/server-plugins-and-indexers.md §11, and the reason the
|
|
4
|
+
// in-process runner is not the answer on Workers: it loads indexer code by importing a
|
|
5
|
+
// `data:` URL, which workerd refuses outright. Every plugin indexer on a Worker
|
|
6
|
+
// deployment failed with `No such module "data:text/javascript;base64,…"`, once per
|
|
7
|
+
// file, silently. See `InProcessIndexerRuntime.probe()`.
|
|
8
|
+
//
|
|
9
|
+
// A Worker Loader is the fix the doc already chose over Workers-for-Platforms dispatch
|
|
10
|
+
// namespaces: no per-install upload step, so the install flow stays identical across
|
|
11
|
+
// runtimes — every deployment stores the same package blob, and only execution differs.
|
|
12
|
+
// The parent Worker never evaluates plugin code at all; the loader builds a genuinely
|
|
13
|
+
// separate isolate for it.
|
|
14
|
+
//
|
|
15
|
+
// THE BYTES PROBLEM, and why it is solved with a URL rather than RPC. An indexer reads
|
|
16
|
+
// its file adaptively — the audiobook one walks MP4 boxes, so which range it wants next
|
|
17
|
+
// depends on what the last range said. That is a conversation, and a conversation across
|
|
18
|
+
// an isolate boundary is either RPC plumbing on every deployment or one thing the
|
|
19
|
+
// sandbox can already do: fetch. So the host mints a time-limited presigned read URL
|
|
20
|
+
// (§8, `ctx.presignRead()`) and the shim implements `readRange` as a ranged GET against
|
|
21
|
+
// it. The sandbox reaches exactly one object, for a few minutes, and the host holds no
|
|
22
|
+
// open channel it has to police.
|
|
23
|
+
//
|
|
24
|
+
// What the sandbox is allowed to do is therefore precisely: fetch that URL. `env` carries
|
|
25
|
+
// no bindings — no storage, no database, no secrets beyond the plugin's own config — and
|
|
26
|
+
// the entrypoint is called once with one node.
|
|
27
|
+
|
|
28
|
+
import { TroveError } from '../errors.js';
|
|
29
|
+
import { withTimeout } from '../retry.js';
|
|
30
|
+
import { clampContribution, DEFAULT_CAPS } from '../indexers/contribution.js';
|
|
31
|
+
import { IndexerRuntime } from './runtime.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The module that runs INSIDE the sandbox, wrapping the plugin's own entry.
|
|
35
|
+
*
|
|
36
|
+
* It exists because the plugin's `index(node, ctx)` contract is a function call and a
|
|
37
|
+
* dynamic Worker's contract is `fetch(request)`. This is the adapter between them, and
|
|
38
|
+
* it is the only host-authored code in there.
|
|
39
|
+
*
|
|
40
|
+
* `readRange` is deliberately the same shape the in-process context offers, so an
|
|
41
|
+
* indexer cannot tell which runtime it is on — that is what makes the audiobook indexer
|
|
42
|
+
* work unchanged on both.
|
|
43
|
+
*/
|
|
44
|
+
export const SHIM = `
|
|
45
|
+
import * as entry from './entry.js';
|
|
46
|
+
|
|
47
|
+
const fn = entry.default || entry.index;
|
|
48
|
+
|
|
49
|
+
export default {
|
|
50
|
+
async fetch(request) {
|
|
51
|
+
const { node, url, maxBytes, config, secrets } = await request.json();
|
|
52
|
+
if (typeof fn !== 'function') {
|
|
53
|
+
return Response.json({ error: 'no default/index export' }, { status: 500 });
|
|
54
|
+
}
|
|
55
|
+
// One ranged GET per call, against the presigned URL and nothing else. \`end\` is
|
|
56
|
+
// exclusive here (as it is host-side) and inclusive in an HTTP Range header — the
|
|
57
|
+
// off-by-one that would silently drop the last byte of every read.
|
|
58
|
+
const readRange = async (start = 0, end = node.size) => {
|
|
59
|
+
const from = Math.max(0, Math.min(start, node.size));
|
|
60
|
+
const to = Math.min(end, from + maxBytes, node.size);
|
|
61
|
+
if (to <= from) return new Uint8Array(0);
|
|
62
|
+
// Concatenation rather than a template literal, deliberately: this whole module is
|
|
63
|
+
// itself a template literal, and a nested one has to be escaped through two levels.
|
|
64
|
+
// The first version got that wrong and every sandbox died with "Failed to start
|
|
65
|
+
// Worker" — a syntax error in generated code, which is invisible at the call site.
|
|
66
|
+
const res = await fetch(url, { headers: { Range: 'bytes=' + from + '-' + (to - 1) } });
|
|
67
|
+
if (!res.ok && res.status !== 206) throw new Error('range read failed: ' + res.status);
|
|
68
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
69
|
+
};
|
|
70
|
+
const ctx = {
|
|
71
|
+
readRange,
|
|
72
|
+
readBytes: () => readRange(0, node.size),
|
|
73
|
+
readText: async () => new TextDecoder().decode(await readRange(0, node.size)),
|
|
74
|
+
maxBytes, config, secrets,
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
return Response.json({ contribution: (await fn(node, ctx)) ?? null });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return Response.json({ error: err?.message || String(err) }, { status: 500 });
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
export class WorkerLoaderIndexerRuntime extends IndexerRuntime {
|
|
86
|
+
/**
|
|
87
|
+
* @param {object} opts
|
|
88
|
+
* @param {{get: Function}} opts.loader the `worker_loaders` binding (env.LOADER)
|
|
89
|
+
* @param {string} [opts.compatibilityDate] what the sandbox is compiled against
|
|
90
|
+
* @param {number} [opts.timeoutMs]
|
|
91
|
+
* @param {object} [opts.caps]
|
|
92
|
+
*/
|
|
93
|
+
constructor({ loader, compatibilityDate = '2025-01-01', timeoutMs = 10_000, caps = DEFAULT_CAPS } = {}) {
|
|
94
|
+
super();
|
|
95
|
+
if (!loader?.get) throw TroveError.invalid('WorkerLoaderIndexerRuntime needs a worker_loaders binding');
|
|
96
|
+
this.loader = loader;
|
|
97
|
+
this.compatibilityDate = compatibilityDate;
|
|
98
|
+
this.timeoutMs = timeoutMs;
|
|
99
|
+
this.caps = { ...DEFAULT_CAPS, ...caps };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The binding either exists or it does not, and if it does it works — unlike the
|
|
104
|
+
* in-process runner, there is no capability here that a runtime might withhold.
|
|
105
|
+
* Constructing this class already refused a missing binding.
|
|
106
|
+
*/
|
|
107
|
+
async probe() { return { ok: true }; }
|
|
108
|
+
|
|
109
|
+
async run(spec, node, ctx) {
|
|
110
|
+
// A presigned URL is the whole mechanism, so its absence is a hard error rather
|
|
111
|
+
// than a degraded path. It fails LOUDLY and per-install-shaped: a backend that
|
|
112
|
+
// cannot presign cannot host server indexers on Workers, and saying that is more
|
|
113
|
+
// use than an empty contribution.
|
|
114
|
+
if (typeof ctx.presignRead !== 'function') {
|
|
115
|
+
throw TroveError.unsupported(
|
|
116
|
+
`Indexer "${spec.id}" needs a presigned read URL to run in a sandbox, and this storage backend cannot mint one`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const url = await ctx.presignRead();
|
|
120
|
+
|
|
121
|
+
const code = spec.files?.[spec.entry];
|
|
122
|
+
if (!code) throw TroveError.invalid(`Indexer "${spec.id}" is missing its entry "${spec.entry}"`);
|
|
123
|
+
|
|
124
|
+
// Keyed by cacheKey, which embeds the package DIGEST — so a reinstall at the same
|
|
125
|
+
// version gets a different isolate rather than the old code kept warm. The loader
|
|
126
|
+
// may reuse an isolate for a repeated id; that is exactly what we want within a
|
|
127
|
+
// digest and exactly what we must not have across one.
|
|
128
|
+
const stub = this.loader.get(spec.cacheKey || spec.id, async () => ({
|
|
129
|
+
compatibilityDate: this.compatibilityDate,
|
|
130
|
+
mainModule: 'shim.js',
|
|
131
|
+
modules: {
|
|
132
|
+
'shim.js': SHIM,
|
|
133
|
+
'entry.js': new TextDecoder().decode(code),
|
|
134
|
+
},
|
|
135
|
+
// No bindings. The sandbox gets its instructions in the request body and its bytes
|
|
136
|
+
// from one URL; there is nothing else for it to reach.
|
|
137
|
+
env: {},
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const res = await withTimeout(
|
|
141
|
+
stub.getEntrypoint().fetch('https://indexer.invalid/run', {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: { 'content-type': 'application/json' },
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
node: { id: node.id, name: node.name, contentType: node.contentType, size: node.size },
|
|
146
|
+
url,
|
|
147
|
+
maxBytes: ctx.maxBytes ?? 2 * 1024 * 1024,
|
|
148
|
+
config: ctx.config || {},
|
|
149
|
+
secrets: ctx.secrets || {},
|
|
150
|
+
}),
|
|
151
|
+
}),
|
|
152
|
+
this.timeoutMs,
|
|
153
|
+
`Indexer "${spec.id}" timed out after ${this.timeoutMs}ms`,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const body = await res.json().catch(() => ({ error: 'indexer returned a non-JSON response' }));
|
|
157
|
+
if (!res.ok || body.error) throw TroveError.invalid(`Indexer "${spec.id}" failed: ${body.error || res.status}`);
|
|
158
|
+
return clampContribution(body.contribution, this.caps);
|
|
159
|
+
}
|
|
160
|
+
}
|
package/packages/core/src/vfs.js
CHANGED
|
@@ -345,8 +345,9 @@ export class Vfs {
|
|
|
345
345
|
collectionId, name, storageKey, size: plaintextSize ?? info.size, contentType: ct,
|
|
346
346
|
etag: info.etag, encryption,
|
|
347
347
|
});
|
|
348
|
-
//
|
|
349
|
-
//
|
|
348
|
+
// Indexing is awaited on BOTH write paths — here and in `completeUpload` — so search
|
|
349
|
+
// is ready when the call returns. See the note there for why the background variant
|
|
350
|
+
// could not work on a request-scoped runtime.
|
|
350
351
|
await this.indexing.indexNode(node).catch((e) => console.error('index error', e));
|
|
351
352
|
return node;
|
|
352
353
|
}
|
|
@@ -763,7 +764,17 @@ export class Vfs {
|
|
|
763
764
|
size: obj.size, contentType: obj.contentType, etag: obj.etag,
|
|
764
765
|
overwrite: obj.overwrite, encryption: obj.encryption || null,
|
|
765
766
|
});
|
|
766
|
-
|
|
767
|
+
// AWAITED, like the small-write path above. It used to be fire-and-forget, and the
|
|
768
|
+
// reasoning ("large client uploads index in the background") only holds on a
|
|
769
|
+
// runtime with a process that outlives the request. On Workers there is none: the
|
|
770
|
+
// response returns, the isolate goes, and the indexing is cut off mid-read — so an
|
|
771
|
+
// uploaded file got no contributions, no search entry and NO ISSUE either, because
|
|
772
|
+
// nothing failed. It simply never ran.
|
|
773
|
+
//
|
|
774
|
+
// Awaiting is bounded rather than open-ended: an indexer reads through `readRange`,
|
|
775
|
+
// capped at `maxIndexBytes` (2 MiB by default), so this costs a couple of megabytes
|
|
776
|
+
// of reads regardless of whether the file is 400 KB or 400 MB.
|
|
777
|
+
await this.indexing.indexNode(node).catch((e) => console.error('index error', e));
|
|
767
778
|
return node;
|
|
768
779
|
} catch (err) {
|
|
769
780
|
await (await this.storageFor(obj.collectionId)).delete(obj.storageKey).catch(() => {});
|
|
@@ -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
|
+
}
|