dsh-plugin-pack 0.0.0-stage → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +537 -3
- package/bin/dsh-plugin-pack.js +200 -0
- package/client.js +2337 -0
- package/cordis.patch.yml +24 -0
- package/hotload.js +230 -0
- package/lib/export.js +452 -0
- package/lib/home.js +75 -0
- package/lib/import-plugin.js +356 -0
- package/lib/import-tgz.js +115 -0
- package/lib/import.js +831 -0
- package/lib/inbox.js +194 -0
- package/lib/index.js +725 -0
- package/lib/jobs.js +208 -0
- package/lib/patch-text.js +672 -0
- package/lib/scan.js +488 -0
- package/lib/server.js +926 -0
- package/lib/tar.js +241 -0
- package/lib/util.js +333 -0
- package/lib/zip-stream.js +166 -0
- package/lib/zip.js +514 -0
- package/package.json +72 -6
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# dsh-plugin-pack bundle patch
|
|
2
|
+
#
|
|
3
|
+
# 只插一行:插件本体。
|
|
4
|
+
# - 宿主侧:工具 plugin_pack_*、/pack 命令、/plugin-pack/* HTTP 路由(lib/index.js + lib/server.js)
|
|
5
|
+
# - 客户端:插件页顶部「导入 / 导出」两个标签页 + 插件行详情 + 设置页(client.js)
|
|
6
|
+
# - 热加载:hotload.js,改完 client 代码或 package.json 不必重启(/pack hotload 或界面按钮)
|
|
7
|
+
- insert:
|
|
8
|
+
- id: dsh-plugin-pack
|
|
9
|
+
name: dsh-plugin-pack
|
|
10
|
+
config:
|
|
11
|
+
enabled: true
|
|
12
|
+
# 空字符串 = $DSH_HOME/exports
|
|
13
|
+
exportDir: ''
|
|
14
|
+
# 默认不打包插件自己的数据目录(可能含 token)
|
|
15
|
+
includeData: false
|
|
16
|
+
# 默认剔除 .env / *.pem / credentials.json 之类
|
|
17
|
+
includeSecrets: false
|
|
18
|
+
# 体积上限,按**未压缩**字节算(压缩比约 1:3)。
|
|
19
|
+
# 默认 2 GiB 原始 ≈ 600~700 MiB 成品,够全量备份;界面里也能改,0 = 不限制。
|
|
20
|
+
maxBytes: 2147483648
|
|
21
|
+
# 导入时是否覆盖已存在的同 id patch 行
|
|
22
|
+
overwritePatchConfig: false
|
|
23
|
+
# 启动时自动把自己的客户端半身热加载一次(失败只记日志,不阻断)
|
|
24
|
+
selfHotload: true
|
package/hotload.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 客户端 bundle 热加载。
|
|
3
|
+
*
|
|
4
|
+
* 背景(读宿主 dsh-client-modules 源码得出的事实):
|
|
5
|
+
*
|
|
6
|
+
* 1. 宿主启动时扫描 Loader 行 → 读 package.json → 判定 `dsh.client.platform === 'web'`
|
|
7
|
+
* 且 `exports["./client"]` 存在 → 才把包入表,之后 `/plugins/??<id>/client.js&rev=<hash>` 才有内容。
|
|
8
|
+
* 2. 判定结果(包括"不是客户端包"这种否定结论)缓存在 `pkgMeta: Map<sourceKey, meta|null>` 里,
|
|
9
|
+
* **没有任何失效机制** —— `resolveMeta()` 命中缓存就直接返回,从不重读 package.json。
|
|
10
|
+
* 这就是为什么改了 package.json 也看不到界面,必须重启。
|
|
11
|
+
* 3. 唯一能把改动送进图表的是 `rebuilt(id)`,但它只在 `table` 里已有这个包时才有效。
|
|
12
|
+
* 4. `dirty: Set<entryName>` + `flush(onError)` 是重扫入口,`processOne` → `resolveSource`
|
|
13
|
+
* → `resolveMeta` 会**再走一次**判定 —— 只要把缓存删掉,重扫就会读到新值。
|
|
14
|
+
*
|
|
15
|
+
* 所以热加载 = 删缓存 + 标记 dirty + flush,再对已入表的包走 rebuilt。
|
|
16
|
+
* 全部从宿主进程内部做,不需要重启。
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-plugin-pack/hotload
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
/** 宿主服务名。 */
|
|
24
|
+
const SERVICE = 'clientModules';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从 ctx 里取出 clientModules 服务实例。
|
|
28
|
+
*
|
|
29
|
+
* 优先 ctx.get(),其次直接取属性 —— 不同 cordis 版本暴露方式略有差别。
|
|
30
|
+
*
|
|
31
|
+
* @param {object} ctx
|
|
32
|
+
* @returns {object|null}
|
|
33
|
+
*/
|
|
34
|
+
export function getRegistry(ctx) {
|
|
35
|
+
if (!ctx) return null;
|
|
36
|
+
try {
|
|
37
|
+
const viaGet = typeof ctx.get === 'function' ? ctx.get(SERVICE) : undefined;
|
|
38
|
+
if (viaGet) return viaGet;
|
|
39
|
+
} catch {
|
|
40
|
+
/* 服务不存在时 cordis 会抛 */
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const direct = ctx[SERVICE];
|
|
44
|
+
if (direct) return direct;
|
|
45
|
+
} catch {
|
|
46
|
+
/* 需要 inject 才能取属性 */
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 一个 Loader 行的 name 是否属于这个包。 */
|
|
52
|
+
function isRowOf(loaderName, packageName, packageDir) {
|
|
53
|
+
if (loaderName === packageName) return true;
|
|
54
|
+
// 路径形态的行(patch 里写死绝对路径的插件)
|
|
55
|
+
const normalized = String(loaderName).replace(/\\/g, '/');
|
|
56
|
+
const lowered = normalized.toLowerCase();
|
|
57
|
+
const dir = String(packageDir).replace(/\\/g, '/').toLowerCase();
|
|
58
|
+
if (!lowered.includes('/') && !lowered.includes(':')) return false;
|
|
59
|
+
return lowered === dir || lowered.startsWith(`${dir}/`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 收集 registry 里所有和这个包相关的 sourceKey。
|
|
64
|
+
*
|
|
65
|
+
* pkgMeta 的 key 形如 `<baseUrl>\0<loaderName>`,所以要按 loaderName 匹配。
|
|
66
|
+
*
|
|
67
|
+
* @param {object} registry
|
|
68
|
+
* @param {string} packageName
|
|
69
|
+
* @param {string} packageDir
|
|
70
|
+
* @returns {{ sourceKeys: string[], loaderNames: string[] }}
|
|
71
|
+
*/
|
|
72
|
+
export function keysForPackage(registry, packageName, packageDir) {
|
|
73
|
+
const sourceKeys = [];
|
|
74
|
+
const loaderNames = new Set();
|
|
75
|
+
const meta = registry?.pkgMeta;
|
|
76
|
+
if (!(meta instanceof Map)) return { sourceKeys, loaderNames };
|
|
77
|
+
|
|
78
|
+
for (const key of meta.keys()) {
|
|
79
|
+
const raw = String(key);
|
|
80
|
+
const at = raw.indexOf('\0');
|
|
81
|
+
const loaderName = at >= 0 ? raw.slice(at + 1) : raw;
|
|
82
|
+
if (isRowOf(loaderName, packageName, packageDir)) {
|
|
83
|
+
sourceKeys.push(key);
|
|
84
|
+
loaderNames.add(loaderName);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { sourceKeys, loaderNames: [...loaderNames] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 让宿主重新扫描一个包,把改动送进客户端图表。
|
|
92
|
+
*
|
|
93
|
+
* @param {object} ctx cordis 上下文
|
|
94
|
+
* @param {object} options
|
|
95
|
+
* @param {string} options.packageName 包名(Loader 行 name 常见取值)
|
|
96
|
+
* @param {string} options.packageDir 包目录绝对路径(用于匹配路径形态的 Loader 行)
|
|
97
|
+
* @param {(msg: string) => void} [options.log]
|
|
98
|
+
* @returns {object} 结果
|
|
99
|
+
*/
|
|
100
|
+
export function reloadClientPackage(ctx, options = {}) {
|
|
101
|
+
const log = typeof options.log === 'function' ? options.log : () => {};
|
|
102
|
+
const packageName = String(options.packageName ?? '').trim();
|
|
103
|
+
const packageDir = String(options.packageDir ?? '').trim();
|
|
104
|
+
if (!packageName) return { ok: false, error: '需要 packageName' };
|
|
105
|
+
|
|
106
|
+
const registry = getRegistry(ctx);
|
|
107
|
+
if (!registry) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
error: `宿主没有 ${SERVICE} 服务(可能不是 Web/桌面端,或版本不支持)`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const steps = [];
|
|
115
|
+
const { sourceKeys, loaderNames } = keysForPackage(registry, packageName, packageDir);
|
|
116
|
+
steps.push(`匹配到 ${sourceKeys.length} 条缓存 / ${loaderNames.length} 个 Loader 行`);
|
|
117
|
+
|
|
118
|
+
// 1) 删掉否定缓存,让 resolveMeta 重读 package.json
|
|
119
|
+
if (registry.pkgMeta instanceof Map) {
|
|
120
|
+
for (const key of sourceKeys) registry.pkgMeta.delete(key);
|
|
121
|
+
steps.push(`已清除 pkgMeta 缓存 ${sourceKeys.length} 条`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 2) 标记 dirty:processOne 会重新走 resolveSource → resolveMeta
|
|
125
|
+
const names = loaderNames.length ? loaderNames : [packageName];
|
|
126
|
+
if (!(registry.dirty instanceof Set)) {
|
|
127
|
+
return { ok: false, error: 'registry.dirty 不是 Set,宿主版本不兼容' };
|
|
128
|
+
}
|
|
129
|
+
for (const name of names) registry.dirty.add(name);
|
|
130
|
+
steps.push(`已标记 dirty: ${names.join(', ')}`);
|
|
131
|
+
|
|
132
|
+
// 3) 触发重扫
|
|
133
|
+
const failures = [];
|
|
134
|
+
if (typeof registry.flush !== 'function') {
|
|
135
|
+
return { ok: false, error: 'registry.flush 不可用,宿主版本不兼容' };
|
|
136
|
+
}
|
|
137
|
+
registry.flush((error) => failures.push(String(error?.message ?? error)));
|
|
138
|
+
steps.push(`flush 完成,失败 ${failures.length} 条`);
|
|
139
|
+
|
|
140
|
+
// 4) 若已入表,走 rebuilt 让浏览器拿到新 rev
|
|
141
|
+
const inTable = registry.table instanceof Map ? registry.table.has(packageName) : false;
|
|
142
|
+
let rev = null;
|
|
143
|
+
if (inTable && typeof registry.rebuilt === 'function') {
|
|
144
|
+
rev = registry.rebuilt(packageName) ?? null;
|
|
145
|
+
steps.push(`rebuilt → rev=${rev}`);
|
|
146
|
+
} else {
|
|
147
|
+
steps.push('未入表(重扫可能失败,或 Loader 行不属于本包)');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const step of steps) log(step);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
ok: inTable,
|
|
154
|
+
packageName,
|
|
155
|
+
inTable,
|
|
156
|
+
rev,
|
|
157
|
+
clearedCaches: sourceKeys.length,
|
|
158
|
+
loaderNames: names,
|
|
159
|
+
failures,
|
|
160
|
+
steps,
|
|
161
|
+
error: inTable ? null : '重扫后仍不在图表里:检查 dsh.client.platform 是否为 web、exports["./client"] 是否存在、Loader 行 name 是否为包名',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 只读检查:这个包现在在宿主的客户端图表里吗?
|
|
167
|
+
*
|
|
168
|
+
* @param {object} ctx
|
|
169
|
+
* @param {string} packageName
|
|
170
|
+
* @returns {object}
|
|
171
|
+
*/
|
|
172
|
+
export function clientStatus(ctx, packageName) {
|
|
173
|
+
const registry = getRegistry(ctx);
|
|
174
|
+
if (!registry) return { available: false, reason: `没有 ${SERVICE} 服务` };
|
|
175
|
+
const inTable = registry.table instanceof Map ? registry.table.has(packageName) : false;
|
|
176
|
+
const record = inTable ? registry.table.get(packageName) : null;
|
|
177
|
+
const composed = registry.composed ?? null;
|
|
178
|
+
const rows = Array.isArray(composed?.entries) ? composed.entries : [];
|
|
179
|
+
const row = rows.find((item) => item.id === packageName) ?? null;
|
|
180
|
+
return {
|
|
181
|
+
available: true,
|
|
182
|
+
inTable,
|
|
183
|
+
rev: record?.entry?.rev ?? row?.rev ?? null,
|
|
184
|
+
clientPath: record?.meta?.clientPath ?? null,
|
|
185
|
+
inject: record?.meta?.inject ?? null,
|
|
186
|
+
immediately: record?.meta?.immediately ?? null,
|
|
187
|
+
bootstrapIds: composed?.bootstrapIds ?? [],
|
|
188
|
+
entries: rows.map((item) => item.id),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 直接读包自己的声明,判断"该不该"被宿主接受(不依赖宿主)。
|
|
194
|
+
*
|
|
195
|
+
* @param {string} packageDir
|
|
196
|
+
* @returns {object}
|
|
197
|
+
*/
|
|
198
|
+
export function localVerdict(packageDir) {
|
|
199
|
+
const pkgFile = path.join(packageDir, 'package.json');
|
|
200
|
+
let pkg;
|
|
201
|
+
try {
|
|
202
|
+
pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return { ok: false, error: `读 package.json 失败: ${error?.message ?? error}` };
|
|
205
|
+
}
|
|
206
|
+
const decl = pkg.dsh?.client;
|
|
207
|
+
if (!decl) return { ok: false, error: '没有 dsh.client 声明', name: pkg.name, version: pkg.version };
|
|
208
|
+
if (decl.platform !== 'web') return { ok: false, error: `platform=${decl.platform},需要 web`, name: pkg.name };
|
|
209
|
+
|
|
210
|
+
let rel = pkg.exports?.['./client'];
|
|
211
|
+
if (rel && typeof rel === 'object') rel = rel.default ?? rel.import ?? rel.require;
|
|
212
|
+
if (typeof rel !== 'string') {
|
|
213
|
+
return { ok: false, error: 'exports 里没有 "./client"', name: pkg.name };
|
|
214
|
+
}
|
|
215
|
+
const clientPath = path.join(packageDir, rel.replace(/^\.\//, ''));
|
|
216
|
+
if (!fs.existsSync(clientPath)) {
|
|
217
|
+
return { ok: false, error: `client bundle 不存在: ${clientPath}`, name: pkg.name };
|
|
218
|
+
}
|
|
219
|
+
const stat = fs.statSync(clientPath);
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
name: pkg.name,
|
|
223
|
+
version: pkg.version,
|
|
224
|
+
clientPath,
|
|
225
|
+
bytes: stat.size,
|
|
226
|
+
mtimeMs: stat.mtimeMs,
|
|
227
|
+
inject: decl.inject ?? [],
|
|
228
|
+
immediately: decl.immediately === true,
|
|
229
|
+
};
|
|
230
|
+
}
|