@3sln/trove 0.0.2
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/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Node adapter. Uses the standard http server and converts between Node's
|
|
2
|
+
// req/res and Web Request/Response (Node 20+ has global fetch/Request/Response
|
|
3
|
+
// and Readable.toWeb/fromWeb). Serves the built web app from disk when present,
|
|
4
|
+
// with SPA fallback, so `node adapters/node.js` runs the whole thing.
|
|
5
|
+
//
|
|
6
|
+
// TROVE_STORAGE=filesystem TROVE_FS_ROOT=./data/objects \
|
|
7
|
+
// TROVE_METADATA=sqlite TROVE_DB_PATH=./data/trove.db \
|
|
8
|
+
// node packages/server/src/adapters/node.js
|
|
9
|
+
|
|
10
|
+
import http from 'node:http';
|
|
11
|
+
import fs, { readFileSync } from 'node:fs';
|
|
12
|
+
import fsp from 'node:fs/promises';
|
|
13
|
+
import { Readable } from 'node:stream';
|
|
14
|
+
import { createServer, configFromEnv, warnOnOpenAccess } from '../index.js';
|
|
15
|
+
import { findWebDist } from './webDist.js';
|
|
16
|
+
import { createStaticAssets } from './staticAssets.js';
|
|
17
|
+
|
|
18
|
+
// A JWKS held in a file rather than inlined in the environment: multi-line JSON is
|
|
19
|
+
// awkward in env vars and shows up in `docker inspect`, while a mounted secret file
|
|
20
|
+
// does not. Read here rather than in configFromEnv, which has to stay loadable on
|
|
21
|
+
// Workers where there is no filesystem.
|
|
22
|
+
if (process.env.TROVE_JWT_JWKS_FILE && !process.env.TROVE_JWT_JWKS) {
|
|
23
|
+
process.env.TROVE_JWT_JWKS = readFileSync(process.env.TROVE_JWT_JWKS_FILE, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
// TROVE_-prefixed to match every other setting; bare PORT/HOST still work, since that
|
|
28
|
+
// is what most platforms inject and breaking them would be gratuitous.
|
|
29
|
+
const PORT = Number(process.env.TROVE_PORT || process.env.PORT || 8787);
|
|
30
|
+
const HOST = process.env.TROVE_HOST || process.env.HOST || '0.0.0.0';
|
|
31
|
+
|
|
32
|
+
// Built web assets, if the app has been built — see webDist.js for why this is a
|
|
33
|
+
// resolution rather than a relative path.
|
|
34
|
+
const { dir: WEB_DIST, source: WEB_DIST_SOURCE } = findWebDist();
|
|
35
|
+
|
|
36
|
+
// Where to look, what to refuse and what to say about caching is shared with the Bun
|
|
37
|
+
// adapter — see staticAssets.js. All that differs here is how a file is read.
|
|
38
|
+
const staticAssets = WEB_DIST && createStaticAssets({
|
|
39
|
+
dir: WEB_DIST,
|
|
40
|
+
read: async (filePath) => {
|
|
41
|
+
try {
|
|
42
|
+
const stat = await fsp.stat(filePath);
|
|
43
|
+
if (!stat.isFile()) return null;
|
|
44
|
+
return {
|
|
45
|
+
size: stat.size,
|
|
46
|
+
mtime: stat.mtimeMs,
|
|
47
|
+
open: () => Readable.toWeb(fs.createReadStream(filePath)),
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function toWebRequest(nodeReq) {
|
|
56
|
+
const url = `http://${nodeReq.headers.host || 'localhost'}${nodeReq.url}`;
|
|
57
|
+
const method = nodeReq.method;
|
|
58
|
+
const headers = new Headers();
|
|
59
|
+
for (const [k, v] of Object.entries(nodeReq.headers)) if (v) headers.set(k, Array.isArray(v) ? v.join(',') : v);
|
|
60
|
+
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
61
|
+
return new Request(url, {
|
|
62
|
+
method, headers,
|
|
63
|
+
body: hasBody ? Readable.toWeb(nodeReq) : undefined,
|
|
64
|
+
duplex: hasBody ? 'half' : undefined,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writeWebResponse(res, webRes) {
|
|
69
|
+
res.statusCode = webRes.status;
|
|
70
|
+
webRes.headers.forEach((v, k) => res.setHeader(k, v));
|
|
71
|
+
if (webRes.body) {
|
|
72
|
+
await new Promise((resolve, reject) =>
|
|
73
|
+
Readable.fromWeb(webRes.body).pipe(res).on('finish', resolve).on('error', reject),
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
res.end();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const hasWeb = !!WEB_DIST;
|
|
81
|
+
const envConfig = configFromEnv();
|
|
82
|
+
warnOnOpenAccess(envConfig);
|
|
83
|
+
const { handle, close } = await createServer({
|
|
84
|
+
...envConfig,
|
|
85
|
+
assets: hasWeb ? staticAssets : undefined,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const server = http.createServer(async (nodeReq, nodeRes) => {
|
|
89
|
+
try {
|
|
90
|
+
const webReq = await toWebRequest(nodeReq);
|
|
91
|
+
const webRes = await handle(webReq);
|
|
92
|
+
await writeWebResponse(nodeRes, webRes);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.error('server error', err);
|
|
95
|
+
if (!nodeRes.headersSent) nodeRes.statusCode = 500;
|
|
96
|
+
nodeRes.end('Internal error');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
server.listen(PORT, HOST, () => {
|
|
101
|
+
console.log(`Trove server on http://${HOST}:${PORT} (web assets: ${hasWeb ? WEB_DIST : `none — ${WEB_DIST_SOURCE}; run npm run build:web`})`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Graceful shutdown: stop accepting connections, then flush notifications, dispose
|
|
105
|
+
// the sidecar, and close SQLite cleanly so a redeploy doesn't lose in-flight work.
|
|
106
|
+
let shuttingDown = false;
|
|
107
|
+
async function shutdown(signal) {
|
|
108
|
+
if (shuttingDown) return;
|
|
109
|
+
shuttingDown = true;
|
|
110
|
+
console.log(`Trove shutting down (${signal})…`);
|
|
111
|
+
server.close();
|
|
112
|
+
try { await close(); } catch (err) { console.error('shutdown error', err); }
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => shutdown(sig));
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Serving the built web app.
|
|
2
|
+
//
|
|
3
|
+
// This was copied into the Node and Bun adapters, and had already drifted — one of them
|
|
4
|
+
// had a content-type fallback the other did not. What differs between the two runtimes
|
|
5
|
+
// is how you read a file; everything else (where to look, what to refuse, what to say
|
|
6
|
+
// about caching) is one policy, so it lives here and each adapter passes in its reader.
|
|
7
|
+
//
|
|
8
|
+
// Three things this gets right that the copies did not:
|
|
9
|
+
//
|
|
10
|
+
// A miss under /assets/ is a 404, not the SPA fallback. The fallback answered EVERY
|
|
11
|
+
// non-/api miss with index.html at status 200 — including a request for a hashed
|
|
12
|
+
// asset. After a deploy the old hashed names are gone, so a client still running the
|
|
13
|
+
// previous index.html asked for `/assets/main-OLD.js` and got HTML with
|
|
14
|
+
// `content-type: text/html`. The module fails to load, and because the service worker
|
|
15
|
+
// caches anything that came back 200, the HTML was stored under the JS URL and served
|
|
16
|
+
// from cache forever after. A 404 is a miss the browser and the worker both
|
|
17
|
+
// understand.
|
|
18
|
+
//
|
|
19
|
+
// Cache-Control is claimed where it is true and nowhere else. Everything under
|
|
20
|
+
// /assets/ is content-addressed — the filename changes when the bytes do — so it can
|
|
21
|
+
// be kept for a year and never revalidated. Everything else keeps a stable name and
|
|
22
|
+
// must be revalidated on every load. Marking the second group immutable is what turns
|
|
23
|
+
// a deploy into a blank page; leaving the first group unmarked is what makes the
|
|
24
|
+
// hashing pointless, which is what was happening: the responses carried no
|
|
25
|
+
// Cache-Control at all.
|
|
26
|
+
//
|
|
27
|
+
// There are validators. `no-cache` does not mean "do not cache", it means "revalidate
|
|
28
|
+
// before use" — but a revalidation with nothing to revalidate AGAINST is just a fresh
|
|
29
|
+
// download. sql-wasm.wasm is 650 kB and sits at a stable name, so without an ETag it
|
|
30
|
+
// came down in full every time a plugin touched client storage.
|
|
31
|
+
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
// The policy lives in its own module because index.js needs it too and has to stay
|
|
34
|
+
// loadable on Workers, where `node:path` is not there to be imported.
|
|
35
|
+
import { IMMUTABLE_PREFIX, cacheControlFor } from '../cachePolicy.js';
|
|
36
|
+
|
|
37
|
+
export { IMMUTABLE_PREFIX, cacheControlFor };
|
|
38
|
+
|
|
39
|
+
export const MIME = {
|
|
40
|
+
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',
|
|
41
|
+
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
42
|
+
'.ico': 'image/x-icon', '.woff2': 'font/woff2', '.map': 'application/json',
|
|
43
|
+
'.webmanifest': 'application/manifest+json', '.wasm': 'application/wasm',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Should a miss here fall back to the SPA entry point?
|
|
48
|
+
*
|
|
49
|
+
* The client never puts a path in the URL — `navigation.js` calls pushState with no URL
|
|
50
|
+
* argument, so the whole app lives at `/`. The fallback is therefore for robustness
|
|
51
|
+
* (a refresh somewhere unexpected), never for deep links, which is what makes it safe
|
|
52
|
+
* to refuse anything that looks like a file rather than a route.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} pathname
|
|
55
|
+
*/
|
|
56
|
+
export function shouldFallBack(pathname) {
|
|
57
|
+
if (pathname.startsWith('/api/')) return false;
|
|
58
|
+
// Content-addressed: a miss is a stale reference to a build that no longer exists.
|
|
59
|
+
// Answering it with HTML is what poisons a service worker cache.
|
|
60
|
+
if (pathname.startsWith(IMMUTABLE_PREFIX)) return false;
|
|
61
|
+
// Anything with an extension is asking for a file, not a view.
|
|
62
|
+
if (path.extname(pathname)) return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A weak validator from what a stat already tells us — no hashing, no read. */
|
|
67
|
+
export const etagFor = ({ size, mtime }) => `W/"${size.toString(16)}-${Math.floor(mtime).toString(16)}"`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the `assets` fetcher the server takes.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} opts
|
|
73
|
+
* @param {string} opts.dir the built web app
|
|
74
|
+
* @param {(filePath: string) => Promise<{size: number, mtime: number, type?: string, open: () => any}|null>} opts.read
|
|
75
|
+
* runtime-specific: resolve to null when the path is not a readable file. `open` is
|
|
76
|
+
* separate from the stat so a 304 or a HEAD does not open a handle it will not read.
|
|
77
|
+
*/
|
|
78
|
+
export function createStaticAssets({ dir, read }) {
|
|
79
|
+
return async function staticAssets(req) {
|
|
80
|
+
const url = new URL(req.url);
|
|
81
|
+
let pathname;
|
|
82
|
+
try {
|
|
83
|
+
pathname = decodeURIComponent(url.pathname);
|
|
84
|
+
} catch {
|
|
85
|
+
return null; // a bad escape names nothing
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const served = await serve(pathname === '/' ? '/index.html' : pathname);
|
|
89
|
+
if (served) return served;
|
|
90
|
+
|
|
91
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && shouldFallBack(pathname)) {
|
|
92
|
+
return serve('/index.html', { as: pathname });
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
|
|
96
|
+
async function serve(rel, { as = rel } = {}) {
|
|
97
|
+
const filePath = path.join(dir, path.normalize(rel));
|
|
98
|
+
// path.normalize collapses `..`, but only a prefix check proves the result is
|
|
99
|
+
// still inside the directory we meant.
|
|
100
|
+
if (filePath !== dir && !filePath.startsWith(dir + path.sep)) return null;
|
|
101
|
+
|
|
102
|
+
const file = await read(filePath);
|
|
103
|
+
if (!file) return null;
|
|
104
|
+
|
|
105
|
+
const type = MIME[path.extname(filePath)] || file.type || 'application/octet-stream';
|
|
106
|
+
const etag = etagFor(file);
|
|
107
|
+
const headers = {
|
|
108
|
+
'content-type': type,
|
|
109
|
+
// The URL the client asked for decides the policy, not the file that answered:
|
|
110
|
+
// index.html served as an SPA fallback must still be revalidated.
|
|
111
|
+
'cache-control': cacheControlFor(as),
|
|
112
|
+
etag,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// A validator is only worth attaching if we also honour it — otherwise every
|
|
116
|
+
// revalidation is a full download of something the client already has.
|
|
117
|
+
if (req.headers.get('if-none-match') === etag) {
|
|
118
|
+
return new Response(null, { status: 304, headers });
|
|
119
|
+
}
|
|
120
|
+
return new Response(req.method === 'HEAD' ? null : file.open(), { headers });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Where the built web app is.
|
|
2
|
+
//
|
|
3
|
+
// This was once a guess about the shape of the tree three levels above the file doing
|
|
4
|
+
// the guessing:
|
|
5
|
+
//
|
|
6
|
+
// path.resolve(__dirname, '../../../web/dist')
|
|
7
|
+
//
|
|
8
|
+
// It was right inside the repo and right again when installed, but only while
|
|
9
|
+
// `@trove/server` and `@trove/web` happened to land as siblings under one `@trove/`
|
|
10
|
+
// directory — a fact about a particular installer's layout rather than anything either
|
|
11
|
+
// package promised. Nest the install, vendor one of them, or hoist differently and the
|
|
12
|
+
// drive silently served no web app, with a 404 on `/` as the only clue.
|
|
13
|
+
//
|
|
14
|
+
// Trove now ships as a single package with the built app inside it, which retires the
|
|
15
|
+
// problem rather than working around it: server and web are no longer two things an
|
|
16
|
+
// installer arranges relative to each other, they are two directories in one tarball,
|
|
17
|
+
// and their arrangement is fixed by the package that contains both. So the relative
|
|
18
|
+
// path below is no longer a guess — it is the layout this package's own `files` field
|
|
19
|
+
// guarantees.
|
|
20
|
+
//
|
|
21
|
+
// Resolution is still tried first, because it is the one that keeps working when this
|
|
22
|
+
// file is *not* where it thinks it is: a bundler that flattened the tree, or a copy
|
|
23
|
+
// vendored somewhere else. The two answers agree whenever both are available, and each
|
|
24
|
+
// covers the other's failure mode.
|
|
25
|
+
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { existsSync } from 'node:fs';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
|
|
31
|
+
const WEB_DIST_IN_PACKAGE = 'packages/web/dist';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Locate the built web assets, or null when the app has not been built.
|
|
35
|
+
*
|
|
36
|
+
* Order matters: an explicit setting is never second-guessed, and if it points
|
|
37
|
+
* somewhere empty that is reported rather than quietly worked around — an operator who
|
|
38
|
+
* set TROVE_WEB_DIST wants to know it was wrong, not to be silently given a different
|
|
39
|
+
* directory that happened to exist.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} [envDist] the value of TROVE_WEB_DIST
|
|
42
|
+
* @returns {{dir: string|null, source: string}} where it looked and how it decided
|
|
43
|
+
*/
|
|
44
|
+
export function findWebDist(envDist = process.env.TROVE_WEB_DIST) {
|
|
45
|
+
if (envDist) {
|
|
46
|
+
const dir = path.resolve(envDist);
|
|
47
|
+
return existsSync(dir)
|
|
48
|
+
? { dir, source: 'TROVE_WEB_DIST' }
|
|
49
|
+
: { dir: null, source: `TROVE_WEB_DIST=${envDist} (no such directory)` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The package root, wherever the resolver says it is. A package can reference itself
|
|
53
|
+
// by name, so this resolves both from a checkout and from inside `node_modules` —
|
|
54
|
+
// and it reaches the manifest because `./package.json` is one of the subpaths
|
|
55
|
+
// `exports` names. If that ever stops being true this throws rather than returning
|
|
56
|
+
// something wrong, which is why it is wrapped.
|
|
57
|
+
try {
|
|
58
|
+
const require = createRequire(import.meta.url);
|
|
59
|
+
const root = path.dirname(require.resolve('@3sln/trove/package.json'));
|
|
60
|
+
const dir = path.join(root, WEB_DIST_IN_PACKAGE);
|
|
61
|
+
if (existsSync(dir)) return { dir, source: '@3sln/trove' };
|
|
62
|
+
} catch { /* not resolvable from here — fall through to the layout we ship */ }
|
|
63
|
+
|
|
64
|
+
// Four levels up from packages/server/src/adapters/ is the package root.
|
|
65
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
const dir = path.resolve(here, '../../../..', WEB_DIST_IN_PACKAGE);
|
|
67
|
+
if (existsSync(dir)) return { dir, source: 'relative to this file' };
|
|
68
|
+
|
|
69
|
+
return { dir: null, source: 'not built' };
|
|
70
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Background work on Workers, in a Durable Object.
|
|
2
|
+
//
|
|
3
|
+
// The TaskRegistry is in-memory and per-process, and that is the right lifetime: a task
|
|
4
|
+
// describes work in flight, and work in flight does not survive the thing doing it.
|
|
5
|
+
// What breaks on Workers is not the lifetime — it is that the process boundary stopped
|
|
6
|
+
// matching the work boundary. On a long-lived server one process runs the scan AND
|
|
7
|
+
// answers the polls, so "in memory" and "where the work is" are the same place. On
|
|
8
|
+
// Workers they come apart: the scan runs in whichever isolate served the POST, and the
|
|
9
|
+
// GET that asks about it lands wherever the router feels like.
|
|
10
|
+
//
|
|
11
|
+
// The fix is NOT to make the task list durable. A stored record saying "running" after
|
|
12
|
+
// the isolate that owned it was evicted is a phantom that nothing can ever correct —
|
|
13
|
+
// on a server a restart clears it, in durable storage it is forever. The fix is to give
|
|
14
|
+
// the work a real process again. A Durable Object is one: addressable by name, so every
|
|
15
|
+
// request reaches the same instance; single-threaded, so "is one already running?" can
|
|
16
|
+
// be answered truthfully; and long-lived, with `setAlarm` to pick work back up.
|
|
17
|
+
//
|
|
18
|
+
// So the registry stays exactly as it is, and moves in here with the work it describes.
|
|
19
|
+
//
|
|
20
|
+
// Three things this fixes that `waitUntil` alone does not:
|
|
21
|
+
// • progress polling — /api/tasks reaches the isolate that owns the task
|
|
22
|
+
// • cancel — the AbortController is in the same place as the work it aborts
|
|
23
|
+
// • the "already running" guard — one instance, not one per isolate
|
|
24
|
+
//
|
|
25
|
+
// Eviction is still possible mid-slice, and that is what the alarm is for: the scan
|
|
26
|
+
// cursor is persisted, `pending:` says the collection is not finished, and the alarm
|
|
27
|
+
// brings the object back to continue. Losing the isolate costs a slice, not the scan.
|
|
28
|
+
|
|
29
|
+
import { TaskRegistry } from '@3sln/trove/core';
|
|
30
|
+
|
|
31
|
+
/** How long one scan slice may run before it yields and stores its cursor. */
|
|
32
|
+
const DEFAULT_SLICE_MS = 20_000;
|
|
33
|
+
/** Gap between slices. Long enough not to hammer the store, short enough to feel live. */
|
|
34
|
+
const SLICE_GAP_MS = 5_000;
|
|
35
|
+
const PENDING = 'pending:';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The Durable Object. Export it from the Worker entry module and declare it in
|
|
39
|
+
* wrangler.toml; see the README section "Work that outlives a request".
|
|
40
|
+
*
|
|
41
|
+
* @param {(env: object) => Promise<object>} getServer builds (and caches) the server
|
|
42
|
+
*/
|
|
43
|
+
export function createTaskHost(getServer) {
|
|
44
|
+
return class TroveTasks {
|
|
45
|
+
constructor(state, env) {
|
|
46
|
+
this.state = state;
|
|
47
|
+
this.env = env;
|
|
48
|
+
this.server = null;
|
|
49
|
+
this.sliceMs = Number(env?.TROVE_SLICE_MS || DEFAULT_SLICE_MS);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async #boot() {
|
|
53
|
+
this.server ||= await getServer(this.env);
|
|
54
|
+
return this.server;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Wake up in `ms` — unless we are already due sooner. */
|
|
58
|
+
async #arm(ms) {
|
|
59
|
+
const at = Date.now() + ms;
|
|
60
|
+
const current = await this.state.storage.getAlarm();
|
|
61
|
+
if (current == null || current > at) await this.state.storage.setAlarm(at);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async #beginScan(collectionId, reason) {
|
|
65
|
+
const server = await this.#boot();
|
|
66
|
+
const { task, alreadyRunning, done } = await server.beginScan(collectionId, {
|
|
67
|
+
reason, deadlineMs: this.sliceMs,
|
|
68
|
+
});
|
|
69
|
+
if (!alreadyRunning) {
|
|
70
|
+
// Recorded BEFORE the slice runs. If this object is evicted half way through,
|
|
71
|
+
// the alarm is what brings it back — and it can only do that if it knows the
|
|
72
|
+
// collection is unfinished. Writing this after the slice would mean an eviction
|
|
73
|
+
// silently ends the scan, which is the failure this whole file is about.
|
|
74
|
+
await this.state.storage.put(`${PENDING}${collectionId}`, { reason: reason || 'Continuing' });
|
|
75
|
+
this.state.waitUntil?.(done.then(
|
|
76
|
+
// `stopped`, not `nextCursor`: a slice that runs out of budget before it
|
|
77
|
+
// processes anything stops with a null cursor — correctly, since it got
|
|
78
|
+
// nowhere and must resume from the start. Reading that as "finished" would
|
|
79
|
+
// drop the collection off the list having scanned none of it.
|
|
80
|
+
(r) => (r && !r.stopped ? this.state.storage.delete(`${PENDING}${collectionId}`) : null),
|
|
81
|
+
() => null, // a failed slice stays pending; the alarm retries it
|
|
82
|
+
));
|
|
83
|
+
}
|
|
84
|
+
await this.#arm(SLICE_GAP_MS);
|
|
85
|
+
return { task, alreadyRunning };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async #beginReindex(reason) {
|
|
89
|
+
const server = await this.#boot();
|
|
90
|
+
const { task, alreadyRunning, done } = await server.beginReindex({ reason });
|
|
91
|
+
// A reindex has no cursor to resume from, so there is nothing to continue — it
|
|
92
|
+
// either finishes in this object's lifetime or it is retried, and a failure
|
|
93
|
+
// leaves an Issue behind either way.
|
|
94
|
+
if (!alreadyRunning) this.state.waitUntil?.(done.catch(() => null));
|
|
95
|
+
return { task, alreadyRunning };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async fetch(request) {
|
|
99
|
+
const url = new URL(request.url);
|
|
100
|
+
const body = request.method === 'POST' ? await request.json().catch(() => ({})) : {};
|
|
101
|
+
const json = (v) => new Response(JSON.stringify(v), { headers: { 'content-type': 'application/json' } });
|
|
102
|
+
const server = await this.#boot();
|
|
103
|
+
switch (url.pathname) {
|
|
104
|
+
case '/begin':
|
|
105
|
+
return json(body.kind === 'index'
|
|
106
|
+
? await this.#beginReindex(body.reason)
|
|
107
|
+
: await this.#beginScan(body.collectionId || 'default', body.reason));
|
|
108
|
+
case '/tasks':
|
|
109
|
+
return json({ tasks: server.tasks.list(body) });
|
|
110
|
+
case '/task':
|
|
111
|
+
return json({ task: server.tasks.get(body.id) });
|
|
112
|
+
case '/cancel':
|
|
113
|
+
return json({ cancelled: server.tasks.cancel(body.id) });
|
|
114
|
+
case '/dismiss':
|
|
115
|
+
server.tasks.dismiss(body.id);
|
|
116
|
+
return json({ ok: true });
|
|
117
|
+
case '/maintain':
|
|
118
|
+
return json({ result: await server.runMaintenance({ budgetMs: body.budgetMs ?? this.sliceMs }) });
|
|
119
|
+
default:
|
|
120
|
+
return new Response('not found', { status: 404 });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Continue every collection that has not finished its pass. This is the durable
|
|
126
|
+
* half: a Cron Trigger starts things, but it is the alarm that keeps a bucket too
|
|
127
|
+
* large for one slice moving, without waiting for the next cron tick.
|
|
128
|
+
*/
|
|
129
|
+
async alarm() {
|
|
130
|
+
const server = await this.#boot();
|
|
131
|
+
const pending = await this.state.storage.list({ prefix: PENDING });
|
|
132
|
+
for (const [key, meta] of pending) {
|
|
133
|
+
const collectionId = key.slice(PENDING.length);
|
|
134
|
+
const result = await server
|
|
135
|
+
.startScan(collectionId, { reason: meta?.reason || 'Continuing', deadlineMs: this.sliceMs })
|
|
136
|
+
.catch(() => null);
|
|
137
|
+
// Done only when a pass ran to the end. `alreadyRunning` means another slice
|
|
138
|
+
// holds the claim right now — still unfinished, so it stays on the list.
|
|
139
|
+
if (result && !result.alreadyRunning && !result.stopped) await this.state.storage.delete(key);
|
|
140
|
+
}
|
|
141
|
+
if ((await this.state.storage.list({ prefix: PENDING })).size) await this.#arm(SLICE_GAP_MS);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The other side: a TaskRegistry that answers from the Durable Object rather than from
|
|
148
|
+
* this isolate's memory. Injected as `config.tasks`, so every route that reads tasks
|
|
149
|
+
* gets the truth without knowing where it lives.
|
|
150
|
+
*/
|
|
151
|
+
export class RemoteTasks extends TaskRegistry {
|
|
152
|
+
constructor(stub) {
|
|
153
|
+
super();
|
|
154
|
+
this.stub = stub;
|
|
155
|
+
}
|
|
156
|
+
async #rpc(path, payload) {
|
|
157
|
+
const res = await this.stub.fetch(`https://trove.tasks${path}`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'content-type': 'application/json' },
|
|
160
|
+
body: JSON.stringify(payload || {}),
|
|
161
|
+
});
|
|
162
|
+
return res.json();
|
|
163
|
+
}
|
|
164
|
+
async list(opts = {}) { return (await this.#rpc('/tasks', opts)).tasks; }
|
|
165
|
+
async get(id) { return (await this.#rpc('/task', { id })).task; }
|
|
166
|
+
async cancel(id) { return (await this.#rpc('/cancel', { id })).cancelled; }
|
|
167
|
+
async dismiss(id) { await this.#rpc('/dismiss', { id }); }
|
|
168
|
+
/**
|
|
169
|
+
* Nothing to wait on here. The work is in the Durable Object, which keeps itself
|
|
170
|
+
* alive; this isolate only forwarded a message. Returning the base class's answer
|
|
171
|
+
* would make `ctx.waitUntil` hold the request open for work it does not own.
|
|
172
|
+
*/
|
|
173
|
+
pending() { return null; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Everything the front-line Worker needs to hand background work to the object.
|
|
178
|
+
* Pass into `createServer` as `{ tasks, background }`.
|
|
179
|
+
*/
|
|
180
|
+
export function remoteBackground(namespace) {
|
|
181
|
+
// One instance for the whole drive, by name. Tasks are few and long, so there is no
|
|
182
|
+
// throughput argument for sharding — and one instance is what makes GET /api/tasks a
|
|
183
|
+
// complete answer rather than a per-shard sample.
|
|
184
|
+
const stub = namespace.get(namespace.idFromName('trove-tasks'));
|
|
185
|
+
const begin = (payload) => stub
|
|
186
|
+
.fetch('https://trove.tasks/begin', {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: { 'content-type': 'application/json' },
|
|
189
|
+
body: JSON.stringify(payload),
|
|
190
|
+
})
|
|
191
|
+
.then((r) => r.json());
|
|
192
|
+
return {
|
|
193
|
+
tasks: new RemoteTasks(stub),
|
|
194
|
+
background: {
|
|
195
|
+
beginScan: (collectionId, { reason } = {}) => begin({ kind: 'scan', collectionId, reason }),
|
|
196
|
+
beginReindex: ({ reason } = {}) => begin({ kind: 'index', reason }),
|
|
197
|
+
},
|
|
198
|
+
maintain: (budgetMs) => stub
|
|
199
|
+
.fetch('https://trove.tasks/maintain', {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'content-type': 'application/json' },
|
|
202
|
+
body: JSON.stringify({ budgetMs }),
|
|
203
|
+
})
|
|
204
|
+
.then((r) => r.json()),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Cloudflare Worker adapter. A Worker's fetch handler is already Web-standard,
|
|
2
|
+
// so this is thin. Storage should be S3-compatible (R2 via the S3 API) so the
|
|
3
|
+
// SigV4 signer and presigned URLs work. Bind a D1 database as `DB` and metadata, the
|
|
4
|
+
// KV store, plugin installs, and keyword search persist there; bind `VECTORIZE` for the
|
|
5
|
+
// semantic half. Without `DB` everything falls back to memory, which looks fine until
|
|
6
|
+
// the isolate is recycled. Static assets are served from the ASSETS binding.
|
|
7
|
+
//
|
|
8
|
+
// wrangler.toml sketch:
|
|
9
|
+
// [vars] TROVE_S3_BUCKET = "..." TROVE_S3_REGION = "auto"
|
|
10
|
+
// TROVE_S3_ENDPOINT = "https://<acct>.r2.cloudflarestorage.com"
|
|
11
|
+
// (secrets: TROVE_S3_ACCESS_KEY_ID, TROVE_S3_SECRET_ACCESS_KEY)
|
|
12
|
+
// [[d1_databases]] binding = "DB" -> metadata, kv, keyword search
|
|
13
|
+
// [[vectorize]] binding = "VECTORIZE" -> semantic search
|
|
14
|
+
// [ai] binding = "AI" -> LLM query understanding (optional)
|
|
15
|
+
// [[durable_objects.bindings]] name = "TASKS" class_name = "TroveTasks"
|
|
16
|
+
// -> owns scans and reindexes (see below)
|
|
17
|
+
|
|
18
|
+
import { D1SqliteProvider } from '@3sln/trove/core';
|
|
19
|
+
import { createServer, configFromEnv } from '../index.js';
|
|
20
|
+
import { createTaskHost, remoteBackground } from './worker-tasks.js';
|
|
21
|
+
|
|
22
|
+
// Two DIFFERENT servers can be asked for here: the front-line one, which hands long
|
|
23
|
+
// work to the Durable Object, and the one INSIDE that object, which does it. Keyed,
|
|
24
|
+
// because they cannot share a slot — a delegating server handed back inside the object
|
|
25
|
+
// would have it forward work to itself. Cloudflare gives a Durable Object its own
|
|
26
|
+
// isolate today, so this has not been reachable; an invariant that costs one line to
|
|
27
|
+
// state is cheaper than one that holds by luck.
|
|
28
|
+
const cached = new Map();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} env Worker env bindings
|
|
32
|
+
* @param {(env) => object} [buildVfs] optional: return { storage, metadata } (e.g. D1)
|
|
33
|
+
* @param {{delegate?: boolean}} [opts] delegate=false builds the server that DOES the
|
|
34
|
+
* background work (inside the Durable Object) rather than one that hands it off
|
|
35
|
+
*/
|
|
36
|
+
async function getServer(env, buildVfs, { delegate = true } = {}) {
|
|
37
|
+
const slot = delegate ? 'edge' : 'worker';
|
|
38
|
+
if (cached.has(slot)) return cached.get(slot);
|
|
39
|
+
const config = configFromEnv(env);
|
|
40
|
+
if (buildVfs) config.vfs = buildVfs(env);
|
|
41
|
+
// D1 → metadata, kv, plugin installs, and the keyword half of search. Without this a
|
|
42
|
+
// Worker falls back to in-memory everything, which looks like it works right up until
|
|
43
|
+
// the isolate is recycled and the drive is empty. Bind `DB` and it persists.
|
|
44
|
+
if (env.DB && !config.sqlite) {
|
|
45
|
+
config.sqlite = new D1SqliteProvider({
|
|
46
|
+
db: env.DB,
|
|
47
|
+
// Plugin storage. `scopes: { plugins: … }` named a key that is already a CORE key
|
|
48
|
+
// (the install-record store), so the binding was silently ignored and every
|
|
49
|
+
// /api/plugins/:id/sql call was a 501 — the real keys look like
|
|
50
|
+
// `pstore:<user>:plg:<pluginId>` and cannot be pre-bound at all.
|
|
51
|
+
pluginStore: env.PLUGIN_DB || null,
|
|
52
|
+
});
|
|
53
|
+
config.metadata = { driver: 'sqlite' };
|
|
54
|
+
}
|
|
55
|
+
// Cloudflare Vectorize binding → first-class vector store (no REST creds needed).
|
|
56
|
+
if (env.VECTORIZE) {
|
|
57
|
+
config.vectorStore = { driver: 'vectorize', binding: env.VECTORIZE };
|
|
58
|
+
}
|
|
59
|
+
// Cloudflare Workers AI binding → LLM-assisted search transformer (human text →
|
|
60
|
+
// semantic text + tag filters). Enabled just by binding `AI`; picks a cheap model.
|
|
61
|
+
if (env.AI) {
|
|
62
|
+
config.searchTransformer = { driver: 'workers-ai', ai: env.AI, model: env.TROVE_SEARCH_MODEL };
|
|
63
|
+
}
|
|
64
|
+
// Serve static assets from the ASSETS binding (Workers Sites / assets).
|
|
65
|
+
if (env.ASSETS) {
|
|
66
|
+
config.assets = async (req) => {
|
|
67
|
+
const res = await env.ASSETS.fetch(req);
|
|
68
|
+
return res.status === 404 ? null : res;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// The Durable Object that owns long work. Bound as TASKS, it becomes the one place
|
|
72
|
+
// scans and reindexes run and the one place their tasks are listed — so a client
|
|
73
|
+
// polling /api/tasks reaches the isolate that actually has the task, and Cancel
|
|
74
|
+
// reaches the AbortController it is meant to abort. Without the binding the drive
|
|
75
|
+
// still works: the work runs in the request isolate under waitUntil, which is what
|
|
76
|
+
// it did before, with the caveats in the README.
|
|
77
|
+
if (delegate && env.TASKS) {
|
|
78
|
+
const remote = remoteBackground(env.TASKS);
|
|
79
|
+
config.tasks = remote.tasks;
|
|
80
|
+
config.background = remote.background;
|
|
81
|
+
config.maintain = remote.maintain;
|
|
82
|
+
// Timers do not survive a request here, and the object has its own alarm loop.
|
|
83
|
+
config.startFlusher = false;
|
|
84
|
+
}
|
|
85
|
+
const server = await createServer(config);
|
|
86
|
+
server.maintain = config.maintain || null;
|
|
87
|
+
cached.set(slot, server);
|
|
88
|
+
return server;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Default export usable directly as a Worker module.
|
|
93
|
+
*
|
|
94
|
+
* Two things here are Workers-specific and neither is optional.
|
|
95
|
+
*
|
|
96
|
+
* `ctx.waitUntil` — a Worker's isolate may be torn down the moment the response
|
|
97
|
+
* resolves. Work that was started and not awaited (a scan, a reindex) is not merely
|
|
98
|
+
* slow after that, it is CANCELLED, at whatever point it had reached. `waitUntil` is
|
|
99
|
+
* the only way to say "this response is done but I am not", so anything the request
|
|
100
|
+
* kicked off is handed to it.
|
|
101
|
+
*
|
|
102
|
+
* `scheduled` — a Cron Trigger. `setInterval` is how a long-lived process does periodic
|
|
103
|
+
* work and it does not work here at all: a timer registered inside a request does not
|
|
104
|
+
* outlive the request, so the maintenance and scan intervals never fire on Workers.
|
|
105
|
+
* Point a cron at the Worker and this runs one bounded slice per firing.
|
|
106
|
+
*
|
|
107
|
+
* [triggers]
|
|
108
|
+
* crons = ["*/5 * * * *"]
|
|
109
|
+
*
|
|
110
|
+
* A third piece, `TroveTasks`, is exported below: `waitUntil` can keep work alive but
|
|
111
|
+
* cannot let another isolate SEE it, which is what progress polling and Cancel need.
|
|
112
|
+
*/
|
|
113
|
+
export default {
|
|
114
|
+
async fetch(request, env, ctx) {
|
|
115
|
+
const server = await getServer(env);
|
|
116
|
+
const res = await server.handle(request);
|
|
117
|
+
// Anything the request started but did not await — see `pendingWork` below.
|
|
118
|
+
const pending = server.tasks.pending?.();
|
|
119
|
+
if (pending && ctx?.waitUntil) ctx.waitUntil(pending);
|
|
120
|
+
return res;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Cron Trigger. Sweeps expired uploads and idle sidecars, applies trash retention,
|
|
125
|
+
* and advances each collection's scan by one time-boxed slice — the scanner stores
|
|
126
|
+
* the cursor it reached, so the next firing continues rather than starting over.
|
|
127
|
+
*/
|
|
128
|
+
async scheduled(event, env, ctx) {
|
|
129
|
+
const server = await getServer(env);
|
|
130
|
+
const budgetMs = Number(env.TROVE_CRON_BUDGET_MS || 20_000);
|
|
131
|
+
// AWAITED, not fire-and-forget: `scheduled` gets its own budget, and the runtime
|
|
132
|
+
// keeps the isolate alive exactly as long as this promise is pending. With the
|
|
133
|
+
// Durable Object bound this hands the slice to it instead — the object then keeps
|
|
134
|
+
// itself going on its own alarm, so a bucket too large for one firing does not
|
|
135
|
+
// wait for the next cron tick to continue.
|
|
136
|
+
const work = Promise.resolve(
|
|
137
|
+
server.maintain ? server.maintain(budgetMs) : server.runMaintenance({ budgetMs }),
|
|
138
|
+
).catch((e) => console.error('[trove] scheduled maintenance failed', e));
|
|
139
|
+
if (ctx?.waitUntil) ctx.waitUntil(work);
|
|
140
|
+
await work;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The Durable Object class. Declare it in wrangler.toml and it becomes the home of
|
|
146
|
+
* every scan and reindex — see worker-tasks.js for why that is the fix rather than
|
|
147
|
+
* making the task list durable.
|
|
148
|
+
*
|
|
149
|
+
* [[durable_objects.bindings]]
|
|
150
|
+
* name = "TASKS"
|
|
151
|
+
* class_name = "TroveTasks"
|
|
152
|
+
*
|
|
153
|
+
* [[migrations]]
|
|
154
|
+
* tag = "v1"
|
|
155
|
+
* new_sqlite_classes = ["TroveTasks"]
|
|
156
|
+
*/
|
|
157
|
+
export const TroveTasks = createTaskHost((env) => getServer(env, undefined, { delegate: false }));
|
|
158
|
+
|
|
159
|
+
export { getServer };
|