diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml new file mode 100644 index 0000000000..d1ecdce5be --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md +2026-09-22-desktop-main-bundle-after-workspace-tsdown.md: 4aea81fd11873a53d54b16bc8fd4326d923d714b +2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md: 0f2fd83835254c241f9ccfffab93d5a0abd2c17b diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md new file mode 100644 index 0000000000..4aea81fd11 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md @@ -0,0 +1,29 @@ +# Agent Note: Bundle the Desktop main process after the workspace tsdown pass + +Status: implemented + +English | [中文](2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md) + +## Problem + +A macOS Desktop package built from a fresh worktree crashed at launch with `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`. The packaging log showed why: rolldown had reported `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`, and the same for `@deepseek-ai/dsh-app-boot` and `@deepseek-ai/dsh-deepseek-account`. The three packages are workspace devDependencies of `apps/desktop` that the main-process bundle must inline, because electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`. + +The root tsdown config builds every matched workspace member concurrently (`Promise.all` over the resolved configs in tsdown 0.22, with no dependency ordering), and `apps/desktop` was one of those members. Its bundle resolved the three packages through their `exports` to `lib/index.js` files that other concurrent builds had not written yet. On a checkout that had been built before, the stale `lib/` outputs existed and the bundle inlined them, which is why the defect surfaced only on a clean tree; `clean: false` in both configs kept it hidden. The broken bundle was also smaller (189 KB instead of 315 KB) and the packaging run passed every existing check, because the runtime payload smoke exercises the dsh runtime, not the Electron main process. + +## Decision + +`apps/desktop` leaves the root tsdown `workspace` list. The root `build:lib:host` script runs the Host tsdown pass and then `pnpm --filter @deepseek-ai/dsh-desktop run bundle`, so the main bundle resolves workspace `lib/` outputs that already exist. The package's own `build` script keeps the same `tsc -b` then `bundle` order for local use. A standalone run of the Desktop config produces byte-identical output to the former workspace-mode run: the config was already self-contained, and the root plugins contributed nothing to it. + +The Desktop config also asserts the invariant the crash violated. [`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) is a rolldown plugin on every Desktop bundle whose `generateBundle` hook fails the build when a chunk imports a bare specifier the packaged application cannot resolve: the main bundle may import `electron` and the manifest's `dependencies`, sandboxed preloads only `electron`, and Node builtins are always allowed. The check names the actual requirement rather than the symptom, so it also rejects a workspace package moved out of `dependencies` or a broken `exports` entry, and a rejected bundle writes no output. + +## Alternatives considered + +**Declare the three packages as runtime `dependencies` so electron-builder ships them in the asar.** This is how `@deepseek-ai/cordis` and `@deepseek-ai/dsh-api-gateway` reach the asar today, and it removes the ordering problem. `dsh-app-boot` would bring a large dependency tree into `app.asar/node_modules`, duplicating the copy already inside the packed dsh runtime and enlarging the payload the runtime file policy must screen. Inlining the main-process helpers was a deliberate choice; the fix keeps it. + +**Fail on rolldown's `UNRESOLVED_IMPORT` warning** through `inputOptions.onLog` or `failOnWarn`. It catches only the unresolved case and would not have covered a dependency misdeclared as a devDependency, which produces a resolved, inlined bundle when the output exists and an external import when it does not. The packaged-imports check covers both with one rule; a warning-based check remains an optional addition. + +**Order the workspace build by dependency graph.** tsdown offers no such ordering, and every other member either inlines nothing from the workspace or externalizes declared `dependencies`, which need no ordering. One extra step for the one package that needs it is smaller than a general scheduler. + +## Consequences + +A clean checkout, including CI and a fresh worktree, produces the same Desktop main bundle as a long-lived one. A missing or misdeclared runtime import now fails `pnpm run build` and every packaging command with the offending file and specifiers, before signing and notarization spend their time. The Host build gains one sequential step of about a second on a development machine. `docs/development.md` lists the desktop bundle step in the root build order, and the [Desktop README](../../../../apps/desktop/README.md#bundled-workspace-dependencies) owns the import rule. diff --git a/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md new file mode 100644 index 0000000000..0f2fd83835 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md @@ -0,0 +1,29 @@ +# Agent Note:Desktop 主进程在工作区 tsdown 阶段之后再打 bundle + +Status: implemented + +[English](2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) | 中文 + +## 问题 + +从全新 worktree 打出的 macOS Desktop 安装包启动即崩:`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`。打包日志给出了原因:rolldown 报告了 `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`,`@deepseek-ai/dsh-app-boot` 与 `@deepseek-ai/dsh-deepseek-account` 也是同样情况。这三个包是 `apps/desktop` 的工作区 devDependencies,主进程 bundle 必须把它们内联,因为 electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`。 + +根 tsdown 配置并发构建所有匹配到的 workspace 成员(tsdown 0.22 对解析出的配置做 `Promise.all`,没有任何依赖排序),而 `apps/desktop` 是其中一员。它的 bundle 通过这三个包的 `exports` 解析到 `lib/index.js`,而其他并发构建此时还没有写出这些文件。在此前构建过的检出目录里,陈旧的 `lib/` 产物存在,bundle 会把它们内联,因此缺陷只在干净树上出现;两份配置里的 `clean: false` 一直掩盖着它。坏的 bundle 也更小(189 KB 而不是 315 KB),并且打包流程通过了所有已有检查,因为运行时载荷 smoke 检验的是 dsh 运行时,不是 Electron 主进程。 + +## 决策 + +`apps/desktop` 退出根 tsdown 的 `workspace` 列表。根 `build:lib:host` 脚本先跑 Host tsdown 阶段,再执行 `pnpm --filter @deepseek-ai/dsh-desktop run bundle`,让主进程 bundle 解析的是已经存在的工作区 `lib/` 产物。该包自己的 `build` 脚本为本地使用保留同样的先 `tsc -b` 再 `bundle` 的顺序。单独运行 Desktop 配置得到的产物与此前 workspace 模式下的产物逐字节一致:该配置本来就是自足的,根配置的插件没有为它贡献任何内容。 + +Desktop 配置同时断言这次崩溃所违反的不变量。[`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) 是挂在每个 Desktop bundle 上的 rolldown 插件,其 `generateBundle` 钩子在某个 chunk 导入了打包应用无法解析的裸说明符时让构建失败:主进程 bundle 可以导入 `electron` 与清单的 `dependencies`,沙箱 preload 只能导入 `electron`,Node 内置模块始终允许。这项检查陈述的是真正的要求而非症状,因此它同样拒绝被移出 `dependencies` 的工作区包或损坏的 `exports` 条目,且被拒绝的 bundle 不写出任何产物。 + +## 备选方案 + +**把这三个包声明为运行时 `dependencies`,让 electron-builder 装进 asar。** `@deepseek-ai/cordis` 与 `@deepseek-ai/dsh-api-gateway` 今天就是这样进入 asar 的,这也消除了顺序问题。但 `dsh-app-boot` 会把一棵很大的依赖树带进 `app.asar/node_modules`,与 packed dsh 运行时内已有的副本重复,并扩大运行时文件策略必须筛查的载荷。内联主进程辅助包是有意的选择;本修复保留它。 + +**通过 `inputOptions.onLog` 或 `failOnWarn` 让 rolldown 的 `UNRESOLVED_IMPORT` warning 直接失败。** 它只覆盖未解析这一种情况,覆盖不了被误声明为 devDependency 的依赖:产物存在时它会被解析并内联,不存在时才留成外部导入。打包导入检查用一条规则同时覆盖两者;基于 warning 的检查仍可作为可选补充。 + +**按依赖图给 workspace 构建排序。** tsdown 不提供这种排序,而其他每个成员要么不从工作区内联任何内容,要么把已声明的 `dependencies` 留成外部导入,都不需要排序。为唯一需要它的包多加一步,比通用调度器更小。 + +## 后果 + +干净的检出目录(包括 CI 与全新 worktree)产出的 Desktop 主进程 bundle 与长期使用的检出目录一致。缺失或误声明的运行时导入现在会让 `pnpm run build` 和每条打包命令在签名与公证耗时之前失败,并报出违规文件与说明符。Host 构建多出一个串行步骤,在开发机上约一秒。`docs/development.md` 在根构建顺序中列出了 desktop bundle 步骤,导入规则由 [Desktop README](../../../../apps/desktop/README.zh.md#bundled-workspace-dependencies) 负责。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 77500865f6..628c27e58b 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e -README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c +README.md: e534a9443be4fdc576878f39f132fff34973db2f +README.zh.md: 8123832ac97185539bff87257cdb8921e332d2d9 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b07b2b9d9..e534a9443b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -26,6 +26,8 @@ The macOS PNG uses an inset rounded background for legacy ICNS packaging, with r ### Bundled workspace dependencies +electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`, so the Electron main bundle `lib/main.js` inlines its workspace devDependencies and leaves `electron` and those `dependencies` as its only bare imports; sandboxed preloads import only `electron`. The main bundle resolves the inlined packages from their `lib/` output, so the root `build:lib:host` bundles `apps/desktop` after the concurrent workspace tsdown pass, and [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) fails any Desktop bundle whose external imports the packaged application cannot resolve. Without that check an import rolldown could not resolve ships as an external specifier and fails at launch with `ERR_MODULE_NOT_FOUND`. The [bundle-order decision](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) records the alternatives. + Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy. Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4ebae34332..8123832ac9 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -24,8 +24,12 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。 + + ### 内置工作区依赖 +electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`,因此 Electron 主进程 bundle `lib/main.js` 内联其工作区 devDependencies,裸导入只剩 `electron` 与这些 `dependencies`;沙箱 preload 只导入 `electron`。主进程 bundle 从被内联包的 `lib/` 产物解析它们,所以根 `build:lib:host` 在并发的工作区 tsdown 阶段之后才为 `apps/desktop` 打 bundle,并由 [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) 让任何外部导入无法在打包应用内解析的 Desktop bundle 直接失败。没有这项检查时,rolldown 无法解析的导入会作为外部说明符进入产物,并在启动时以 `ERR_MODULE_NOT_FOUND` 失败。[bundle 顺序决策](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md)记录了备选方案。 + Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。 Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d7c8fbdd09..fa87ea8ad7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,8 @@ "main": "lib/main.js", "scripts": { "test:installer": "node scripts/test-windows-installer.mjs", - "build": "tsc -b && tsdown", + "build": "tsc -b && pnpm run bundle", + "bundle": "tsdown", "dev": "tsx scripts/dev.ts", "start": "tsx scripts/dev.ts --skip-build", "test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs", diff --git a/apps/desktop/scripts/desktop-bundle-imports.d.mts b/apps/desktop/scripts/desktop-bundle-imports.d.mts new file mode 100644 index 0000000000..abb95a8474 --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.d.mts @@ -0,0 +1,24 @@ +import type { Rolldown } from 'tsdown' + +/** + * Package name of a bare import specifier. + * @param specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns The package name without its subpath. + */ +export function importPackageName(specifier: string): string + +/** + * Bare imports the packaged application cannot resolve. + * @param imports - External specifiers a chunk imports, statically or dynamically. + * @param packaged - Package names available to the bundle at runtime. + * @returns Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports: readonly string[], packaged: ReadonlySet): string[] + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. + * @param packaged - Package names available to the bundle at runtime. + * @returns The plugin. + */ +export function packagedImportsPlugin(packaged: ReadonlySet): Rolldown.Plugin diff --git a/apps/desktop/scripts/desktop-bundle-imports.mjs b/apps/desktop/scripts/desktop-bundle-imports.mjs new file mode 100644 index 0000000000..16d8c45990 --- /dev/null +++ b/apps/desktop/scripts/desktop-bundle-imports.mjs @@ -0,0 +1,66 @@ +/** + * Keep every bare import a Desktop bundle leaves to the runtime resolvable + * inside the packaged application. + * + * electron-builder copies only the manifest's `dependencies` into + * `app.asar/node_modules`, so a bare import of anything else bundles without + * complaint and fails at launch with `ERR_MODULE_NOT_FOUND`. The main-process + * bundle inlines its workspace devDependencies; when one of their `lib/` + * outputs is missing at bundle time, rolldown reports `UNRESOLVED_IMPORT` as a + * warning and keeps the specifier as an external import, which is exactly the + * broken artifact. Sandboxed preloads may `require` only `electron`. This + * check fails the bundle instead of the installed application. + */ + +import { isBuiltin } from 'node:module' + +/** + * Package name of a bare import specifier. + * @param {string} specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`. + * @returns {string} The package name without its subpath. + */ +export function importPackageName(specifier) { + const segments = specifier.split('/') + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] +} + +/** + * Bare imports the packaged application cannot resolve. + * @param {readonly string[]} imports - External specifiers a chunk imports, statically or dynamically. + * @param {ReadonlySet} packaged - Package names available to the bundle at runtime. + * @returns {string[]} Offending specifiers in import order, each once. + */ +export function unpackagedImports(imports, packaged) { + const offending = new Set() + for (const specifier of imports) { + if (isBuiltin(specifier)) continue + if (packaged.has(importPackageName(specifier))) continue + offending.add(specifier) + } + return [...offending] +} + +/** + * Rolldown plugin that fails a bundle whose external imports the packaged + * application cannot resolve. + * @param {ReadonlySet} packaged - Package names available to the bundle at runtime. + * @returns {import('tsdown').Rolldown.Plugin} The plugin. + */ +export function packagedImportsPlugin(packaged) { + return { + name: 'desktop-packaged-imports', + generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') continue + const external = [...output.imports, ...output.dynamicImports].filter(specifier => !(specifier in bundle)) + const offending = unpackagedImports(external, packaged) + if (offending.length === 0) continue + this.error( + `desktop bundle: ${output.fileName} imports ${offending.join(', ')}, which the packaged application does not ship. ` + + `A bare import must name electron or a package in this manifest's dependencies; anything else must be bundled, ` + + 'which requires its lib/ output to exist before this bundle runs (pnpm run build:lib:host).', + ) + } + }, + } +} diff --git a/apps/desktop/tests/desktop-bundle-imports.spec.ts b/apps/desktop/tests/desktop-bundle-imports.spec.ts new file mode 100644 index 0000000000..c37564020c --- /dev/null +++ b/apps/desktop/tests/desktop-bundle-imports.spec.ts @@ -0,0 +1,78 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Rolldown } from 'tsdown' +import { afterAll, describe, expect, it } from 'vitest' +import { + importPackageName, + packagedImportsPlugin, + unpackagedImports, +} from '../scripts/desktop-bundle-imports.mjs' + +const PACKAGED = new Set(['electron', 'electron-updater', '@deepseek-ai/dsh-api-gateway']) + +describe('desktop bundle imports', () => { + it('names the package behind a bare specifier', () => { + expect(importPackageName('electron-updater/out/electronHttpExecutor.js')).toBe('electron-updater') + expect(importPackageName('@deepseek-ai/dsh-api-gateway/stream-protocol')).toBe('@deepseek-ai/dsh-api-gateway') + expect(importPackageName('ws')).toBe('ws') + }) + + it('accepts Node builtins and packaged dependencies, including their subpaths', () => { + expect(unpackagedImports([ + 'node:fs/promises', + 'crypto', + 'electron', + 'electron-updater/out/electronHttpExecutor.js', + '@deepseek-ai/dsh-api-gateway/stream-protocol', + ], PACKAGED)).toEqual([]) + }) + + it('reports each specifier the packaged application cannot resolve once, in import order', () => { + expect(unpackagedImports([ + '@deepseek-ai/dsh-home-paths', + 'ws', + '@deepseek-ai/dsh-home-paths', + 'electron', + ], PACKAGED)).toEqual(['@deepseek-ai/dsh-home-paths', 'ws']) + }) +}) + +describe('packaged imports plugin', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-bundle-imports-')) + const entry = join(root, 'entry.js') + writeFileSync(entry, [ + 'import { app } from "electron";', + 'import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";', + 'export const home = resolveDshHome(app);', + ].join('\n')) + afterAll(() => { + rmSync(root, { recursive: true, force: true }) + }) + + async function generate(packaged: ReadonlySet): Promise { + // Every import stays external, which is what an unresolved workspace lib/ output produces. + const bundle = await Rolldown.rolldown({ + input: entry, + external: () => true, + logLevel: 'silent', + plugins: [packagedImportsPlugin(packaged)], + }) + try { + return await bundle.generate({ format: 'esm' }) + } finally { + await bundle.close() + } + } + + it('fails the bundle that leaves an import the packaged application does not ship', async () => { + await expect(generate(new Set(['electron']))).rejects.toThrow( + /entry\.js imports @deepseek-ai\/dsh-home-paths, which the packaged application does not ship/u, + ) + }) + + it('passes the bundle whose external imports are all packaged', async () => { + const output = await generate(new Set(['electron', '@deepseek-ai/dsh-home-paths'])) + expect(output.output.map(chunk => chunk.fileName)).toEqual(['entry.js']) + }) +}) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6db212aee7..8af998c518 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -1,11 +1,25 @@ import { defineConfig } from 'tsdown' import { build } from 'vite' import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' +import { packagedImportsPlugin } from './scripts/desktop-bundle-imports.mjs' + +// This config runs after the workspace tsdown pass, not inside it: the main bundle inlines +// workspace devDependencies from their lib/ output, which the concurrent workspace pass does +// not order ahead of this package (root package.json build:lib:host). +const manifest = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { + dependencies: Record +} +/** electron-builder ships these next to the main bundle; Electron itself provides `electron`. */ +const mainProcessImports = new Set(['electron', ...Object.keys(manifest.dependencies)]) +/** Sandboxed preloads can require nothing but `electron`. */ +const preloadImports = new Set(['electron']) export default defineConfig([ { entry: ['lib/types/main.js'], + plugins: [packagedImportsPlugin(mainProcessImports)], onSuccess: async () => { await build({ configFile: false, @@ -49,6 +63,7 @@ export default defineConfig([ ...(['preload-app', 'preload-welcome', 'preload-platform-account', 'preload-mandatory', 'preload-update-dialog'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. entry: { [name]: `lib/types/${name}.js` }, + plugins: [packagedImportsPlugin(preloadImports)], outDir: 'lib', format: 'cjs' as const, codeSplitting: false, diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index e11ee881f4..f515b0eff1 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 957f40775857d8f57af436a44690fd5a453aeeae -api-gateway.zh.md: 1062adcc89909d5ce3701ac82b55c5d0ece21ab0 +api-gateway.md: d6a8444c9f18a7c3df9cd64e4a836d1831f8a0d8 +api-gateway.zh.md: 23c7c18d3d784fc2b6abe4c1df75e08447100b98 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 957f407758..d6a8444c9f 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -98,7 +98,7 @@ The API Gateway package owns the Host dispatcher and Client Remote endpoint as p The root build runs `build:lib:host`, `build:lib:client`, and `build:web` in order. The Host lib phase first runs `tsc -b tsconfig.host.json`, then `tsdown --env.DSH_BUILD_FACE host`; the normal Host Project Reference graph compiles the Typert generator, which runs during this tsdown pass with the Host aggregate as its only `ts.Program` seed. The Client lib phase then runs `tsc -b tsconfig.client.json` and `tsdown --env.DSH_BUILD_FACE client`, consuming the newly generated Remote Client declarations and runtime contributions without starting Typert again. -Both tsdown passes receive the complete workspace and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. +Both tsdown passes receive the same workspace match ([build order](development.md#typescript-project-layout)) and bundle only JavaScript emitted to `lib/types` by the corresponding tsc phase. The root config does not scan Client artifacts, classify package names, or pass a maintained filter to tsdown; package-local configs return entries for the current phase based on `DSH_BUILD_FACE`. An ordinary Client plugin produces both its Node loader entry and browser bundle during the Client phase. `api/remotes`, `api/gateway`, `api/session-controller`, and `api/workspace-controller` (plus `client/connection`) split TypeScript faces. `api/remotes`' Client project depends on `/remote` declarations generated for business packages during Host tsdown; root aggregates and direct consumers must reference each split package's `tsconfig.host.json` or `tsconfig.client.json` respectively. `api-remotes`' `clientBundle(..., { hostPhase: true })` produces its Host entry during Host tsdown and leaves only the browser entry for Client tsdown. The Agent/Session lookup policy lives in `@deepseek-ai/dsh-api-session-controller`, not in `api-remotes`. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 1062adcc89..23c7c18d3d 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -98,7 +98,7 @@ API Gateway 包同时拥有 Host dispatcher 与 Client Remote endpoint 两个对 根构建依次执行 `build:lib:host`、`build:lib:client` 与 `build:web`。Host lib 阶段先运行 `tsc -b tsconfig.host.json`,再运行 `tsdown --env.DSH_BUILD_FACE host`;Typert generator 由正常 Host Project Reference 图编译,并在这次 tsdown 中以 Host aggregate 为唯一 `ts.Program` 种子运行。Client lib 阶段随后运行 `tsc -b tsconfig.client.json` 与 `tsdown --env.DSH_BUILD_FACE client`,使用刚生成的 Remote Client 声明和运行时贡献,但不再次启动 Typert。 -两次 tsdown 都接收完整 workspace,且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 +两次 tsdown 都接收同一组 workspace 匹配([构建顺序](development.zh.md#typescript-project-layout)),且都只打包 `lib/types` 中由对应 tsc 阶段发射的 JavaScript。根配置不扫描 Client 产物、不按包名分类,也不向 tsdown 传维护式 filter;各包的本地配置根据 `DSH_BUILD_FACE` 返回当前阶段的入口。普通 Client 插件在 Client 阶段一起生成 Node loader 入口与 browser bundle。 `api/remotes`、`api/gateway`、`api/session-controller` 与 `api/workspace-controller`(外加 `client/connection`)都拆分 TypeScript face。`api/remotes` 的 Client project 依赖业务包在 Host tsdown 中生成的 `/remote` 声明;根 aggregate 与直接消费方必须分别引用各拆分包自己的 `tsconfig.host.json` 或 `tsconfig.client.json`。`api-remotes` 的 `clientBundle(..., { hostPhase: true })` 让 Host 入口在 Host tsdown 中生成,让 Client tsdown 只生成 browser 入口。Agent/Session lookup 策略位于 `@deepseek-ai/dsh-api-session-controller`,而非 `api-remotes`。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index fd6198a8ca..a14f6e2fd7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304 -development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0 +development.md: 812cabbe3e2af5965033ef0c73301e5cd28cba28 +development.zh.md: b45e8e3e7b3c5fc0ee05967273a1c73e407b32fc diff --git a/docs/development.md b/docs/development.md index 7e8d0ded70..812cabbe3e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,12 +74,13 @@ The root build follows the generated dependency order: ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -Both tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. +Both tsdown passes use the same workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. diff --git a/docs/development.zh.md b/docs/development.zh.md index 84d0d76d8c..b45e8e3e7b 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -78,12 +78,13 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 ```sh tsc -b tsconfig.host.json tsdown --env.DSH_BUILD_FACE host +pnpm --filter @deepseek-ai/dsh-desktop run bundle tsc -b tsconfig.client.json tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。 +两次 tsdown 都使用同一组 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员,因此主进程 bundle 要从工作区 devDependencies 的 `lib/` 产物内联它们的 `apps/desktop` 在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 diff --git a/package.json b/package.json index a7acc3ab12..d71d45d55f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", - "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", diff --git a/tsdown.config.ts b/tsdown.config.ts index 2f24d4910e..2435127329 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,14 +11,17 @@ function isBuildFaceClient(value: unknown): boolean { * The ordinary workspace build consumes JavaScript emitted by the Host * TypeScript project and runs Typert. The Client pass selects packages that * declare a browser bundle and lets their package-local configs emit both - * their Node loader entry and browser artifact. + * their Node loader entry and browser artifact. `apps/desktop` bundles after + * this pass (root package.json `build:lib:host`): its main bundle inlines + * workspace devDependencies from their lib/ output, and tsdown builds + * workspace members concurrently without ordering them. */ export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { workspace: client ? ['vendor/*', 'packages/*/*', 'apps/cli'] - : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop', 'apps/desktop-host'], + : ['vendor/*', 'packages/*/*', 'apps/cli', 'apps/desktop-host'], entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'],