@international-iot-association/plugin-vite-config 1.0.0-rc.1

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/index.d.mts ADDED
@@ -0,0 +1,42 @@
1
+ import type { UserConfig, Plugin } from "vite";
2
+
3
+ /**
4
+ * 兼容旧签名保留:本包自身所在的 workspace 根(向上找 pnpm-workspace.yaml,
5
+ * 找不到则退到本包自身根目录)。resolvePackageRoot 内部不再依赖它。
6
+ */
7
+ export declare const pluginsMonorepoRoot: string;
8
+
9
+ /** 解析 npm 包根目录(含 package.json)。 */
10
+ export declare function resolvePackageRoot(name: string, pluginRoot: string): string;
11
+
12
+ /**
13
+ * 插件 UI 的共享 Vite 配置(单文件 + React 单例)。
14
+ * @param configFileUrl 插件 vite.config.ts 的 `import.meta.url`
15
+ * @param overrides 极少需要;会浅合并进默认配置
16
+ */
17
+ export declare function definePluginUiViteConfig(
18
+ configFileUrl: string,
19
+ overrides?: UserConfig,
20
+ ): UserConfig;
21
+
22
+ /** 开发模式(vite serve)下写入 <meta> 的 CSP。 */
23
+ export declare const DEV_UI_CSP: string;
24
+
25
+ /** 把 html 里 CSP <meta> 的 content 替换为 DEV_UI_CSP。 */
26
+ export declare function rewriteCspForDev(html: string): string;
27
+
28
+ /** Vite 插件:仅 serve(`pnpm dev` / 外壳调试页)时替换 CSP meta;build 不生效。 */
29
+ export declare function uiDevCsp(): Plugin;
30
+
31
+ export type SingleReactAssertion =
32
+ | { ok: true; versions: string[] }
33
+ | { ok: false; error: string; versions?: string[] };
34
+
35
+ /** 从 html 里提取出现过的 react 版本字面量。 */
36
+ export declare function findReactVersionsInHtml(html: string): string[];
37
+
38
+ /**
39
+ * 扫描单个插件目录的 ui-dist/index.html,拒绝打进多份 React 的产物。
40
+ * 纯产物断言,不含「禁 lockfile」检查(那是仓内 monorepo-only 规则)。
41
+ */
42
+ export declare function assertPluginUiSingleReact(pluginDir: string): SingleReactAssertion;
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@international-iot-association/plugin-vite-config",
3
+ "version": "1.0.0-rc.1",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/International-IoT-Association/MES.git",
8
+ "directory": "electron-station-plugins/packages/plugin-vite-config"
9
+ },
10
+ "type": "module",
11
+ "description": "插件 UI 共享 Vite 配置:单文件构建 + React 单例 + dev CSP 适配 + 产物单例断言。",
12
+ "main": "./src/index.mjs",
13
+ "types": "./index.d.mts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./index.d.mts",
17
+ "default": "./src/index.mjs"
18
+ }
19
+ },
20
+ "files": [
21
+ "src",
22
+ "index.d.mts"
23
+ ],
24
+ "peerDependencies": {
25
+ "vite": "^7.2.6",
26
+ "@vitejs/plugin-react": "^5.1.1",
27
+ "@tailwindcss/vite": "^4.3.1",
28
+ "tailwindcss": "^4.3.1",
29
+ "vite-plugin-singlefile": "^2.2.0"
30
+ },
31
+ "x-internal-version": "1.0.0",
32
+ "x-source-revision": "410a2f3b85ef03b678f04f22ea0a47718f5ddae1",
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "registry": "https://registry.npmjs.org/"
36
+ }
37
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 插件 UI 产物单例断言:扫描 ui-dist/index.html,拒绝「打进多份 React」的产物。
3
+ *
4
+ * 启发式:统计产物中出现的 react@x.y.z 版本字面量;超过一个不同版本 → fail。
5
+ *
6
+ * 注意:仓内「禁止插件目录 pnpm-lock.yaml」是 monorepo-only 规则(根因是与根
7
+ * pnpm.overrides 分叉),本包只做纯产物断言,不含该检查——它保留在仓内
8
+ * electron-station-plugins/tools/verify.mjs 的 checkPluginStatic 里。仓外单目录
9
+ * 打包按定义就是安装根,必然有 lockfile,这条检查放在这里会让仓外打包 100% 失败。
10
+ */
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ /** @returns {string[]} 如 ["19.2.7", "19.2.8"] */
15
+ export function findReactVersionsInHtml(html) {
16
+ const found = new Set();
17
+ // 包内 version 字段、source map、error 文案里常见 "19.2.x"
18
+ const re = /\b(?:react(?:-dom)?@)?(1[89]\.\d+\.\d+)\b/g;
19
+ let m;
20
+ while ((m = re.exec(html)) !== null) {
21
+ found.add(m[1]);
22
+ }
23
+ // 更稳:package.json 嵌入的 "version":"19.2.8" 靠近 react 上下文
24
+ const reJson = /"name"\s*:\s*"react"\s*,\s*"version"\s*:\s*"(1[89]\.\d+\.\d+)"/g;
25
+ while ((m = reJson.exec(html)) !== null) {
26
+ found.add(m[1]);
27
+ }
28
+ return [...found].sort();
29
+ }
30
+
31
+ /**
32
+ * @param {string} pluginDir
33
+ * @returns {{ ok: true, versions: string[] } | { ok: false, error: string, versions?: string[] }}
34
+ */
35
+ export function assertPluginUiSingleReact(pluginDir) {
36
+ const uiHtml = join(pluginDir, "ui-dist", "index.html");
37
+ if (!existsSync(uiHtml)) {
38
+ // 未构建 UI 的插件(无 vite / 未 build)跳过
39
+ return { ok: true, versions: [] };
40
+ }
41
+
42
+ const html = readFileSync(uiHtml, "utf8");
43
+ if (!html.includes("createRoot") && !html.includes("react")) {
44
+ return { ok: true, versions: [] };
45
+ }
46
+
47
+ const versions = findReactVersionsInHtml(html);
48
+ if (versions.length > 1) {
49
+ return {
50
+ ok: false,
51
+ versions,
52
+ error:
53
+ `${pluginDir}/ui-dist/index.html 打进了多份 React(${versions.join(" + ")})。` +
54
+ `会导致 useRef null / 空白页。请用 definePluginUiViteConfig(@international-iot-association/plugin-vite-config),` +
55
+ `并确保安装根的 React 版本被钉死(overrides / resolutions)后重新 install && build。`,
56
+ };
57
+ }
58
+ return { ok: true, versions };
59
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 插件 UI 的共享 Vite 配置(单文件 + React 单例)。
3
+ *
4
+ * 仓内 plugins 与 templates/plugin 下的 vite.config.ts:
5
+ *
6
+ * import { definePluginUiViteConfig } from "../../tools/define-plugin-ui-vite-config.mjs";
7
+ * export default definePluginUiViteConfig(import.meta.url);
8
+ *
9
+ * 仓外工程:
10
+ *
11
+ * import { definePluginUiViteConfig } from "@international-iot-association/plugin-vite-config";
12
+ * export default definePluginUiViteConfig(import.meta.url);
13
+ *
14
+ * ## 为什么强制 React 单例
15
+ * 插件 UI 经 vite-plugin-singlefile 打成单个 index.html。若解析到两份物理 React
16
+ * (典型:插件目录单独 pnpm install 出 19.2.8,workspace 包用 monorepo 根 19.2.7),
17
+ * 运行时 hooks dispatcher 为 null → Cannot read properties of null (reading useRef)
18
+ * → 整页空白。见 tsc-label 2026-08-24 现场。
19
+ *
20
+ * 本配置:
21
+ * 1. resolve.dedupe + alias 钉死 react / react-dom 到同一物理目录;
22
+ * 2. 优先安装根 hoisted 包,避免插件私有 node_modules 分叉。
23
+ *
24
+ * 注意:本文件注释里不要写星号斜杠连续字符(会提前结束块注释)。
25
+ */
26
+ import { createRequire } from "node:module";
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import { defineConfig } from "vite";
31
+ import react from "@vitejs/plugin-react";
32
+ import tailwindcss from "@tailwindcss/vite";
33
+ import { viteSingleFile } from "vite-plugin-singlefile";
34
+ import { uiDevCsp } from "./vite-ui-dev-csp.mjs";
35
+
36
+ const toolsDir = path.dirname(fileURLToPath(import.meta.url));
37
+
38
+ /**
39
+ * 兼容旧签名保留:尽量猜测「本包自身」所在的 workspace 根(向上找
40
+ * pnpm-workspace.yaml),找不到则退到本包自身根目录。
41
+ *
42
+ * 注意:resolvePackageRoot 不再用它做候选——本包被 hoist 到哪里,跟消费它的
43
+ * 插件在哪个 workspace 里,是两件不一定重合的事(本包一旦从 tools/ 迁到
44
+ * packages/plugin-vite-config/src/,这个常量的相对深度就和插件所在层级脱钩了,
45
+ * 曾导致 resolvePackageRoot 退化成「就近优先」而不是「monorepo 根优先」)。
46
+ */
47
+ export const pluginsMonorepoRoot = (() => {
48
+ let dir = toolsDir;
49
+ for (;;) {
50
+ if (existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
51
+ const parent = path.dirname(dir);
52
+ if (parent === dir) return path.resolve(toolsDir, "..");
53
+ dir = parent;
54
+ }
55
+ })();
56
+
57
+ /**
58
+ * 判断 dir 是否是一个 workspace 根标记目录:pnpm-workspace.yaml,或
59
+ * package.json 里声明了 workspaces 字段(npm/yarn 风格,兼容两种写法)。
60
+ * @param {string} dir
61
+ * @returns {boolean}
62
+ */
63
+ function isWorkspaceRootDir(dir) {
64
+ if (existsSync(path.join(dir, "pnpm-workspace.yaml"))) return true;
65
+ const pkgJsonPath = path.join(dir, "package.json");
66
+ if (!existsSync(pkgJsonPath)) return false;
67
+ try {
68
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
69
+ if (Array.isArray(pkg.workspaces)) return true;
70
+ if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) return true;
71
+ } catch {
72
+ // 解析失败不当作 workspace 根,退到「无标记」分支
73
+ }
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * 从 pluginRoot 向上走到 workspace 根(isWorkspaceRootDir 命中的目录,找不到
79
+ * 则走到文件系统根),返回沿途每一级目录(由近到远)与是否实际命中了标记。
80
+ * @param {string} startDir
81
+ * @returns {{ ancestors: string[], foundWorkspaceRoot: boolean }}
82
+ */
83
+ function collectAncestorsUntilWorkspaceRoot(startDir) {
84
+ const ancestors = [];
85
+ let dir = path.resolve(startDir);
86
+ for (;;) {
87
+ ancestors.push(dir);
88
+ if (isWorkspaceRootDir(dir)) return { ancestors, foundWorkspaceRoot: true };
89
+ const parent = path.dirname(dir);
90
+ if (parent === dir) break;
91
+ dir = parent;
92
+ }
93
+ return { ancestors, foundWorkspaceRoot: false };
94
+ }
95
+
96
+ /**
97
+ * 解析 npm 包根目录(含 package.json)。
98
+ *
99
+ * 顺序:
100
+ * - 若 pluginRoot 向上能走到一个实际的 workspace 根(pnpm-workspace.yaml 或
101
+ * package.json.workspaces),以 workspace 根为起点、由远到近逐层找
102
+ * node_modules/<name>(workspace 根优先),不被插件私有分叉版本抢先——这是
103
+ * 单例门要防的场景。
104
+ * - 若没有任何 workspace 标记(仓外单目录插件,没有 monorepo 概念),只把
105
+ * pluginRoot 自身当唯一候选,不再盲目往上翻——沿途任何无关祖先目录的
106
+ * node_modules/<name>(比如用户主目录、上层其它工程)都不该被当作「根」。
107
+ * - 以上都没找到时退到 Node 的 createRequire 解析算法。
108
+ *
109
+ * @param {string} name
110
+ * @param {string} pluginRoot
111
+ * @returns {string}
112
+ */
113
+ export function resolvePackageRoot(name, pluginRoot) {
114
+ const { ancestors, foundWorkspaceRoot } = collectAncestorsUntilWorkspaceRoot(pluginRoot);
115
+ const candidates = foundWorkspaceRoot ? ancestors : [ancestors[0]];
116
+ for (let i = candidates.length - 1; i >= 0; i--) {
117
+ const candidate = path.join(candidates[i], "node_modules", name);
118
+ if (existsSync(path.join(candidate, "package.json"))) {
119
+ return path.resolve(candidate);
120
+ }
121
+ }
122
+ const requireFrom = existsSync(path.join(pluginRoot, "package.json"))
123
+ ? path.join(pluginRoot, "package.json")
124
+ : import.meta.url;
125
+ const require = createRequire(requireFrom);
126
+ return path.dirname(require.resolve(`${name}/package.json`));
127
+ }
128
+
129
+ /**
130
+ * @param {string} configFileUrl 插件 vite.config.ts 的 `import.meta.url`
131
+ * @param {import("vite").UserConfig} [overrides] 极少需要;会浅合并进默认配置
132
+ */
133
+ export function definePluginUiViteConfig(configFileUrl, overrides = {}) {
134
+ if (typeof configFileUrl !== "string" || !configFileUrl.includes(":")) {
135
+ throw new Error(
136
+ "definePluginUiViteConfig(import.meta.url):必须传入插件 vite.config 的 import.meta.url",
137
+ );
138
+ }
139
+ const pluginRoot = path.dirname(fileURLToPath(configFileUrl));
140
+ const reactRoot = resolvePackageRoot("react", pluginRoot);
141
+ const reactDomRoot = resolvePackageRoot("react-dom", pluginRoot);
142
+
143
+ /** @type {import("vite").UserConfig} */
144
+ const base = {
145
+ root: "ui",
146
+ base: "./",
147
+ plugins: [react(), tailwindcss(), viteSingleFile(), uiDevCsp()],
148
+ resolve: {
149
+ dedupe: ["react", "react-dom"],
150
+ alias: {
151
+ react: reactRoot,
152
+ "react-dom": reactDomRoot,
153
+ "react/jsx-runtime": path.join(reactRoot, "jsx-runtime.js"),
154
+ "react/jsx-dev-runtime": path.join(reactRoot, "jsx-dev-runtime.js"),
155
+ },
156
+ },
157
+ build: {
158
+ outDir: "../ui-dist",
159
+ emptyOutDir: true,
160
+ chunkSizeWarningLimit: 4096,
161
+ // Siemens iX 样式内嵌字体/图形必须内联(CSP 无网络)。
162
+ assetsInlineLimit: 16 * 1024 * 1024,
163
+ },
164
+ };
165
+
166
+ return defineConfig({
167
+ ...base,
168
+ ...overrides,
169
+ resolve: {
170
+ ...base.resolve,
171
+ ...(overrides.resolve ?? {}),
172
+ dedupe: [
173
+ ...new Set([...(base.resolve?.dedupe ?? []), ...((overrides.resolve?.dedupe) ?? [])]),
174
+ ],
175
+ alias: {
176
+ ...(base.resolve?.alias ?? {}),
177
+ ...(overrides.resolve?.alias ?? {}),
178
+ },
179
+ },
180
+ plugins: overrides.plugins ?? base.plugins,
181
+ build: {
182
+ ...base.build,
183
+ ...(overrides.build ?? {}),
184
+ },
185
+ });
186
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @international-iot-association/plugin-vite-config —— 统一入口,重新导出三块能力:
3
+ * - definePluginUiViteConfig / resolvePackageRoot / pluginsMonorepoRoot(共享 vite 配置)
4
+ * - uiDevCsp / rewriteCspForDev / DEV_UI_CSP(dev 模式 CSP 适配)
5
+ * - assertPluginUiSingleReact / findReactVersionsInHtml(产物单例断言,纯断言,不含
6
+ * 「禁 lockfile」检查——那是仓内 monorepo-only 规则,见 verify.mjs 的 checkPluginStatic)
7
+ */
8
+ export {
9
+ definePluginUiViteConfig,
10
+ pluginsMonorepoRoot,
11
+ resolvePackageRoot,
12
+ } from "./define-plugin-ui-vite-config.mjs";
13
+ export { uiDevCsp, rewriteCspForDev, DEV_UI_CSP } from "./vite-ui-dev-csp.mjs";
14
+ export {
15
+ assertPluginUiSingleReact,
16
+ findReactVersionsInHtml,
17
+ } from "./assert-single-react-ui.mjs";
@@ -0,0 +1,58 @@
1
+ // packages/plugin-vite-config/src/vite-ui-dev-csp.mjs —— 插件 UI 的 dev 模式 CSP 适配(仅 vite serve 生效)。
2
+ //
3
+ // 背景:ui/index.html 里的 CSP <meta> 是按「构建后单文件、无网络」的产物形态写的:
4
+ // 脚本/样式全部内联('unsafe-inline')、connect-src 'none'。但外壳「调试」页走的是
5
+ // vite dev server,页面不再是单文件——module script(/@vite/client、main.tsx …)
6
+ // 要从 dev server 按 URL 加载,HMR 依赖本机 WebSocket。沿用产物形态的 CSP 会把
7
+ // 调试页直接拦成空白(script-src 不含 'self'),或杀掉热更新(connect-src 'none')。
8
+ //
9
+ // 本插件只在 serve 模式把 <meta> 里的 CSP 换成等价的「开发版」:
10
+ // 脚本/样式放行 'self'(dev server 同源资源),connect 只放行本机 ws/wss(HMR 与
11
+ // vite ping),其余仍然拒绝外部网络——与生产“UI 无网络”的行为保持一致,避免开发期
12
+ // 误写出依赖网络的 UI。build 产物完全不受影响(apply: 'serve')。
13
+ //
14
+ // 与外壳的关系:正式安装的插件 UI 经 station-plugin:// 提供,外壳会在响应头注入
15
+ // PLUGIN_UI_CSP(见 electron-station-shell/src/main/security.ts),meta 只能更严
16
+ // 不能更松;调试页 iframe 加载的是 http://127.0.0.1:<port>,此时 meta 是唯一的
17
+ // 文档级 CSP,本插件负责让它与 dev server 的加载形态匹配。
18
+
19
+ /** 开发模式(vite serve)下写入 <meta> 的 CSP。 */
20
+ export const DEV_UI_CSP = [
21
+ "default-src 'self'",
22
+ "script-src 'self' 'unsafe-inline'",
23
+ "style-src 'self' 'unsafe-inline'",
24
+ "img-src 'self' data: blob:",
25
+ "font-src 'self' data:",
26
+ "media-src 'self' blob: data:",
27
+ "worker-src blob:",
28
+ // 仅本机 ws/wss(vite HMR)与同源 fetch(vite ping / 模块加载);外部网络仍拒绝。
29
+ "connect-src 'self' ws://127.0.0.1:* ws://localhost:* wss://127.0.0.1:* wss://localhost:*",
30
+ ].join("; ");
31
+
32
+ const CSP_META_RE =
33
+ /(<meta\s[\s\S]*?http-equiv="Content-Security-Policy"[\s\S]*?content=")([^"]*)(")/i;
34
+
35
+ /**
36
+ * 把 html 里 CSP <meta> 的 content 替换为 DEV_UI_CSP。
37
+ * 没有 CSP <meta> 时原样返回(dev server 页面本就没有文档级 CSP 限制)。
38
+ * @param {string} html
39
+ * @returns {string}
40
+ */
41
+ export function rewriteCspForDev(html) {
42
+ return html.replace(CSP_META_RE, (_m, pre, _val, post) => `${pre}${DEV_UI_CSP}${post}`);
43
+ }
44
+
45
+ /**
46
+ * Vite 插件:仅 serve(`pnpm dev` / 外壳调试页)时替换 CSP meta;build 不生效。
47
+ * @returns {import("vite").Plugin}
48
+ */
49
+ export function uiDevCsp() {
50
+ return {
51
+ name: "rti-ui-dev-csp",
52
+ apply: "serve",
53
+ transformIndexHtml: {
54
+ order: "pre",
55
+ handler: (html) => rewriteCspForDev(html),
56
+ },
57
+ };
58
+ }