From 6a4e985a960dc0c1fcf7776082292b717d25a6d8 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 22 Mar 2026 23:36:20 -0700 Subject: [PATCH] WIP: feat(lib): Incremental Module Loader API been playing with `node:vm`'s experimental modules. it is neat because it gives us a hook into the module system that no other runtime has: - able to reflect what files are pointed to by each file - can adjust how files are processed before loading - can unload/reload modules, with old ones being garbage collected this commit rebuilds `hot.ts` and `incremental.ts` as a new system that only depends on these node builtins, which (TODO) provides: - simple module loading `new Incremental().import("src/main.ts");` - plugin system to load extensions like `.tsx`, `.marko`, `.svelte` - tracing the files used in resolution and initialization - allowing arbitrary dependencies to be added - allowing arbitrary jobs that cache when re-run - creating a filesystem watcher to detect changes - performing trivial module reloading (maybe full HMR) - serializing the graph state to disk, along with v8 cache data by implementing this, clover sitegen can: - stop patching node.js internals - drop the server-side esbuild code (frontend still needs it) - use real es modules - provide a base for more features (issue 4 and 5), which could possibly be solved as their own top-level projects. this is also a great time to solve issue 27 (incremntal tracking) --- framework/lib/path.ts | 24 +- framework/marko.ts | 138 +- lib/async.ts | 2 +- lib/error.ts | 2 + lib/examples/vm.ts | 17 + lib/incremental.esm.test.ts | 298 ++++ lib/incremental.resolution.test.ts | 435 ++++++ lib/incremental.serialization.test.ts | 49 + lib/incremental.sources.test.ts | 125 ++ lib/incremental.ts | 1890 +++++++++++++++++++++++ lib/log/stack.ts | 2 +- lib/mime.ts | 5 +- lib/package.json | 4 - lib/progress.ts | 496 +++--- lib/readme.md | 5 +- lib/ts.ts | 44 +- {framework => old_stuff}/hot.ts | 1 - {framework => old_stuff}/incremental.ts | 0 package-lock.json | 6 +- playground.ts | 19 + run.js | 225 ++- tsconfig.json | 3 +- 22 files changed, 3291 insertions(+), 499 deletions(-) create mode 100644 lib/examples/vm.ts create mode 100644 lib/incremental.esm.test.ts create mode 100644 lib/incremental.resolution.test.ts create mode 100644 lib/incremental.serialization.test.ts create mode 100644 lib/incremental.sources.test.ts create mode 100644 lib/incremental.ts rename {framework => old_stuff}/hot.ts (99%) rename {framework => old_stuff}/incremental.ts (100%) create mode 100644 playground.ts diff --git a/framework/lib/path.ts b/framework/lib/path.ts index 2033131f3405f39ff8fda91b916a12453019a133..523bde57814fdcef7aca6f5455fb384514ca8be8 100644 --- a/framework/lib/path.ts +++ b/framework/lib/path.ts @@ -11,10 +11,9 @@ export class Path { } return base; } - return new Path(path.resolve( - typeof base === "string" ? base : base.raw, - ...more, - )); + return new Path( + path.resolve(typeof base === "string" ? base : base.raw, ...more), + ); } static createDirSync(...parts: string[]): Path; @@ -150,12 +149,8 @@ export class Path { async readIfExists(encoding?: "buffer"): Promise; async readIfExists(encoding: "json"): Promise; async readIfExists(encoding: "utf-8"): Promise; - async readIfExists( - encoding?: "utf-8" | "buffer" | "json", - ): Promise; - async readIfExists( - encoding?: "utf-8" | "buffer" | "json", - ): Promise { + async readIfExists(encoding?: "utf-8" | "buffer" | "json"): Promise; + async readIfExists(encoding?: "utf-8" | "buffer" | "json"): Promise { try { return await this.read(encoding); } catch (err) { @@ -182,11 +177,10 @@ export class Path { return fs.readdirSync(this.raw).map((sub) => this.join(sub)); } - async readDir( - { recursive }: { recursive?: boolean } = {}, - ): Promise> { - return (await fs.readdir(this.raw, { recursive })) - .map((sub) => this.join(sub)); + async readDir({ recursive }: { recursive?: boolean } = {}): Promise< + Array + > { + return (await fs.readdir(this.raw, { recursive })).map((sub) => this.join(sub)); } async writeJsonFile(json: unknown) { diff --git a/framework/marko.ts b/framework/marko.ts index e1719d850e8a571139bbc3af324a96ec911fdceb..52cbcc2617d310c926ad578a642a3c016a06ea33 100644 --- a/framework/marko.ts +++ b/framework/marko.ts @@ -1,98 +1,64 @@ -export interface MarkoCacheEntry { - src: string; - scannedClientRefs: string[]; -} - -export const markoCache = new Map(); - -export function loadMarko(module: NodeJS.Module, filepath: string) { - return loadMarkoExtra(module, filepath, "html"); -} -export function loadMarkodown(module: NodeJS.Module, filepath: string) { - return loadMarkoExtra(module, filepath, "markdown"); -} - -function loadMarkoExtra( - module: NodeJS.Module, - filepath: string, - mode: "html" | "markdown", -) { - let cache = markoCache.get(filepath); - if (!cache) { - let src = fs.readFileSync(filepath, "utf8"); - if (mode === "markdown") { - let transform; - try { - transform = markodown.transform({ - source: src, - selfImport: `./${path.basename(filepath)}`, - }); - } catch (err) { - throw new Error(`Failed ${filepath}: Markodown Compiler Panic`, { - cause: err, - }); - } - if (!transform.success) { - // TODO: better error handling - throw new Error( - "Failed " + filepath + ": " + JSON.stringify(transform.errors), - ); - } - src = transform.text; - - fs.writeMkdirSync( - ".clover/marko/" + path.relative(".", filepath).replace(/^src\//, "") - + ".marko", - src, +export default function loadMarko({ source, file }: incremental.LoaderInput) { + // transform markodown + if (file.endsWith('.mdo')) { + let transform; + try { + transform = markodown.transform({ + source, + selfImport: `./${path.basename(file)}`, + }); + } catch (err) { + throw new Error(`Failed ${file}: Markodown Compiler Panic`, { + cause: err, + }); + } + if (!transform.success) { + // TODO: better error handling + throw new Error( + "Failed " + file + ": " + JSON.stringify(transform.errors), ); } + source = transform.text; + } + + // a non-standard thing here is Clover Sitegen implements + // its own client side scripting stuff, so it overrides + // bare client import statements to it's own usage. + const scannedClientRefs = new Set(); + if (source.match(/^\s*client\s+import\s+["']/m)) { + source = source.replace( + /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, + (_, src) => { + const ref = JSON.parse(`"${src.slice(1, -1)}"`); - // A non-standard thing here is Clover Sitegen implements - // its own client side scripting stuff, so it overrides - // bare client import statements to it's own usage. - const scannedClientRefs = new Set(); - if (src.match(/^\s*client\s+import\s+["']/m)) { - src = src.replace( - /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, - (_, src) => { - const ref = JSON.parse(`"${src.slice(1, -1)}"`); - const resolved = hot.resolveClientRef(filepath, ref); - scannedClientRefs.add(resolved); - return ``; - }, - ) + "\nimport { CloverScriptInclude } from \"#sitegen\";\n"; - } + // TODO: WTF + const resolved = hot.resolveClientRef(file, ref); + scannedClientRefs.add(resolved); - let compiled = marko.compileSync(src, filepath, { - sourceMaps: true, - }); - // workaround an esbuild bug? - (compiled.map as unknown as { sourceRoot: string }).sourceRoot = "/"; - compiled.map.sources[0] = filepath; - src = compiled.code - + "\n//# sourceMappingURL=data:application/json;charset=utf-8;base64," - + Buffer.from(JSON.stringify(compiled.map), "utf-8").toString("base64"); - cache = { src, scannedClientRefs: Array.from(scannedClientRefs) }; - markoCache.set(filepath, cache); - fs.writeMkdirSync( - ".clover/marko/" + path.relative(".", filepath).replace(/^src\//, "") - + ".js", - src, - ); + return ``; + }, + ) + "\nimport { CloverScriptInclude } from \"#sitegen\";\n"; } - const { src, scannedClientRefs } = cache; - return hot.loadEsbuildCode(module, filepath, src, { - scannedClientRefs, + let compiled = marko.compileSync(source, file, { + sourceMaps: true, }); + // workaround an esbuild bug? + (compiled.map as unknown as { sourceRoot: string }).sourceRoot = "/"; + compiled.map.sources[0] = file; + + return { + format: "js", + content: compiled.code + + "\n//# sourceMappingURL=data:application/json;charset=utf-8;base64," + + Buffer.from(JSON.stringify(compiled.map), "utf-8").toString("base64"), + data: { + scannedClientRefs: Array.from(scannedClientRefs), + }, + }; } -import * as fs from "#sitegen/fs"; import * as markodown from "@clo/markodown"; import * as marko from "@marko/compiler"; import * as path from "node:path"; -import * as hot from "./hot.ts"; +import * as incremental from "@clo/lib/incremental"; diff --git a/lib/async.ts b/lib/async.ts index 3de0ecc3d3aa3e20c3fb875bd528c6fcfb9ae47c..7acac4e3b67c4734ad216739def315b8bbad884f 100644 --- a/lib/async.ts +++ b/lib/async.ts @@ -152,7 +152,7 @@ export class DedupeConcurrent { if (promise) return promise; promise = this.fn(...args); this.pending.set(k, promise); - void promise.finally(() => void this.pending.delete(k)); + void promise.catch(() => {}).finally(() => void this.pending.delete(k)); return promise; }; } diff --git a/lib/error.ts b/lib/error.ts index a4f980f8c7e0d01260dd46b07894d9d01f24a215..5a4376afe7002f94a1e901f2b5329697a09b72af 100644 --- a/lib/error.ts +++ b/lib/error.ts @@ -2,6 +2,8 @@ * helper functions for dealing with the `unknown` type, mainly in `catch` * blocks or promise rejection callbacks. * + * TODO: rename from `error` to `exception`, less shadowing + * * @module */ diff --git a/lib/examples/vm.ts b/lib/examples/vm.ts new file mode 100644 index 0000000000000000000000000000000000000000..a170a7cf65223e8027f97f300899bdcd523ee6f2 --- /dev/null +++ b/lib/examples/vm.ts @@ -0,0 +1,17 @@ +import * as incremental from '../incremental.ts'; + +export async function main() { + const g = new incremental.Graph({ + loaders: { + '.marko': ({ source, file }) => { + const { code } = marko.compileSync(source, file); + return { content: code, format: 'js' } + } + } + }); + const log = await g.import('framework/generate.ts'); + console.log(log); + console.log(g); +} + +import * as marko from "@marko/compiler"; diff --git a/lib/incremental.esm.test.ts b/lib/incremental.esm.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c05d369824e58ba9084cebf7082acb12739a9cb --- /dev/null +++ b/lib/incremental.esm.test.ts @@ -0,0 +1,298 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { expect, test } from "vitest"; +import * as inc from "./incremental.ts"; + +test("Graph.import loads a typescript module graph", async () => { + const fs = new VirtualFs({ + "/app/package.json": JSON.stringify({ type: "module" }), + "/app/main.ts": [ + "import { value } from './dep.ts';", + "export const total: number = value + 1;", + "export default total;", + ].join("\n"), + "/app/dep.ts": [ + "export interface Thing { value: number }", + "export const value: number = 41;", + ].join("\n"), + }); + + const graph = new inc.Graph({ fs }); + const mod = await graph.import<{ + total: number; + default: number; + }>("/app/main.ts"); + + expect(mod.total).toBe(42); + expect(mod.default).toBe(42); +}); + +test("Graph.import uses package imports during linking", async () => { + const fs = new VirtualFs({ + "/app/package.json": JSON.stringify({ + type: "module", + imports: { + "#dep": "./dep.ts", + }, + }), + "/app/main.ts": [ + "import { value } from '#dep';", + "export const loaded = value;", + ].join("\n"), + "/app/dep.ts": "export const value: number = 7;", + }); + + const graph = new inc.Graph({ fs }); + const mod = await graph.import("/app/main.ts"); + + expect(mod.loaded).toBe(7); +}); + +test("Graph.import loads a .cjs entrypoint as a namespace", async () => { + await withTempDir( + { + "main.cjs": "module.exports = { value: 42, extra: true };", + }, + async (dir) => { + const graph = new inc.Graph(); + const mod = await graph.import<{ + default: { value: number }; + value: number; + }>(path.join(dir, "main.cjs")); + + expect(mod.default).toEqual({ value: 42, extra: true }); + expect(mod.value).toBe(42); + }, + ); +}); + +test("Graph.import rejects .cts entrypoints", async () => { + await withTempDir( + { + "main.cts": [ + "const value: number = 7;", + "module.exports = { value };", + ].join("\n"), + }, + async (dir) => { + const graph = new inc.Graph(); + await expect(graph.import(path.join(dir, "main.cts"))).rejects.toThrow( + "CommonJS TypeScript (.cts) is not supported", + ); + }, + ); +}); + +test("Graph.import lets ESM import CommonJS default and named exports", async () => { + await withTempDir( + { + "package.json": JSON.stringify({ type: "module" }), + "main.ts": [ + "import dep, { named } from './dep.cjs';", + "export const total = dep.named + named;", + "export const direct = named;", + ].join("\n"), + "dep.cjs": "module.exports = { named: 5, other: 1 };", + }, + async (dir) => { + const graph = new inc.Graph(); + const mod = await graph.import( + path.join(dir, "main.ts"), + ); + + expect(mod.total).toBe(10); + expect(mod.direct).toBe(5); + }, + ); +}); + +test("Graph.import executes native CommonJS require for json, builtins, and sloppy package resolution", async () => { + await withTempDir( + { + "package.json": JSON.stringify({ type: "commonjs" }), + "main.js": [ + "const dep = require('./dep');", + "const dir = require('./dir');", + "const data = require('./data.json');", + "const pkg = require('pkg-sloppy');", + "const { sep } = require('node:path');", + "module.exports = { total: dep.value + dir.value + data.value + pkg.value, sep };", + ].join("\n"), + "dep.js": "module.exports = { value: 1 };", + "dir/index.js": "module.exports = { value: 2 };", + "data.json": JSON.stringify({ value: 3 }), + "node_modules/pkg-sloppy/package.json": JSON.stringify({ + name: "pkg-sloppy", + main: "./dist/index", + }), + "node_modules/pkg-sloppy/dist/index.js": "module.exports = { value: 4 };", + }, + async (dir) => { + const graph = new inc.Graph(); + const mod = await graph.import<{ + default: { total: number; sep: string }; + }>(path.join(dir, "main.js")); + + expect(mod.default.total).toBe(10); + expect(mod.default.sep).toBeTypeOf("string"); + }, + ); +}); + +test("Graph.import treats .ts as CommonJS under package type commonjs", async () => { + await withTempDir( + { + "package.json": JSON.stringify({ type: "commonjs" }), + "main.ts": "module.exports = { value: 9 };", + }, + async (dir) => { + const graph = new inc.Graph(); + const mod = await graph.import<{ + default: { value: number }; + value: number; + }>(path.join(dir, "main.ts")); + + expect(mod.default).toEqual({ value: 9 }); + expect(mod.value).toBe(9); + }, + ); +}); + +test("Graph.import reuses a shared package.json cache populated by resolve", async () => { + await withTempDir( + { + "package.json": JSON.stringify({ + type: "commonjs", + imports: { "#dep": "./dep.js" }, + }), + "dep.js": "module.exports = 1;", + "main.js": "module.exports = { value: require('./dep.js') };", + }, + async (dir) => { + const packageJsonPath = path.join(dir, "package.json"); + const packageJsonCache = new Map(); + + await expect( + inc.resolve({ + specifier: "#dep", + resolveDir: dir, + packageJsonCache, + }), + ).resolves.toMatchObject({ type: "success" }); + expect(packageJsonCache.get(packageJsonPath)?.type).toBe("commonjs"); + + const graph = new inc.Graph({ packageJsonCache }); + const mod = await graph.import<{ + default: { value: number }; + value: number; + }>(path.join(dir, "main.js")); + + expect(mod.default).toEqual({ value: 1 }); + expect(packageJsonCache.get(packageJsonPath)?.type).toBe("commonjs"); + }, + ); +}); + +test("Graph.import supports CommonJS cycles through the native module cache", async () => { + await withTempDir( + { + "a.cjs": [ + "module.exports = { name: 'a' };", + "const b = require('./b.cjs');", + "module.exports.fromB = b.name;", + ].join("\n"), + "b.cjs": [ + "module.exports = { name: 'b' };", + "const a = require('./a.cjs');", + "module.exports.fromA = a.name;", + ].join("\n"), + }, + async (dir) => { + const graph = new inc.Graph(); + const a = await graph.import<{ + default: { name: string; fromB: string }; + }>(path.join(dir, "a.cjs")); + const b = await graph.import<{ + default: { name: string; fromA: string }; + }>(path.join(dir, "b.cjs")); + + expect(a.default).toEqual({ name: "a", fromB: "b" }); + expect(b.default).toEqual({ name: "b", fromA: "a" }); + }, + ); +}); + +async function withTempDir( + files: Record, + run: (dir: string) => Promise, +): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "incremental-test-")); + try { + for (const [relative, content] of Object.entries(files)) { + const abs = path.join(dir, relative); + await mkdir(path.dirname(abs), { recursive: true }); + await writeFile(abs, content); + } + return await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +class VirtualFs implements inc.Fs { + #files: Map; + #directories: Set; + + constructor(files: Record) { + this.#files = new Map(Object.entries(files)); + this.#directories = new Set(["/"]); + + for (const file of this.#files.keys()) { + const parts = file.split("/").filter(Boolean); + let current = ""; + for (let i = 0; i < parts.length - 1; i++) { + current += `/${parts[i]}`; + this.#directories.add(current); + } + } + } + + async readTextFile(path: string): Promise { + const contents = this.#files.get(path); + if (contents !== undefined) return contents; + const error = new Error(`ENOENT: no such file, open '${path}'`); + // @ts-expect-error node-style error code + error.code = "ENOENT"; + throw error; + } + + async readDir(path: string): Promise { + if (this.#directories.has(path)) return []; + const error = new Error(`ENOENT: no such directory, scandir '${path}'`); + // @ts-expect-error node-style error code + error.code = "ENOENT"; + throw error; + } + + async realPath(path: string): Promise { + return path; + } + + async stat(path: string): Promise { + const contents = this.#files.get(path); + if (contents !== undefined) { + return { kind: "file", size: contents.length, time: 0 }; + } + if (this.#directories.has(path)) { + return { kind: "directory", size: 0, time: 0 }; + } + const error = new Error( + `ENOENT: no such file or directory, stat '${path}'`, + ); + // @ts-expect-error node-style error code + error.code = "ENOENT"; + throw error; + } +} diff --git a/lib/incremental.resolution.test.ts b/lib/incremental.resolution.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..377eec47024342c5e9fe88011dc16568bfb4016f --- /dev/null +++ b/lib/incremental.resolution.test.ts @@ -0,0 +1,435 @@ +import { expect, test } from "vitest"; +import * as inc from "./incremental.ts"; + +test("finds node.js builtin module", async () => { + const fs = new VirtualFs({}); + + await expect(inc.resolve({ specifier: "node:fs", resolveDir: "/", fs })).resolves.toEqual({ + type: "success", + namespace: "node", + subPath: "fs", + files: [], + }); + await expect(inc.resolve({ specifier: "fs", resolveDir: "/", fs })).resolves.toEqual({ + type: "success", + namespace: "node", + subPath: "fs", + files: [], + }); + await expect(inc.resolve({ specifier: "util/types", resolveDir: "/", fs })).resolves.toEqual({ + type: "success", + namespace: "node", + subPath: "util/types", + files: [], + }); + await expect(inc.resolve({ specifier: "node:test", resolveDir: "/", fs })).resolves.toEqual({ + type: "success", + namespace: "node", + subPath: "test", + files: [], + }); + await expect(inc.resolve({ specifier: "test", resolveDir: "/", fs })).resolves.toEqual({ + type: "error", + error: expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }), + files: [ + "/node_modules/test/package.json", + ], + }); +}); + +test("relative file path imports", async () => { + const fs = new VirtualFs({ + "/src/a.js": "", + "/src/b.ts": "", + "/src/shared/util.js": "", + "/src/feature/main.js": "", + "/src/node_modules/a/index.js": "", + }); + + await expect(inc.resolve({ specifier: "./a.js", resolveDir: "/src", fs })).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/src/a.js", + files: ["/src/a.js"], + }); + + await expect(inc.resolve({ specifier: "./c.ts", resolveDir: "/src", fs })).resolves.toEqual({ + type: "error", + error: expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }), + files: ["/src/c.ts"], + }); + + await expect( + inc.resolve({ specifier: "../shared/util.js", resolveDir: "/src/feature", fs }), + ).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/src/shared/util.js", + files: ["/src/shared/util.js"], + }); + + await expect(inc.resolve({ specifier: "./a", resolveDir: "/src", fs })).resolves.toEqual({ + type: "error", + error: expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }), + files: ["/src/a"], + }); + await expect(inc.resolve({ specifier: "./node_modules/a", resolveDir: "/src", fs })).resolves.toEqual({ + type: "error", + error: expect.objectContaining({ code: "ERR_UNSUPPORTED_DIR_IMPORT" }), + files: ["/src/node_modules/a"], + }); + await expect(inc.resolve({ specifier: "./node_modules/a/index", resolveDir: "/src", fs })).resolves.toEqual({ + type: "error", + error: expect.objectContaining({ code: "ERR_MODULE_NOT_FOUND" }), + files: ["/src/node_modules/a/index"], + }); + await expect(inc.resolve({ specifier: "./node_modules/a/index.js", resolveDir: "/src", fs })).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/src/node_modules/a/index.js", + files: ["/src/node_modules/a/index.js"], + }); +}); + +test("sloppy resolution", async () => { + const fs = new VirtualFs({ + "/src/a.js": "", + "/src/dir/index.js": "", + }); + + await expect(inc.resolve({ + specifier: "./a", + resolveDir: "/src", + fs, + sloppy: true, + })).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/src/a.js", + files: ["/src/a", "/src/a.js"], + }); + + await expect(inc.resolve({ + specifier: "./dir", + resolveDir: "/src", + fs, + sloppy: true, + })).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/src/dir/index.js", + files: [ + "/src/dir", + "/src/dir.js", + "/src/dir.json", + "/src/dir.node", + "/src/dir/package.json", + "/src/dir/index.js", + ], + }); + + await expect( + inc.resolve({ specifier: "./dir", resolveDir: "/src", fs, sloppy: false }), + ).resolves.toEqual({ + type: "error", + error: expect.objectContaining({ code: "ERR_UNSUPPORTED_DIR_IMPORT" }), + files: ["/src/dir"], + }); +}); + +test("package resolution handles exports and imports maps", async () => { + const fs = new VirtualFs({ + "/app/src/main.js": "", + "/app/package.json": JSON.stringify({ + imports: { + "#src/*": "./src/*.js", + }, + }), + "/app/src/feature.js": "export const feature = true;", + "/app/node_modules/pkg/package.json": JSON.stringify({ + name: "pkg", + exports: "./dist/index.js", + }), + "/app/node_modules/pkg/dist/index.js": "export const value = 1;", + "/app/node_modules/pkg/dist/feature.js": "export const feature = true;", + "/app/node_modules/pkg-subpath/package.json": JSON.stringify({ + name: "pkg-subpath", + exports: { + "./feature": "./dist/feature.js", + }, + }), + "/app/node_modules/pkg-subpath/dist/feature.js": "export const feature = true;", + }); + + await expect(inc.resolve({ specifier: "pkg", resolveDir: "/app/src", fs })).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/app/node_modules/pkg/dist/index.js", + files: [ + "/app/src/node_modules/pkg/package.json", + "/app/node_modules/pkg/package.json", + "/app/node_modules/pkg/dist/index.js", + ], + }); + + await expect(inc.resolve({ specifier: "pkg-subpath/feature", resolveDir: "/app/src", fs })).resolves + .toEqual({ + type: "success", + namespace: "file", + subPath: "/app/node_modules/pkg-subpath/dist/feature.js", + files: [ + "/app/src/node_modules/pkg-subpath/package.json", + "/app/node_modules/pkg-subpath/package.json", + "/app/node_modules/pkg-subpath/dist/feature.js", + ], + }); + + await expect(inc.resolve({ + specifier: "#src/feature", + resolveDir: "/app/src", + fs, + })).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/app/src/feature.js", + files: [ + "/app/src/package.json", + "/app/package.json", + "/app/src/feature.js", + ], + }); +}); + +test("package resolution reports export and import errors and supports sloppy main fallback", async () => { + const fs = new VirtualFs({ + "/app/package.json": JSON.stringify({ + imports: { + "#src/*": "./src/*.js", + }, + }), + "/app/node_modules/pkg/package.json": JSON.stringify({ + name: "pkg", + exports: { + "./feature": "./dist/feature.js", + }, + }), + "/app/node_modules/pkg-sloppy/package.json": JSON.stringify({ + name: "pkg-sloppy", + main: "./dist/index", + }), + "/app/node_modules/pkg-sloppy/dist/index.js": "export const value = 1;", + "/app/node_modules/pkg-legacy-main/package.json": JSON.stringify({ + name: "pkg-legacy-main", + main: "lib/main.js", + }), + "/app/node_modules/pkg-legacy-main/lib/main.js": "module.exports = {};", + "/app/node_modules/pkg-legacy-module/package.json": JSON.stringify({ + name: "pkg-legacy-module", + module: "lib/module.js", + }), + "/app/node_modules/pkg-legacy-module/lib/module.js": "module.exports = {};", + }); + + await expect(inc.resolve({ specifier: "pkg/missing", resolveDir: "/app/src", fs })).resolves + .toEqual({ + type: "error", + error: expect.objectContaining({ code: "ERR_PACKAGE_PATH_NOT_EXPORTED" }), + files: ["/app/src/node_modules/pkg/package.json", "/app/node_modules/pkg/package.json"], + }); + + await expect(inc.resolve({ specifier: "#missing", resolveDir: "/app/src", fs })).resolves + .toEqual({ + type: "error", + error: expect.objectContaining({ code: "ERR_PACKAGE_IMPORT_NOT_DEFINED" }), + files: ["/app/src/package.json", "/app/package.json"], + }); + + await expect(inc.resolve({ + specifier: "pkg-sloppy", + resolveDir: "/app/src", + fs, + sloppy: true, + })).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/app/node_modules/pkg-sloppy/dist/index.js", + files: [ + "/app/src/node_modules/pkg-sloppy/package.json", + "/app/node_modules/pkg-sloppy/package.json", + "/app/node_modules/pkg-sloppy/dist/index", + "/app/node_modules/pkg-sloppy/dist/index.js", + ], + }); + + await expect(inc.resolve({ + specifier: "pkg-legacy-main", + resolveDir: "/app/src", + fs, + sloppy: true, + })).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/app/node_modules/pkg-legacy-main/lib/main.js", + files: [ + "/app/src/node_modules/pkg-legacy-main/package.json", + "/app/node_modules/pkg-legacy-main/package.json", + "/app/node_modules/pkg-legacy-main/lib/main.js", + ], + }); + + await expect(inc.resolve({ + specifier: "pkg-legacy-module", + resolveDir: "/app/src", + fs, + sloppy: true, + })).resolves.toEqual({ + type: "success", + namespace: "file", + subPath: "/app/node_modules/pkg-legacy-module/lib/module.js", + files: [ + "/app/src/node_modules/pkg-legacy-module/package.json", + "/app/node_modules/pkg-legacy-module/package.json", + "/app/node_modules/pkg-legacy-module/lib/module.js", + ], + }); +}); + +test("resolve reuses package.json cache for repeated imports lookups", async () => { + const fs = new VirtualFs({ + "/app/package.json": JSON.stringify({ + imports: { + "#dep": "./src/dep.js", + }, + }), + "/app/src/dep.js": "export const dep = true;", + }); + const packageJsonCache = new Map(); + + await expect(inc.resolve({ + specifier: "#dep", + resolveDir: "/app", + fs, + packageJsonCache, + })).resolves.toMatchObject({ + type: "success", + subPath: "/app/src/dep.js", + }); + expect(fs.readCount("/app/package.json")).toBe(1); + + await expect(inc.resolve({ + specifier: "#dep", + resolveDir: "/app", + fs, + packageJsonCache, + })).resolves.toMatchObject({ + type: "success", + subPath: "/app/src/dep.js", + files: ["/app/package.json", "/app/src/dep.js"], + }); + expect(fs.readCount("/app/package.json")).toBe(1); +}); + +test("resolve reuses package.json cache for repeated exports lookups", async () => { + const fs = new VirtualFs({ + "/app/src/main.js": "", + "/app/node_modules/pkg/package.json": JSON.stringify({ + name: "pkg", + exports: { + ".": "./dist/index.js", + }, + }), + "/app/node_modules/pkg/dist/index.js": "export const value = true;", + }); + const packageJsonCache = new Map(); + + await expect(inc.resolve({ + specifier: "pkg", + resolveDir: "/app/src", + fs, + packageJsonCache, + })).resolves.toMatchObject({ + type: "success", + subPath: "/app/node_modules/pkg/dist/index.js", + }); + expect(fs.readCount("/app/node_modules/pkg/package.json")).toBe(1); + + await expect(inc.resolve({ + specifier: "pkg", + resolveDir: "/app/src", + fs, + packageJsonCache, + })).resolves.toMatchObject({ + type: "success", + subPath: "/app/node_modules/pkg/dist/index.js", + files: [ + "/app/src/node_modules/pkg/package.json", + "/app/node_modules/pkg/package.json", + "/app/node_modules/pkg/dist/index.js", + ], + }); + expect(fs.readCount("/app/node_modules/pkg/package.json")).toBe(1); +}); + +class VirtualFs implements inc.Fs { + #files: Map; + #directories: Set; + #readCounts: Map; + + constructor(files: Record) { + this.#files = new Map(Object.entries(files)); + this.#directories = new Set(["/"]); + this.#readCounts = new Map(); + + for (const file of this.#files.keys()) { + const parts = file.split("/").filter(Boolean); + let current = ""; + for (let i = 0; i < parts.length - 1; i++) { + current += `/${parts[i]}`; + this.#directories.add(current); + } + } + } + + async readTextFile(path: string): Promise { + this.#readCounts.set(path, (this.#readCounts.get(path) ?? 0) + 1); + const contents = this.#files.get(path); + if (contents !== undefined) return contents; + const error = new Error(`ENOENT: no such file, open '${path}'`); + // @ts-expect-error node-style error code + error.code = "ENOENT"; + throw error; + } + + async readDir(path: string): Promise { + if (!this.#directories.has(path)) { + const error = new Error(`ENOENT: no such directory, scandir '${path}'`); + // @ts-expect-error node-style error code + error.code = "ENOENT"; + throw error; + } + + return []; + } + + async stat(path: string): Promise { + const contents = this.#files.get(path); + if (contents !== undefined) { + return { kind: "file", size: contents.length, time: 0 }; + } + if (this.#directories.has(path)) { + return { kind: "directory", size: 0, time: 0 }; + } + const error = new Error(`ENOENT: no such file or directory, stat '${path}'`); + // @ts-expect-error node-style error code + error.code = "ENOENT"; + throw error; + } + + async realPath(path: string): Promise { + return path; + } + + readCount(path: string): number { + return this.#readCounts.get(path) ?? 0; + } +} diff --git a/lib/incremental.serialization.test.ts b/lib/incremental.serialization.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a62edafd0c914ac0428a213559825291a57ae8e7 --- /dev/null +++ b/lib/incremental.serialization.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "vitest"; +import * as inc from "./incremental.ts"; + +test("readBlob returns the cached entry when available", () => { + const db = new inc.Database(null); + const value = { + message: "hello", + bytes: new Uint8Array([1, 2, 3]), + when: new Date("2024-01-02T03:04:05.000Z"), + } satisfies inc.Serializable; + + const ref = db.writeBlob(value); + const roundTrip = db.readBlob(ref.blob); + + expect(roundTrip).toBe(value); +}); + +test("readBlob falls back to the database and revives serialized values", () => { + const db = new inc.Database(null); + const value = { + text: "hello", + number: Infinity, + missing: undefined, + when: new Date("2024-01-02T03:04:05.000Z"), + set: new Set([1, "two"]), + map: new Map([ + ["three", 3], + [4, "four"], + ]), + bytes: new Uint8Array([4, 5, 6]), + nested: { + $clover: "escaped", + blob: new Uint8Array([7, 8, 9]), + }, + } satisfies inc.Serializable; + + const ref = db.writeBlob(value, false); + db.clearInMemoryCache(); + + const roundTrip = db.readBlob(ref.blob) as typeof value; + + expect(roundTrip).toEqual(value); + expect(roundTrip).not.toBe(value); + expect(roundTrip.when).toBeInstanceOf(Date); + expect(roundTrip.set).toBeInstanceOf(Set); + expect(roundTrip.map).toBeInstanceOf(Map); + expect(roundTrip.bytes).toBeInstanceOf(Uint8Array); + expect(roundTrip.nested.blob).toBeInstanceOf(Uint8Array); +}); diff --git a/lib/incremental.sources.test.ts b/lib/incremental.sources.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..38cba4286dd42f6bed56d16690b92da88a1e50b8 --- /dev/null +++ b/lib/incremental.sources.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "vitest"; +import * as inc from "./incremental.ts"; + +test("Database stores cache entries with complete URL sources", () => { + const db = new inc.Database(null); + const file = { + url: "file:///site/src/page.ts", + kind: "file", + time: 100, + size: 12, + hash: "sha1-page-v1", + } satisfies inc.Input; + const query = { + url: "sql://content/posts/by-slug?slug=hello", + kind: "query", + time: 7, + size: 1, + hash: "revision-7", + } satisfies inc.Input; + + db.writeCache("page:hello", { html: "

Hello

" }, [file, query]); + const cache = db.readCache("page:hello")!; + + expect(db.readBlob(cache.blob)).toEqual({ html: "

Hello

" }); + expect(collect(cache.inputs)).toEqual([ + { id: expect.anything(), ...file }, + { id: expect.anything(), ...query }, + ]); +}); + +test("Database invalidates caches that depend on changed sources", () => { + const db = new inc.Database(null); + const page = { + url: "file:///site/src/page.ts", + kind: "file", + time: 100, + size: 12, + hash: "sha1-page-v1", + } satisfies inc.Input; + const layout = { + url: "file:///site/src/layout.ts", + kind: "file", + time: 200, + size: 34, + hash: "sha1-layout-v1", + } satisfies inc.Input; + + db.writeCache("page", "page result", [page]); + db.writeCache("layout", "layout result", [layout]); + + const storedPage = collect(db.sources()).find( + (source) => source.url === page.url, + )!; + db.invalidate(storedPage.id); + db.writeSource({ ...page, time: 101, size: 13, hash: "sha1-page-v2" }); + + expect(db.readCache("page")).toBeNull(); + expect(db.readBlob(db.readCache("layout")!.blob)).toBe("layout result"); +}); + +test("Database treats source kind changes as invalidation", () => { + const db = new inc.Database(null); + const missing = { + url: "file:///site/src/optional.ts", + kind: "unknown", + time: 0, + size: 0, + hash: "", + } satisfies inc.Input; + + db.writeCache("resolution:optional", "missing", [missing]); + + const [source] = collect(db.sources()); + db.invalidate(source!.id); + db.writeSource({ + ...missing, + kind: "file", + time: 300, + size: 44, + hash: "sha1-optional", + }); + + expect(db.readCache("resolution:optional")).toBeNull(); +}); + +test("Database lets callers skip invalidation when source state is unchanged", () => { + const db = new inc.Database(null); + const source = { + url: "https://example.com/feed.xml", + kind: "response", + time: 500, + size: 2048, + hash: "etag-feed-v1", + } satisfies inc.Input; + + db.writeCache("feed", new Uint8Array([1, 2, 3]), [source]); + + const [stored] = collect(db.sources()); + expect(stored).toMatchObject(source); + expect(db.readBlob(db.readCache("feed")!.blob)).toEqual( + new Uint8Array([1, 2, 3]), + ); +}); + +test("Database requires source identities to be complete URLs", () => { + const db = new inc.Database(null); + + expect(() => + db.writeSource({ + url: "file:./relative.ts", + kind: "file", + time: 1, + size: 1, + hash: "sha1-relative", + }), + ).toThrow("source url must be canonical"); +}); + +function collect(iterator: Iterator): T[] { + const out: T[] = []; + for (let next = iterator.next(); !next.done; next = iterator.next()) { + out.push(next.value); + } + return out; +} diff --git a/lib/incremental.ts b/lib/incremental.ts new file mode 100644 index 0000000000000000000000000000000000000000..37d41f6653b949c74bbf2ac73d4ac9b5faa5ef00 --- /dev/null +++ b/lib/incremental.ts @@ -0,0 +1,1890 @@ +/** + * Clover's `incremental` module provides a way to load and reload modules in + * Node.js, as well as a foundation to build incremental developer tools. + * Incremental build systems stem from the {@linkcode Ref#step|step} function, + * which runs a closure given a IO object that allows tracing + * {@linkcode Input|inputs} ({@link File|file reads}, network fetches, database + * calls, and more). {@linkcode Step}s cache their output data and re-run when + * changed. By archetecting the build system steps, you can balance 'size of + * the cache' vs 'how fine grained is tracing'. + * + * The entire system is built around the idea of being able to flush incremental + * state to the filesystem, and revive cached work on a second run, so many + * places require {@link Serializable|serializable} data. upon revival, the + * filesystem (and other input sources) are re-queried for changes. Because of + * this design, reading inputs is not allowed outside of steps. + * + * A central part of building incremental systems is being able to trace source + * code itself, so Clover Incremental includes a full set of tools for + * + * `--experimental-vm-modules` support is required as it is the only way to + * interact with an ES Module Loader with the ability to garbage collect modules. + * + * TODO, explain these visions more in details: + * - simple module loading `new incremental.Graph().import("src/main.ts");` + * - plugin system to load extensions like `.tsx`, `.marko`, `.svelte` + * - tracing the files used in resolution and initialization + * - allowing arbitrary dependencies to be added + * - allowing arbitrary jobs that cache when re-run + * - easy base to implement web frameworks, testing tools, linters, etc + * - creating a filesystem watcher to detect changes + * - performing trivial module reloading (perhaps... full HMR) + * - serializing the graph state to disk, along with jobs and v8 caches + * + * `@clo/lib/incremental` is implemented as a single file depending only on the + * node.js builtins and other `@clo/lib` libraries. i intend for this codebase + * to be a good introduction to how an incremental system can function. + * + * @module + */ + +// TODO (this list is NOT in order) +// - [X] ES and CJS module loading +// - [ ] perfectly imports into the database +// - [ ] save and restore v8 cache +// - [ ] represent all loaders as virtual "steps" +// - [ ] partial hot graph replacement +// - [ ] steps +// - [ ] re-validating file inputs +// - [ ] re-validating network inputs +// - [ ] custom source types +// - [ ] depend on the results of other steps `readStep` +// - [ ] shaking old steps away +// - [ ] build output +// - [ ] write file (without storing the file's contents entirely in-database) +// - [ ] copy file +// - [ ] module tracing API (example: "if i imported X, list all the .css files") +// - [X] serialization API +// - [ ] filesystem watcher +// - [ ] ability to watch for other source types +// - [ ] trace every file ever + other insane details like runtime info + +/** + * serialization special cases top-level `Uint8Array`s and `string`s to use more + * effective data storage strategies. other variants use JSON + a reviver. + */ +export type Serializable = + | string + | number + | boolean + | null + | undefined + | Uint8Array + | Date + | Set + | Map + | Serializable[] + | { [k: string]: Serializable }; + +export class Ref implements progress.Ref { + /** owning graph */ + #graph: Graph; + /** parent progress ref */ + #ref: progress.Ref; + /** promises for the build */ + #build: PendingBuild; + + constructor(options: { + graph: Graph; + progress: progress.Ref; + /** build status */ + build: PendingBuild; + }) { + this.#graph = options.graph; + this.#build = options.build; + this.#ref = options.progress; + } + + /** + * creates a new trackable unit of work as a child of this one. + * when given an estimate, a progress bar is rendered. + */ + start = (text: string, opts?: progress.StartOptions): ProgressNode => { + const node = this.#ref.start(text, opts); + return ts.mixin( + node, + new Ref({ + graph: this.#graph, + progress: node, + build: this.#build, + }), + ); + }; + + /** + * run a deterministic "step function" and cache the result. the cache entry + * is busted if the given key or any referenced {@linkcode Input}s are + * changed. inside the step, the {@linkcode Run} provides access to the + * filesystem, network, and a base for other input sources to use. + * + * on a cache hit, all {@linkcode Input}s are re-queried for changes before + * the step re-runs. in the case some inputs changed, the ones that stayed the + * same can be re-used from this querying pass. + * + * implicitly, the caller file is added as an `Input` so the implementation of + * the builder is tracked incrementally. the caller of `new incremental.Graph` + * is also given this behavior. + * + * NOTE: the provided callback MAY capture arbitrary values that are not a part + * of `key`. this is by design to bypass the cache key when needed. the step's + * logic MUST be deterministic, and future debugging tools reserve the right to + * arbitrarily re-call the step function multiple times per build, or even + * after a build has completed. + */ + step( + key: Key, + fn: StepFn, + ): Step { + ASSERT(!this.#build.closed, "cannot start a step after the build has closed"); + const callerFile = UNWRAP(stack.capture()[0], "runtime bug").file; + const keyHash = hashSerializable(key, callerFile); + ASSERT( + !this.#build.steps.has(keyHash), + () => `duplicate step key. caller may not be passing enough information to it's "key"`, + ); + this.#build.steps.add(keyHash); + + // TODO: validate cache inputs before accepting an existing entry. + const existing = this.#graph.db.readCache(keyHash); + if (existing) { + const value = this.#graph.db.readBlob(existing.blob) as Result; + return { + id: existing.id, + value, + then: (onfufilled) => Promise.resolve(value).then(onfufilled), + hit: true, + }; + } + + const run = new Run({ + key, + build: this.#build, + progress: this.#ref, + }); + const defer = Promise.withResolvers(); + defer.promise.catch(() => {}); // handled by `step.then` or `incrementalBuild` + + let rejectionObserved = false; + const step: Step = { + id: null, + value: null, + then: (onfufilled, onrejected) => { + if (typeof onrejected === "function") rejectionObserved = true; + return defer.promise.then(onfufilled, onrejected); + }, + hit: false, + }; + + void (async () => { + this.#build.activeSteps.add(defer.promise); + try { + const result = await fn(run); + const inputs = await run.finish(); + step.id = this.#graph.db.writeCache(keyHash, result, inputs); + step.value = result; + defer.resolve(result); + } catch (error) { + run.close(); + this.#build.failures.push(() => rejectionObserved ? { observed: true } : { observed: false, error }); + defer.reject(error); + } finally { + this.#build.activeSteps.delete(defer.promise); + } + })(); + + return step; + } + + // TODO: function to write a file + // TODO: function to copy a file +} + +export type StepFn = ( + t: Run, +) => Result | Promise; + +export type ProgressNode = progress.Node & Ref; + +export interface Step extends PromiseLike { + /** @internal */ + // unset until resolved. + id: RowId | null; + /** returns `null` if the value is not yet done being computed */ + value: T | null; + /** true if this was a cache hit */ + hit: boolean; +} + +/** state shared between different cache boundaries throughout a single `incrementalBuild` call */ +interface PendingBuild { + /** key is a URL. values may be waiting on an expensive freshness token. */ + inputs: Map>; + steps: Set; + activeSteps: Set>; + /** failures from steps that were started but not awaited by user code. */ + failures: Array<() => StepFailureStatus>; + // TODO: invert this + closed: boolean; + // TODO: output? want to store a list of written + copied files. tho some more details to this tbh. +} + +async function waitForBuildSteps(build: PendingBuild): Promise { + while (build.activeSteps.size > 0) { + await Promise.all(build.activeSteps); + } +} + +// TODO: this is cooked +type StepFailureStatus = + | { observed: true } + | { observed: false; error: unknown }; + +/** + * blob-like object. the wrapper allows `@clo/lib/incremental` to hash the file + * in combination to the caller's usage, ensuring there is at most one full read. + */ +export class File { + path: string; + #handle: unknown; + + /** file modification time */ + time: number; + /** size in bytes */ + size: number; + #fs: Fs; + #hash: string | null = null; + + constructor(fs: Fs, path: string, stat: Stat) { + ASSERT(stat.kind === "file"); + this.path = path; + this.time = stat.time; + this.size = stat.size; + this.#fs = fs; + } + + async stream(options?: { chunkSize: number }): Promise { + const chunkSize = options?.chunkSize ?? 65536; + // return a pullable readable stream, hashing every chunk as it is read + } + async arrayBuffer(): Promise { + // allocate the backing buffer, then incrementally read into it. hash the + // entire buffer at the end. + } + async text(): Promise { + const decoder = new TextDecoder("utf-8"); + const buffer = new Uint8Array(Math.min(this.size, 65536)); + // take turns filling the buffer, hashing and decoding it as you go + let str = ""; + // str += decoder.decode(buffer, { stream: true }) + } + + async hash(): Promise { + // retrieve the hash once its done + } + + async bytes(): Promise { + return new Uint8Array(await this.arrayBuffer()); + } +} + +/** + * state of a running {@linkcode StepFn}, contains a {@linkcode progress.Ref} + * and methods to track {@linkcode Input}s. + */ +// TODO: still not happy with the naming of this structure +export class Run implements progress.Ref { + #build: PendingBuild; + #inputs: Promise[] = []; + #closed = false; + + constructor(options: { + key: Key; + progress: progress.Ref; + build: PendingBuild; + }) { + this.key = options.key; + this.start = options.progress.start; + this.#build = options.build; + } + + start: progress.Ref["start"]; + /** as-is from the key argument in `.cachedWork()` */ + key: Key; + /** appends an {@linkcode Input} to the work's inputs list */ + track(input: Input | Promise) { + ASSERT(!this.#closed, "cannot track an input after the step has finished"); + ASSERT(!this.#build.closed, "cannot track an input after the build has closed"); + this.#inputs.push(Promise.resolve(input)); + } + + /** @internal stop tracking and return the inputs recorded by this step. */ + async finish(): Promise { + this.close(); + return await Promise.all(this.#inputs); + } + + /** @internal stop accepting new inputs. */ + close(): void { + this.#closed = true; + } + + /** appends all of the inputs from `step`, unwrapping it's promise */ + async readStep(step: Step): Promise { + void step; + throw new Error("Run.readStep is not implemented yet"); + } + /** opens a file for reading */ + async readFile(path: string): Promise { + void path; + throw new Error("Run.readFile is not implemented yet"); + } + // /** lists all of the children of a directory */ + // async readDir(path: string): Promise {} + // /** throws on HTTP or network I/O failure */ + // fetchFile(input: string | URL | Request, init?: RequestInit): Promise; + // /** throws only on network I/O failure */ + // fetchResponse( + // input: string | URL | Request, + // init?: RequestInit, + // options?: { cache?: "all" | "body" }, + // ): Promise; + // /** load a module, with a "trust me bro" type parameter */ + // import(absPath: string): Promise { } + // /** follow all the imports of a module without actually executing it. throws on syntax and resolution errors. */ + // trackModule(absPath: string): Promise; +} + +export interface Fs { + /** read a whole UTF-8 text file */ + readTextFile(path: string): Promise; + readDir(path: string): Promise; + realPath(abs: string): Promise; + stat(path: string): Promise; + + open(path: string): Promise; + statHandle(handle: FileHandle): Promise; + readHandle( + handle: FileHandle, + offset: number, + length: number, + destination: Uint8Array, + ): Promise; + closeHandle(handle: FileHandle): Promise; +} + +export type FileHandle = ts.Brand<"@clo/lib/incremental/FileHandle"> & number; + +export interface Stat { + kind: "file" | "directory" | "other"; + size: number; + time: number; +} + +function defaultFs(): Fs { + return { + readTextFile: (path: string) => fs.readFile(path, "utf-8"), + readDir: fs.readdir, + realPath: fs.realpath, + async stat(path) { + const st = await fs.stat(path); + return { + kind: st.isFile() ? "file" : st.isDirectory() ? "directory" : "other", + size: st.size, + time: st.mtimeMs, + }; + }, + async open(path) { + return await new Promise((resolve, reject) => { + fscb.open(path, "r", (error, fd) => { + if (error) reject(error); + else resolve(fd as FileHandle); + }); + }); + }, + async readHandle(handle, offset, length, destination) { + const result = await new Promise((resolve, reject) => { + fscb.read( + handle, + destination, + 0, + length, + offset, + (error, bytesRead) => { + if (error) reject(error); + else resolve(bytesRead); + }, + ); + }); + return result; + }, + async statHandle(handle) { + const result = await new Promise((resolve, reject) => { + fscb.fstat( + handle, + (error, st) => { + if (error) reject(error); + else {resolve({ + kind: st.isFile() ? "file" : st.isDirectory() ? "directory" : "other", + size: st.size, + time: st.mtimeMs, + });} + }, + ); + }); + return result; + }, + async closeHandle(handle) { + await new Promise((resolve, reject) => { + fscb.close(handle, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + }, + }; +} + +// TODO one day, this will just be a variant of the `Trace` object. for now just passing the source string +export interface LoaderInput { + source: string; + file: string; + getPackageMeta(): Promise; +} + +export type LoaderResult = + | { + content: string; + format: "js" | "ts" | "cjs"; + } + | { + content: Serializable; + format: "serializable"; + }; + +type LoaderFunction = (input: LoaderInput) => ts.Awaitable; + +export interface GraphOptions { + /** file path to a database, or `null` for an in-memory database. */ + db: Database | string | null; + + /** register dynamic sources. */ + sources: SourceOptions[]; + + /** override the filesystem implementation. disables CommonJS support. */ + fs?: Fs; + /** + * key is an extension including the `.`, mapping to a function to load it's + * source code. appended or overwrites the default JavaScript+Typescript + * (`.[mc]?[tj]s`), `.json`, `.node` loaders. + */ + loaders?: Record; + /** appended to the defaults. */ + conditions?: string[]; + /** caller-owned shared package.json metadata cache. */ + packageJsonCache?: Map; +} + +/** + * a reference to this object is needed to track sources + */ +interface SourceOptions { + protocol: string; + verify( + record: SourceRecord, + ): Promise<{ value: Serializable; kind: string } | "unchanged" | "changed">; + query(url: URL): Promise<{ value: Serializable } & Input>; +} + +let fileSource: SourceOptions = { + protocol: "file", + async verify(record) { + throw new Error("Function not implemented."); + }, + async query(url) { + throw new Error("Function not implemented."); + }, +}; + +/** in form `${namespace}:${subPath}` */ +type Key = `${string}:${string}`; + +function toKey(namespace: string, subPath: string): Key { + ASSERT(!namespace.includes(":")); + return `${namespace}:${subPath}` as Key; +} + +function parseKey(key: Key): { namespace: string; subpath: string } { + const [_, namespace, subpath] = UNWRAP( + key.match(/^([^:]+):(.*)$/), + `invalid key format: ${key}`, + ) as [string, string, string]; + return { namespace, subpath }; +} + +/** holds module loading state */ +export class Graph { + db: Database; + #fs: Fs; + #loaders: Record; + #conditions: string[]; + #packageJsonCache: Map; + #usesNativeFs: boolean; + registry: Map; + + constructor(options: GraphOptions) { + if (typeof vm.SourceTextModule !== "function") { + throw new Error( + "@clo/lib/incremental requires node to run with --experimental-vm-modules", + ); + } + this.db = options.db instanceof Database ? options.db : new Database(options.db); + this.#fs = options.fs ?? defaultFs(); + this.#loaders = { ...defaultLoaders, ...options.loaders }; + this.#conditions = [...defaultConditions, ...(options.conditions ?? [])]; + this.#packageJsonCache = options.packageJsonCache ?? new Map(); + this.#usesNativeFs = !options.fs; + this.registry = new Map(); + } + + /** + * runs the provided build pipeline. the pipeline is an orchestration of + * {@linkcode StepFn|steps}, async functions that read and process data. by + * keeping all actual reads in these step functions, this means an incremental + * re-run can quickly skip all of the actual logic. this can only be done + * because the caching primitives are perfect. at the end, the pipeline should + * write/copy its intended outputs with the output APIs in {@linkcode Ref}. + */ + async incrementalBuild(options: { + buildAndWriteOutput: (topLevel: Ref) => Promise; + progress: progress.Ref; + }): Promise { + const build: PendingBuild = { + inputs: new Map(), + steps: new Set(), + activeSteps: new Set(), + failures: [], + closed: false, + }; + let result: T; + let thrown: unknown; + let didThrow = false; + try { + result = await options.buildAndWriteOutput( + new Ref({ + graph: this, + progress: options.progress, + build, + }), + ); + } catch (error) { + thrown = error; + didThrow = true; + } finally { + await waitForBuildSteps(build); + build.closed = true; + } + + if (didThrow) throw thrown; + const failures = build.failures.flatMap((failure) => { + const result = failure(); + return result.observed ? [] : [result.error]; + }); + if (failures.length > 0) throw new AggregateError(failures); + + // TODO: post-build stuff. for example, assert that all input files are still ok? + return result!; + } + + /** load a module without tracking its owner */ + // TODO: tracing + async import(absPath: string): Promise { + const root = toKey("file", await this.#fs.realPath(absPath)); + const entry = await this.#loadModule.get(root); + const queue = [entry]; + const visited = new Set([root]); + + while (queue.length > 0) { + const current = queue.shift()!; + if (current.module instanceof vm.SyntheticModule) continue; + + const dependencies = await Promise.all( + current.resolutions.map( + async (key) => (await this.#loadModule.get(key)).module, + ), + ); + current.module.linkRequests(dependencies); + + for (let i = 0; i < current.resolutions.length; i++) { + const dependencyKey = current.resolutions[i]!; + if (visited.has(dependencyKey)) continue; + visited.add(dependencyKey); + queue.push(await this.#loadModule.get(dependencyKey)); + } + } + + if (entry.module instanceof vm.SourceTextModule) entry.module.instantiate(); + await entry.module.evaluate(); + return entry.module.namespace as T; + } + + #loadModule = new async.DedupeConcurrent( + async (key: Key): Promise => { + const cached = this.registry.get(key); + if (cached) return cached; + + const { namespace, subpath } = parseKey(key); + try { + let entry: GraphEntry; + if (namespace === "file") { + entry = await this.#createEntryFromFile(subpath); + } else if (namespace === "node") { + entry = await this.#createEntryFromNodeBuiltin(subpath); + } else { + throw new Error(`unsupported module namespace: ${namespace}`); + } + + this.registry.set(key, entry); + return entry; + } catch (err) { + exception.rethrowWithMetadata(err, { namespace, subpath }); + } + }, + ([key]) => key, + ); + + /** constructs a new graph entry */ + async #createEntryFromFile(absPath: string): Promise { + const ext = path.extname(absPath); + const loader = this.#loaders[ext]; + if (!loader) { + throw new Error( + `No loader registered for extension ${JSON.stringify(ext)}`, + ); + } + + const loaded = await loader({ + file: absPath, + source: await this.#fs.readTextFile(absPath), + getPackageMeta: async () => { + return await this.lookupPackageMeta(path.dirname(absPath)); + }, + }); + + if (loaded.format === "serializable") { + const value = loaded.content; + return { + module: new vm.SyntheticModule( + ["default"], + function() { + this.setExport("default", value); + }, + { identifier: absPath }, + ), + resolutions: [], + }; + } + + let content = loaded.content; + if (loaded.format === "ts") { + try { + content = module.stripTypeScriptTypes(content); + } catch { + content = module.stripTypeScriptTypes(content, { mode: "transform" }); + } + } + + if (loaded.format === "cjs") { + if (!this.#usesNativeFs) { + throw new Error( + `CommonJS support is not implemented for a virtual fs.`, + ); + } + return await this.#createEntryFromCommonJs(absPath); + } + + const mod = new vm.SourceTextModule(content, { + identifier: absPath, + initializeImportMeta(meta) { + meta.dirname = path.dirname(absPath); + meta.filename = absPath; + meta.url = url.pathToFileURL(absPath).href; + meta.main = false; + }, + }); + + const resolutions = await Promise.all( + mod.moduleRequests.map(({ specifier }) => this.#resolveDependency(absPath, specifier, false)), + ); + + return { module: mod, resolutions }; + } + + /** constructs a new graph entry */ + async #createEntryFromNodeBuiltin(name: string): Promise { + const builtin = node.process?.getBuiltinModule(name as never); + if (!builtin) { + throw new Error(`Could not load builtin module ${JSON.stringify(name)}`); + } + return { + module: new vm.SyntheticModule( + Object.keys(builtin).concat("default"), + function() { + for (const key of Object.keys(builtin)) { + this.setExport(key, builtin[key as keyof typeof builtin]); + } + this.setExport("default", builtin); + }, + { identifier: `node:${name}` }, + ), + resolutions: [], + }; + } + + async #resolveDependency( + fromFile: string, + specifier: string, + sloppy: boolean, + ): Promise { + const result = await resolve({ + specifier, + resolveDir: path.dirname(fromFile), + fs: this.#fs, + sloppy, + conditions: this.#conditions, + packageJsonCache: this.#packageJsonCache, + }); + if (result.type === "error") { + exception.rethrowWithMetadata(result.error, { + source: fromFile, + specifier, // TODO: error message should always be clear as day + }); + } + return toKey(result.namespace, result.subPath); + } + + async lookupPackageMeta(startDir: string): Promise { + for (const dir of walkUpDirectories(startDir)) { + const packageJsonPath = path.join(dir, "package.json"); + try { + const stat = await this.#fs.stat(packageJsonPath); + if (stat.kind !== "file") continue; + const meta = await readPackageJsonMeta( + this.#fs, + packageJsonPath, + this.#packageJsonCache, + ); + return meta; + } catch (error) { + if (exception.code(error) === "ENOENT") continue; + throw error; + } + } + return null; + } + + async #createEntryFromCommonJs(absPath: string): Promise { + const exportsValue = module.createRequire(absPath)(absPath); + const exportNames = ["default", ...getCommonJsNamedExports(exportsValue)]; + const synthetic = new vm.SyntheticModule( + exportNames, + function() { + this.setExport("default", exportsValue); + for (const name of exportNames) { + if (name === "default") continue; + this.setExport(name, (exportsValue as Record)[name]); + } + }, + { identifier: absPath }, + ); + return { + module: synthetic, + resolutions: [], + }; + } +} + +interface GraphEntry { + module: vm.SourceTextModule | vm.SyntheticModule; + resolutions: Key[]; +} + +export interface PackageMeta { + type: "module" | "commonjs" | null; + imports: unknown; + exports: unknown; +} + +const defaultLoaders: Record = { + ".js": async ({ source, getPackageMeta }) => ({ + content: source, + format: ( + { + commonjs: "cjs", + module: "js", + } as const + )[(await getPackageMeta())?.type ?? "commonjs"], + }), + ".ts": async ({ source }) => ({ content: source, format: "ts" }), + ".mjs": async ({ source }) => ({ content: source, format: "js" }), + ".cjs": async ({ source }) => ({ content: source, format: "cjs" }), + ".mts": async ({ source }) => ({ content: source, format: "ts" }), + + ".json": async ({ source }) => ({ + content: JSON.parse(source), + format: "serializable", + }), +}; + +// --- module resolution --- + +export interface ResolveOptions { + specifier: string; + resolveDir: string; + /** override the filesystem implementation */ + fs?: Fs; + /** + * enable legacy commonjs resolution features + * @default false + */ + sloppy?: boolean; + conditions?: string[]; + /** caller-owned shared package.json metadata cache */ + packageJsonCache?: Map; +} + +export type Resolution = + & { + /** the files that could have affected this resolution */ + files: string[]; + } + & ( + | { + type: "success"; + namespace: string; + subPath: string; + error?: undefined; + } + | { + type: "error"; + error: unknown; + namespace?: undefined; + subPath?: undefined; + } + ); + +const defaultConditions = ["node", "import", "default"]; +const sloppyExtensions = [".js", ".json", ".node"]; + +/** + * perform filesystem-based module resolution. by default, this adheres to + * everything node.js supports in it's ESM import resolution. the difference + * between this and something like `require.resolve` is it returns the full list + * of paths that could affect the outcome of the resolution. + */ +export async function resolve({ + specifier, + resolveDir, + fs = defaultFs(), + sloppy = false, + conditions = defaultConditions, + packageJsonCache = new Map(), +}: ResolveOptions): Promise { + const r = new Resolver(fs, conditions, packageJsonCache); + try { + const { namespace, subPath } = await r.resolve( + specifier, + resolveDir, + sloppy, + ); + return { type: "success", namespace, subPath, files: Array.from(r.files) }; + } catch (error) { + return { type: "error", error, files: Array.from(r.files) }; + } +} + +/** sanity class to track file read states during module resolution */ +class Resolver { + fs: Fs; + files = new Set(); + conditions: string[]; + packageJsonCache: Map; + + constructor( + fs: Fs, + conditions: string[], + packageJsonCache: Map, + ) { + this.fs = fs; + this.conditions = conditions; + this.packageJsonCache = packageJsonCache; + } + + async resolve( + specifier: string, + resolveDir: string, + sloppy: boolean, + ): Promise<{ namespace: string; subPath: string }> { + const isNodeBuiltin = specifier.startsWith("node:"); + if (isNodeBuiltin || module.builtinModules.includes(specifier)) { + return { + namespace: "node", + subPath: isNodeBuiltin ? specifier.slice(5) : specifier, + }; + } + + if ( + specifier.startsWith("/") + || specifier.startsWith("./") + || specifier.startsWith("../") + ) { + const abs = path.resolve(resolveDir, specifier); + const result = sloppy + ? await this.resolvePathSloppy(abs) + : await this.resolvePathExact(abs); + return { namespace: "file", subPath: await this.fs.realPath(result) }; + } + + if (specifier.startsWith("#")) { + const packageJsonPath = await this.findClosestPackageJson(resolveDir); + if (!packageJsonPath) { + this.throwWithCode( + `Cannot find package.json for import ${JSON.stringify(specifier)} from ${resolveDir}`, + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + ); + } + const meta = await this.readPackageJsonMeta(packageJsonPath); + const target = this.resolvePackageImportCondition( + meta.imports, + specifier, + ); + if (!target) { + this.throwWithCode( + `Package import specifier ${JSON.stringify(specifier)} is not defined in ${packageJsonPath}`, + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + ); + } + return { + namespace: "file", + subPath: await this.resolvePackageTarget( + path.dirname(packageJsonPath), + target, + sloppy, + ), + }; + } + + if (!this.isBareSpecifier(specifier)) { + this.throwWithCode( + `Invalid module specifier ${JSON.stringify(specifier)}`, + "ERR_INVALID_MODULE_SPECIFIER", + ); + } + + const resolved = await this.resolveBareSpecifier( + specifier, + resolveDir, + sloppy, + ); + return { namespace: "file", subPath: await this.fs.realPath(resolved) }; + } + + async stat(file: string): Promise { + this.files.add(file); + try { + return await this.fs.stat(file); + } catch (error) { + if (exception.code(error) === "ENOENT") return null; + throw error; + } + } + + async readJsonObject(file: string): Promise> { + this.files.add(file); + return JSON.parse(await this.fs.readTextFile(file)) as Record; + } + + async readPackageJsonMeta(file: string): Promise { + this.files.add(file); + const cached = this.packageJsonCache.get(file); + if (cached) return cached; + + const json = JSON.parse(await this.fs.readTextFile(file)) as Record< + string, + unknown + >; + const meta: PackageMeta = { + type: json.type === "module" || json.type === "commonjs" ? json.type : null, + imports: json.imports, + exports: json.exports, + }; + this.packageJsonCache.set(file, meta); + return meta; + } + + async resolvePathExact(abs: string): Promise { + const stat = await this.stat(abs); + if (!stat) { + this.throwWithCode( + `Cannot find module ${JSON.stringify(abs)}`, + "ERR_MODULE_NOT_FOUND", + ); + } + if (stat.kind === "directory") { + this.throwWithCode( + `Directory import ${JSON.stringify(abs)} is not supported`, + "ERR_UNSUPPORTED_DIR_IMPORT", + ); + } + if (stat.kind !== "file") { + this.throwWithCode( + `Cannot load module ${JSON.stringify(abs)}`, + "ERR_MODULE_NOT_FOUND", + ); + } + return abs; + } + + async resolvePathSloppy(abs: string): Promise { + const exact = await this.stat(abs); + if (exact?.kind === "file") return abs; + + for (const ext of sloppyExtensions) { + const candidate = `${abs}${ext}`; + const stat = await this.stat(candidate); + if (stat?.kind === "file") return candidate; + } + + const packageJson = path.join(abs, "package.json"); + const packageStat = await this.stat(packageJson); + if (packageStat?.kind === "file") { + const meta = await this.readPackageJsonMeta(packageJson); + let target = this.resolvePackageImportCondition(meta.exports, "."); + if (!target) { + const json = await this.readJsonObject(packageJson); + const moduleField = typeof json.module === "string" ? json.module : null; + const mainField = typeof json.main === "string" ? json.main : null; + target = moduleField ?? mainField; + } + if (target) { + return await this.resolveLegacyPackageTarget(abs, target, true); + } + } + + for (const ext of sloppyExtensions) { + const indexCandidate = path.join(abs, `index${ext}`); + const stat = await this.stat(indexCandidate); + if (stat?.kind === "file") return indexCandidate; + } + + if (exact?.kind === "directory" || packageStat?.kind === "file") { + this.throwWithCode( + `Directory import ${JSON.stringify(abs)} is not supported`, + "ERR_UNSUPPORTED_DIR_IMPORT", + ); + } + + this.throwWithCode( + `Cannot find module ${JSON.stringify(abs)}`, + "ERR_MODULE_NOT_FOUND", + ); + } + + async findClosestPackageJson(startDir: string): Promise { + for (const dir of walkUpDirectories(startDir)) { + const candidate = path.join(dir, "package.json"); + const stat = await this.stat(candidate); + if (stat?.kind === "file") return candidate; + } + return null; + } + + async resolveBareSpecifier( + specifier: string, + resolveDir: string, + sloppy: boolean, + ): Promise { + const { packageName, packageSubpath } = this.parseBareSpecifier(specifier); + + for (const dir of walkUpDirectories(resolveDir)) { + const packageDir = path.join(dir, "node_modules", packageName); + const packageJsonPath = path.join(packageDir, "package.json"); + const packageJsonStat = await this.stat(packageJsonPath); + if (!packageJsonStat) continue; + if (packageJsonStat.kind !== "file") { + this.throwWithCode( + `Invalid package.json for ${packageName}`, + "ERR_MODULE_NOT_FOUND", + ); + } + + const meta = await this.readPackageJsonMeta(packageJsonPath); + const subpath = packageSubpath ? `./${packageSubpath}` : "."; + + if (meta.exports !== undefined) { + const target = this.resolvePackageImportCondition( + meta.exports, + subpath, + ); + if (!target) { + this.throwWithCode( + `Package subpath ${JSON.stringify(subpath)} is not exported by ${packageName}`, + "ERR_PACKAGE_PATH_NOT_EXPORTED", + ); + } + return await this.resolvePackageTarget(packageDir, target, sloppy); + } + + if (subpath !== ".") { + return sloppy + ? await this.resolvePathSloppy(path.join(packageDir, packageSubpath)) + : await this.resolvePathExact(path.join(packageDir, packageSubpath)); + } + + const json = await this.readJsonObject(packageJsonPath); + const mainField = typeof json.module === "string" + ? json.module + : typeof json.main === "string" + ? json.main + : null; + if (mainField) { + return await this.resolveLegacyPackageTarget( + packageDir, + mainField, + sloppy, + ); + } + + return sloppy + ? await this.resolvePathSloppy(path.join(packageDir, "index")) + : await this.resolvePathExact(path.join(packageDir, "index.js")); + } + + this.throwWithCode( + `Cannot find package ${JSON.stringify(packageName)}`, + "ERR_MODULE_NOT_FOUND", + ); + } + + async resolvePackageTarget( + packageDir: string, + target: string, + sloppy: boolean, + ): Promise { + if (target.startsWith("#")) { + const packageJsonPath = await this.findClosestPackageJson(packageDir); + if (!packageJsonPath) { + this.throwWithCode( + `Package import specifier ${JSON.stringify(target)} is not defined`, + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + ); + } + const meta = await this.readPackageJsonMeta(packageJsonPath); + const nestedTarget = this.resolvePackageImportCondition( + meta.imports, + target, + ); + if (!nestedTarget) { + this.throwWithCode( + `Package import specifier ${JSON.stringify(target)} is not defined in ${packageJsonPath}`, + "ERR_PACKAGE_IMPORT_NOT_DEFINED", + ); + } + return await this.resolvePackageTarget( + path.dirname(packageJsonPath), + nestedTarget, + sloppy, + ); + } + if ( + !target.startsWith("./") + && !target.startsWith("../") + && !target.startsWith("/") + ) { + this.throwWithCode( + `Invalid package target ${JSON.stringify(target)}`, + "ERR_INVALID_PACKAGE_TARGET", + ); + } + const abs = path.resolve(packageDir, target); + return sloppy + ? await this.resolvePathSloppy(abs) + : await this.resolvePathExact(abs); + } + + async resolveLegacyPackageTarget( + packageDir: string, + target: string, + sloppy: boolean, + ): Promise { + if (target.startsWith("#")) { + return await this.resolvePackageTarget(packageDir, target, sloppy); + } + if ( + target.startsWith("./") + || target.startsWith("../") + || target.startsWith("/") + ) { + return await this.resolvePackageTarget(packageDir, target, sloppy); + } + const abs = path.resolve(packageDir, target); + return sloppy + ? await this.resolvePathSloppy(abs) + : await this.resolvePathExact(abs); + } + + isBareSpecifier(specifier: string): boolean { + return ( + !specifier.startsWith(".") + && !specifier.startsWith("/") + && !specifier.startsWith("#") + ); + } + + parseBareSpecifier(specifier: string): { + packageName: string; + packageSubpath: string; + } { + const parts = specifier.split("/"); + const packageName = specifier.startsWith("@") + ? parts.slice(0, 2).join("/") + : (parts[0] ?? ""); + const packageSubpath = specifier + .slice(packageName.length) + .replace(/^\/+/, ""); + return { packageName, packageSubpath }; + } + + resolvePackageImportCondition( + map: unknown, + specifier: string, + ): string | null { + if (map !== null && typeof map === "object" && !Array.isArray(map)) { + const keys = Object.keys(map as object); + const hasPathKeys = keys.some( + (k) => k.startsWith(".") || k.startsWith("#"), + ); + if (hasPathKeys) { + return this.resolveSubpathMap( + map as Record, + specifier, + ); + } + } + return this.resolveTarget(map, null); + } + + private resolveSubpathMap( + matchObj: Record, + matchKey: string, + ): string | null { + if (matchKey in matchObj) { + return this.resolveTarget(matchObj[matchKey], null); + } + + const patternKeys = Object.keys(matchObj) + .filter((k) => k.includes("*")) + .sort((a, b) => b.indexOf("*") - a.indexOf("*")); + + for (const expansionKey of patternKeys) { + const starIdx = expansionKey.indexOf("*"); + const patternBase = expansionKey.slice(0, starIdx); + const patternTrailer = expansionKey.slice(starIdx + 1); + + if (!matchKey.startsWith(patternBase)) continue; + if (matchKey.length < expansionKey.length) continue; + if (patternTrailer.length > 0 && !matchKey.endsWith(patternTrailer)) { + continue; + } + + const patternMatch = matchKey.slice( + patternBase.length, + matchKey.length - patternTrailer.length, + ); + return this.resolveTarget(matchObj[expansionKey], patternMatch); + } + + return null; + } + + private resolveTarget( + target: unknown, + patternMatch: string | null, + ): string | null { + if (typeof target === "string") { + if (!target.startsWith("./") && !target.startsWith("#")) { + this.throwWithCode( + `invalid target: "${target}"`, + "ERR_INVALID_PACKAGE_TARGET", + ); + } + return patternMatch !== null + ? target.replaceAll("*", patternMatch) + : target; + } + + if (Array.isArray(target)) { + for (const item of target) { + try { + const result = this.resolveTarget(item, patternMatch); + if (result !== null) return result; + } catch { + // + } + } + return null; + } + + if (target !== null && typeof target === "object") { + for ( + const [key, value] of Object.entries( + target as Record, + ) + ) { + if (key === "default" || this.conditions.includes(key)) { + const result = this.resolveTarget(value, patternMatch); + if (result !== null) return result; + } + } + } + + return null; + } + + private throwWithCode(message: string, code: node.ErrorCode): never { + exception.rethrowWithMetadata(new Error(message), { code }); + } +} + +async function readPackageJsonMeta( + fs: Fs, + file: string, + packageJsonCache: Map, +): Promise { + const cached = packageJsonCache.get(file); + if (cached) return cached; + + const json = JSON.parse(await fs.readTextFile(file)) as Record; + const meta: PackageMeta = { + type: json.type === "module" || json.type === "commonjs" ? json.type : null, + imports: json.imports, + exports: json.exports, + }; + packageJsonCache.set(file, meta); + return meta; +} + +function* walkUpDirectories(startDir: string): Generator { + let current = path.resolve(startDir); + while (true) { + yield current; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } +} + +function getCommonJsNamedExports(value: unknown): string[] { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) return []; + return Array.from( + new Set(Object.keys(value).filter((key) => key !== "default")), + ); +} + +// --- database & serialization --- +export type RowId = number | bigint; + +/** + * a file, network request, or other input referenced within a + * {@linkcode StepFn} function. unlike the `key` to a cache boundary, `Input` + * urls can stay constant but have underlying changes. file paths are the main + * use case, but this also extends to something like a database query. + */ +export interface Input { + /** global key for the changable object, with enough information to re-query. */ + url: string; + /** inline data known about this object, such as a file kind. */ + kind: string; + + // these values are dependant on the implementation of whatever revalidates if + // an `Input` has changed, and are modelled after a filesystem stat. + + /** modification time or revision number. */ + time: number; + /** byte size or other cheap source-specific magnitude. */ + size: number; + /** a freshness token, such as a content hash. */ + hash: string; +} + +export interface SourceRecord extends Input { + id: RowId; +} + +export interface CacheRecord { + id: RowId; + inputs: IterableIterator; + /** read via `readBlob` */ + blob: RowId; +} + +/** dangerous trust-me-bro types to quickly add some type safety to sqlite */ +type TypedSqliteStatement< + Input extends object, + Output extends object = never, +> = Omit & { + run(input: Input): sqlite.StatementResultingChanges; + get(input: Input): Output | undefined; + all(input: Input): Output[]; + iterate(input: Input): IterableIterator; +}; + +function prepareQueries(sql: sqlite.DatabaseSync) { + return { + insertBlob: sql.prepare( + "insert into blobs (type, content) values ($type, $content);", + ) as unknown as TypedSqliteStatement<{ + type: "j" | "s" | "b"; + content: string | Uint8Array; + }>, + readBlob: sql.prepare( + "select type, content from blobs where id = $id;", + ) as unknown as TypedSqliteStatement< + { id: RowId }, + { type: "j" | "s" | "b"; content: string | Uint8Array } + >, + upsertSource: sql.prepare( + "insert into sources (url, kind, time, size, hash) " + + "values ($url, $kind, $time, $size, $hash) " + + "on conflict(url) do update set " + + "kind = excluded.kind, " + + "time = excluded.time, " + + "size = excluded.size, " + + "hash = excluded.hash;", + ) as unknown as TypedSqliteStatement<{ + url: string; + kind: string; + time: number; + size: number; + hash: string; + }>, + readSource: sql.prepare( + "select id, url, kind, time, size, hash from sources where url = $url;", + ) as unknown as TypedSqliteStatement<{ url: string }, SourceRecord>, + readSources: sql.prepare( + "select id, url, kind, time, size, hash from sources order by url;", + ) as unknown as TypedSqliteStatement, SourceRecord>, + selectSourceId: sql.prepare( + "select id from sources where url = $url;", + ) as unknown as TypedSqliteStatement<{ url: string }, { id: RowId }>, + insertCache: sql.prepare( + "insert into caches (key, blob) values ($key, $blob) " + + "on conflict(key) do update set blob = excluded.blob;", + ) as unknown as TypedSqliteStatement<{ key: string; blob: RowId }>, + selectCache: sql.prepare( + "select id, blob from caches where key = $key;", + ) as unknown as TypedSqliteStatement< + { key: string }, + { id: RowId; blob: RowId } + >, + deleteCacheInputs: sql.prepare( + "delete from inputs where cache = $cache;", + ) as unknown as TypedSqliteStatement<{ cache: RowId }>, + insertInput: sql.prepare( + "insert or ignore into inputs (source, cache) values ($source, $cache);", + ) as unknown as TypedSqliteStatement<{ source: RowId; cache: RowId }>, + readCacheInputs: sql.prepare( + "select sources.id, sources.url, sources.kind, sources.time, " + + "sources.size, sources.hash " + + "from sources " + + "join inputs on inputs.source = sources.id " + + "where inputs.cache = $cache " + + "order by sources.url;", + ) as unknown as TypedSqliteStatement<{ cache: RowId }, SourceRecord>, + }; +} + +/** backing storage for incremental artifacts built on `node:sqlite` */ +export class Database { + sql: sqlite.DatabaseSync; + #prep: ReturnType; + #cachedBlobs = new Map(); // WeakRef? + + constructor(path: string | null) { + ASSERT(!path?.startsWith(":")); + this.sql = new sqlite.DatabaseSync(path ?? ":memory:"); + this.sql.exec( + [ + "pragma foreign_keys = ON;", + + "create table if not exists blobs (", + /**/ "id integer primary key asc,", + /**/ "type text CHECK(type IN ('j','s','b')) not null,", + /**/ "content any not null", + ") strict;", + + "create table if not exists sources (", + /**/ "id integer primary key asc,", + /**/ "url text unique not null,", + /**/ "kind text not null,", + /**/ "time integer not null,", + /**/ "size integer not null,", + /**/ "hash text not null", + ") strict;", + + "create table if not exists caches (", + /**/ "id integer primary key asc,", + /**/ "key text unique not null,", + /**/ "blob integer not null references blobs(id) on delete cascade", + ") strict;", + + "create table if not exists inputs (", + /**/ "source integer not null references sources(id) on delete cascade,", + /**/ "cache integer not null references caches(id) on delete cascade,", + /**/ "primary key(source, cache)", + ") strict;", + + // TODO: name `a` and `b` to clearly draw the line + "create table if not exists deps (", + /**/ "a integer not null references sources(id) on delete cascade,", + /**/ "b integer not null references sources(id) on delete cascade,", + /**/ "primary key(a, b)", + ") strict;", + ].join(""), + ); + this.#prep = prepareQueries(this.sql); + } + + /** forget serialized blobs that were held in memory */ + clearInMemoryCache() { + this.#cachedBlobs.clear(); + } + + writeSource(source: Input): RowId { + this.#validateSource(source); + this.#prep.upsertSource.run(source); + return UNWRAP(this.#prep.selectSourceId.get({ url: source.url })).id; + } + + sources(): IterableIterator { + return this.#prep.readSources.iterate({}) as IterableIterator; + } + + invalidate(source: RowId | Iterable): void { + const sources = typeof source === "number" || typeof source === "bigint" + ? [source] + : Array.from(source); + if (sources.length === 0) return; + const params = Object.fromEntries( + sources.map((source, i) => [`source${i}`, source]), + ); + const placeholders = sources.map((_, i) => `$source${i}`).join(", "); + this.sql + .prepare( + `delete from caches where id in (` + + `select cache from inputs where source in (${placeholders})` + + `);`, + ) + .run(params); + } + + /** + * read a cache entry. caller should first examine `.inputs` to determine if + * the cache value is truly fresh, and only then deserialize the blob. + */ + readCache(key: string): CacheRecord | null { + const row = this.#prep.selectCache.get({ key }); + if (!row) return null; + return { + id: row.id, + blob: row.blob, + inputs: this.#prep.readCacheInputs.iterate({ cache: row.id }), + }; + } + + /** read the source records linked to a cache entry. */ + readCacheInputs(cache: RowId): IterableIterator { + return this.#prep.readCacheInputs.iterate({ cache }); + } + + /** link a key to a value, with a set of `inputs` that bust this cache entry */ + writeCache(key: string, value: Serializable, inputs: Input[]): RowId { + const { blob } = this.writeBlob(value); + this.#prep.insertCache.run({ key, blob }); + const cache = UNWRAP(this.#prep.selectCache.get({ key })).id; + this.#prep.deleteCacheInputs.run({ cache }); + for (const input of inputs) { + const source = this.writeSource(input); + this.#prep.insertInput.run({ source, cache }); + } + return cache; + } + + /** write a {@linkcode Serializable} to the database */ + writeBlob(data: Serializable, cache = true): { blob: number | bigint } { + let content: string | Uint8Array; + let type: "j" | "s" | "b"; + if (typeof data === "string") { + type = "s"; + content = data; + } else if (data instanceof Uint8Array) { + type = "b"; + content = data; + } else { + type = "j"; + content = JSON.stringify(this.#serializeObject(data, cache)); + } + const insert = this.#prep.insertBlob.run({ type, content }); + if (cache) this.#cachedBlobs.set(insert.lastInsertRowid, data); + return { blob: insert.lastInsertRowid }; + } + + /** write a {@linkcode Serializable} via it's ID */ + readBlob(blob: RowId): Serializable { + const cached = this.#cachedBlobs.get(blob); + if (cached !== undefined || this.#cachedBlobs.has(blob)) return cached; + + const row = this.#prep.readBlob.get({ id: blob }); + if (!row) throw new Error(`missing blob row ${String(blob)}`); + + const value = row.type === "s" + ? (row.content as string) + : row.type === "b" + ? (row.content as Uint8Array) + : this.#deserializeObject(JSON.parse(row.content as string)); + + this.#cachedBlobs.set(blob, value); + return value; + } + + #validateSource(source: Input): void { + let parsed: URL; + try { + parsed = new URL(source.url); + } catch { + throw new Error(`source url must be a complete URL: ${source.url}`); + } + if (parsed.href !== source.url) { + throw new Error(`source url must be canonical: ${source.url}`); + } + if (!Number.isFinite(source.time)) { + throw new Error(`source time must be a finite number: ${source.url}`); + } + if (!Number.isFinite(source.size)) { + throw new Error(`source size must be a finite number: ${source.url}`); + } + } + + #serializeObject(data: Serializable, cache = true): unknown { + if ( + data === null + || typeof data === "string" + || typeof data === "boolean" + ) { + return data; + } + if (typeof data === "number") { + return Number.isFinite(data) + ? data + : { $clover: "number", value: String(data) }; + } + if (typeof data === "undefined") { + return { $clover: "undefined" }; + } + if (data instanceof Uint8Array) { + return { $clover: "buffer", blob: this.writeBlob(data, cache).blob }; + } + if (data instanceof Date) { + return { $clover: "date", time: data.getTime() }; + } + if (data instanceof Set) { + return { + $clover: "set", + entries: Array.from(data, (value) => this.#serializeObject(value, cache)), + }; + } + if (data instanceof Map) { + return { + $clover: "map", + entries: Array.from(data, ([key, value]) => [ + this.#serializeObject(key, cache), + this.#serializeObject(value, cache), + ]), + }; + } + if (Array.isArray(data)) { + return data.map((value) => this.#serializeObject(value, cache)); + } + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + out[key] = this.#serializeObject(value, cache); + } + if (Object.hasOwn(out, "$clover")) { + return { $clover: "escaped", value: out }; + } + return out; + } + + #deserializeObject(data: unknown): Serializable { + if ( + data === null + || typeof data === "string" + || typeof data === "boolean" + ) { + return data; + } + if (typeof data === "number") return data; + if (Array.isArray(data)) { + return data.map((value) => this.#deserializeObject(value)); + } + if (!data || typeof data !== "object") { + throw new Error(`unsupported serialized value: ${String(data)}`); + } + + const object = data as Record; + switch (object.$clover) { + case undefined: { + const out: Record = {}; + for (const [key, value] of Object.entries(object)) { + out[key] = this.#deserializeObject(value); + } + return out; + } + case "number": + return Number(object.value); + case "undefined": + return undefined; + case "buffer": + return this.readBlob(object.blob as RowId) as Uint8Array; + case "date": + return new Date(object.time as number); + case "set": + return new Set( + (object.entries as unknown[]).map((value) => this.#deserializeObject(value)), + ); + case "map": + return new Map( + (object.entries as [unknown, unknown][]).map(([key, value]) => [ + this.#deserializeObject(key), + this.#deserializeObject(value), + ]), + ); + case "escaped": { + const value = object.value as Record; + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + out[key] = this.#deserializeObject(entry); + } + return out; + } + default: + throw new Error(`unknown serialized marker: ${String(object.$clover)}`); + } + } +} + +export function lazyLoader( + load: () => Promise, +): LoaderFunction { + const once = async.once(load); + return async (input) => (await once()).default(input); +} + +function hashSerializable(...values: Serializable[]): string { + const hash = crypto.createHash("sha256"); + writeSerializableToHasher(hash, values); + return hash.digest("base64url"); +} + +function writeSerializableToHasher( + hash: crypto.Hash, + value: Serializable, +): void { + if (value === null) { + hash.update("n;"); + return; + } + if (typeof value === "undefined") { + hash.update("u;"); + return; + } + if (typeof value === "boolean") { + hash.update(value ? "t;" : "f;"); + return; + } + if (typeof value === "number") { + writeStringToHasher(hash, "#", Object.is(value, -0) ? "-0" : String(value)); + return; + } + if (typeof value === "string") { + writeStringToHasher(hash, "s", value); + return; + } + if (value instanceof Uint8Array) { + hash.update(`b${value.byteLength}:`); + hash.update(value); + hash.update(";"); + return; + } + if (value instanceof Date) { + writeStringToHasher(hash, "d", String(value.getTime())); + return; + } + if (value instanceof Set) { + hash.update(`e${value.size}:[`); + for (const entry of value) writeSerializableToHasher(hash, entry); + hash.update("]"); + return; + } + if (value instanceof Map) { + hash.update(`m${value.size}:{`); + for (const [key, entry] of value) { + writeSerializableToHasher(hash, key); + writeSerializableToHasher(hash, entry); + } + hash.update("}"); + return; + } + if (Array.isArray(value)) { + hash.update(`a${value.length}:[`); + for (const entry of value) writeSerializableToHasher(hash, entry); + hash.update("]"); + return; + } + const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b)); + hash.update(`o${entries.length}:{`); + for (const [key, entry] of entries) { + writeStringToHasher(hash, "k", key); + writeSerializableToHasher(hash, entry); + } + hash.update("}"); +} + +function writeStringToHasher( + hash: crypto.Hash, + tag: string, + value: string, +): void { + const byteLength = Buffer.byteLength(value); + hash.update(`${tag}${byteLength}:`); + hash.update(value); + hash.update(";"); +} + +import { ASSERT, UNWRAP } from "./assert.ts"; +import * as async from "./async.ts"; +import * as exception from "./error.ts"; +import * as stack from "./log/stack.ts"; +import * as node from "./node.ts"; +import * as progress from "./progress.ts"; +import * as ts from "./ts.ts"; + +import * as crypto from "node:crypto"; +import * as fscb from "node:fs"; +import * as fs from "node:fs/promises"; +import * as module from "node:module"; +import * as path from "node:path"; +import * as sqlite from "node:sqlite"; +import * as url from "node:url"; +import * as vm from "node:vm"; diff --git a/lib/log/stack.ts b/lib/log/stack.ts index 1921f99faac506da811162e896f7bffc79a76d1d..243eff3ba221d3be35ae111c2b499f371d0d9d9f 100644 --- a/lib/log/stack.ts +++ b/lib/log/stack.ts @@ -90,7 +90,7 @@ export function format(error: Error, colors = false): string { const { name, message, stack: _, ...payload } = error; let out = (colors ? ansi.fgRed : "") - + (name && name !== "Error" ? `[${name}] ` : "") + + (name && name !== "Error" && name !== "TypeError" ? `[${name}] ` : "") + message + (colors ? ansi.fgReset : "") + "\n"; out += Object.entries(payload) diff --git a/lib/mime.ts b/lib/mime.ts index fe7caeda2cd466eb8f8b56c6a2aa32130eedd261..04a75e3010b6919556f0c92f193e3b7e9cda5a48 100644 --- a/lib/mime.ts +++ b/lib/mime.ts @@ -127,7 +127,6 @@ const extensions = new Map( + ".xls" + "#" + "application/vnd.ms-excel" + "\n" + ".xlsx" + "#" + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + "\n" + ".xml" + "#" + "application/xml" + "\n" - + ".xps" + "#" + "application/vnd.ms-xpsdocument" + "\n" - + +"" - ).slice(0, -1).split("\n").map((line) => line.split("#") as [string, string]), + + ".xps" + "#" + "application/vnd.ms-xpsdocument" + ).split("\n").map((line) => line.split("#") as [string, string]), ); diff --git a/lib/package.json b/lib/package.json index 9fa59c20b92f6b8e824a67e9a17549a8f3fc0767..2023d4878e016bd85b3c6ac274dcb4d6f5de49ee 100644 --- a/lib/package.json +++ b/lib/package.json @@ -1,9 +1,5 @@ { "name": "@clo/lib", "type": "module", - "dependencies": { - "@types/node": "^24.0.0", - "@clo/lib": "file:." - }, "exports": { "./*": "./*.ts" } } diff --git a/lib/progress.ts b/lib/progress.ts index 9fcc01deb41e0165f6980a5d112a2f22a1bc196e..f6f22c8b312e1bd4c9c042be3856577a18e52f62 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -85,7 +85,7 @@ export interface Ref { * creates a new trackable unit of work as a child of this one. * when given an estimate, a progress bar is rendered. */ - start(text: string, opts?: StartOptions): Node; + start: (text: string, opts?: StartOptions) => Node; } /** @@ -205,17 +205,19 @@ export function slashValueFormatter( ): ValueFormatter { return (value: number, total: number | null) => value > 0 - ? fmtNumber(value) - + (total != null && total > 0 ? "/" + fmtNumber(total) : "") + ? fmtNumber(value) + + (total != null && total > 0 ? "/" + fmtNumber(total) : "") : ""; } /** the formatter used when passing `units: "count"` to {@linkcode start} */ -export const defaultValueFormatter: ValueFormatter = /* @__PURE__ */ +export const defaultValueFormatter: ValueFormatter = + /* @__PURE__ */ slashValueFormatter(String); /** the formatter used when passing `units: "bytes"` to {@linkcode start} */ -export const bytesValueFormatter: ValueFormatter = /* @__PURE__ */ +export const bytesValueFormatter: ValueFormatter = + /* @__PURE__ */ slashValueFormatter(string.formatByteSize); /** the formatter used when passing `units: "percent"` to {@linkcode start} */ @@ -342,18 +344,17 @@ export class Root< #emitChangeSoon = () => { if (this.#debounce) return; - (this.#debounce = this.delay(1)) - .then(() => { - this.#debounce = null; - this.emit("change", this.#active); - }); + (this.#debounce = this.delay(1)).then(() => { + this.#debounce = null; + this.emit("change", this.#active); + }); }; } /** the built in events provided by {@linkcode Root} */ export type RootEventMap = { // this event is the debounced "ready to re-render" event. do not emit. - "change": [rootNodes: readonly ReadOnlyNode[]]; + change: [rootNodes: readonly ReadOnlyNode[]]; // these events are emitted without a debounce, and are used for general // communication within `Root`'s implementation. do not emit. "node-start": [newNode: ReadOnlyNode, parentNode: ReadOnlyNode | null]; @@ -363,8 +364,8 @@ export type RootEventMap = { // these events are used for result communication. they can be sent via // `emit` or the shorthand methods `end` and `error`. sending either will put // the `Root` into a "done" state. - "end": [result: Result]; - "error": [error: unknown]; + end: [result: Result]; + error: [error: unknown]; }; const rootEvents: ReadonlySet = new Set([ @@ -378,13 +379,14 @@ const rootEvents: ReadonlySet = new Set([ ]); type MergeRootEvents = { - [K in keyof Map | keyof RootEventMap]: K extends keyof RootEventMap ? RootEventMap[K] + [K in keyof Map | keyof RootEventMap]: K extends keyof RootEventMap + ? RootEventMap[K] : Map[K]; }; /** a read-only version of {@linkcode Node}, provided to renderers. */ export type ReadOnlyNode = Readonly< - & Pick< + Pick< Required, | "text" | "value" @@ -395,8 +397,7 @@ export type ReadOnlyNode = Readonly< | "showTotal" | "sortChildren" | "valueFormatter" - > - & { + > & { /** unique identifier. once {@linkcode Node} is disposed, the key is reused. */ key: number; logs: readonly log.Message[]; @@ -416,8 +417,8 @@ interface Internal extends ts.Writeable { } // TypeScript Test -void function(unknown: unknown) { - (unknown as Internal) satisfies ReadOnlyNode; +void function (unknown: unknown) { + unknown as Internal satisfies ReadOnlyNode; }; /** @@ -445,11 +446,13 @@ function newNode( children: [], parent: null, sortChildren: opts.sortChildren ?? null, - valueFormatter: opts.valueFormatter ?? { - count: defaultValueFormatter, - bytes: bytesValueFormatter, - percent: percentValueFormatter, - }[opts.units ?? "count"], + valueFormatter: + opts.valueFormatter ?? + { + count: defaultValueFormatter, + bytes: bytesValueFormatter, + percent: percentValueFormatter, + }[opts.units ?? "count"], detached: false, }; @@ -464,8 +467,11 @@ function newNode( function mutate(key: keyof ReadOnlyNode) { if (state.detached) return; if ( - key === "value" && estimator && state.total > 0 && state.value > 0 - && state.value < state.total + key === "value" && + estimator && + state.total > 0 && + state.value > 0 && + state.value < state.total ) { // originally, auto-estimation would use the Root's timing primitives, // but this makes it so that `state.estimatedTime` is in terms of the @@ -587,18 +593,15 @@ export const nullNode: Node = { get text() { return "[detached]"; }, - set text(_) { - }, + set text(_) {}, get value() { return 0; }, - set value(_) { - }, + set value(_) {}, get total() { return 0; }, - set total(_) { - }, + set total(_) {}, inc: noop, log: { info: noop, @@ -627,7 +630,7 @@ function endNode(owner: Root, state: Internal) { const i = state.parent.children.indexOf(state); ASSERT(i !== -1); let p: Internal | null = state; - while (p = p.parent) title = p.text + " / " + title; + while ((p = p.parent)) title = p.text + " / " + title; state.parent.children.splice(i, 1); } owner.emit("node-end", state); @@ -650,36 +653,45 @@ const spinnerFps = 12.5; const box = { tee: "├─ ", line: "│ ", langle: "└─ " }; const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; const fullBar = "█"; -const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] - .map((frame) => ansi.style(ansi.fgBlue, frame)); +const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].map( + (frame) => ansi.style(ansi.fgBlue, frame), +); function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { const { text, total, showTotal, value, estimatedTime } = state; const dateNow = Date.now(); - const showEstimate = estimatedTime && !findLongerEstimate(state, estimatedTime); - const estimate = showEstimate && (estimatedTime > dateNow + 1000) - ? ", " - + string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000)) - : ""; + const showEstimate = + estimatedTime && !findLongerEstimate(state, estimatedTime); + const estimate = + showEstimate && estimatedTime > dateNow + 1000 + ? ", " + + string.formatDurationLetters( + Math.round((estimatedTime - dateNow) / 1000), + ) + : ""; const valueFormatted = state.valueFormatter(value, total); if (total && showTotal) { - return ansi.style( - ansi.bgBrightBlack + ansi.fgBlue, - formatUnicodeBar( - value / total, - Math.max(4, depth > 0 ? 12 : 25), - ), - ) - + (valueFormatted || estimate ? ` [${valueFormatted}${estimate}] ` : " ") - + text; + return ( + ansi.style( + ansi.bgBrightBlack + ansi.fgBlue, + formatUnicodeBar(value / total, Math.max(4, depth > 0 ? 12 : 25)), + ) + + (valueFormatted || estimate ? ` [${valueFormatted}${estimate}] ` : " ") + + text + ); } const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length; - return ((depth === 0 ? spinner[frame] + " " : "") - + (valueFormatted ? `[${valueFormatted}] ` : "") + text); + return ( + (depth === 0 ? spinner[frame] + " " : "") + + (valueFormatted ? `[${valueFormatted}] ` : "") + + text + ); } function hasChildren(states: ReadOnlyNode): boolean { - return states.children.some((x) => !x.hidden && (!x.passive || hasChildren(x))); + return states.children.some( + (x) => !x.hidden && (!x.passive || hasChildren(x)), + ); } function findLongerEstimate( @@ -701,12 +713,14 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { let truncated = 0; let out = ""; let { children } = state; - children = children.filter((x) => !x.hidden && (!x.passive || hasChildren(x))); + children = children.filter( + (x) => !x.hidden && (!x.passive || hasChildren(x)), + ); if (children.length === 0) return ""; for (let i = 0, { length } = children; i < length; i += 1) { const child = UNWRAP(children[i]); let item = ""; - const left = depth.map((x) => x ? box.line : " ").join(""); + const left = depth.map((x) => (x ? box.line : " ")).join(""); item += left + (i === length - 1 && !truncated ? box.langle : box.tee); item += renderAnsiMainLine(child, now, 1 + depth.length) + "\n"; item += renderChildren(child, now, depth.concat(i < length - 1)); @@ -721,17 +735,21 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { .trim(); if (logLines) { for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) { - item += left + (i === length - 1 && !truncated ? " " : box.line) - + " " - + ansi.style(ansi.fgBrightBlack, ">") - + " " + line + "\n"; + item += + left + + (i === length - 1 && !truncated ? " " : box.line) + + " " + + ansi.style(ansi.fgBrightBlack, ">") + + " " + + line + + "\n"; } } maxHeight -= h + string.countNewlines(logLines); out += item; } if (truncated) { - out += depth.map((x) => x ? box.line : " ").join("") + box.langle; + out += depth.map((x) => (x ? box.line : " ")).join("") + box.langle; out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`); } return out; @@ -779,43 +797,52 @@ export function formatUnicodeBar(progress: number, width: number): string { */ export function attachToScreen( root: Root, - { writeOutput, startWidget }: Pick< - log.WidgetHost, - "writeOutput" | "startWidget" - >, + { + writeOutput, + startWidget, + }: Pick, ): ts.Dispose { const stack = new DisposableStack(); let widget: log.WidgetInstance | null = null; - stack.use(root.on("change", (items) => { - if (items.length > 0) { - widget ??= startWidget({ - format: ({ now }) => formatAnsi(now, root.active), - }) ?? null; - if (!widget) return; - widget.fps = items.some((x) => x.showTotal !== false && x.total > 0) - ? spinnerFps - : null; - widget.redraw(); - } else { - widget?.stop(); - widget = null; - } - })); - stack.use(root.on("node-detached-log", (msg) => { - writeOutput(log.formatAnsiMessage(msg, true)); - })); - stack.use(root.on("node-end", (node) => { - let title = node.text; - let p: ReadOnlyNode | null = node; - while (p = p.parent) title = p.text + " / " + title; - const { logs } = node; - if (logs.length > 0) { - const header = `[logs from ${title}]`; - writeOutput(ansi.style(ansi.fgBrightBlack, header) + "\n"); - writeOutput(logs.map((msg) => log.formatAnsiMessage(msg, true)).join("")); - } - })); + stack.use( + root.on("change", (items) => { + if (items.length > 0) { + widget ??= + startWidget({ + format: ({ now }) => formatAnsi(now, root.active), + }) ?? null; + if (!widget) return; + widget.fps = items.some((x) => x.showTotal !== false && x.total > 0) + ? spinnerFps + : null; + widget.redraw(); + } else { + widget?.stop(); + widget = null; + } + }), + ); + stack.use( + root.on("node-detached-log", (msg) => { + writeOutput(log.formatAnsiMessage(msg, true)); + }), + ); + stack.use( + root.on("node-end", (node) => { + let title = node.text; + let p: ReadOnlyNode | null = node; + while ((p = p.parent)) title = p.text + " / " + title; + const { logs } = node; + if (logs.length > 0) { + const header = `[logs from ${title}]`; + writeOutput(ansi.style(ansi.fgBrightBlack, header) + "\n"); + writeOutput( + logs.map((msg) => log.formatAnsiMessage(msg, true)).join(""), + ); + } + }), + ); return ts.defer(() => stack[Symbol.dispose]); } @@ -902,7 +929,7 @@ export function encodeEventStream< // fun fact, this type is impossible to write without generics root: Root, { - throttleMs = 1000 / 30, /* 30fps */ + throttleMs = 1000 / 30 /* 30fps */, serializeStackTraces = false, }: EncodeStreamOptions = {}, ): ReadableStream { @@ -934,47 +961,56 @@ export function encodeEventStream< }); let isFirst = true; - return new ReadableStream({ - start(controller) { - if (root.active.length > 0) { + return new ReadableStream( + { + start(controller) { + if (root.active.length > 0) { + controller.enqueue(s.getDelta(isFirst)); + isFirst = false; + } + + // TODO: why does ts require two args + stack.use( + root.on("node-start", (newNode, _) => { + if (!newNode.parent) s.rootChildrenUpdated = true; + s.changed.set(newNode, new Set()); + emitSoon(); + }), + ); + stack.use( + root.on("node-change", (node, key) => { + let set = s.changed.get(node); + if (!set) s.changed.set(node, (set = new Set())); + set.add(key); + emitSoon(); + }), + ); + stack.use( + root.on("node-end", (node) => { + if (!node.parent) s.rootChildrenUpdated = true; + s.changed.delete(node); + s.deleted.add(node); // handle add and remove in same frame + emitSoon(); + }), + ); + }, + async pull(controller) { + await ready.until((x) => x === true); + ready.value = false; + if (timer) { + timer.cancel(); + timer = null; + } controller.enqueue(s.getDelta(isFirst)); isFirst = false; - } - - // TODO: why does ts require two args - stack.use(root.on("node-start", (newNode, _) => { - if (!newNode.parent) s.rootChildrenUpdated = true; - s.changed.set(newNode, new Set()); - emitSoon(); - })); - stack.use(root.on("node-change", (node, key) => { - let set = s.changed.get(node); - if (!set) s.changed.set(node, set = new Set()); - set.add(key); - emitSoon(); - })); - stack.use(root.on("node-end", (node) => { - if (!node.parent) s.rootChildrenUpdated = true; - s.changed.delete(node); - s.deleted.add(node); // handle add and remove in same frame - emitSoon(); - })); + }, + cancel() { + stack.dispose(); + (0, performance.now)(); + }, }, - async pull(controller) { - await ready.until((x) => x === true); - ready.value = false; - if (timer) { - timer.cancel(); - timer = null; - } - controller.enqueue(s.getDelta(isFirst)); - isFirst = false; - }, - cancel() { - stack.dispose(); - (0, performance.now)(); - }, - }, { highWaterMark: 0 }); + { highWaterMark: 0 }, + ); } /** @@ -1064,13 +1100,10 @@ class Encoder< const { updated = [], started = [] } = Object.groupBy( this.changed, - ([node]) => this.keyMap.has(node) ? "updated" : "started", + ([node]) => (this.keyMap.has(node) ? "updated" : "started"), ); - const payload: StreamEvent = [ - isFirst ? -1 : deleted.length, - ...deleted, - ]; + const payload: StreamEvent = [isFirst ? -1 : deleted.length, ...deleted]; if (started.length > 0) { this.beginNodesDepthFirst( @@ -1082,8 +1115,8 @@ class Encoder< for (const [node, props] of updated) { let logs = undefined; if ( - props.has("logs") - && node.logs.length > (this.logsLength.get(node) ?? 0) + props.has("logs") && + node.logs.length > (this.logsLength.get(node) ?? 0) ) { logs = this.redactLogsIfNeeded( node.logs.slice(this.logsLength.get(node) ?? 0), @@ -1103,11 +1136,13 @@ class Encoder< ? node.children.map((node) => UNWRAP(this.keyMap.get(node))) : undefined, E: props.has("estimatedTime") ? node.estimatedTime : undefined, - V: node.valueFormatter !== defaultValueFormatter - && (props.has("value") || props.has("total") - || props.has("valueFormatter")) - ? node.valueFormatter(node.value, node.total) - : undefined, + V: + node.valueFormatter !== defaultValueFormatter && + (props.has("value") || + props.has("total") || + props.has("valueFormatter")) + ? node.valueFormatter(node.value, node.total) + : undefined, [kNode]: node, }); } @@ -1125,16 +1160,13 @@ class Encoder< return payload; } - beginNodesDepthFirst( - nodes: ReadOnlyNode[], - out: StreamEvent, - ) { + beginNodesDepthFirst(nodes: ReadOnlyNode[], out: StreamEvent) { for (let i = 0; i < nodes.length; i += 1) { const node = UNWRAP(nodes[i]); nodes.push(...node.children); } let next; - while (next = nodes.pop()) { + while ((next = nodes.pop())) { if (!this.keyMap.has(next)) { out.push(this.beginNode(next)); } @@ -1150,19 +1182,25 @@ class Encoder< v: node.value !== 0 ? node.value : undefined, e: node.total !== 0 ? node.total : undefined, t: node.text.length > 0 ? node.text : undefined, - l: node.logs.length > 0 - ? (this.logsLength.set(node, node.logs.length), this.redactLogsIfNeeded(node.logs.slice())) - : undefined, - c: node.children.length > 0 - ? node.children.map((node) => UNWRAP(this.keyMap.get(node), () => [node.children, this.keyMap])) - : undefined, + l: + node.logs.length > 0 + ? (this.logsLength.set(node, node.logs.length), + this.redactLogsIfNeeded(node.logs.slice())) + : undefined, + c: + node.children.length > 0 + ? node.children.map((node) => + UNWRAP(this.keyMap.get(node), () => [node.children, this.keyMap]), + ) + : undefined, p: node.passive === true ? true : undefined, s: node.showTotal === false ? false : undefined, h: node.hidden === true ? true : undefined, E: node.estimatedTime != null ? node.estimatedTime : undefined, - V: node.valueFormatter !== defaultValueFormatter - ? node.valueFormatter(node.value, node.total) - : undefined, + V: + node.valueFormatter !== defaultValueFormatter + ? node.valueFormatter(node.value, node.total) + : undefined, [kNode]: node, }; } @@ -1173,10 +1211,7 @@ class Encoder< } } -class Decoder< - Result = void, - EventMap extends Events.Map = ts.EmptyObject, -> { +class Decoder { target: Ref; events: Events; resolve: (result: Result) => void; @@ -1284,7 +1319,7 @@ class Decoder< } let entry; - while (entry = this.pendingStart.entries().next().value) { + while ((entry = this.pendingStart.entries().next().value)) { const [key, value] = entry; this.startRecursive(key, value); } @@ -1302,9 +1337,8 @@ class Decoder< startRecursive(key: EncodedKey, opts: PendingStart): Ref { ASSERT(this.pendingStart.delete(key)); const parentId = UNWRAP(this.pendingParents.get(key)); - let ref: Ref | null = parentId === 0 - ? this.target - : this.active.get(parentId) ?? null; + let ref: Ref | null = + parentId === 0 ? this.target : (this.active.get(parentId) ?? null); if (!ref) { ref = this.startRecursive( parentId, @@ -1379,10 +1413,7 @@ export function encodeByteStream< } /** convert {@linkcode StreamEvent} into a byte payload. Mutates the input event. */ -function writeStreamEvent( - event: StreamEvent, - writer: stream.WriteTarget, -) { +function writeStreamEvent(event: StreamEvent, writer: stream.WriteTarget) { using w = new stream.BufferedWriter(writer); let deleteCount = event.shift(); @@ -1402,8 +1433,13 @@ function writeStreamEvent( } } - const rootChildren = event.find((x): x is StreamRootChildren => Array.isArray(x) && typeof x[0] === "number"); - const changedNodes = event.filter((x): x is StreamNode => typeof x === "object" && !Array.isArray(x)); + const rootChildren = event.find( + (x): x is StreamRootChildren => + Array.isArray(x) && typeof x[0] === "number", + ); + const changedNodes = event.filter( + (x): x is StreamNode => typeof x === "object" && !Array.isArray(x), + ); w.varUint(changedNodes.length + +!!rootChildren); for (const obj of changedNodes) { ASSERT(typeof obj === "object" && "k" in obj); @@ -1422,19 +1458,21 @@ function writeStreamEvent( ASSERT(messages == null || messages.length > 0); w.varUint(key); - w.u16(encodeDeltaFlags({ - hidden, - passive, - showTotal, - changedValue: value !== undefined, - changedTotal: total !== undefined, - changedLogs: messages !== undefined, - changedText: text !== undefined, - changedChildren: childrenSet !== undefined, - changedEstimatedTime: estimatedTime !== undefined, - changedFormattedValue: formattedValue !== undefined, - estimatedTimeNonNull: estimatedTime !== null, - })); + w.u16( + encodeDeltaFlags({ + hidden, + passive, + showTotal, + changedValue: value !== undefined, + changedTotal: total !== undefined, + changedLogs: messages !== undefined, + changedText: text !== undefined, + changedChildren: childrenSet !== undefined, + changedEstimatedTime: estimatedTime !== undefined, + changedFormattedValue: formattedValue !== undefined, + estimatedTimeNonNull: estimatedTime !== null, + }), + ); if (value !== undefined) w.f64(value); if (total !== undefined) w.f64(total); if (text !== undefined) w.stringWithLength(text); @@ -1444,10 +1482,10 @@ function writeStreamEvent( let level = logLevelSerialize.indexOf(msg.level ?? "info"); if (level === -1) level = 0; w.u8( - level - + (msg.scope ? 1 << 5 : 0) - + (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0) - + (msg.custom !== undefined ? 1 << 7 : 0), + level + + (msg.scope ? 1 << 5 : 0) + + (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0) + + (msg.custom !== undefined ? 1 << 7 : 0), ); w.stringWithLength(msg.text); w.varUint(Math.floor(msg.time)); @@ -1484,7 +1522,9 @@ function writeStreamEvent( } } - const customEvents = event.filter((x): x is StreamCustomEvent => Array.isArray(x) && typeof x[0] === "string"); + const customEvents = event.filter( + (x): x is StreamCustomEvent => Array.isArray(x) && typeof x[0] === "string", + ); w.varUint(customEvents.length); for (const [event, ...args] of customEvents) { w.stringWithLength(event); @@ -1563,9 +1603,9 @@ async function readStreamEvent(r: stream.BufferedReader): Promise { while (len > 0) { const msgFlags = await r.u8(); const level = logLevelSerialize[msgFlags & 0b1111] ?? "info"; - const hasScope = msgFlags && (1 << 5) > 0; - const hasStack = msgFlags && (1 << 6) > 0; - const hasCustom = msgFlags && (1 << 7) > 0; + const hasScope = msgFlags && 1 << 5 > 0; + const hasStack = msgFlags && 1 << 6 > 0; + const hasCustom = msgFlags && 1 << 7 > 0; const text = await r.stringWithLength(); const time = await r.varUint(); const scope = hasScope ? await r.stringWithLength() : undefined; @@ -1596,10 +1636,12 @@ async function readStreamEvent(r: stream.BufferedReader): Promise { if (flags.changedChildren) { c = []; let len = await r.varUint(); - while (len-- > 0) c.push(await r.varUint() as EncodedKey); + while (len-- > 0) c.push((await r.varUint()) as EncodedKey); } const E = flags.changedEstimatedTime - ? flags.estimatedTimeNonNull ? await r.varUint() : null + ? flags.estimatedTimeNonNull + ? await r.varUint() + : null : undefined; const V = flags.changedFormattedValue ? await r.stringWithLength() @@ -1608,7 +1650,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise { // a "v2"'s packet will go here, contents ignored by this version. if (flags.futureProofingPacket) { const len = await r.varUint(); - void await r.readExactly(len); + void (await r.readExactly(len)); } event.push({ @@ -1641,37 +1683,37 @@ const logLevelSerialize = ["info", "error", "warn", "debug"] as const; function encodeDeltaFlags(node: Partial) { return ( - (node.hidden ? 1 << 0 : 0) - + (node.passive ? 1 << 1 : 0) - + (node.showTotal ? 1 << 2 : 0) - + (node.changedValue ? 1 << 3 : 0) - + (node.changedTotal ? 1 << 4 : 0) - + (node.changedLogs ? 1 << 6 : 0) - + (node.changedText ? 1 << 7 : 0) - + (node.changedChildren ? 1 << 8 : 0) - + (node.changedEstimatedTime ? 1 << 9 : 0) - + (node.changedFormattedValue ? 1 << 10 : 0) - + (node.estimatedTimeNonNull ? 1 << 11 : 0) - + (node.futureProofingPacket ? 1 << 14 : 0) - + (node.deleted ? 1 << 15 : 0) + (node.hidden ? 1 << 0 : 0) + + (node.passive ? 1 << 1 : 0) + + (node.showTotal ? 1 << 2 : 0) + + (node.changedValue ? 1 << 3 : 0) + + (node.changedTotal ? 1 << 4 : 0) + + (node.changedLogs ? 1 << 6 : 0) + + (node.changedText ? 1 << 7 : 0) + + (node.changedChildren ? 1 << 8 : 0) + + (node.changedEstimatedTime ? 1 << 9 : 0) + + (node.changedFormattedValue ? 1 << 10 : 0) + + (node.estimatedTimeNonNull ? 1 << 11 : 0) + + (node.futureProofingPacket ? 1 << 14 : 0) + + (node.deleted ? 1 << 15 : 0) ); } function decodeDeltaFlags(flags: number): DeltaFlags { return { - hidden: (flags & 1 << 0) !== 0, - passive: (flags & 1 << 1) !== 0, - showTotal: (flags & 1 << 2) !== 0, - changedValue: (flags & 1 << 3) !== 0, - changedTotal: (flags & 1 << 4) !== 0, - changedLogs: (flags & 1 << 6) !== 0, - changedText: (flags & 1 << 7) !== 0, - changedChildren: (flags & 1 << 8) !== 0, - changedEstimatedTime: (flags & 1 << 9) !== 0, - changedFormattedValue: (flags & 1 << 10) !== 0, - estimatedTimeNonNull: (flags & 1 << 11) !== 0, - futureProofingPacket: (flags & 1 << 14) !== 0, - deleted: (flags & 1 << 15) !== 0, + hidden: (flags & (1 << 0)) !== 0, + passive: (flags & (1 << 1)) !== 0, + showTotal: (flags & (1 << 2)) !== 0, + changedValue: (flags & (1 << 3)) !== 0, + changedTotal: (flags & (1 << 4)) !== 0, + changedLogs: (flags & (1 << 6)) !== 0, + changedText: (flags & (1 << 7)) !== 0, + changedChildren: (flags & (1 << 8)) !== 0, + changedEstimatedTime: (flags & (1 << 9)) !== 0, + changedFormattedValue: (flags & (1 << 10)) !== 0, + estimatedTimeNonNull: (flags & (1 << 11)) !== 0, + futureProofingPacket: (flags & (1 << 14)) !== 0, + deleted: (flags & (1 << 15)) !== 0, }; } @@ -1731,9 +1773,10 @@ export class Ema implements EstimationAlgorithm { if (elapsed === 0) return null; // too quick const rate = current / elapsed; - this.estimate = this.estimate != null - ? (1 - this.alpha) * this.estimate + this.alpha * rate - : rate; + this.estimate = + this.estimate != null + ? (1 - this.alpha) * this.estimate + this.alpha * rate + : rate; if (elapsed < 10_000 || this.estimate <= 0 || this.samples < 3) return null; const remaining = total - current; return time + remaining / this.estimate; @@ -1750,7 +1793,10 @@ type EncodedKey = number & { brand: typeof kNode }; const globalKeyPool = new KeyPool(); -const global: Ref = /** @__PURE__ */ ((root = new Root()) => (attachToScreen(root, log), root))(); +const global: Ref = /** @__PURE__ */ ((root = new Root()) => ( + attachToScreen(root, log), + root +))(); /** * a {@linkcode Ref} to the global progress root. unlike referencing the diff --git a/lib/readme.md b/lib/readme.md index 4622965780f42d10cc919bd5a77b6bba05aa04ba..bd4706e6801cdea9dbf445f70da3409d4b503767 100644 --- a/lib/readme.md +++ b/lib/readme.md @@ -19,11 +19,12 @@ depend on each other. there are no external dependencies; code is easy to audit. - `bytes` - `Uint8Array` helpers - `error` - interop with the `unknown` type in error handlers - `Events` - typed event emitter +- `incremental` - for build tools, tracable `node:vm`, job caching - `log` - logging, terminal i/o, interactive widgets - `log/stack` - stack trace parse and formatter - `Lru` - least recently used cache - `mime` - trimmed database of ext -> mime types -- `node` - the Node.js javascript runtime +- `node` - tree-shakable loaders for Node.js builtins - `progress` - cli progress bars, nesting status indication - `queue` - priority queue with rescheduling - `stream` - ReadableStream helpers @@ -35,7 +36,7 @@ depend on each other. there are no external dependencies; code is easy to audit. - `ts` typescript types and general purpose code there is currently no hosted documentation site. please refer to the source -files' comments. +files' comments and the JSR generated documentation. ## usage diff --git a/lib/ts.ts b/lib/ts.ts index 15b5f1781294dbcd14245bd72c3b6923df8de07f..e406839498903de0eb1fc8fa68bb78e307d659de 100644 --- a/lib/ts.ts +++ b/lib/ts.ts @@ -10,13 +10,19 @@ export type Timer = ReturnType; export type Interval = ReturnType; /** opposite of the built-in `Readonly` type */ export type Writeable = { -readonly [P in keyof T]: T[P] }; +/** A type that can be `await`ed to return `T` */ +export type Awaitable = T | PromiseLike; /** opposite of the built-in `Readonly` type, but recursive */ // TODO: tuples? export type DeepWriteable = { - -readonly [P in keyof T]: T[P] extends ReadonlyArray ? Array + -readonly [P in keyof T]: T[P] extends ReadonlyArray + ? Array : DeepWriteable; }; +declare const brand: unique symbol; +export type Brand = { [brand]: T }; + /** * redeclared here because it is only provided by `lib: ["dom"]` and not * the JavaScript standard types. @@ -25,7 +31,7 @@ export interface VoidFunction { (): void; } -/** Convenience type. Construct with `defer` */ +/** convenience type. Construct with `defer` */ export interface Dispose extends Disposable, VoidFunction {} /** @@ -51,12 +57,10 @@ export function mixin( other: B, ): Mixin { const prototype = Object.getPrototypeOf(other); - for ( - const key of [ - ...Object.getOwnPropertyNames(prototype), - ...Object.getOwnPropertySymbols(prototype), - ] - ) { + for (const key of [ + ...Object.getOwnPropertyNames(prototype), + ...Object.getOwnPropertySymbols(prototype), + ]) { if (key === "constructor") continue; if (key in base) continue; const fn = prototype[key]; @@ -69,15 +73,21 @@ export function mixin( export type EmptyObject = Record; /** TODO: this type has many subtle bugs */ -export type ToJson = T extends JsonValue ? T - : T extends { toJSON(): infer J } ? ToJson - : T extends Set | Map | Record ? EmptyObject - : T extends unknown[] ? { - [K in keyof Omit]: T[K] extends undefined | void ? null - : ToJson; - } - : T extends object ? { [K in keyof Omit]: ToJson } - : never; +export type ToJson = T extends JsonValue + ? T + : T extends { toJSON(): infer J } + ? ToJson + : T extends Set | Map | Record + ? EmptyObject + : T extends unknown[] + ? { + [K in keyof Omit]: T[K] extends undefined | void + ? null + : ToJson; + } + : T extends object + ? { [K in keyof Omit]: ToJson } + : never; export type Json = JsonValue | undefined | void; export type JsonValue = | string diff --git a/framework/hot.ts b/old_stuff/hot.ts similarity index 99% rename from framework/hot.ts rename to old_stuff/hot.ts index 82311f8239c40ff6cc7a4c823bbdec8818d864c3..61168531376c1732d6de1677968b7fdddb82e8fe 100644 --- a/framework/hot.ts +++ b/old_stuff/hot.ts @@ -300,7 +300,6 @@ declare module "node:module" { import * as fs from "#sitegen/fs"; import { UNWRAP } from "@clo/lib/assert"; import * as esbuild from "esbuild"; -import { Buffer } from "node:buffer"; import { createRequire } from "node:module"; import * as path from "node:path"; import { pathToFileURL } from "node:url"; diff --git a/framework/incremental.ts b/old_stuff/incremental.ts similarity index 100% rename from framework/incremental.ts rename to old_stuff/incremental.ts diff --git a/package-lock.json b/package-lock.json index 1ccbd263d5efa344cb0a579268a0df2eff3ca4ab..14a679dc7e2f27a9163cf4c2671a5c0c337fc641 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,11 +29,7 @@ } }, "lib": { - "name": "@clo/lib", - "dependencies": { - "@clo/lib": "file:.", - "@types/node": "^24.0.0" - } + "name": "@clo/lib" }, "node_modules/@clo/lib": { "resolved": "lib", diff --git a/playground.ts b/playground.ts new file mode 100644 index 0000000000000000000000000000000000000000..131d7de4add0287b104c4afb75386819862643d2 --- /dev/null +++ b/playground.ts @@ -0,0 +1,19 @@ +import * as incremental from "./lib/incremental.ts"; +import * as progress from "./lib/progress.ts"; + +const g = new incremental.Graph({ db: "" }); +g.incrementalBuild({ + async buildAndWriteOutput(ref) { + using node = ref.start("sitegen!"); + + const y = node.cache("meow", async (t) => { + const meow = JSON.parse( + await t.fs.readFile("package.json"), + ) as typeof import("./package.json"); + return meow.imports; + }); + + console.log({ y }); + }, + progress, +}); diff --git a/run.js b/run.js index 4457d25bca4bef5cabc974d15633677c29458917..4571cfd94981f68b1dbf55447162d39586c49a46 100644 --- a/run.js +++ b/run.js @@ -1,152 +1,103 @@ #!/usr/bin/env node // This file allows using Node.js in combination with // all available plugins. Usage: "node run