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.
@@ -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
+ }