feat(cordis): add repository package cache
This commit is contained in:
5
vendor/README.md
vendored
5
vendor/README.md
vendored
@@ -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
|
||||
|
||||
|
||||
8
vendor/loader/package.json
vendored
8
vendor/loader/package.json
vendored
@@ -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
191
vendor/loader/src/repository.ts
vendored
Normal 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
18
vendor/loader/tsdown.config.ts
vendored
Normal 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'] },
|
||||
])
|
||||
Reference in New Issue
Block a user