Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions src/generators/legacy-html/index.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
'use strict';

import { cp, readFile, rm, writeFile } from 'node:fs/promises';
import { readFile, rm, writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';

import HTMLMinifier from '@minify-html/node';

import buildContent from './utils/buildContent.mjs';
import dropdowns from './utils/buildDropdowns.mjs';
import { safeCopy } from './utils/safeCopy.mjs';
import tableOfContents from './utils/tableOfContents.mjs';
import { groupNodesByModule } from '../../utils/generators.mjs';
import { getRemarkRehype } from '../../utils/remark.mjs';
Expand Down Expand Up @@ -169,6 +170,9 @@ export default {
}

if (output) {
// Define the source folder for API docs assets
const srcAssets = join(baseDir, 'assets');

// Define the output folder for API docs assets
const assetsFolder = join(output, 'assets');

Expand All @@ -177,13 +181,11 @@ export default {
// If the path does not exists, it will simply ignore and continue
await rm(assetsFolder, { recursive: true, force: true, maxRetries: 10 });

// We copy all the other assets to the output folder at the end of the process
// to ensure that all latest changes on the styles are applied to the output
// Note.: This is not meant to be used for DX/developer purposes.
await cp(join(baseDir, 'assets'), assetsFolder, {
recursive: true,
force: true,
});
// Creates the assets folder if it does not exist
await mkdir(assetsFolder, { recursive: true });

// Copy all files from assets folder to output, skipping unchanged files
await safeCopy(srcAssets, assetsFolder);
}

return generatedValues;
Expand Down
38 changes: 38 additions & 0 deletions src/generators/legacy-html/utils/safeCopy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use strict';

import { readFile, writeFile, stat, readdir } from 'node:fs/promises';
import { join } from 'node:path';

/**
* Safely copies files from source to target directory, skipping files that haven't changed
* based on file stats (size and modification time)
*
* @param {string} srcDir - Source directory path
* @param {string} targetDir - Target directory path
*/
export async function safeCopy(srcDir, targetDir) {
const files = await readdir(srcDir);

for (const file of files) {
const sourcePath = join(srcDir, file);
const targetPath = join(targetDir, file);

const [sStat, tStat] = await Promise.allSettled([
stat(sourcePath),
stat(targetPath),
]);

const shouldWrite =
tStat.status === 'rejected' ||
sStat.value.size !== tStat.value.size ||
sStat.value.mtimeMs > tStat.value.mtimeMs;

if (!shouldWrite) {
continue;
}

const fileContent = await readFile(sourcePath);

await writeFile(targetPath, fileContent);
Comment on lines +17 to +36
Copy link
Contributor

@aduh95 aduh95 Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not "safe", there's still a TOCTOU: you call stat without locking the file, so when writeFile is reached the shouldWrite can be outdated (and with several processes running concurrently, it's almost guaranteed to happen).

Copy link
Member Author

@ovflowd ovflowd Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this PR doesn't prevent concurrent reads/writes, it just "prevents" the fs issues you were facing. It's safe in the sense that it shouldn't fail due to concurrency issues, or very unlikely.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well I'm still getting ENOENT

}
}
Loading