Files
dsh-market/tsdown.config.ts
fkysly 8830b8bcac chore: stop committing client.js.map, which is where the conflicts were (#533) (#539)
@liuwenji007 proposed a bot that rebuilds client/ on PR branches, because
every front-end PR conflicts there after another one lands. The problem is
real — they were hitting it on three stacked PRs at once — but the diagnosis
narrows further than the proposal assumed.

Measured on the two committed artifacts:

  client/client.js       11,201 lines, unminified, real identifiers
  client/client.js.map        0 newlines — one 789KB line

Line-based merges handle the first and cannot handle the second. That
matches what the queue actually did: #511 rebased with zero conflicts, and
#518's only conflict is the map. The bundle is not the problem; the map is,
and it always will be, because a single-line file has no merge granularity
at all.

So: no sourcemap for the client bundle. The reasoning is about this
repository, not the browser — the map was bought for debugging, and the
bundle it maps is already readable, so a stack trace against it names the
real functions either way. Not worth a permanent tax on everyone who
touches the client.

It also rode along in the published package, where nothing consumed it:
1.0 MB → 809 kB packed, 3.9 MB → 3.1 MB unpacked.

What stays: the bundle is still committed (the market must install where
build scripts are blocked), CI still enforces that it matches the source,
and the pipeline still preserves line counts — that property serves
determinism as well as the map that is now gone.

The bot idea is declined separately on the issue; the short version is that
pushing to fork branches needs `pull_request_target`, which runs privileged
in the base repo's context against untrusted code.
2026-09-07 21:48:40 +08:00

128 lines
6.2 KiB
TypeScript

/**
* Browser client bundle for the dshmarket plugin, mirroring the DeepSeek
* Harness client preset (packages/client/tsdown.client.ts) for an external
* package: a closure-factory artifact that calls
* window.__ModuleLoader__.load({ id, factory }) and resolves externals
* through the injected require (loader module table). CSS Modules compile via
* lightningcss inside the bundle: importing `x.module.css` yields the hashed
* class map, and the css text auto-injects a <style data-plugin> tag at
* factory execution (the loader removes plugin-owned tags on unload).
*
* scripts/preflight.mjs asserts the emitted client/client.js starts with the
* exact `window.__ModuleLoader__.load({ id: "dshmarket"` prefix.
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, relative, resolve as resolvePath } from 'node:path'
import { defineConfig } from 'tsdown'
import { transform } from 'lightningcss'
const id = 'dshmarket'
/**
* Externals resolved from the loader module table at runtime. Only the
* platform seed entries this bundle actually requires; everything else
* (nothing today) inlines.
*/
const CLIENT_EXTERNALS = ['react', 'react/jsx-runtime', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives']
/**
* Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline
* (which requires @tsdown/css). The suffix matters: tsdown's guard matches
* ids ending in `.css`, so the virtual id must not.
*/
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
export default defineConfig({
entry: { client: 'src/client/index.ts' },
// The published artifact location: package.json exports "./client" points
// at client/client.js, so the bundle lands there directly.
outDir: 'client',
format: 'cjs',
platform: 'browser',
target: 'es2022',
// Host types ship from lib/types (tsc); dts here would wrap the
// banner/footer into .d.cts and break parsing.
dts: false,
// No sourcemap, and the reason is the repository rather than the browser.
//
// `client.js` is committed (the market must install where build scripts are
// blocked) and CI enforces that it matches the source. That is fine for the
// bundle itself: 11k unminified lines with real identifiers, which git
// merges line by line like any other file — measured across a run of
// front-end PRs, it did not conflict once.
//
// The map is one 789KB line. Every change to it is a whole-file conflict,
// for every contributor, every time another front-end PR lands first
// (#533 by @liuwenji007, who was hitting it on three stacked PRs). It also
// rode along in the published package, where nothing consumed it.
//
// The debugging it bought was small, because the bundle it maps is already
// readable — a stack trace against it names the real functions. Not worth a
// permanent tax on everyone who touches the client.
sourcemap: false,
clean: false,
external: [...CLIENT_EXTERNALS],
// tsdown auto-externalizes package dependencies; anything NOT in the loader
// module table must inline instead — a require() the table cannot answer is
// a guaranteed runtime throw.
noExternal: (source: string) => (CLIENT_EXTERNALS.includes(source) ? undefined : true),
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
'import.meta.env.MODE': JSON.stringify('production'),
'import.meta.env': JSON.stringify({ MODE: 'production' }),
},
plugins: [{
name: 'dsh-css-modules-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.module.css')) return null
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(this: { addWatchFile(file: string): void }, virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
// The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
this.addWatchFile(fileId)
const source = await readFile(fileId)
// The filename feeds lightningcss's `[hash]`. Handing it the absolute
// path made the class prefix a fingerprint of the checkout location:
// the same commit built `.SOz1_a_root` on one machine and
// `.eGUBIq_root` on the CI runner (#472's second half, measured by the
// reproducibility guard this repo now runs). Repo-relative with posix
// separators is the same input everywhere, including Windows.
const { code, exports: cssExports } = transform({
filename: relative(process.cwd(), fileId).split('\\').join('/'),
code: source,
cssModules: { pattern: '[hash]_[local]' },
minify: true,
// Without targets, lightningcss collapses the hand-written
// `backdrop-filter` + `-webkit-backdrop-filter` pair to the -webkit-
// form only, losing Firefox. Targets (major << 16) keep both.
targets: { chrome: 90 << 16, firefox: 100 << 16, safari: 13 << 16, edge: 90 << 16 },
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
// One <style data-plugin> per module file; idempotent under re-evaluation.
return [
`const css = ${JSON.stringify(code.toString())};`,
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
' const tag = document.createElement(\'style\');',
` tag.dataset.plugin = ${JSON.stringify(id)};`,
' tag.dataset.pluginCss = tagId;',
' tag.textContent = css;',
' document.head.appendChild(tag);',
'}',
`export default ${JSON.stringify(classMap)};`,
].join('\n')
},
}],
outputOptions: {
entryFileNames: 'client.js',
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(id)}, factory: (require) => {`,
footer: 'return module.exports; } });',
intro: 'var module = { exports: {} }; var exports = module.exports;',
},
})