feat(cordis): add repository package cache

This commit is contained in:
Tianyi Cui
2026-07-30 03:45:49 +08:00
parent f941ba1b5f
commit fd4d369907
14 changed files with 482 additions and 6 deletions

5
vendor/README.md vendored
View File

@@ -39,8 +39,9 @@ Keep this log exhaustive — every divergence from upstream must be listed.
7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`.
10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm.
11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`.
## Sync procedure

View File

@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./repository": {
"types": "./lib/types/repository.d.ts",
"default": "./lib/repository.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/repository.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -32,6 +37,7 @@
}
},
"dependencies": {
"cosmokit": "^1.8.1"
"cosmokit": "^1.8.1",
"pnpm": "11.7.0"
}
}

191
vendor/loader/src/repository.ts vendored Normal file
View File

@@ -0,0 +1,191 @@
/**
* Exact-specifier repository packages installed through the Loader's bundled
* pnpm. The caller owns source validation and the cache root; this module owns
* isolated installation, single-flight reuse, and atomic cache publication.
*/
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
/** Exact pnpm release shipped with the Loader for repository installation. */
export const BUNDLED_PNPM_VERSION = '11.7.0'
const DEPENDENCY_NAME = 'repository'
const MARKER_NAME = '.repository-cache.json'
const MAX_ERROR_OUTPUT = 32 * 1024
const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
/** Injectable isolated-install boundary used by {@link RepositoryCache}. */
export type RepositoryInstall = (directory: string) => Promise<void>
interface CacheMarker {
specifier: string
}
function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name)))
}
function appendOutput(current: string, chunk: Uint8Array): string {
const combined = current + Buffer.from(chunk).toString('utf8')
return combined.length <= MAX_ERROR_OUTPUT ? combined : combined.slice(-MAX_ERROR_OUTPUT)
}
async function installWithBundledPnpm(directory: string): Promise<void> {
const require = createRequire(import.meta.url)
const pnpmManifest = require.resolve('pnpm')
const pnpmBin = join(dirname(pnpmManifest), 'bin', 'pnpm.mjs')
let output = ''
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
const child = spawn(process.execPath, [
pnpmBin,
'install',
'--no-frozen-lockfile',
'--reporter=append-only',
], {
cwd: directory,
env: scrubEnvironment(),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
})
child.stdout.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) })
child.stderr.on('data', (chunk: Uint8Array) => { output = appendOutput(output, chunk) })
child.once('error', reject)
child.once('close', (code, signal) => { resolve({ code, signal }) })
})
if (result.signal !== null) {
throw new Error(`bundled pnpm install was killed by ${result.signal}${output ? `\n${output.trimEnd()}` : ''}`)
}
if (result.code !== 0) {
throw new Error(`bundled pnpm install exited with code ${String(result.code)}${output ? `\n${output.trimEnd()}` : ''}`)
}
}
function cacheKey(specifier: string): string {
return createHash('sha256').update(specifier).digest('hex')
}
async function readCached(directory: string, specifier: string): Promise<string | undefined> {
let content: string
try {
content = await readFile(join(directory, MARKER_NAME), 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
throw error
}
let parsed: unknown
try {
parsed = JSON.parse(content) as unknown
} catch (error) {
throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`, { cause: error })
}
if (typeof parsed !== 'object' || parsed === null || typeof (parsed as Partial<CacheMarker>).specifier !== 'string') {
throw new Error(`repository cache marker is invalid: ${join(directory, MARKER_NAME)}`)
}
const marker = parsed as CacheMarker
if (marker.specifier !== specifier) {
throw new Error(`repository cache key collision for ${JSON.stringify(specifier)}`)
}
const packageDirectory = join(directory, 'node_modules', DEPENDENCY_NAME)
let packageStat
try {
packageStat = await stat(packageDirectory)
} catch (error) {
throw new Error(`repository cache entry is incomplete: ${directory}`, { cause: error })
}
if (!packageStat.isDirectory()) throw new Error(`repository cache package is not a directory: ${packageDirectory}`)
return packageDirectory
}
async function removeStaging(directory: string, cause: unknown): Promise<never> {
try {
await rm(directory, { recursive: true, force: true })
} catch (cleanupError) {
throw new AggregateError([cause, cleanupError], `failed to clean repository staging directory ${directory}`)
}
throw cause
}
/**
* Persistent exact-specifier package cache backed by bundled pnpm.
*
* One isolated project contains one dependency named `repository`. A successful
* install is atomically renamed into its SHA-256 key, so failed installs never
* become cache hits. The exact specifier is immutable: callers change the
* specifier (normally its Git ref) to request another generation.
*/
export class RepositoryCache {
/** Absolute directory containing immutable repository cache entries. */
readonly directory: string
private readonly tasks = new Map<string, Promise<string>>()
/**
* @param directory - caller-owned persistent cache root.
* @param install - isolated package installation boundary; defaults to the bundled pnpm.
*/
constructor(directory: string, private readonly install: RepositoryInstall = installWithBundledPnpm) {
this.directory = resolve(directory)
}
/**
* Resolve one package-manager-native dependency specifier to its installed package directory.
* @param specifier - exact immutable dependency specifier used as the permanent cache identity.
* @returns the installed `repository` dependency directory.
* @throws when the specifier is empty/padded, installation fails, or a published cache entry is corrupt.
*/
resolve(specifier: string): Promise<string> {
if (!specifier || specifier.trim() !== specifier) {
throw new TypeError('repository specifier must be a non-empty unpadded string')
}
const existing = this.tasks.get(specifier)
if (existing) return existing
const task = this.resolveUncached(specifier).finally(() => {
if (this.tasks.get(specifier) === task) this.tasks.delete(specifier)
})
this.tasks.set(specifier, task)
return task
}
private async resolveUncached(specifier: string): Promise<string> {
const finalDirectory = join(this.directory, cacheKey(specifier))
const cached = await readCached(finalDirectory, specifier)
if (cached) return cached
await mkdir(this.directory, { recursive: true })
const staging = await mkdtemp(join(this.directory, '.repository-'))
try {
await writeFile(join(staging, 'package.json'), `${JSON.stringify({
name: 'cordis-repository-cache-entry',
private: true,
version: '0.0.0',
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
dependencies: { [DEPENDENCY_NAME]: specifier },
}, undefined, 2)}\n`)
await writeFile(join(staging, 'pnpm-workspace.yaml'), [
'packages: []',
'dangerouslyAllowAllBuilds: true',
'',
].join('\n'))
await this.install(staging)
const packageDirectory = join(staging, 'node_modules', DEPENDENCY_NAME)
const packageStat = await stat(packageDirectory)
if (!packageStat.isDirectory()) throw new Error(`installed repository is not a directory: ${packageDirectory}`)
await writeFile(join(staging, MARKER_NAME), `${JSON.stringify({ specifier })}\n`)
try {
await rename(staging, finalDirectory)
} catch (error) {
const winner = await readCached(finalDirectory, specifier)
if (!winner) throw error
await rm(staging, { recursive: true, force: true })
return winner
}
} catch (error) {
return removeStaging(staging, new Error(`failed to prepare repository ${JSON.stringify(specifier)}`, { cause: error }))
}
return (await readCached(finalDirectory, specifier))!
}
}

18
vendor/loader/tsdown.config.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'tsdown'
/** Keep the browser-reachable Loader entry separate from the Node-only repository cache. */
const shared = {
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
outputOptions: { codeSplitting: false },
dts: false,
clean: false,
} as const
export default defineConfig([
{ ...shared, entry: ['lib/types/index.js'] },
{ ...shared, entry: ['lib/types/repository.js'] },
])