feat: 新增web配置中心

This commit is contained in:
lenmotion 2026-04-23 17:25:37 +08:00
parent cab53e7947
commit 8d20792fc3
7 changed files with 650 additions and 7 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
Thumbs.db

View File

@ -95,7 +95,21 @@ npm run typecheck
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `http://localhost:<proxyPort>/__config` | 查看当前路由与配置 |
| `POST` | `http://localhost:<proxyPort>/__config` | 保存 `config.json`(包含 `routes``config` |
| `POST` | `http://localhost:<proxyPort>/__reload-config` | 手动重新加载 `config.json` |
| `POST` | `http://localhost:<proxyPort>/__routes` | 动态新增单个路由与 mock 文件 |
| `GET` | `http://localhost:<proxyPort>/__admin` | 配置管理页面Element UI |
#### `POST /__routes` 请求示例
```json
{
"route": "/api/new/mock",
"filePath": "mock/new-api.json",
"fileContent": "{\"code\":0,\"message\":\"ok\"}",
"overwrite": false
}
```
### TypeScript 与编译说明

293
admin.html Normal file
View File

@ -0,0 +1,293 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API Proxy Mock 配置管理</title>
<link
rel="stylesheet"
href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"
/>
<style>
body {
margin: 0;
background: #f5f7fa;
}
.container {
max-width: 1200px;
margin: 20px auto;
padding: 0 16px 24px;
}
.section-card {
margin-bottom: 16px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.small-text {
color: #909399;
font-size: 12px;
}
</style>
</head>
<body>
<div id="app" class="container">
<el-card class="section-card">
<div slot="header"><strong>基础配置</strong></div>
<el-form :model="form.config" label-width="180px">
<el-form-item label="Mock 开关">
<el-switch v-model="form.config.mockEnabled"></el-switch>
</el-form-item>
<el-form-item label="自动重载配置文件">
<el-switch v-model="form.config.reloadOnChange"></el-switch>
</el-form-item>
<el-form-item label="默认响应 Content-Type">
<el-input v-model="form.config.defaultContentType"></el-input>
</el-form-item>
<el-form-item label="本地代理端口">
<el-input-number
v-model="form.config.proxyPort"
:min="1"
:max="65535"
></el-input-number>
</el-form-item>
<el-form-item label="目标主机">
<el-input v-model="form.config.targetHost"></el-input>
</el-form-item>
<el-form-item label="目标端口">
<el-input-number
v-model="form.config.targetPort"
:min="1"
:max="65535"
></el-input-number>
</el-form-item>
<el-form-item label="目标 HTTPS">
<el-switch v-model="form.config.targetHttps"></el-switch>
</el-form-item>
</el-form>
</el-card>
<el-card class="section-card">
<div slot="header" style="display:flex;justify-content:space-between;align-items:center;">
<strong>路由配置routes</strong>
<el-button size="mini" type="primary" @click="addRouteRow">新增一行</el-button>
</div>
<el-table :data="form.routes" border>
<el-table-column label="请求路径" min-width="260">
<template slot-scope="scope">
<el-input v-model="scope.row.route" placeholder="/api/example"></el-input>
</template>
</el-table-column>
<el-table-column label="Mock 文件路径" min-width="300">
<template slot-scope="scope">
<el-input
v-model="scope.row.filePath"
placeholder="mock/example.json"
></el-input>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template slot-scope="scope">
<el-button size="mini" type="danger" @click="removeRoute(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="small-text" style="margin-top:8px;">
说明:如果路由以 # 开头,会作为注释保留,不参与 mock 命中。
</div>
</el-card>
<el-card class="section-card">
<div slot="header"><strong>动态新增接口配置文件</strong></div>
<el-form :model="newRoute" label-width="180px">
<el-form-item label="内容模板">
<el-select
v-model="newRoute.template"
placeholder="请选择模板"
@change="applyTemplate"
style="width: 100%;"
>
<el-option label="自定义内容" value="custom"></el-option>
<el-option label="基础失败模板" value="basicError"></el-option>
</el-select>
</el-form-item>
<el-form-item label="请求路径">
<el-input v-model="newRoute.route" placeholder="/api/new/mock"></el-input>
</el-form-item>
<el-form-item label="Mock 文件路径">
<el-input
v-model="newRoute.filePath"
placeholder="mock/new-api.json"
:disabled="newRoute.template === 'basicError'"
></el-input>
</el-form-item>
<el-form-item label="文件内容">
<el-input
type="textarea"
:rows="8"
v-model="newRoute.fileContent"
placeholder='{"code":0,"message":"ok"}'
:disabled="newRoute.template === 'basicError'"
></el-input>
</el-form-item>
<el-form-item label="覆盖已存在文件">
<el-switch
v-model="newRoute.overwrite"
:disabled="newRoute.template === 'basicError'"
></el-switch>
</el-form-item>
</el-form>
<el-button type="success" @click="createRouteAndFile">创建路由+文件</el-button>
</el-card>
<el-card>
<div class="actions">
<el-button type="primary" @click="saveConfig">保存配置到 config.json</el-button>
<el-button @click="reloadConfig">重载服务内存配置</el-button>
<el-button @click="loadConfig">刷新页面数据</el-button>
</div>
</el-card>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<script>
new Vue({
el: "#app",
data: function () {
return {
form: {
config: {
mockEnabled: true,
cacheConfig: true,
reloadOnChange: true,
defaultContentType: "application/json",
proxyPort: 8877,
targetHost: "",
targetPort: 443,
targetHttps: true,
},
routes: [],
},
newRoute: {
template: "custom",
route: "",
filePath: "mock/",
fileContent: "",
overwrite: false,
},
};
},
created: function () {
this.loadConfig();
},
methods: {
toRouteArray: function (routesObj) {
return Object.keys(routesObj || {}).map(function (route) {
return { route: route, filePath: routesObj[route] };
});
},
toRouteObject: function (routeArray) {
var obj = {};
(routeArray || []).forEach(function (item) {
var route = (item.route || "").trim();
var filePath = (item.filePath || "").trim();
if (route && filePath) {
obj[route] = filePath;
}
});
return obj;
},
addRouteRow: function () {
this.form.routes.push({ route: "", filePath: "" });
},
removeRoute: function (index) {
this.form.routes.splice(index, 1);
},
loadConfig: async function () {
try {
var resp = await fetch("/__config");
var data = await resp.json();
this.form.config = Object.assign({}, this.form.config, data.config || {});
this.form.routes = this.toRouteArray(data.routes || {});
} catch (err) {
this.$message.error("加载配置失败: " + err.message);
}
},
saveConfig: async function () {
var payload = {
config: this.form.config,
routes: this.toRouteObject(this.form.routes),
};
try {
var resp = await fetch("/__config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "保存失败");
}
this.$message.success("配置已保存");
} catch (err) {
this.$message.error("保存失败: " + err.message);
}
},
reloadConfig: async function () {
try {
var resp = await fetch("/__reload-config", { method: "POST" });
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "重载失败");
}
this.$message.success("配置已重载");
this.loadConfig();
} catch (err) {
this.$message.error("重载失败: " + err.message);
}
},
createRouteAndFile: async function () {
try {
var resp = await fetch("/__routes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
Object.assign({}, this.newRoute, {
useExistingFile: this.newRoute.template === "basicError",
}),
),
});
var data = await resp.json();
if (!resp.ok || data.success === false) {
throw new Error(data.error || "创建失败");
}
this.$message.success("接口配置文件创建成功");
this.newRoute.template = "custom";
this.newRoute.route = "";
this.newRoute.filePath = "mock/";
this.newRoute.fileContent = "";
this.newRoute.overwrite = false;
this.loadConfig();
} catch (err) {
this.$message.error("创建失败: " + err.message);
}
},
applyTemplate: function (template) {
if (template === "basicError") {
this.newRoute.filePath = "mock/basic-error.json";
this.newRoute.fileContent =
'{"code": -1, "success": false, "msg":"失败"}';
this.newRoute.overwrite = false;
return;
}
this.newRoute.filePath = "mock/";
this.newRoute.fileContent = "";
},
},
});
</script>
</body>
</html>

View File

@ -1,6 +1,7 @@
{
"routes": {
"/api2/admin/banner/list": "mock/banner.txt"
"/api2/admin/banner/list": "mock/banner.txt",
"/api2/home/ad/list": "mock/basic-error.json"
},
"config": {
"mockEnabled": true,

View File

@ -33,6 +33,7 @@ interface ConfigFile {
// 存储当前的路由配置
let MOCK_ROUTES: RouteConfig = {};
let RAW_ROUTES: RouteConfig = {};
let CONFIG: AppConfig = {
cacheConfig: true,
reloadOnChange: true,
@ -110,13 +111,15 @@ function loadConfig() {
const configData: ConfigFile = JSON.parse(
fs.readFileSync(CONFIG_FILE, "utf-8"),
);
MOCK_ROUTES = filterActiveRoutes(configData.routes || {});
RAW_ROUTES = configData.routes || {};
MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES);
CONFIG = configData.config || CONFIG;
} else {
const configData: ConfigFile = JSON.parse(
fs.readFileSync(CONFIG_FILE, "utf-8"),
);
MOCK_ROUTES = filterActiveRoutes(configData.routes || {});
RAW_ROUTES = configData.routes || {};
MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES);
CONFIG = configData.config || CONFIG;
console.log(
`[CONFIG] 配置文件已加载,共 ${Object.keys(MOCK_ROUTES).length} 个路由${CONFIG.mockEnabled !== false ? "" : "mock 已关闭,全部走代理)"}`,
@ -131,6 +134,7 @@ function loadConfig() {
MOCK_ROUTES = {
"/api2/user/list": "mock/user_list.txt",
};
RAW_ROUTES = { ...MOCK_ROUTES };
CONFIG = {
cacheConfig: true,
reloadOnChange: true,
@ -142,6 +146,31 @@ function loadConfig() {
}
}
function buildCurrentConfigFile(): ConfigFile {
return {
routes: { ...RAW_ROUTES },
config: { ...CONFIG },
};
}
function saveConfigFile(nextConfig: ConfigFile): void {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(nextConfig, null, 2), "utf-8");
RAW_ROUTES = nextConfig.routes || {};
MOCK_ROUTES = filterActiveRoutes(RAW_ROUTES);
CONFIG = nextConfig.config || CONFIG;
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
// 验证mock文件是否存在
function validateMockFiles() {
console.log(`[CONFIG] 验证mock文件...`);
@ -255,27 +284,58 @@ const proxyServer = http.createServer((clientReq, clientRes) => {
}
if (requestPath === "/__config") {
if (clientReq.method !== "GET") {
if (clientReq.method !== "GET" && clientReq.method !== "POST") {
clientRes.writeHead(405, {
"Content-Type": "application/json",
Allow: "GET",
Allow: "GET, POST",
});
clientRes.end(
JSON.stringify({
success: false,
error: "Method Not Allowed",
allow: ["GET"],
allow: ["GET", "POST"],
}),
);
return;
}
if (clientReq.method === "POST") {
readBody(clientReq)
.then((bodyText) => {
const body = bodyText ? JSON.parse(bodyText) : {};
const nextConfig: ConfigFile = {
routes: body.routes || {},
config: body.config || CONFIG,
};
saveConfigFile(nextConfig);
validateMockFiles();
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: true,
message: "Configuration saved successfully",
totalRoutes: Object.keys(MOCK_ROUTES).length,
}),
);
})
.catch((error) => {
clientRes.writeHead(400, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: false,
error: (error as Error).message,
}),
);
});
return;
}
try {
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify(
{
routes: MOCK_ROUTES,
routes: RAW_ROUTES,
config: CONFIG,
timestamp: new Date().toISOString(),
totalRoutes: Object.keys(MOCK_ROUTES).length,
@ -296,6 +356,105 @@ const proxyServer = http.createServer((clientReq, clientRes) => {
return;
}
if (requestPath === "/__routes") {
if (clientReq.method !== "POST") {
clientRes.writeHead(405, {
"Content-Type": "application/json",
Allow: "POST",
});
clientRes.end(
JSON.stringify({
success: false,
error: "Method Not Allowed",
allow: ["POST"],
}),
);
return;
}
readBody(clientReq)
.then((bodyText) => {
const body = bodyText ? JSON.parse(bodyText) : {};
const route = String(body.route || "").trim();
const filePath = String(body.filePath || "").trim();
const fileContent = String(body.fileContent || "");
const overwrite = body.overwrite === true;
const template = String(body.template || "").trim();
const useExistingFile =
body.useExistingFile === true || template === "basicError";
if (!route.startsWith("/")) {
throw new Error("route must start with '/'");
}
if (!filePath) {
throw new Error("filePath is required");
}
if (path.isAbsolute(filePath)) {
throw new Error("filePath must be a relative path");
}
const fullPath = path.join(__dirname, filePath);
if (!fullPath.startsWith(__dirname)) {
throw new Error("filePath is invalid");
}
if (useExistingFile) {
if (!fs.existsSync(fullPath)) {
throw new Error("mock file does not exist");
}
} else {
if (!overwrite && fs.existsSync(fullPath)) {
throw new Error(
"mock file already exists, set overwrite=true to replace",
);
}
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, fileContent, "utf-8");
}
const nextConfig = buildCurrentConfigFile();
nextConfig.routes[route] = filePath;
saveConfigFile(nextConfig);
clientRes.writeHead(200, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: true,
message: "Route and mock file created successfully",
route,
filePath,
}),
);
})
.catch((error) => {
clientRes.writeHead(400, { "Content-Type": "application/json" });
clientRes.end(
JSON.stringify({
success: false,
error: (error as Error).message,
}),
);
});
return;
}
if (requestPath === "/__admin") {
if (clientReq.method !== "GET") {
clientRes.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" });
clientRes.end("Method Not Allowed");
return;
}
const adminHtmlPath = path.join(__dirname, "admin.html");
if (!fs.existsSync(adminHtmlPath)) {
clientRes.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
clientRes.end("admin.html not found");
return;
}
const html = fs.readFileSync(adminHtmlPath, "utf-8");
clientRes.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
clientRes.end(html);
return;
}
// 检查是否为需要mock的路由
if (isMockRoute(requestPath)) {
const mockFile = getMockFilePath(requestPath);

1
mock/basic-error.json Normal file
View File

@ -0,0 +1 @@
{"code": -1, "success": false, "msg": "失败"}

168
pnpm-lock.yaml generated Normal file
View File

@ -0,0 +1,168 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^25.5.0
version: 25.6.0
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@25.6.0)(typescript@5.9.3)
typescript:
specifier: ^5.7.3
version: 5.9.3
packages:
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@tsconfig/node10@1.0.12':
resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==}
'@tsconfig/node12@1.0.11':
resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
'@tsconfig/node14@1.0.3':
resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
acorn-walk@8.3.5:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
create-require@1.1.1:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
diff@4.0.4:
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
engines: {node: '>=0.3.1'}
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
ts-node@10.9.2:
resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
hasBin: true
peerDependencies:
'@swc/core': '>=1.2.50'
'@swc/wasm': '>=1.2.50'
'@types/node': '*'
typescript: '>=2.7'
peerDependenciesMeta:
'@swc/core':
optional: true
'@swc/wasm':
optional: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
snapshots:
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@tsconfig/node10@1.0.12': {}
'@tsconfig/node12@1.0.11': {}
'@tsconfig/node14@1.0.3': {}
'@tsconfig/node16@1.0.4': {}
'@types/node@25.6.0':
dependencies:
undici-types: 7.19.2
acorn-walk@8.3.5:
dependencies:
acorn: 8.16.0
acorn@8.16.0: {}
arg@4.1.3: {}
create-require@1.1.1: {}
diff@4.0.4: {}
make-error@1.3.6: {}
ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
'@tsconfig/node10': 1.0.12
'@tsconfig/node12': 1.0.11
'@tsconfig/node14': 1.0.3
'@tsconfig/node16': 1.0.4
'@types/node': 25.6.0
acorn: 8.16.0
acorn-walk: 8.3.5
arg: 4.1.3
create-require: 1.1.1
diff: 4.0.4
make-error: 1.3.6
typescript: 5.9.3
v8-compile-cache-lib: 3.0.1
yn: 3.1.1
typescript@5.9.3: {}
undici-types@7.19.2: {}
v8-compile-cache-lib@3.0.1: {}
yn@3.1.1: {}