Skip to content

Commit e89bc19

Browse files
committed
Fix dev server directory events and output cleanup races.
1 parent 549a89d commit e89bc19

3 files changed

Lines changed: 115 additions & 22 deletions

File tree

‎packages/ts-ssg/src/build/incremental/incremental.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,40 @@ describe('incremental builder', () => {
5959
await logger?.close()
6060
})
6161

62+
it('ignores directory notifications and refreshes removed subtrees without deleting directories as files', async () => {
63+
await withTempDir(async (base) => {
64+
const contentDir = path.join(base, 'content')
65+
const outDir = path.join(base, 'out')
66+
const assetsDir = path.join(contentDir, 'assets')
67+
const emptyDir = path.join(contentDir, 'empty')
68+
await fs.mkdir(assetsDir, { recursive: true })
69+
await fs.mkdir(emptyDir)
70+
await fs.mkdir(path.join(outDir, 'empty'), { recursive: true })
71+
await fs.writeFile(path.join(assetsDir, 'example.txt'), 'asset content')
72+
await fs.writeFile(path.join(contentDir, 'index.mdx'), '# Home')
73+
const builder = await createIncrementalBuilder({
74+
siteConfig: { rootDir: base, contentDir, outDir },
75+
})
76+
await builder.prepareForRequests()
77+
await builder.prepareAssetByUrlPath('/assets/example.txt')
78+
expect((await builder.applyChange(assetsDir)).fullRebuild).toBe(false)
79+
expect((await builder.applyChange(contentDir)).fullRebuild).toBe(false)
80+
expect(
81+
await fs.readFile(path.join(outDir, 'assets', 'example.txt'), 'utf8'),
82+
).toBe('asset content')
83+
await fs.rmdir(emptyDir)
84+
expect((await builder.applyChange(emptyDir)).fullRebuild).toBe(false)
85+
expect((await fs.stat(path.join(outDir, 'empty'))).isDirectory()).toBe(
86+
true,
87+
)
88+
await fs.rm(assetsDir, { recursive: true, force: true })
89+
expect((await builder.applyChange(assetsDir)).fullRebuild).toBe(true)
90+
expect(
91+
await fs.readFile(path.join(outDir, 'assets', 'example.txt'), 'utf8'),
92+
).toBe('asset content')
93+
})
94+
})
95+
6296
it('removes deleted content outputs and updates manifest', async () => {
6397
await withTempDir(async (base) => {
6498
const contentDir = path.join(base, 'content')

‎packages/ts-ssg/src/build/incremental/index.ts‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fsPromises from 'node:fs/promises'
12
import path from 'node:path'
23
import type { SiteConfig } from '@purestack/ts-common'
34
import { themes } from '@purestack/ts-style'
@@ -475,13 +476,37 @@ class IncrementalRuntime {
475476
const result = this.changeApplier.createResult(
476477
`content change: ${filePath}`,
477478
)
478-
if (isOutsideContentRoot(relPath)) return result
479+
if (!relPath || isOutsideContentRoot(relPath)) return result
480+
const sourceStats = await fsPromises
481+
.stat(filePath)
482+
.catch((error: NodeJS.ErrnoException) => {
483+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
484+
return undefined
485+
throw error
486+
})
487+
// Recursive watchers also emit directory events. They are not asset files.
488+
if (sourceStats?.isDirectory()) return result
479489
if (isSiteConfigFile(relPath)) {
480490
result.fullRebuild = true
481491
return result
482492
}
483493

484494
await this.ensureContentReady()
495+
if (!sourceStats) {
496+
const prefix = `${toPosixPath(relPath)}/`
497+
const removedDirectory =
498+
this.context.contentRoutes.pages.some((file) =>
499+
toPosixPath(file.relPath).startsWith(prefix),
500+
) ||
501+
[...this.staticAssets.values()].some((file) =>
502+
toPosixPath(file.relPath).startsWith(prefix),
503+
)
504+
if (removedDirectory) {
505+
// Refresh discovery for a removed subtree; never rm its output as a file.
506+
result.fullRebuild = true
507+
return result
508+
}
509+
}
485510
this.contentState.takeMarkedPageCount()
486511
const ext = path.extname(relPath).toLowerCase()
487512
if (
@@ -494,9 +519,18 @@ class IncrementalRuntime {
494519
path.basename(relPath).toUpperCase() !==
495520
DEFAULT_NAV_FILENAME.toUpperCase()
496521
) {
497-
await removeFile(
498-
path.join(this.config.outDir, toOutputAssetRelPath(relPath)),
522+
const cachedAsset = this.manifest.assets[relPath]
523+
const knownAsset = this.staticAssets.get(
524+
`/${toPosixPath(toOutputAssetRelPath(relPath))}`,
499525
)
526+
// A missing, empty directory can also arrive as an event. Only invalidate
527+
// output for paths previously discovered or copied as actual files.
528+
if (cachedAsset || knownAsset) {
529+
await removeFile(
530+
cachedAsset?.outPath ??
531+
path.join(this.config.outDir, toOutputAssetRelPath(relPath)),
532+
)
533+
}
500534
delete this.manifest.assets[relPath]
501535
await this.discoverRequestAssets()
502536
this.contentState.refreshAssets(

‎packages/ts-ssg/src/dev/server.ts‎

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,21 @@ export async function startDevServer(
9696

9797
const { host, port, watch, liveReload, fullRender } =
9898
resolveDevServerOptions(input)
99-
let fullBuild: Promise<unknown> | undefined
99+
let setupReady: Promise<void> = Promise.resolve()
100+
const activeResponses = new Set<Promise<void>>()
101+
const beginSiteRequest = async () => {
102+
while (true) {
103+
const ready = setupReady
104+
await ready
105+
if (ready !== setupReady) continue
106+
const finished = Promise.withResolvers<void>()
107+
activeResponses.add(finished.promise)
108+
return () => {
109+
activeResponses.delete(finished.promise)
110+
finished.resolve()
111+
}
112+
}
113+
}
100114

101115
const clients: LiveReloadClients = new Map()
102116
let liveReloadVersion = 0
@@ -169,7 +183,13 @@ export async function startDevServer(
169183
reason: string,
170184
options?: { recreateBuilder?: boolean; reloadConfig?: boolean },
171185
) => {
186+
const previousSetup = setupReady
187+
const prepared = Promise.withResolvers<void>()
188+
setupReady = prepared.promise
172189
try {
190+
await previousSetup
191+
// Output cleanup must not race HTML reads or streamed asset responses.
192+
await Promise.all([...activeResponses])
173193
if (options?.reloadConfig) {
174194
buildInput = await projectConfig.load(baseBuildInput)
175195
watchConfigDependencies()
@@ -181,8 +201,7 @@ export async function startDevServer(
181201
devMiddleware = composeDevMiddleware(buildInput.options?.plugins ?? [])
182202
}
183203
if (fullRender && !initialSetupDone) {
184-
fullBuild = incremental.buildAll(reason)
185-
await fullBuild
204+
await incremental.buildAll(reason)
186205
}
187206
await incremental.prepareForRequests()
188207
if (!initialSetupDone) {
@@ -194,6 +213,8 @@ export async function startDevServer(
194213
notifyReload(reason)
195214
} catch (error) {
196215
logError(log, error, 'dev setup failed')
216+
} finally {
217+
prepared.resolve()
197218
}
198219
}
199220

@@ -248,9 +269,7 @@ export async function startDevServer(
248269
basePath: config.basePath,
249270
i18n: config.i18n,
250271
liveReload,
251-
waitForFullBuild: async () => {
252-
if (fullRender) await fullBuild
253-
},
272+
beginSiteRequest,
254273
// Config changes replace the builder, so requests ask for the current one.
255274
getIncremental: () => incremental,
256275
handlePluginRequest: (req, res) => devMiddleware(req, res),
@@ -267,16 +286,6 @@ export async function startDevServer(
267286
})
268287
server.keepAliveTimeout = 1000
269288
server.headersTimeout = 5000
270-
server.listen(port, host, () => {
271-
log.info('dev server listening', {
272-
host,
273-
port,
274-
outDir: config.outDir,
275-
liveReload,
276-
watch,
277-
fullRender,
278-
})
279-
})
280289

281290
if (watch) {
282291
watcher = await watchTree(config.contentDir, (filePath) => {
@@ -287,6 +296,16 @@ export async function startDevServer(
287296
watchConfigDependencies()
288297

289298
void requestRebuild()
299+
server.listen(port, host, () => {
300+
log.info('dev server listening', {
301+
host,
302+
port,
303+
outDir: config.outDir,
304+
liveReload,
305+
watch,
306+
fullRender,
307+
})
308+
})
290309

291310
const shutdown = async () => {
292311
if (shuttingDown) return
@@ -376,7 +395,7 @@ type DevServerRequestHandlerInput = {
376395
basePath: string
377396
i18n: I18nConfig
378397
liveReload: boolean
379-
waitForFullBuild: () => Promise<void>
398+
beginSiteRequest: () => Promise<() => void>
380399
getIncremental: () => IncrementalBuilder
381400
/** Resolves true when a plugin's dev middleware responded. */
382401
handlePluginRequest: (
@@ -396,7 +415,7 @@ function createDevServerRequestHandler(input: DevServerRequestHandlerInput) {
396415
basePath,
397416
i18n,
398417
liveReload,
399-
waitForFullBuild,
418+
beginSiteRequest,
400419
getIncremental,
401420
handlePluginRequest,
402421
clients,
@@ -441,7 +460,13 @@ function createDevServerRequestHandler(input: DevServerRequestHandlerInput) {
441460
let fileResult: Awaited<ReturnType<typeof resolveRequestFile>>
442461
let incremental: IncrementalBuilder
443462
try {
444-
await waitForFullBuild()
463+
const finishRequest = await beginSiteRequest()
464+
if (res.destroyed) {
465+
finishRequest()
466+
return
467+
}
468+
res.once('finish', finishRequest)
469+
res.once('close', finishRequest)
445470
incremental = getIncremental()
446471
res.setTimeout(REQUEST_TIMEOUT_MS, () => {
447472
res.destroy()

0 commit comments

Comments
 (0)