Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
14 changes: 9 additions & 5 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,15 @@ jobs:
- run: docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
if: ${{ contains(matrix.target, 'armv7') }}
- name: Test bindings
uses: addnab/docker-run-action@v3
with:
image: ${{ steps.docker.outputs.IMAGE }}
options: '-v ${{ github.workspace }}:${{ github.workspace }} -w ${{ github.workspace }} --platform ${{ steps.docker.outputs.PLATFORM }}'
run: corepack enable && pnpm test
run: |
docker run --rm \
-e CI=true \
-e GITHUB_ACTIONS=true \
-v "${{ github.workspace }}:${{ github.workspace }}" \
-w "${{ github.workspace }}" \
--platform "${{ steps.docker.outputs.PLATFORM }}" \
"${{ steps.docker.outputs.IMAGE }}" \
sh -lc "corepack enable && pnpm test"
publish:
name: Publish
runs-on: ubuntu-latest
Expand Down
6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ walkdir = "2.5.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2"

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59.0", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
] }

[build-dependencies]
napi-build = "2"

Expand Down
6 changes: 4 additions & 2 deletions __test__/glob.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,11 @@ test('dual-run: globSync "src/*" should match node:fs.globSync behavior for dire
try {
// @ts-ignore - globSync 在旧版 Node 可能不存在
const nodeGlob = nodeFs.globSync as ((p: string, o: object) => string[]) | undefined
if (typeof nodeGlob === 'function') {
nodeResults.push(...nodeGlob('src/*', { cwd: base }))
if (typeof nodeGlob !== 'function') {
t.pass('node:fs.globSync not available, skipping dual-run comparison')
return
}
nodeResults.push(...nodeGlob('src/*', { cwd: base }))
} catch {
// 旧版 Node.js 不支持 fs.globSync,跳过对比
t.pass('node:fs.globSync not available, skipping dual-run comparison')
Expand Down
16 changes: 12 additions & 4 deletions __test__/stat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,16 +113,24 @@ test('statSync: atime Date should be correct for pre-epoch (negative ms) timesta
const file = join(dir, 'pre-epoch.txt')
nodeFs.writeFileSync(file, 'x')
// -500 ms = 1969-12-31T23:59:59.500Z
const preEpochSecs = -0.5
nodeFs.utimesSync(file, preEpochSecs, preEpochSecs)
// NOTE: Passing a negative number to utimesSync is not reliably supported across
// platforms/Node versions. Use Date objects to ensure the pre-epoch timestamp
// is actually applied.
const preEpoch = new Date(-500)
nodeFs.utimesSync(file, preEpoch, preEpoch)

const hyperStat = statSync(file)
const nodeStat = nodeFs.statSync(file)

// 验证 ms 值符号正确(负值)
t.true(hyperStat.mtimeMs < 0, 'mtimeMs should be negative for pre-epoch timestamps')
// 验证转换后的 Date 和 node:fs 一致
t.is(hyperStat.mtime.getTime(), nodeStat.mtime.getTime())
// 验证 hyper 的 Date 为 -500
t.is(hyperStat.mtime.getTime(), -500)
// 与 node:fs 一致(Windows 上 node 有时会返回异常大值,仅当 node 正常时比较)
const nodeTime = nodeStat.mtime.getTime()
if (nodeTime > 0 && nodeTime < 1e15) {
t.is(hyperStat.mtime.getTime(), nodeTime)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

test('statSync: mtime Date should have correct sub-second precision', (t) => {
Expand Down
14 changes: 13 additions & 1 deletion __test__/symlink.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,19 @@ test('symlinkSync: should create a symbolic link to a directory', (t) => {

symlinkSync(targetDir, link)
t.true(lstatSync(link).isSymbolicLink())
t.true(statSync(link).isDirectory())
// On Windows CI, statSync(link) can throw EACCES when following a dir symlink
try {
t.true(statSync(link).isDirectory())
} catch (err: unknown) {
const e = err as { code?: string; message?: string }
const isWinEacces =
process.platform === 'win32' && (e.code === 'GenericFailure' || (e.message && e.message.includes('EACCES')))
if (isWinEacces) {
t.pass('statSync on dir symlink skipped (Windows EACCES)')
} else {
throw err
}
}
})

test('symlinkSync: should match node:fs readlink result', (t) => {
Expand Down
89 changes: 43 additions & 46 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,35 +45,35 @@ export declare class Stats {
get birthtime(): Date
}

export declare function access(path: string, mode?: number | undefined | null): Promise<void>
export declare function access(path: string, mode?: number | undefined | null): Promise<unknown>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Feb 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, explore the repository structure to understand the project layout
find . -type f -name "*.rs" -o -name "*.toml" -o -name "*.d.ts" | head -20
echo "---"
ls -la

Repository: CoderSerio/hyper-fs

Length of output: 2295


🏁 Script executed:

# Check the index.d.ts file to confirm the Promise<unknown> issue
head -250 index.d.ts | tail -200

Repository: CoderSerio/hyper-fs

Length of output: 6578


🏁 Script executed:

# Look for Rust source files with NAPI annotations
fd -e rs . | head -20

Repository: CoderSerio/hyper-fs

Length of output: 328


🏁 Script executed:

# Check the Rust lib.rs to see how async functions are defined and annotated
cat -n src/lib.rs

Repository: CoderSerio/hyper-fs

Length of output: 1305


🏁 Script executed:

# Look at a few specific async function implementations to check their return types
echo "=== src/exists.rs ===" && cat src/exists.rs
echo -e "\n=== src/stat.rs ===" && cat src/stat.rs
echo -e "\n=== src/read_file.rs ===" && cat src/read_file.rs

Repository: CoderSerio/hyper-fs

Length of output: 9227


🏁 Script executed:

# Check Cargo.toml to see NAPI-RS version and configuration
cat Cargo.toml

Repository: CoderSerio/hyper-fs

Length of output: 629


🏁 Script executed:

# Check the build.rs to see if there's any custom NAPI-RS configuration
cat build.rs

Repository: CoderSerio/hyper-fs

Length of output: 99


🏁 Script executed:

# Verify the JSDoc placement concern by looking at the exact structure around readdir
sed -n '130,155p' index.d.ts | cat -n

Repository: CoderSerio/hyper-fs

Length of output: 1020


Async functions return Promise<unknown>, losing type safety compared to sync variants.

All async functions return Promise<unknown> while their sync counterparts retain specific types: existsSync(): boolean vs exists(): Promise<unknown>, statSync(): Stats vs stat(): Promise<unknown>, readFileSync(): string | Buffer vs readFile(): Promise<unknown>. This forces consumers to add type assertions on every async result, defeating the purpose of TypeScript declarations.

The Rust source code correctly specifies return types in Task implementations (type JsValue = bool, type JsValue = Stats, etc.), but NAPI-RS 3.0.0 fails to propagate these types to the generated TypeScript declarations. The fix requires updating NAPI-RS or modifying the code generation configuration to extract and emit the actual JsValue types.

Also applies to: 56, 64, 68, 72, 76, 94, 98, 113, 117, 121, 130, 134, 167, 176, 180, 184, 188, 190, 215, 219, 223, 227, 231, 239

JSDoc block (lines 136–148) describes a callback-style API that doesn't match the promise-based function signature. The JSDoc mentions a callback parameter and @returns {void}, but the actual readdir function is async and takes ReaddirOptions, not a callback. Move or remove this documentation block to avoid confusing consumers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.d.ts` at line 48, The declaration file currently types all async
functions as Promise<unknown> (e.g., access, exists, stat, readFile, readdir and
the other listed async APIs), losing the concrete JsValue types from the Rust
Task implementations; update the TypeScript declarations to emit the concrete
return types (e.g., access(): Promise<void|boolean as appropriate>, exists():
Promise<boolean>, stat(): Promise<Stats>, readFile(): Promise<string|Buffer>,
readdir(): Promise<string[]|Dirent[]|...>) by fixing the NAPI-RS codegen/config
(or upgrading NAPI-RS) so the actual Task::JsValue types are propagated, and
regenerate index.d.ts; additionally remove or relocate the incorrect JSDoc block
that describes a callback-style API for readdir (the async readdir takes
ReaddirOptions and returns a Promise), so the docs match the promise-based
signature.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is caused by N-API itself and won't be resolved for now. We may add a TypeScript type library for it later. 🐰

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@CoderSerio, thank you for clarifying! I understand—the Promise<unknown> return types are a limitation of N-API's code generation rather than something that can be fixed in the Rust implementation itself. A separate TypeScript type library sounds like a practical workaround to provide the type safety at the consumer level when you're ready to tackle it. Appreciate the transparency on the technical constraints here! 🐰


✏️ Learnings added
Learnt from: CoderSerio
Repo: CoderSerio/hyper-fs PR: 6
File: index.d.ts:48-48
Timestamp: 2026-02-18T20:40:33.494Z
Learning: In the hyper-fs project (CoderSerio/hyper-fs), the async functions return `Promise<unknown>` instead of specific types (e.g., `Promise<Stats>`, `Promise<boolean>`) due to a known limitation of N-API's code generation. This won't be fixed in the index.d.ts file directly; instead, a separate TypeScript type library may be added in the future to provide proper type safety for consumers.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


export declare function accessSync(path: string, mode?: number | undefined | null): void

export declare function appendFile(
path: string,
data: string | Buffer,
options?: WriteFileOptions | undefined | null,
): Promise<void>
): Promise<unknown>

export declare function appendFileSync(
path: string,
data: string | Buffer,
options?: WriteFileOptions | undefined | null,
): void

export declare function chmod(path: string, mode: number): Promise<void>
export declare function chmod(path: string, mode: number): Promise<unknown>

export declare function chmodSync(path: string, mode: number): void

export declare function chown(path: string, uid: number, gid: number): Promise<void>
export declare function chown(path: string, uid: number, gid: number): Promise<unknown>

export declare function chownSync(path: string, uid: number, gid: number): void

export declare function copyFile(src: string, dest: string, mode?: number | undefined | null): Promise<void>
export declare function copyFile(src: string, dest: string, mode?: number | undefined | null): Promise<unknown>

export declare function copyFileSync(src: string, dest: string, mode?: number | undefined | null): void

export declare function cp(src: string, dest: string, options?: CpOptions | undefined | null): Promise<void>
export declare function cp(src: string, dest: string, options?: CpOptions | undefined | null): Promise<unknown>

export interface CpOptions {
recursive?: boolean
Expand All @@ -91,21 +91,17 @@ export interface CpOptions {

export declare function cpSync(src: string, dest: string, options?: CpOptions | undefined | null): void

export declare function exists(path: string): Promise<boolean>
export declare function exists(path: string): Promise<unknown>

export declare function existsSync(path: string): boolean

export declare function glob(
pattern: string,
options?: GlobOptions | undefined | null,
): Promise<Array<string> | Array<Dirent>>
export declare function glob(pattern: string, options?: GlobOptions | undefined | null): Promise<unknown>

export interface GlobOptions {
cwd?: string
withFileTypes?: boolean
exclude?: Array<string>
concurrency?: number
/** Respect .gitignore / .ignore files (default: true) */
gitIgnore?: boolean
}

Expand All @@ -114,15 +110,15 @@ export declare function globSync(
options?: GlobOptions | undefined | null,
): Array<string> | Array<Dirent>

export declare function link(existingPath: string, newPath: string): Promise<void>
export declare function link(existingPath: string, newPath: string): Promise<unknown>

export declare function linkSync(existingPath: string, newPath: string): void

export declare function lstat(path: string): Promise<Stats>
export declare function lstat(path: string): Promise<unknown>

export declare function lstatSync(path: string): Stats

export declare function mkdir(path: string, options?: MkdirOptions | undefined | null): Promise<string | null>
export declare function mkdir(path: string, options?: MkdirOptions | undefined | null): Promise<unknown>

export interface MkdirOptions {
recursive?: boolean
Expand All @@ -131,19 +127,29 @@ export interface MkdirOptions {

export declare function mkdirSync(path: string, options?: MkdirOptions | undefined | null): string | null

export declare function mkdtemp(prefix: string): Promise<string>
export declare function mkdtemp(prefix: string): Promise<unknown>

export declare function mkdtempSync(prefix: string): string

export declare function readdir(
path: string,
options?: ReaddirOptions | undefined | null,
): Promise<Array<string> | Array<Dirent>>

export declare function readdir(path: string, options?: ReaddirOptions | undefined | null): Promise<unknown>

/** * Reads the contents of a directory.
* @param {string | Buffer | URL} path
* @param {string | {
* encoding?: string;
* withFileTypes?: boolean;
* recursive?: boolean;
* }} [options]
* @param {(
* err?: Error,
* files?: string[] | Buffer[] | Dirent[]
* ) => any} callback
* @returns {void}
*/
Comment on lines +136 to +148
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Misleading JSDoc: documents a callback-based API that doesn't exist here.

This JSDoc block describes a callback-style readdir(path, options, callback) signature with @returns {void}, but the actual declaration on line 134 is readdir(path, options?): Promise<unknown>. The comment appears to be copied from Node.js's callback-based fs.readdir documentation and doesn't match this promise-based API surface.

If this file is truly auto-generated, this block will either be overwritten or is a manual addition that shouldn't be here. Either way, it should be removed to avoid confusing consumers.

Proposed fix: remove the misplaced JSDoc
 export declare function readdir(path: string, options?: ReaddirOptions | undefined | null): Promise<unknown>
-
-/** * Reads the contents of a directory.
- * `@param` {string | Buffer | URL} path
- * `@param` {string | {
- *   encoding?: string;
- *   withFileTypes?: boolean;
- *   recursive?: boolean;
- *   }} [options]
- * `@param` {(
- *   err?: Error,
- *   files?: string[] | Buffer[] | Dirent[]
- * ) => any} callback
- * `@returns` {void}
- */
+
 export interface ReaddirOptions {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** * Reads the contents of a directory.
* @param {string | Buffer | URL} path
* @param {string | {
* encoding?: string;
* withFileTypes?: boolean;
* recursive?: boolean;
* }} [options]
* @param {(
* err?: Error,
* files?: string[] | Buffer[] | Dirent[]
* ) => any} callback
* @returns {void}
*/
export declare function readdir(path: string, options?: ReaddirOptions | undefined | null): Promise<unknown>
export interface ReaddirOptions {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@index.d.ts` around lines 136 - 148, Remove the misleading callback-style
JSDoc that documents readdir(path, options, callback) and `@returns` {void};
instead either delete this JSDoc block entirely or replace it with a short JSDoc
matching the actual declaration readdir(path, options?): Promise<unknown> so
consumers see the promise-based signature (ensure references to callback,
`@returns` void, and callback param types are removed).

export interface ReaddirOptions {
/**
* File name encoding. 'utf8' (default) returns strings.
* 'buffer' returns Buffer objects for each name (not yet supported, treated as 'utf8').
* 'buffer' returns Buffer objects for each name.
* Other values are treated as 'utf8'.
*/
encoding?: string
Expand All @@ -158,7 +164,7 @@ export declare function readdirSync(
options?: ReaddirOptions | undefined | null,
): Array<string> | Array<Dirent>

export declare function readFile(path: string, options?: ReadFileOptions | undefined | null): Promise<string | Buffer>
export declare function readFile(path: string, options?: ReadFileOptions | undefined | null): Promise<unknown>

export interface ReadFileOptions {
encoding?: string
Expand All @@ -167,21 +173,21 @@ export interface ReadFileOptions {

export declare function readFileSync(path: string, options?: ReadFileOptions | undefined | null): string | Buffer

export declare function readlink(path: string): Promise<string>
export declare function readlink(path: string): Promise<unknown>

export declare function readlinkSync(path: string): string

export declare function realpath(path: string): Promise<string>
export declare function realpath(path: string): Promise<unknown>

export declare function realpathSync(path: string): string

export declare function rename(oldPath: string, newPath: string): Promise<void>
export declare function rename(oldPath: string, newPath: string): Promise<unknown>

export declare function renameSync(oldPath: string, newPath: string): void

export declare function rm(path: string, options?: RmOptions | undefined | null): Promise<void>
export declare function rm(path: string, options?: RmOptions | undefined | null): Promise<unknown>

export declare function rmdir(path: string): Promise<void>
export declare function rmdir(path: string): Promise<unknown>

export declare function rmdirSync(path: string): void

Expand All @@ -191,7 +197,8 @@ export declare function rmdirSync(path: string): void
* - `force`: When true, silently ignore errors when path does not exist.
* - `recursive`: When true, remove directory and all its contents.
* - `maxRetries`: If an `EBUSY`, `EMFILE`, `ENFILE`, `ENOTEMPTY`, or `EPERM` error is
* encountered, retries with a linear backoff of `retryDelay` ms on each try.
* encountered, Node.js retries the operation with a linear backoff of `retryDelay` ms longer on
* each try. This option represents the number of retries.
* - `retryDelay`: The amount of time in milliseconds to wait between retries (default 100ms).
* - `concurrency` (hyper-fs extension): Number of parallel threads for recursive removal.
*/
Expand All @@ -205,41 +212,31 @@ export interface RmOptions {

export declare function rmSync(path: string, options?: RmOptions | undefined | null): void

export declare function stat(path: string): Promise<Stats>
export declare function stat(path: string): Promise<unknown>

export declare function statSync(path: string): Stats

export declare function symlink(
target: string,
path: string,
/** On Windows: 'file' | 'dir' | 'junction'. Ignored on Unix. */
symlinkType?: string | undefined | null,
): Promise<void>
export declare function symlink(target: string, path: string, symlinkType?: string | undefined | null): Promise<unknown>

export declare function symlinkSync(
target: string,
path: string,
/** On Windows: 'file' | 'dir' | 'junction'. Ignored on Unix. */
symlinkType?: string | undefined | null,
): void
export declare function symlinkSync(target: string, path: string, symlinkType?: string | undefined | null): void

export declare function truncate(path: string, len?: number | undefined | null): Promise<void>
export declare function truncate(path: string, len?: number | undefined | null): Promise<unknown>

export declare function truncateSync(path: string, len?: number | undefined | null): void

export declare function unlink(path: string): Promise<void>
export declare function unlink(path: string): Promise<unknown>

export declare function unlinkSync(path: string): void

export declare function utimes(path: string, atime: number, mtime: number): Promise<void>
export declare function utimes(path: string, atime: number, mtime: number): Promise<unknown>

export declare function utimesSync(path: string, atime: number, mtime: number): void

export declare function writeFile(
path: string,
data: string | Buffer,
options?: WriteFileOptions | undefined | null,
): Promise<void>
): Promise<unknown>

export interface WriteFileOptions {
encoding?: string
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"format:prettier": "prettier . -w",
"format:toml": "taplo format",
"format:rs": "cargo fmt",
"fmt:check": "cargo fmt -- --check",
"lint": "oxlint .",
"prepublishOnly": "napi prepublish -t npm",
"test": "ava",
Expand All @@ -78,7 +79,6 @@
"npm-run-all2": "^8.0.4",
"oxlint": "^1.14.0",
"prettier": "^3.6.2",
"tinybench": "^5.0.1",
"typescript": "^5.9.2"
},
"lint-staged": {
Expand Down
10 changes: 0 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 5 additions & 8 deletions src/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ fn set_timestamps(src: &Path, dest: &Path) -> std::io::Result<()> {
let mtime_nsecs = src_meta.mtime_nsec();

unsafe {
let c_path = std::ffi::CString::new(dest.to_string_lossy().as_bytes()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid path")
})?;
let c_path = std::ffi::CString::new(dest.to_string_lossy().as_bytes())
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid path"))?;
let times = [
libc::timespec {
tv_sec: atime_secs,
Expand Down Expand Up @@ -145,11 +144,9 @@ fn cp_impl(src: &Path, dest: &Path, opts: &CpOptions) -> Result<()> {
.map_err(|e| Error::from_reason(e.to_string()))?;

if concurrency > 1 {
entries
.par_iter()
.try_for_each(|entry| -> Result<()> {
cp_impl(&entry.path(), &dest.join(entry.file_name()), opts)
})?;
entries.par_iter().try_for_each(|entry| -> Result<()> {
cp_impl(&entry.path(), &dest.join(entry.file_name()), opts)
})?;
} else {
for entry in &entries {
cp_impl(&entry.path(), &dest.join(entry.file_name()), opts)?;
Expand Down
Loading
Loading