vitecache 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +17 -26
  2. package/dist/browser.d.ts +9 -6
  3. package/dist/browser.js +28 -159
  4. package/dist/browser.js.map +1 -1
  5. package/dist/discover.d.ts +15 -0
  6. package/dist/discover.js +131 -0
  7. package/dist/discover.js.map +1 -0
  8. package/dist/disk.d.ts +2 -9
  9. package/dist/disk.js +9 -63
  10. package/dist/disk.js.map +1 -1
  11. package/dist/entry.d.ts +31 -10
  12. package/dist/entry.js +11 -94
  13. package/dist/entry.js.map +1 -1
  14. package/dist/node.d.ts +9 -6
  15. package/dist/node.js +101 -6
  16. package/dist/node.js.map +1 -1
  17. package/dist/shared.d.ts +17 -0
  18. package/dist/shared.js +5 -0
  19. package/dist/shared.js.map +1 -0
  20. package/dist/vite.d.ts +12 -0
  21. package/dist/vite.js +253 -0
  22. package/dist/vite.js.map +1 -0
  23. package/package.json +17 -9
  24. package/skills/vitecache/SKILL.md +46 -37
  25. package/dist/dependencies.d.ts +0 -15
  26. package/dist/dependencies.js +0 -131
  27. package/dist/dependencies.js.map +0 -1
  28. package/dist/index.d.ts +0 -2
  29. package/dist/index.js +0 -2
  30. package/dist/index.js.map +0 -1
  31. package/dist/key.d.ts +0 -3
  32. package/dist/key.js +0 -10
  33. package/dist/key.js.map +0 -1
  34. package/dist/packages.d.ts +0 -1
  35. package/dist/packages.js +0 -27
  36. package/dist/packages.js.map +0 -1
  37. package/dist/storage.d.ts +0 -7
  38. package/dist/storage.js +0 -162
  39. package/dist/storage.js.map +0 -1
  40. package/dist/types.d.ts +0 -79
  41. package/dist/types.js +0 -2
  42. package/dist/types.js.map +0 -1
  43. package/dist/vite/index.d.ts +0 -8
  44. package/dist/vite/index.js +0 -220
  45. package/dist/vite/index.js.map +0 -1
  46. package/dist/vite/manifest.d.ts +0 -3
  47. package/dist/vite/manifest.js +0 -34
  48. package/dist/vite/manifest.js.map +0 -1
  49. package/dist/vite/resolve.d.ts +0 -8
  50. package/dist/vite/resolve.js +0 -83
  51. package/dist/vite/resolve.js.map +0 -1
package/dist/vite.js ADDED
@@ -0,0 +1,253 @@
1
+ import { appendFile, readdir, rm } from 'node:fs/promises';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import colors from 'picocolors';
4
+ import picomatch from 'picomatch';
5
+ import { glob } from 'tinyglobby';
6
+ import { createServer, normalizePath } from 'vite';
7
+ import { missing, read } from './disk.js';
8
+ import { discover } from './discover.js';
9
+ import { shared } from './shared.js';
10
+ /** Adds the cache directory to the project's .gitignore, since its entries are generated. */
11
+ async function ignore(root, directory) {
12
+ const line = `${relative(root, directory).split('\\').join('/')}/*`;
13
+ const file = join(root, '.gitignore');
14
+ const contents = new TextDecoder().decode(await read(file));
15
+ if (contents.split(/\r?\n/).includes(line))
16
+ return;
17
+ await appendFile(file, `${contents && !contents.endsWith('\n') ? '\n' : ''}${line}\n`);
18
+ }
19
+ function stack(error) {
20
+ return error instanceof Error ? (error.stack ?? error.message) : String(error);
21
+ }
22
+ /** Source files discovery scans, as Tailwind scans for class names, outside dependencies and outputs. */
23
+ const sources = '**/*.{js,jsx,ts,tsx,mjs,mts}';
24
+ /**
25
+ * Finds cached loader loads in the project's source files and warms them in Node, so that browsers read what Node
26
+ * saved, then deletes the entries no load uses. In development it scans and warms at startup and again after every
27
+ * file change, holds cache requests until warming finishes, and deletes entries unused for `pruneAfter`. A build scans
28
+ * and warms once, checks that a second pass saves nothing, and deletes every entry it did not use.
29
+ */
30
+ export function vitecache({ pruneAfter = 60_000 } = {}) {
31
+ // The server a build runs loaders with loads the config, and so this plugin, again.
32
+ if (shared.running)
33
+ return { name: 'vitecache' };
34
+ /** The loads each source file holds. */
35
+ const files = new Map();
36
+ let config;
37
+ let directory;
38
+ /** Runs loaders: the dev server itself, or in a build a server started for that. */
39
+ let server;
40
+ let queue = Promise.resolve();
41
+ /** Whether a pass is queued but has not started, which the next change then need not queue again. */
42
+ let queued = false;
43
+ /** When the latest pass started. Once the queue is idle, every load has used its entry since then. */
44
+ let started = 0;
45
+ let failed = false;
46
+ /** Set once the dev server closes, after which a running pass stops and nothing is pruned. */
47
+ let closing = false;
48
+ let timer;
49
+ /** Project-relative patterns of what is not scanned: dependencies, the build output, and public files. */
50
+ let excluded;
51
+ let isSource;
52
+ /** Records a file's loads. Development logs failures, since a file being edited can fail to parse. */
53
+ async function scan(file) {
54
+ try {
55
+ const code = new TextDecoder().decode(await read(file));
56
+ if (/Cached\w*Loader/.test(code))
57
+ files.set(file, discover(code, file));
58
+ else
59
+ files.delete(file);
60
+ }
61
+ catch (error) {
62
+ if (config.command === 'build')
63
+ throw error;
64
+ log(`${colors.red('cannot scan')} ${error instanceof Error ? error.message : String(error)}`, 'error');
65
+ }
66
+ }
67
+ async function run({ importer, source, name, args }) {
68
+ if (!server) {
69
+ shared.running = true;
70
+ try {
71
+ server = await createServer({
72
+ configFile: config.configFile ?? false,
73
+ root: config.root,
74
+ mode: config.mode,
75
+ logLevel: 'warn',
76
+ server: { middlewareMode: true, hmr: false, ws: false, watch: null },
77
+ });
78
+ }
79
+ finally {
80
+ shared.running = false;
81
+ }
82
+ }
83
+ const resolved = await server.environments.ssr.pluginContainer.resolveId(source, importer);
84
+ if (!resolved)
85
+ throw new Error(`vitecache cannot resolve ${source} from ${importer}`);
86
+ const module = await server.ssrLoadModule(resolved.id);
87
+ const exported = module[name];
88
+ if (typeof exported !== 'function')
89
+ throw new Error(`${source} does not export a class ${name}`);
90
+ const instance = Reflect.construct(exported, []);
91
+ const loadable = typeof instance === 'object' && instance !== null && 'loadAsync' in instance;
92
+ if (!loadable || typeof instance.loadAsync !== 'function')
93
+ throw new Error(`${name} has no loadAsync method`);
94
+ await instance.loadAsync(...args);
95
+ }
96
+ /** Runs every load once, one at a time, logging progress and failures. */
97
+ /** Logs as Vite does with a timestamp, but tagged [vitecache], since Vite's own timestamp option tags lines [vite]. */
98
+ function log(message, level = 'info') {
99
+ const tag = colors.bold(level === 'info' ? colors.cyan('[vitecache]') : colors.red('[vitecache]'));
100
+ config.logger[level](`${colors.dim(new Date().toLocaleTimeString())} ${tag} ${message}`);
101
+ }
102
+ /** Runs every load once, one at a time. Hits are silent; a load that computes is logged, and announced when slow. */
103
+ async function pass() {
104
+ queued = false;
105
+ started = Date.now();
106
+ for (const load of [...files.values()].flat()) {
107
+ if (closing)
108
+ return;
109
+ const file = normalizePath(relative(config.root, load.importer));
110
+ const where = `${load.name} ${colors.dim(`${file}:${load.position}`)}`;
111
+ const start = Date.now();
112
+ const saves = shared.saves;
113
+ const slow = setTimeout(() => log(`${colors.yellow('computing')} ${where}…`), 500);
114
+ try {
115
+ await run(load);
116
+ }
117
+ catch (error) {
118
+ failed = true;
119
+ log(`${colors.red('failed')} ${where}\n${stack(error)}`, 'error');
120
+ continue;
121
+ }
122
+ finally {
123
+ clearTimeout(slow);
124
+ }
125
+ const computed = shared.saves - saves;
126
+ if (!computed)
127
+ continue;
128
+ const time = Date.now() - start;
129
+ const took = time < 1000 ? `${time} ms` : `${(time / 1000).toFixed(1)} s`;
130
+ const entries = computed > 1 ? `, ${computed} entries` : '';
131
+ log(`${colors.green('computed')} ${where} ${colors.dim(`in ${took}${entries}`)}`);
132
+ }
133
+ }
134
+ /** Queues a pass unless one is already waiting. */
135
+ function warm() {
136
+ if (!queued) {
137
+ queued = true;
138
+ queue = queue.then(pass);
139
+ }
140
+ return idle();
141
+ }
142
+ async function idle() {
143
+ let current;
144
+ do {
145
+ current = queue;
146
+ await current;
147
+ } while (current !== queue);
148
+ }
149
+ /** Deletes the entries no load used since `before`. */
150
+ async function prune(before) {
151
+ const names = await readdir(directory).catch((error) => {
152
+ if (missing(error))
153
+ return [];
154
+ throw error;
155
+ });
156
+ for (const name of names) {
157
+ const key = /^([a-f0-9]{64})\.(?:bin|json)$/.exec(name)?.[1];
158
+ if (key && (shared.used.get(key) ?? 0) < before)
159
+ await rm(join(directory, name), { force: true });
160
+ }
161
+ }
162
+ /** Prunes in development, once idle: entries the latest pass used stay, others until `pruneAfter` past their use. */
163
+ async function expire() {
164
+ await idle();
165
+ if (closing)
166
+ return;
167
+ await prune(Math.min(started, Date.now() - pruneAfter));
168
+ }
169
+ async function scanAll() {
170
+ for (const file of await glob(sources, { cwd: config.root, absolute: true, ignore: excluded }))
171
+ await scan(file);
172
+ }
173
+ return {
174
+ name: 'vitecache',
175
+ async configResolved(resolved) {
176
+ config = resolved;
177
+ if (!config.publicDir)
178
+ throw new Error('vitecache requires Vite publicDir');
179
+ // Normalized, as are the files hotUpdate is given.
180
+ directory = normalizePath(join(config.publicDir, '_vitecache'));
181
+ shared.root = config.root;
182
+ shared.directory = directory;
183
+ const outputs = [config.build.outDir, config.publicDir].map((path) => relative(config.root, resolve(config.root, path)));
184
+ // An output at the root itself excludes nothing, since the root holds the sources.
185
+ excluded = ['**/node_modules/**', ...outputs.filter(Boolean).map((path) => `${normalizePath(path)}/**`)];
186
+ isSource = picomatch(sources, { ignore: excluded });
187
+ await ignore(config.root, directory);
188
+ },
189
+ async buildStart() {
190
+ if (config.command !== 'build' || this.environment.name !== 'client')
191
+ return;
192
+ const start = Date.now();
193
+ failed = false;
194
+ try {
195
+ await scanAll();
196
+ await warm();
197
+ if (failed)
198
+ throw new Error('vitecache: warming failed, see the errors above.');
199
+ const saves = shared.saves;
200
+ await warm();
201
+ if (failed)
202
+ throw new Error('vitecache: warming failed, see the errors above.');
203
+ if (shared.saves !== saves)
204
+ throw new Error('vitecache: a second warming saved entries again, so their keys are not stable.');
205
+ }
206
+ finally {
207
+ await server?.close();
208
+ server = undefined;
209
+ }
210
+ await prune(start);
211
+ },
212
+ configureServer(devServer) {
213
+ server = devServer;
214
+ const prefix = `${config.base}_vitecache/`;
215
+ // Serves entries itself, after warming: Vite's public file serving learns of new files only through its watcher.
216
+ devServer.middlewares.use((request, response, next) => {
217
+ if (!request.url?.startsWith(prefix))
218
+ return next();
219
+ const name = new URL(request.url, 'http://vitecache').pathname.slice(prefix.length);
220
+ if (!/^[a-f0-9]{64}\.bin$/.test(name))
221
+ return next();
222
+ void idle()
223
+ .then(() => read(join(directory, name)))
224
+ .then((bytes) => {
225
+ if (!bytes)
226
+ return next();
227
+ response.setHeader('Content-Type', 'application/octet-stream');
228
+ response.setHeader('Cache-Control', 'no-cache');
229
+ response.end(bytes);
230
+ }, next);
231
+ });
232
+ const report = (error) => log(stack(error), 'error');
233
+ // Queued, so that requests wait for the scan too.
234
+ queue = queue.then(scanAll).catch(report);
235
+ void warm().then(expire).catch(report);
236
+ timer = setInterval(() => void expire().catch(report), pruneAfter);
237
+ },
238
+ async hotUpdate({ file }) {
239
+ if (this.environment.name !== 'client' || file.startsWith(directory))
240
+ return;
241
+ if (isSource(normalizePath(relative(config.root, file))))
242
+ await scan(file);
243
+ // Any file can be an input, and an unchanged entry warms with one load. Saves are file changes too.
244
+ void warm();
245
+ },
246
+ async closeBundle() {
247
+ closing = true;
248
+ clearInterval(timer);
249
+ await idle();
250
+ },
251
+ };
252
+ }
253
+ //# sourceMappingURL=vite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.js","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpD,OAAO,MAAM,MAAM,YAAY,CAAC;AAChC,OAAO,SAAS,MAAM,WAAW,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAClC,OAAO,EAAE,YAAY,EAAE,aAAa,EAAwD,MAAM,MAAM,CAAC;AACzG,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,QAAQ,EAAa,MAAM,eAAe,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC,6FAA6F;AAC7F,KAAK,UAAU,MAAM,CAAC,IAAY,EAAE,SAAiB;IACnD,MAAM,IAAI,GAAG,GAAG,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;IACpE,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IACtC,MAAM,QAAQ,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAC5D,IAAI,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO;IACnD,MAAM,UAAU,CAAC,IAAI,EAAE,GAAG,QAAQ,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,IAAI,CAAC,CAAC;AACzF,CAAC;AAED,SAAS,KAAK,CAAC,KAAc;IAC3B,OAAO,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACjF,CAAC;AAED,yGAAyG;AACzG,MAAM,OAAO,GAAG,8BAA8B,CAAC;AAO/C;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,EAAE,UAAU,GAAG,MAAM,KAAuB,EAAE;IACtE,oFAAoF;IACpF,IAAI,MAAM,CAAC,OAAO;QAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;IACjD,wCAAwC;IACxC,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IACxC,IAAI,MAAsB,CAAC;IAC3B,IAAI,SAAiB,CAAC;IACtB,oFAAoF;IACpF,IAAI,MAAiC,CAAC;IACtC,IAAI,KAAK,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAC9B,qGAAqG;IACrG,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,sGAAsG;IACtG,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,8FAA8F;IAC9F,IAAI,OAAO,GAAG,KAAK,CAAC;IACpB,IAAI,KAAiC,CAAC;IAEtC,0GAA0G;IAC1G,IAAI,QAAkB,CAAC;IACvB,IAAI,QAAmC,CAAC;IAExC,sGAAsG;IACtG,KAAK,UAAU,IAAI,CAAC,IAAY;QAC9B,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;YACxD,IAAI,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC;gBAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;;gBACnE,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QAC1B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,MAAM,CAAC,OAAO,KAAK,OAAO;gBAAE,MAAM,KAAK,CAAC;YAC5C,GAAG,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,IAAI,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;QACzG,CAAC;IACH,CAAC;IAED,KAAK,UAAU,GAAG,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAQ;QACvD,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC;YACtB,IAAI,CAAC;gBACH,MAAM,GAAG,MAAM,YAAY,CAAC;oBAC1B,UAAU,EAAE,MAAM,CAAC,UAAU,IAAI,KAAK;oBACtC,IAAI,EAAE,MAAM,CAAC,IAAI;oBACjB,IAAI,EAAE,MAAM,CAAC,IAAI;oBACjB,QAAQ,EAAE,MAAM;oBAChB,MAAM,EAAE,EAAE,cAAc,EAAE,IAAI,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE;iBACrE,CAAC,CAAC;YACL,CAAC;oBAAS,CAAC;gBACT,MAAM,CAAC,OAAO,GAAG,KAAK,CAAC;YACzB,CAAC;QACH,CAAC;QACD,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,GAAG,CAAC,eAAe,CAAC,SAAS,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;QAC3F,IAAI,CAAC,QAAQ;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,MAAM,SAAS,QAAQ,EAAE,CAAC,CAAC;QACtF,MAAM,MAAM,GAA4B,MAAM,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;QAChF,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;QAC9B,IAAI,OAAO,QAAQ,KAAK,UAAU;YAAE,MAAM,IAAI,KAAK,CAAC,GAAG,MAAM,4BAA4B,IAAI,EAAE,CAAC,CAAC;QACjG,MAAM,QAAQ,GAAY,OAAO,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAC1D,MAAM,QAAQ,GAAG,OAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,KAAK,IAAI,IAAI,WAAW,IAAI,QAAQ,CAAC;QAC9F,IAAI,CAAC,QAAQ,IAAI,OAAO,QAAQ,CAAC,SAAS,KAAK,UAAU;YAAE,MAAM,IAAI,KAAK,CAAC,GAAG,IAAI,0BAA0B,CAAC,CAAC;QAC9G,MAAM,QAAQ,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,CAAC;IACpC,CAAC;IAED,0EAA0E;IAC1E,uHAAuH;IACvH,SAAS,GAAG,CAAC,OAAe,EAAE,QAA0B,MAAM;QAC5D,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC;QACnG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,kBAAkB,EAAE,CAAC,IAAI,GAAG,IAAI,OAAO,EAAE,CAAC,CAAC;IAC3F,CAAC;IAED,qHAAqH;IACrH,KAAK,UAAU,IAAI;QACjB,MAAM,GAAG,KAAK,CAAC;QACf,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACrB,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;YAC9C,IAAI,OAAO;gBAAE,OAAO;YACpB,MAAM,IAAI,GAAG,aAAa,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC;YACjE,MAAM,KAAK,GAAG,GAAG,IAAI,CAAC,IAAI,IAAI,MAAM,CAAC,GAAG,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC;YACvE,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YACzB,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;YAC3B,MAAM,IAAI,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;YACnF,IAAI,CAAC;gBACH,MAAM,GAAG,CAAC,IAAI,CAAC,CAAC;YAClB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,MAAM,GAAG,IAAI,CAAC;gBACd,GAAG,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,KAAK,KAAK,KAAK,CAAC,KAAK,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;gBAClE,SAAS;YACX,CAAC;oBAAS,CAAC;gBACT,YAAY,CAAC,IAAI,CAAC,CAAC;YACrB,CAAC;YACD,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,GAAG,KAAK,CAAC;YACtC,IAAI,CAAC,QAAQ;gBAAE,SAAS;YACxB,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC;YAChC,MAAM,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;YAC1E,MAAM,OAAO,GAAG,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,QAAQ,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;YAC5D,GAAG,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,KAAK,IAAI,MAAM,CAAC,GAAG,CAAC,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;QACpF,CAAC;IACH,CAAC;IAED,mDAAmD;IACnD,SAAS,IAAI;QACX,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,GAAG,IAAI,CAAC;YACd,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC3B,CAAC;QACD,OAAO,IAAI,EAAE,CAAC;IAChB,CAAC;IAED,KAAK,UAAU,IAAI;QACjB,IAAI,OAAsB,CAAC;QAC3B,GAAG,CAAC;YACF,OAAO,GAAG,KAAK,CAAC;YAChB,MAAM,OAAO,CAAC;QAChB,CAAC,QAAQ,OAAO,KAAK,KAAK,EAAE;IAC9B,CAAC;IAED,uDAAuD;IACvD,KAAK,UAAU,KAAK,CAAC,MAAc;QACjC,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;YAC9D,IAAI,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO,EAAE,CAAC;YAC9B,MAAM,KAAK,CAAC;QACd,CAAC,CAAC,CAAC;QACH,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,GAAG,GAAG,gCAAgC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;YAC7D,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,MAAM;gBAAE,MAAM,EAAE,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACpG,CAAC;IACH,CAAC;IAED,qHAAqH;IACrH,KAAK,UAAU,MAAM;QACnB,MAAM,IAAI,EAAE,CAAC;QACb,IAAI,OAAO;YAAE,OAAO;QACpB,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,UAAU,CAAC,CAAC,CAAC;IAC1D,CAAC;IAED,KAAK,UAAU,OAAO;QACpB,KAAK,MAAM,IAAI,IAAI,MAAM,IAAI,CAAC,OAAO,EAAE,EAAE,GAAG,EAAE,MAAM,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;YAAE,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;IACnH,CAAC;IAED,OAAO;QACL,IAAI,EAAE,WAAW;QACjB,KAAK,CAAC,cAAc,CAAC,QAAQ;YAC3B,MAAM,GAAG,QAAQ,CAAC;YAClB,IAAI,CAAC,MAAM,CAAC,SAAS;gBAAE,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAC;YAC5E,mDAAmD;YACnD,SAAS,GAAG,aAAa,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;YAChE,MAAM,CAAC,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC;YAC1B,MAAM,CAAC,SAAS,GAAG,SAAS,CAAC;YAC7B,MAAM,OAAO,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACnE,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAClD,CAAC;YACF,mFAAmF;YACnF,QAAQ,GAAG,CAAC,oBAAoB,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;YACzG,QAAQ,GAAG,SAAS,CAAC,OAAO,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,CAAC;YACpD,MAAM,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;QACvC,CAAC;QACD,KAAK,CAAC,UAAU;YACd,IAAI,MAAM,CAAC,OAAO,KAAK,OAAO,IAAI,IAAI,CAAC,WAAW,CAAC,IAAI,KAAK,QAAQ;gBAAE,OAAO;YAC7E,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YACzB,MAAM,GAAG,KAAK,CAAC;YACf,IAAI,CAAC;gBACH,MAAM,OAAO,EAAE,CAAC;gBAChB,MAAM,IAAI,EAAE,CAAC;gBACb,IAAI,MAAM;oBAAE,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC;gBAChF,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;gBAC3B,MAAM,IAAI,EAAE,CAAC;gBACb,IAAI,MAAM;oBAAE,MAAM,IAAI,KAAK,CAAC,kDAAkD,CAAC,CAAC;gBAChF,IAAI,MAAM,CAAC,KAAK,KAAK,KAAK;oBACxB,MAAM,IAAI,KAAK,CAAC,gFAAgF,CAAC,CAAC;YACtG,CAAC;oBAAS,CAAC;gBACT,MAAM,MAAM,EAAE,KAAK,EAAE,CAAC;gBACtB,MAAM,GAAG,SAAS,CAAC;YACrB,CAAC;YACD,MAAM,KAAK,CAAC,KAAK,CAAC,CAAC;QACrB,CAAC;QACD,eAAe,CAAC,SAAS;YACvB,MAAM,GAAG,SAAS,CAAC;YACnB,MAAM,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,aAAa,CAAC;YAC3C,iHAAiH;YACjH,SAAS,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE;gBACpD,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,UAAU,CAAC,MAAM,CAAC;oBAAE,OAAO,IAAI,EAAE,CAAC;gBACpD,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,EAAE,kBAAkB,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;gBACpF,IAAI,CAAC,qBAAqB,CAAC,IAAI,CAAC,IAAI,CAAC;oBAAE,OAAO,IAAI,EAAE,CAAC;gBACrD,KAAK,IAAI,EAAE;qBACR,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC;qBACvC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE;oBACd,IAAI,CAAC,KAAK;wBAAE,OAAO,IAAI,EAAE,CAAC;oBAC1B,QAAQ,CAAC,SAAS,CAAC,cAAc,EAAE,0BAA0B,CAAC,CAAC;oBAC/D,QAAQ,CAAC,SAAS,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;oBAChD,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;gBACtB,CAAC,EAAE,IAAI,CAAC,CAAC;YACb,CAAC,CAAC,CAAC;YACH,MAAM,MAAM,GAAG,CAAC,KAAc,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC,CAAC;YAC9D,kDAAkD;YAClD,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;YAC1C,KAAK,IAAI,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;YACvC,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,UAAU,CAAC,CAAC;QACrE,CAAC;QACD,KAAK,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE;YACtB,IAAI,IAAI,CAAC,WAAW,CAAC,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC;gBAAE,OAAO;YAC7E,IAAI,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;gBAAE,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;YAC3E,oGAAoG;YACpG,KAAK,IAAI,EAAE,CAAC;QACd,CAAC;QACD,KAAK,CAAC,WAAW;YACf,OAAO,GAAG,IAAI,CAAC;YACf,aAAa,CAAC,KAAK,CAAC,CAAC;YACrB,MAAM,IAAI,EAAE,CAAC;QACf,CAAC;KACF,CAAC;AACJ,CAAC","sourcesContent":["import { appendFile, readdir, rm } from 'node:fs/promises';\nimport { join, relative, resolve } from 'node:path';\nimport colors from 'picocolors';\nimport picomatch from 'picomatch';\nimport { glob } from 'tinyglobby';\nimport { createServer, normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite';\nimport { missing, read } from './disk.js';\nimport { discover, type Load } from './discover.js';\nimport { shared } from './shared.js';\n\n/** Adds the cache directory to the project's .gitignore, since its entries are generated. */\nasync function ignore(root: string, directory: string): Promise<void> {\n const line = `${relative(root, directory).split('\\\\').join('/')}/*`;\n const file = join(root, '.gitignore');\n const contents = new TextDecoder().decode(await read(file));\n if (contents.split(/\\r?\\n/).includes(line)) return;\n await appendFile(file, `${contents && !contents.endsWith('\\n') ? '\\n' : ''}${line}\\n`);\n}\n\nfunction stack(error: unknown): string {\n return error instanceof Error ? (error.stack ?? error.message) : String(error);\n}\n\n/** Source files discovery scans, as Tailwind scans for class names, outside dependencies and outputs. */\nconst sources = '**/*.{js,jsx,ts,tsx,mjs,mts}';\n\nexport interface VitecacheOptions {\n /** Development: milliseconds an entry stays after the last load that used it, so that an undone edit reuses it. */\n pruneAfter?: number;\n}\n\n/**\n * Finds cached loader loads in the project's source files and warms them in Node, so that browsers read what Node\n * saved, then deletes the entries no load uses. In development it scans and warms at startup and again after every\n * file change, holds cache requests until warming finishes, and deletes entries unused for `pruneAfter`. A build scans\n * and warms once, checks that a second pass saves nothing, and deletes every entry it did not use.\n */\nexport function vitecache({ pruneAfter = 60_000 }: VitecacheOptions = {}): Plugin {\n // The server a build runs loaders with loads the config, and so this plugin, again.\n if (shared.running) return { name: 'vitecache' };\n /** The loads each source file holds. */\n const files = new Map<string, Load[]>();\n let config: ResolvedConfig;\n let directory: string;\n /** Runs loaders: the dev server itself, or in a build a server started for that. */\n let server: ViteDevServer | undefined;\n let queue = Promise.resolve();\n /** Whether a pass is queued but has not started, which the next change then need not queue again. */\n let queued = false;\n /** When the latest pass started. Once the queue is idle, every load has used its entry since then. */\n let started = 0;\n let failed = false;\n /** Set once the dev server closes, after which a running pass stops and nothing is pruned. */\n let closing = false;\n let timer: NodeJS.Timeout | undefined;\n\n /** Project-relative patterns of what is not scanned: dependencies, the build output, and public files. */\n let excluded: string[];\n let isSource: (file: string) => boolean;\n\n /** Records a file's loads. Development logs failures, since a file being edited can fail to parse. */\n async function scan(file: string): Promise<void> {\n try {\n const code = new TextDecoder().decode(await read(file));\n if (/Cached\\w*Loader/.test(code)) files.set(file, discover(code, file));\n else files.delete(file);\n } catch (error) {\n if (config.command === 'build') throw error;\n log(`${colors.red('cannot scan')} ${error instanceof Error ? error.message : String(error)}`, 'error');\n }\n }\n\n async function run({ importer, source, name, args }: Load): Promise<void> {\n if (!server) {\n shared.running = true;\n try {\n server = await createServer({\n configFile: config.configFile ?? false,\n root: config.root,\n mode: config.mode,\n logLevel: 'warn',\n server: { middlewareMode: true, hmr: false, ws: false, watch: null },\n });\n } finally {\n shared.running = false;\n }\n }\n const resolved = await server.environments.ssr.pluginContainer.resolveId(source, importer);\n if (!resolved) throw new Error(`vitecache cannot resolve ${source} from ${importer}`);\n const module: Record<string, unknown> = await server.ssrLoadModule(resolved.id);\n const exported = module[name];\n if (typeof exported !== 'function') throw new Error(`${source} does not export a class ${name}`);\n const instance: unknown = Reflect.construct(exported, []);\n const loadable = typeof instance === 'object' && instance !== null && 'loadAsync' in instance;\n if (!loadable || typeof instance.loadAsync !== 'function') throw new Error(`${name} has no loadAsync method`);\n await instance.loadAsync(...args);\n }\n\n /** Runs every load once, one at a time, logging progress and failures. */\n /** Logs as Vite does with a timestamp, but tagged [vitecache], since Vite's own timestamp option tags lines [vite]. */\n function log(message: string, level: 'info' | 'error' = 'info'): void {\n const tag = colors.bold(level === 'info' ? colors.cyan('[vitecache]') : colors.red('[vitecache]'));\n config.logger[level](`${colors.dim(new Date().toLocaleTimeString())} ${tag} ${message}`);\n }\n\n /** Runs every load once, one at a time. Hits are silent; a load that computes is logged, and announced when slow. */\n async function pass(): Promise<void> {\n queued = false;\n started = Date.now();\n for (const load of [...files.values()].flat()) {\n if (closing) return;\n const file = normalizePath(relative(config.root, load.importer));\n const where = `${load.name} ${colors.dim(`${file}:${load.position}`)}`;\n const start = Date.now();\n const saves = shared.saves;\n const slow = setTimeout(() => log(`${colors.yellow('computing')} ${where}…`), 500);\n try {\n await run(load);\n } catch (error) {\n failed = true;\n log(`${colors.red('failed')} ${where}\\n${stack(error)}`, 'error');\n continue;\n } finally {\n clearTimeout(slow);\n }\n const computed = shared.saves - saves;\n if (!computed) continue;\n const time = Date.now() - start;\n const took = time < 1000 ? `${time} ms` : `${(time / 1000).toFixed(1)} s`;\n const entries = computed > 1 ? `, ${computed} entries` : '';\n log(`${colors.green('computed')} ${where} ${colors.dim(`in ${took}${entries}`)}`);\n }\n }\n\n /** Queues a pass unless one is already waiting. */\n function warm(): Promise<void> {\n if (!queued) {\n queued = true;\n queue = queue.then(pass);\n }\n return idle();\n }\n\n async function idle(): Promise<void> {\n let current: Promise<void>;\n do {\n current = queue;\n await current;\n } while (current !== queue);\n }\n\n /** Deletes the entries no load used since `before`. */\n async function prune(before: number): Promise<void> {\n const names = await readdir(directory).catch((error: unknown) => {\n if (missing(error)) return [];\n throw error;\n });\n for (const name of names) {\n const key = /^([a-f0-9]{64})\\.(?:bin|json)$/.exec(name)?.[1];\n if (key && (shared.used.get(key) ?? 0) < before) await rm(join(directory, name), { force: true });\n }\n }\n\n /** Prunes in development, once idle: entries the latest pass used stay, others until `pruneAfter` past their use. */\n async function expire(): Promise<void> {\n await idle();\n if (closing) return;\n await prune(Math.min(started, Date.now() - pruneAfter));\n }\n\n async function scanAll(): Promise<void> {\n for (const file of await glob(sources, { cwd: config.root, absolute: true, ignore: excluded })) await scan(file);\n }\n\n return {\n name: 'vitecache',\n async configResolved(resolved) {\n config = resolved;\n if (!config.publicDir) throw new Error('vitecache requires Vite publicDir');\n // Normalized, as are the files hotUpdate is given.\n directory = normalizePath(join(config.publicDir, '_vitecache'));\n shared.root = config.root;\n shared.directory = directory;\n const outputs = [config.build.outDir, config.publicDir].map((path) =>\n relative(config.root, resolve(config.root, path)),\n );\n // An output at the root itself excludes nothing, since the root holds the sources.\n excluded = ['**/node_modules/**', ...outputs.filter(Boolean).map((path) => `${normalizePath(path)}/**`)];\n isSource = picomatch(sources, { ignore: excluded });\n await ignore(config.root, directory);\n },\n async buildStart() {\n if (config.command !== 'build' || this.environment.name !== 'client') return;\n const start = Date.now();\n failed = false;\n try {\n await scanAll();\n await warm();\n if (failed) throw new Error('vitecache: warming failed, see the errors above.');\n const saves = shared.saves;\n await warm();\n if (failed) throw new Error('vitecache: warming failed, see the errors above.');\n if (shared.saves !== saves)\n throw new Error('vitecache: a second warming saved entries again, so their keys are not stable.');\n } finally {\n await server?.close();\n server = undefined;\n }\n await prune(start);\n },\n configureServer(devServer) {\n server = devServer;\n const prefix = `${config.base}_vitecache/`;\n // Serves entries itself, after warming: Vite's public file serving learns of new files only through its watcher.\n devServer.middlewares.use((request, response, next) => {\n if (!request.url?.startsWith(prefix)) return next();\n const name = new URL(request.url, 'http://vitecache').pathname.slice(prefix.length);\n if (!/^[a-f0-9]{64}\\.bin$/.test(name)) return next();\n void idle()\n .then(() => read(join(directory, name)))\n .then((bytes) => {\n if (!bytes) return next();\n response.setHeader('Content-Type', 'application/octet-stream');\n response.setHeader('Cache-Control', 'no-cache');\n response.end(bytes);\n }, next);\n });\n const report = (error: unknown) => log(stack(error), 'error');\n // Queued, so that requests wait for the scan too.\n queue = queue.then(scanAll).catch(report);\n void warm().then(expire).catch(report);\n timer = setInterval(() => void expire().catch(report), pruneAfter);\n },\n async hotUpdate({ file }) {\n if (this.environment.name !== 'client' || file.startsWith(directory)) return;\n if (isSource(normalizePath(relative(config.root, file)))) await scan(file);\n // Any file can be an input, and an unchanged entry warms with one load. Saves are file changes too.\n void warm();\n },\n async closeBundle() {\n closing = true;\n clearInterval(timer);\n await idle();\n },\n };\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vitecache",
3
- "version": "0.2.1",
4
- "description": "Persistent binary caching for expensive computation in browsers and Node.",
3
+ "version": "0.4.0",
4
+ "description": "Persistent binary caching for expensive computation, warmed in Node by a Vite plugin and read in browsers and Node.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -22,18 +22,18 @@
22
22
  "typescript"
23
23
  ],
24
24
  "sideEffects": false,
25
- "main": "./dist/index.js",
26
- "types": "./dist/index.d.ts",
25
+ "main": "./dist/browser.js",
26
+ "types": "./dist/browser.d.ts",
27
27
  "exports": {
28
28
  ".": {
29
- "types": "./dist/index.d.ts",
29
+ "types": "./dist/browser.d.ts",
30
30
  "browser": "./dist/browser.js",
31
31
  "node": "./dist/node.js",
32
32
  "default": "./dist/browser.js"
33
33
  },
34
34
  "./vite": {
35
- "types": "./dist/vite/index.d.ts",
36
- "default": "./dist/vite/index.js"
35
+ "types": "./dist/vite.d.ts",
36
+ "default": "./dist/vite.js"
37
37
  }
38
38
  },
39
39
  "files": [
@@ -52,10 +52,16 @@
52
52
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit"
53
53
  },
54
54
  "dependencies": {
55
- "@noble/hashes": "^2.0.1"
55
+ "@noble/hashes": "^2.0.1",
56
+ "json5": "2.2.3",
57
+ "picocolors": "1.1.1",
58
+ "picomatch": "4.0.3",
59
+ "safe-stable-stringify": "2.5.0",
60
+ "tinyglobby": "0.2.15",
61
+ "write-file-atomic": "6.0.0"
56
62
  },
57
63
  "peerDependencies": {
58
- "vite": ">=6.0.0"
64
+ "vite": ">=8.0.0"
59
65
  },
60
66
  "peerDependenciesMeta": {
61
67
  "vite": {
@@ -63,6 +69,8 @@
63
69
  }
64
70
  },
65
71
  "devDependencies": {
72
+ "@types/picomatch": "4.0.2",
73
+ "@types/write-file-atomic": "4.0.3",
66
74
  "vite": "^8.0.0",
67
75
  "vitest": "^4.0.17"
68
76
  },
@@ -1,54 +1,49 @@
1
1
  ---
2
2
  name: vitecache
3
- description: Cache expensive computation with vitecache in browsers or Node, connect browser saves to a Vite development server, and reuse cached bytes in deployed apps.
3
+ description: Cache expensive computation with vitecache: wrap it in a cached loader that the Vite plugin finds and computes in Node during development and builds, so browsers and Node read the saved bytes instead of recomputing.
4
4
  ---
5
5
 
6
6
  # vitecache
7
7
 
8
- Use vitecache when recomputing a result costs more than reading its encoded bytes. The application owns encoding and decoding. Declare every input that affects the computation as a named dependency. Use `{ value }` for JSON data and `{ package: "elkjs" }` for an installed library. Package dependencies track the named package’s installed version, so declare transitive packages separately when their versions matter. Bump the computation version for changes beyond what the declared dependencies capture.
8
+ Use vitecache when recomputing a result costs more than reading its encoded bytes. The application owns encoding and decoding.
9
9
 
10
- Give dependencies stable names. Use `{ path: "src/simplify.ts" }` for a project-root-relative path or `{ url: modelUrl }` when you have an asset URL served by the same project origin. Paths resolve from the Node `root` or the Vite project root, never from the importing module, page URL, or cache directory. The Vite plugin resolves served URLs to source paths; Node accepts local file URLs. Keep the plugin in the production build configuration when using served URLs so deployed entries can recover the same source paths. Production mappings cover public files and emitted assets with one backing source. Use `{ path }` when an imported asset is inlined or deduplicated from several source files.
10
+ ## Cached loaders
11
11
 
12
- Reordering JSON object properties or dependency declarations does not change identity; array order matters. Changing a dependency name, JSON value, resolved path, or package name does. File contents and installed package versions invalidate the existing result when filesystem validation is available; installation paths do not affect identity.
13
-
14
- Load once and compute on a miss. Reuse that entry when saving: loading captures file contents and package versions before computation, allowing saving to reject a result if those dependencies changed in the meantime. A new entry or a save without a preceding load cannot establish that relationship. Entries containing only value dependencies can save directly. If saving fails because dependencies changed, load again and recompute before retrying. A dependency without a backing file, such as a server route, a virtual module or a package the app does not install, makes `save()` throw `MissingDependencyError`; catch it to carry on with the uncached result. Use a separate entry for each concurrent computation.
12
+ Put each computation in an exported class whose name matches `Cached…Loader`, with a `loadAsync` method, as a three.js `Loader` has; extending one works. The plugin constructs it without arguments, so let only `loadAsync`'s arguments select the result: constructor options, such as a `LoadingManager`, must not change the bytes it saves. `loadAsync` creates a `CacheEntry`, loads, computes on a miss, and saves with the same entry. Call `load()` on every call, without a memo in front of it: the plugin keeps entries that loads use, and an entry a memo answers for goes unused and is deleted. `save()` records the inputs as the preceding `load()` found them, so one that changes during the computation makes the next load miss instead of serving a stale result. Import the computation lazily inside the miss branch, so pages that hit never download its code.
15
13
 
16
14
  ```ts
17
15
  import { CacheEntry } from "vitecache"
18
- import modelUrl from "./models/chair.glb?url"
19
-
20
- const entry = new CacheEntry({
21
- version: "simplify@2",
22
- dependencies: {
23
- settings: { value: { ratio: 0.5, preserveUVs: true } },
24
- algorithm: { path: "src/simplify.ts" },
25
- model: { url: modelUrl },
26
- },
27
- })
28
-
29
- let bytes = await entry.load()
30
-
31
- if (bytes === undefined) {
32
- bytes = encodeMesh(await simplify())
33
- await entry.save(bytes)
34
- }
35
16
 
36
- const mesh = decodeMesh(bytes)
17
+ export class CachedMeshLoader {
18
+ async loadAsync(settings: { ratio: number }): Promise<Mesh> {
19
+ const entry = new CacheEntry({
20
+ version: "simplify@2",
21
+ dependencies: {
22
+ settings: { value: settings },
23
+ algorithm: { path: "src/simplify.ts" },
24
+ engine: { package: "meshoptimizer" },
25
+ },
26
+ })
27
+ let bytes = await entry.load()
28
+ if (bytes === undefined) {
29
+ const { simplify } = await import("./simplify.ts")
30
+ bytes = encodeMesh(await simplify(settings))
31
+ await entry.save(bytes)
32
+ }
33
+ return decodeMesh(bytes)
34
+ }
35
+ }
37
36
  ```
38
37
 
39
- Constructor dependencies select which result an entry holds. When some inputs are only discovered while computing, such as the files a script imported or the textures a model referenced, pass them to `save(bytes, { dependencies })` instead, like a compiler's depfile: paths, URLs and packages only, since anything that selects the result belongs in the constructor. Every later `load()` rechecks them and misses when one changed or disappeared, and the next save replaces the same entry. They are hashed when `save()` runs rather than when the computation read them, so an edit made during a long computation goes unnoticed until the file changes again.
38
+ Declare every input that affects the result, under a stable name. A `{ value }` is JSON that selects which result the entry holds: changing it, a name, a path or a package name changes the key, while reordering object properties does not. File contents and package versions invalidate the entry without changing its key.
40
39
 
41
- ```ts
42
- const entry = new CacheEntry({ version: "bake@1", dependencies: { scene: { url: sceneUrl } } })
43
- let bytes = await entry.load()
44
- if (bytes === undefined) {
45
- const { result, files } = await bake(sceneUrl) // files: URLs the bake read
46
- bytes = encode(result)
47
- await entry.save(bytes, { dependencies: Object.fromEntries(files.map((url, i) => [`file:${i}`, { url }])) })
48
- }
49
- ```
40
+ A `{ path }` is relative to the project root, never to the importing module. A `{ package }` tracks only that package's installed version, so declare transitive packages separately when their versions matter. Bump `version` for changes the dependencies do not capture, such as a linked workspace package changed without a new version.
41
+
42
+ When inputs are only discovered while computing, such as the textures a model references, pass them to `save()` as its `dependencies`, like a compiler's depfile. They are checked on every load without being part of the key.
43
+
44
+ ## Using loaders
50
45
 
51
- Use the Vite plugin when browser computation should produce reusable project files:
46
+ Add the plugin:
52
47
 
53
48
  ```ts
54
49
  import { defineConfig } from "vite"
@@ -57,6 +52,20 @@ import { vitecache } from "vitecache/vite"
57
52
  export default defineConfig({ plugins: [vitecache()] })
58
53
  ```
59
54
 
60
- Generate caches during development or from Node before building when deployed users should download precomputed results. Include the generated public files in the build or deployment even when Git ignores them. The plugin writes files during development; it does not supply a production write server. Browsers without the plugin assume files and package versions unchanged, saved dependencies included, so regenerate affected caches before deployment. Calling `save()` there warns and skips persistence. The plugin answers a missing entry without an HTTP error, but a static host answers it with 404, which the browser console shows; each one marks a result the deployment lacks, so precompute what deployed users will request.
55
+ A load is a `loadAsync` call with literal JSON arguments on `new CachedMeshLoader()`, or on a variable initialized with it, as in `new CachedMeshLoader().loadAsync({ ratio: 0.5 })` or `const meshes = new CachedMeshLoader()` and later `meshes.loadAsync({ ratio: 0.5 })`. Any other use fails the build, and in development is logged: passing a loader or such a variable around, exporting or reassigning it, calling another of its methods such as `setPath`, subclassing the loader, or loading with a variable. To serve several fixed variants, write one literal load per variant.
56
+
57
+ The plugin finds loads by scanning the project's JavaScript and TypeScript files, as Tailwind scans for class names, and matches the loader's imported name through aliases and namespace imports. It skips `node_modules`, the build output and `publicDir`, so a library's loaders are warmed only where the app uses them. It does not read `.vue` or `.svelte` files, and it warms a load in a file the app never imports, such as a test. Since variables are matched by name in the module, give a loader variable a name no other variable in that module uses.
58
+
59
+ The plugin repeats each load in Node by importing the loader's module through Vite's SSR loading. That module and its top-level imports must load without browser globals, and whatever the loader does after loading, such as decoding, runs in Node too. A build loads the Vite config file again to run the loaders, so configure plugins they rely on there rather than inline in a programmatic build.
60
+
61
+ In development the plugin warms every load at startup and again after every file change, and the page's cache requests wait for it, so the browser normally hits. It deletes entries no load uses: at startup, those left from earlier sessions, and later, an entry replaced by an edit once it has gone unused for `pruneAfter`, a minute by default, so that undoing the edit within that time reuses it. A build warms every load, then repeats them all and fails if that second pass saves anything, which means a key that changes between runs, such as one built from a timestamp. It then deletes every entry the build did not use, so the deployment holds exactly what the app requests.
62
+
63
+ Development and builds log each load that computes, by loader and source location, and announce a load still running after half a second; loads that hit are silent.
64
+
65
+ ## Browsers and deployment
66
+
67
+ Browsers only read entries. A browser that misses, for a load the plugin could not see, computes the result itself each time, and `save()` warns once per version instead of writing. A deployed app serves entries without checking their inputs, so build again after changing them.
68
+
69
+ Entries keep their file names when their contents change, so browsers revalidate them before each use. Serve the cache directory with `ETag` or `Last-Modified`, as most static hosts do, and do not mark it immutable. When deploying below a URL prefix, pass that prefix with the cache path as `basePath`, such as `/app/_vitecache/`; the entry does not infer Vite's base.
61
70
 
62
- For custom locations, coordinate the filesystem destination with the URL used by browser callers. A filesystem path and a browser URL are different values. When deploying below a URL prefix, include that prefix in the browser cache path; the client does not infer Vite's base URL. Node jobs should select the same project root as the Vite app when launched from a different working directory.
71
+ The plugin adds the cache directory, `public/_vitecache/`, to `.gitignore`. A Node script outside Vite can use the same loaders. Its entries use `public/_vitecache/` below the working directory, or below `root` when passed.
@@ -1,15 +0,0 @@
1
- import type { Dependencies, Hashes, JsonValue, Paths, ResolvedDependencies, SavedDependencies } from './types.js';
2
- /** A file or package dependency has no backing file or installation, so its state cannot be checked. */
3
- export declare class MissingDependencyError extends Error {
4
- name: string;
5
- }
6
- export declare function record(value: unknown): Record<string, unknown>;
7
- export declare function paths(value: unknown): Paths;
8
- /** Validate and snapshot JSON before asynchronous dependency resolution starts. */
9
- export declare function json(value: unknown, ancestors?: Set<object>): JsonValue;
10
- export declare function dependencies(value: unknown): Dependencies;
11
- export declare function resolvedDependencies(value: unknown): ResolvedDependencies;
12
- /** Saved dependencies are rechecked later, so they cannot be values: values select the result and belong to the key. */
13
- export declare function savedDependencies(value: unknown): SavedDependencies;
14
- export declare function hashes(value: unknown): Hashes;
15
- export declare function equalHashes(first: Hashes, second: Hashes): boolean;
@@ -1,131 +0,0 @@
1
- /** A file or package dependency has no backing file or installation, so its state cannot be checked. */
2
- export class MissingDependencyError extends Error {
3
- name = 'MissingDependencyError';
4
- }
5
- export function record(value) {
6
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
7
- throw new TypeError('Expected a named dependency record.');
8
- }
9
- const prototype = Object.getPrototypeOf(value);
10
- if (prototype !== Object.prototype && prototype !== null)
11
- throw new TypeError('Expected a plain dependency record.');
12
- if (Object.getOwnPropertySymbols(value).length)
13
- throw new TypeError('Dependency names must be strings.');
14
- return Object.fromEntries(Object.keys(value).sort().map((name) => {
15
- const property = Object.getOwnPropertyDescriptor(value, name);
16
- if (!property || !('value' in property))
17
- throw new TypeError('Dependency getters are not supported.');
18
- const item = property.value;
19
- return [name, item];
20
- }));
21
- }
22
- function path(value) {
23
- if (typeof value !== 'string' || !value || value.includes('\0'))
24
- throw new TypeError('File dependencies require nonempty paths.');
25
- if (/^(?:[\\/]|[a-z]:|[a-z][a-z\d+.-]*:\/\/)/i.test(value)) {
26
- throw new TypeError('File dependency paths must be relative to the project root.');
27
- }
28
- const parts = [];
29
- for (const part of value.replace(/\\/g, '/').split('/')) {
30
- if (!part || part === '.')
31
- continue;
32
- if (part !== '..') {
33
- parts.push(part);
34
- continue;
35
- }
36
- if (!parts.length)
37
- throw new TypeError('File dependency paths must stay inside the project root.');
38
- parts.pop();
39
- }
40
- if (!parts.length)
41
- throw new TypeError('File dependencies must identify files, not the project root.');
42
- return parts.join('/');
43
- }
44
- export function paths(value) {
45
- return Object.freeze(Object.fromEntries(Object.entries(record(value)).map(([name, value]) => {
46
- if (!name)
47
- throw new TypeError('File dependencies require nonempty names.');
48
- return [name, path(value)];
49
- })));
50
- }
51
- /** Validate and snapshot JSON before asynchronous dependency resolution starts. */
52
- export function json(value, ancestors = new Set()) {
53
- if (value === null || typeof value === 'string' || typeof value === 'boolean')
54
- return value;
55
- if (typeof value === 'number' && Number.isFinite(value))
56
- return value;
57
- if (typeof value !== 'object')
58
- throw new TypeError('Dependency values must contain only JSON values.');
59
- if (ancestors.has(value))
60
- throw new TypeError('Dependency values must not contain cycles.');
61
- ancestors.add(value);
62
- try {
63
- if (!Array.isArray(value))
64
- return Object.freeze(Object.fromEntries(Object.entries(record(value)).map(([name, item]) => [name, json(item, ancestors)])));
65
- if (Object.getOwnPropertySymbols(value).length)
66
- throw new TypeError('Dependency values must not contain symbol properties.');
67
- const items = [];
68
- for (let index = 0; index < value.length; index++) {
69
- const property = Object.getOwnPropertyDescriptor(value, index);
70
- if (!property || !('value' in property))
71
- throw new TypeError('Dependency values must not contain sparse arrays or getters.');
72
- const item = property.value;
73
- items.push(json(item, ancestors));
74
- }
75
- return Object.freeze(items);
76
- }
77
- finally {
78
- ancestors.delete(value);
79
- }
80
- }
81
- export function dependencies(value) {
82
- const entries = Object.entries(record(value)).map(([name, value]) => {
83
- if (!name)
84
- throw new TypeError('Dependencies require nonempty names.');
85
- const source = record(value);
86
- if (Object.keys(source).length !== 1)
87
- throw new TypeError('Each dependency must have exactly one value, path, url, or package.');
88
- if (Object.hasOwn(source, 'value'))
89
- return [name, Object.freeze({ value: json(source.value) })];
90
- if (Object.hasOwn(source, 'path'))
91
- return [name, Object.freeze({ path: path(source.path) })];
92
- if (Object.hasOwn(source, 'url') && typeof source.url === 'string' && source.url && !source.url.includes('\0')) {
93
- return [name, Object.freeze({ url: source.url })];
94
- }
95
- if (Object.hasOwn(source, 'package') && typeof source.package === 'string' && /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i.test(source.package)) {
96
- return [name, Object.freeze({ package: source.package })];
97
- }
98
- throw new TypeError('Each dependency must have a JSON value, root-relative path, nonempty URL, or package name without a subpath.');
99
- });
100
- return Object.freeze(Object.fromEntries(entries));
101
- }
102
- export function resolvedDependencies(value) {
103
- const entries = Object.entries(dependencies(value)).map(([name, dependency]) => {
104
- if (dependency.url !== undefined)
105
- throw new TypeError('Expected resolved dependencies, not URLs.');
106
- return [name, dependency];
107
- });
108
- return Object.freeze(Object.fromEntries(entries));
109
- }
110
- /** Saved dependencies are rechecked later, so they cannot be values: values select the result and belong to the key. */
111
- export function savedDependencies(value) {
112
- const entries = Object.entries(dependencies(value)).map(([name, dependency]) => {
113
- if (dependency.value !== undefined)
114
- throw new TypeError('Saved dependencies cannot be values: values select the result; pass them to the constructor.');
115
- return [name, dependency];
116
- });
117
- return Object.freeze(Object.fromEntries(entries));
118
- }
119
- export function hashes(value) {
120
- return Object.freeze(Object.fromEntries(Object.entries(record(value)).map(([name, hash]) => {
121
- if (!name || typeof hash !== 'string' || !/^[a-f0-9]{64}$/.test(hash)) {
122
- throw new TypeError('Invalid file dependency hash.');
123
- }
124
- return [name, hash];
125
- })));
126
- }
127
- export function equalHashes(first, second) {
128
- const names = Object.keys(first);
129
- return names.length === Object.keys(second).length && names.every((name) => first[name] === second[name]);
130
- }
131
- //# sourceMappingURL=dependencies.js.map