Skip to content

Commit 96a4098

Browse files
authored
feat: expose programmatic watch controls (#1062)
1 parent 61718d7 commit 96a4098

18 files changed

Lines changed: 173 additions & 57 deletions

File tree

‎__snapshots__/tsnapi/config.snapshot.d.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,16 @@ export interface UserConfig {
6868
external?: ExternalOption;
6969
noExternal?: Arrayable<string | RegExp> | NoExternalFn;
7070
}
71+
export interface UserConfigFnContext {
72+
ci: boolean;
73+
rootConfig?: UserConfig;
74+
watch: TsdownHandle["watch"];
75+
}
7176
// #endregion
7277

7378
// #region Types
7479
export type UserConfigExport = Awaitable<Arrayable<UserConfig> | UserConfigFn>;
75-
export type UserConfigFn = (_: InlineConfig, _: {
76-
ci: boolean;
77-
rootConfig?: UserConfig;
78-
}) => Awaitable<Arrayable<UserConfig>>;
80+
export type UserConfigFn = (_: InlineConfig, _: UserConfigFnContext) => Awaitable<Arrayable<UserConfig>>;
7981
// #endregion
8082

8183
// #region Functions

‎__snapshots__/tsnapi/index.snapshot.d.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ export interface TsdownBundle extends AsyncDisposable {
119119
config: ResolvedConfig;
120120
inlinedDeps: Map<string, Set<string>>;
121121
}
122+
export interface TsdownHandle {
123+
bundles: TsdownBundle[];
124+
watch: {
125+
restart: () => Promise<TsdownHandle>;
126+
close: () => Promise<void>;
127+
};
128+
}
122129
export interface TsdownHooks {
123130
"build:prepare": (_: BuildContext) => void | Promise<void>;
124131
"build:before": (_: BuildContext & RolldownContext) => void | Promise<void>;
@@ -196,6 +203,11 @@ export interface UserConfig {
196203
external?: ExternalOption;
197204
noExternal?: Arrayable<string | RegExp> | NoExternalFn;
198205
}
206+
export interface UserConfigFnContext {
207+
ci: boolean;
208+
rootConfig?: UserConfig;
209+
watch: TsdownHandle["watch"];
210+
}
199211
export interface Workspace {
200212
include?: "auto" | (string & {}) | string[];
201213
exclude?: Arrayable<string>;
@@ -252,18 +264,15 @@ export type TsdownPluginOption<A = any> = Awaitable<TsdownPlugin<A> | RolldownPl
252264
} | undefined | null | void | false | TsdownPluginOption<A>[]>;
253265
export type UnusedOptions = import("unplugin-unused").Options;
254266
export type UserConfigExport = Awaitable<Arrayable<UserConfig> | UserConfigFn>;
255-
export type UserConfigFn = (_: InlineConfig, _: {
256-
ci: boolean;
257-
rootConfig?: UserConfig;
258-
}) => Awaitable<Arrayable<UserConfig>>;
267+
export type UserConfigFn = (_: InlineConfig, _: UserConfigFnContext) => Awaitable<Arrayable<UserConfig>>;
259268
export type WithEnabled<T> = boolean | undefined | CIOption | (T & {
260269
enabled?: boolean | CIOption;
261270
});
262271
// #endregion
263272

264273
// #region Functions
265-
export declare function build(_?: InlineConfig): Promise<TsdownBundle[]>;
266-
export declare function buildWithConfigs(_: ResolvedConfig[], _: Set<string>, _: () => void): Promise<TsdownBundle[]>;
274+
export declare function build(_?: InlineConfig): Promise<TsdownHandle>;
275+
export declare function buildWithConfigs(_: ResolvedConfig[], _: Set<string>, _: () => Promise<TsdownHandle>): Promise<TsdownHandle>;
267276
export declare function defineConfig(_: UserConfig): UserConfig;
268277
export declare function defineConfig(_: UserConfig[]): UserConfig[];
269278
export declare function defineConfig(_: UserConfigFn): UserConfigFn;

‎docs/advanced/programmatic-usage.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ bundle output to disk:
2424
```ts twoslash
2525
import { build } from 'tsdown'
2626

27-
const bundles = await build({
27+
const { bundles } = await build({
2828
entry: ['src/index.ts'],
2929
format: 'esm',
3030
write: false,
@@ -39,9 +39,12 @@ for (const bundle of bundles) {
3939
}
4040
```
4141

42-
`build()` currently returns a `TsdownBundle[]`, with one bundle for each
43-
resolved configuration. Even a single configuration returns an array; its
44-
in-memory Rolldown outputs are available in `bundle.chunks`.
42+
`build()` returns a `TsdownHandle`. Its `bundles` array contains one bundle for
43+
each resolved configuration. In watch mode, `watch.restart()` restarts the build
44+
and resolves to the new handle, while `watch.close()` gracefully closes all
45+
watchers. Each handle can only be restarted once. Even a single configuration
46+
produces an array; its in-memory Rolldown outputs are available in
47+
`bundle.chunks`.
4548

4649
The `write` option controls Rolldown's bundle output, while other file operations
4750
are configured separately. For example, `clean` defaults to `true`, so set

‎docs/zh-CN/advanced/programmatic-usage.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ await build({
2323
```ts twoslash
2424
import { build } from 'tsdown'
2525

26-
const bundles = await build({
26+
const { bundles } = await build({
2727
entry: ['src/index.ts'],
2828
format: 'esm',
2929
write: false,
@@ -38,7 +38,7 @@ for (const bundle of bundles) {
3838
}
3939
```
4040

41-
`build()` 当前返回 `TsdownBundle[]`,每个解析后的配置对应一个 bundle。即使只有单个配置,返回值也是数组;内存中的 Rolldown 输出位于 `bundle.chunks`。
41+
`build()` 返回 `TsdownHandle`。其中 `bundles` 数组为每个解析后的配置包含一个 bundle。在监听模式下,`watch.restart()` 用于重启构建并返回新的 handle,`watch.close()` 会优雅地关闭所有 watcher。每个 handle 只能重启一次。即使只有单个配置,`bundles` 也是数组;内存中的 Rolldown 输出位于 `bundle.chunks`。
4242

4343
`write` 选项控制 Rolldown 打包产物的写入,其他文件操作则单独配置。例如,`clean` 默认为 `true`,因此如果必须保留现有输出目录,请像上面的示例一样设置 `clean: false`。显式启用的 `copy` 或 `exports` 等功能仍可能写入文件。`write: false` 与监听模式不兼容。
4444

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
"devEngines": {
6868
"packageManager": {
6969
"name": "pnpm",
70-
"version": "^12.3.1",
70+
"version": "12.3.1",
7171
"onFail": "download"
7272
}
7373
},
@@ -143,7 +143,7 @@
143143
},
144144
"inlinedDependencies": {
145145
"package-manager-detector": "1.8.0",
146-
"pkg-types": "2.3.1"
146+
"pkg-types": "2.3.2"
147147
},
148148
"devDependencies": {
149149
"@arethetypeswrong/core": "catalog:peer",

‎packages/migrate/tests/fixtures/monorepo/apps/pkg3/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
"build": "tsup src/index.ts"
88
},
99
"dependencies": {
10-
"tsup": "^7.2.0"
10+
"tsup": "^8.5.1"
1111
}
1212
}

‎pnpm-lock.yaml‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/build.ts‎

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
addOutDirToChunks,
3030
type RolldownChunk,
3131
type TsdownBundle,
32+
type TsdownHandle,
3233
} from './utils/chunks.ts'
3334
import { debounce, typeAssert } from './utils/general.ts'
3435
import { globalLogger } from './utils/logger.ts'
@@ -39,11 +40,37 @@ import { styleText } from './utils/style.ts'
3940
*/
4041
export async function build(
4142
inlineConfig: InlineConfig = {},
42-
): Promise<TsdownBundle[]> {
43+
): Promise<TsdownHandle> {
4344
globalLogger.level = inlineConfig.logLevel || 'info'
44-
const { configs, deps: configDeps } = await resolveConfig(inlineConfig)
4545

46-
return buildWithConfigs(configs, configDeps, () => build(inlineConfig))
46+
let isConfigResolved = false
47+
const { configs, deps } = await resolveConfig(inlineConfig, {
48+
restart,
49+
close,
50+
})
51+
const handlePromise = buildWithConfigs(configs, deps, () =>
52+
build(inlineConfig),
53+
)
54+
isConfigResolved = true
55+
return await handlePromise
56+
57+
async function restart() {
58+
if (!isConfigResolved) {
59+
throw new Error(
60+
'`watch.restart` cannot be called before config resolution is complete.',
61+
)
62+
}
63+
return (await handlePromise).watch.restart()
64+
}
65+
66+
async function close() {
67+
if (!isConfigResolved) {
68+
throw new Error(
69+
'`watch.close` cannot be called before config resolution is complete.',
70+
)
71+
}
72+
return (await handlePromise).watch.close()
73+
}
4774
}
4875

4976
/**
@@ -55,23 +82,37 @@ export async function build(
5582
export async function buildWithConfigs(
5683
configs: ResolvedConfig[],
5784
configDeps: Set<string>,
58-
_restart: () => void,
59-
): Promise<TsdownBundle[]> {
85+
rebuild: () => Promise<TsdownHandle>,
86+
): Promise<TsdownHandle> {
87+
const hasWatchConfig = configs.some((config) => config.watch)
6088
let cleanPromise: Promise<void> | undefined
6189
const clean = () => {
6290
if (cleanPromise) return cleanPromise
6391
return (cleanPromise = cleanOutDir(configs))
6492
}
6593

6694
const disposeCbs: Array<() => void | PromiseLike<void>> = []
67-
let restarting = false
68-
async function restart() {
69-
if (restarting) return
70-
restarting = true
95+
let restarted = false
7196

97+
function assertWatchMode() {
98+
if (!hasWatchConfig) {
99+
throw new Error('`watch` is only available in watch mode.')
100+
}
101+
}
102+
async function close() {
103+
assertWatchMode()
72104
await Promise.all(disposeCbs.map((cb) => cb()))
105+
}
106+
107+
async function restart() {
108+
assertWatchMode()
109+
if (restarted) {
110+
throw new Error('`watch.restart` can only be called once per handle.')
111+
}
112+
restarted = true
113+
await close()
73114
clearRequireCache()
74-
_restart()
115+
return rebuild()
75116
}
76117

77118
const configChunksByPkg = initBundleByPkg(configs)
@@ -91,7 +132,10 @@ export async function buildWithConfigs(
91132
configDeps,
92133
isDualFormat,
93134
clean,
94-
restart,
135+
() => {
136+
if (restarted) return
137+
restart().catch((error) => globalLogger.error(error))
138+
},
95139
done,
96140
)
97141
}),
@@ -101,7 +145,6 @@ export async function buildWithConfigs(
101145
(config) => config.devtools && config.devtools.ui,
102146
)
103147

104-
const hasWatchConfig = configs.some((config) => config.watch)
105148
if (hasWatchConfig) {
106149
// Watch mode with shortcuts
107150
disposeCbs.push(shortcuts(restart))
@@ -114,7 +157,10 @@ export async function buildWithConfigs(
114157
startDevtoolsUI(firstDevtoolsConfig.devtools)
115158
}
116159

117-
return bundles
160+
return {
161+
bundles,
162+
watch: { restart, close },
163+
}
118164
}
119165

120166
/**
@@ -164,7 +210,7 @@ async function buildSingle(
164210
const configs = await initBuildOptions()
165211
if (watch) {
166212
watcher = rolldownWatch(configs)
167-
handleWatcher(watcher)
213+
await handleWatcher(watcher)
168214
} else {
169215
const outputs = await config.runBuild(() => rolldownBuild(configs))
170216
for (const { output } of outputs) {
@@ -183,6 +229,7 @@ async function buildSingle(
183229
return bundle
184230

185231
function handleWatcher(watcher: RolldownWatcher) {
232+
const ready = Promise.withResolvers<void>()
186233
const changedFile: string[] = []
187234
let hasError = false
188235

@@ -226,6 +273,7 @@ async function buildSingle(
226273

227274
chunks.length = 0
228275
hasError = false
276+
ready.resolve()
229277
break
230278
}
231279

@@ -261,6 +309,8 @@ async function buildSingle(
261309
}
262310
}
263311
})
312+
313+
return ready.promise
264314
}
265315

266316
async function initBuildOptions() {

‎src/config.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
UserConfig,
33
UserConfigExport,
44
UserConfigFn,
5+
UserConfigFnContext,
56
} from './config/index.ts'
67
/**
78
* Defines the configuration for tsdown.
@@ -14,5 +15,5 @@ export function defineConfig(options: UserConfigExport): UserConfigExport {
1415
return options
1516
}
1617

17-
export type { UserConfig, UserConfigExport, UserConfigFn }
18+
export type { UserConfig, UserConfigExport, UserConfigFn, UserConfigFnContext }
1819
export { mergeConfig } from './config/options.ts'

‎src/config/file.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import { pathToFileURL } from 'node:url'
55
import { depsStore, init, isSupported } from 'import-without-cache'
66
import { createDebug } from 'obug'
77
import { createConfigCoreLoader } from 'unconfig-core'
8-
import { isInCI } from '../utils/ci.ts'
98
import { fsStat } from '../utils/fs.ts'
109
import { importWithError, toArray } from '../utils/general.ts'
1110
import { globalLogger } from '../utils/logger.ts'
1211
import { styleText } from '../utils/style.ts'
13-
import type { InlineConfig, UserConfig, UserConfigExport } from './types.ts'
12+
import type {
13+
InlineConfig,
14+
UserConfig,
15+
UserConfigExport,
16+
UserConfigFnContext,
17+
} from './types.ts'
1418
import type {
1519
ConfigEnv,
1620
UserConfig as ViteUserConfig,
@@ -65,8 +69,8 @@ const configPrefix = 'tsdown.config'
6569

6670
export async function loadConfigFile(
6771
inlineConfig: InlineConfig,
72+
context: UserConfigFnContext,
6873
workspace?: string,
69-
rootConfig?: UserConfig,
7074
): Promise<{
7175
configs: UserConfig[]
7276
deps?: Set<string>
@@ -136,7 +140,7 @@ export async function loadConfigFile(
136140

137141
exported = await exported
138142
if (typeof exported === 'function') {
139-
exported = await exported(inlineConfig, { ci: isInCI(), rootConfig })
143+
exported = await exported(inlineConfig, context)
140144
}
141145
}
142146

0 commit comments

Comments
 (0)