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
1 change: 1 addition & 0 deletions .cursor/rules/coding-style.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ Coding style:
- Use descriptive names: PascalCase for components/types, camelCase for variables/methods/schemas
- Alphabetize imports, group by source type (built-in/external/internal)
- Favor US English over UK English, so `summarizeError` over `summarise Error`
- Favor `.replaceAll('a', 'b)` over `.replace(/a/g, 'b')` or `.replace(new RegExp('a', 'g'), 'b')` when the only need for regeses was replacing all strings. That's usually both easier to read and more performant.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.891.0",
"@aws-sdk/s3-request-presigner": "^3.891.0",
"@transloadit/sev-logger": "^0.0.15",
"debug": "^4.4.3",
"form-data": "^4.0.4",
"got": "14.4.9",
Expand Down
244 changes: 244 additions & 0 deletions src/alphalib/lib/nativeGlobby.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import type { GlobOptionsWithoutFileTypes } from 'node:fs'
import * as fs from 'node:fs'
import { glob as fsGlob, stat as statAsync } from 'node:fs/promises'
Comment on lines +1 to +3

Choose a reason for hiding this comment

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

P1 Badge Avoid Node 20-incompatible fs.glob usage

The new nativeGlobby helper imports glob/globSync from node:fs/promises/node:fs, but those exports only landed in Node 22 while package.json still declares support for Node >=20. Loading this module on the currently supported Node 20.x will fail at module evaluation with The requested module 'node:fs/promises' does not provide an export named 'glob', so any future use of nativeGlobby will crash on the minimum supported runtime unless the engine constraint is raised or a fallback is added.

Useful? React with 👍 / 👎.

import path from 'node:path'

type PatternInput = string | readonly string[]
const { globSync: fsGlobSync } = fs

export interface NativeGlobbyOptions {
cwd?: string
absolute?: boolean
onlyFiles?: boolean
ignore?: readonly string[]
}

interface NormalizedOptions {
cwd?: string
absolute: boolean
onlyFiles: boolean
ignore?: readonly string[]
}

interface Candidate {
rawPath: string
absolutePath: string
}

const normalizeSlashes = (value: string) => value.replace(/\\/g, '/')

const toAbsolutePath = (rawPath: string, cwd?: string): string => {
if (path.isAbsolute(rawPath)) {
return rawPath
}
if (cwd) {
return path.join(cwd, rawPath)
}
return path.resolve(rawPath)
}

const normalizeOptions = (options: NativeGlobbyOptions = {}): NormalizedOptions => ({
cwd: options.cwd ? path.resolve(options.cwd) : undefined,
absolute: options.absolute ?? false,
onlyFiles: options.onlyFiles ?? true,
ignore: options.ignore && options.ignore.length > 0 ? options.ignore : undefined,
})

const hasGlobMagic = (pattern: string) => /[*?[\]{}()!]/.test(pattern)

const expandPatternAsync = async (pattern: string, options: NormalizedOptions) => {
if (hasGlobMagic(pattern)) {
return [pattern]
}

try {
const absolute = toAbsolutePath(pattern, options.cwd)
const stats = await statAsync(absolute)
if (stats.isDirectory()) {
const expanded = normalizeSlashes(path.join(pattern, '**/*'))
return [expanded]
}
} catch {
// ignore missing paths; fall back to original pattern
}

return [pattern]
}

const expandPatternSync = (pattern: string, options: NormalizedOptions) => {
if (hasGlobMagic(pattern)) {
return [pattern]
}

try {
const absolute = toAbsolutePath(pattern, options.cwd)
const stats = fs.statSync(absolute)
if (stats.isDirectory()) {
const expanded = normalizeSlashes(path.join(pattern, '**/*'))
return [expanded]
}
} catch {
// ignore missing paths; fall back to original pattern
}

return [pattern]
}

const splitPatterns = (patterns: PatternInput) => {
const list = Array.isArray(patterns) ? patterns : [patterns]
const positive: string[] = []
const negative: string[] = []

for (const pattern of list) {
if (pattern.startsWith('!')) {
const negated = pattern.slice(1)
if (negated) {
negative.push(negated)
}
} else {
positive.push(pattern)
}
}

return { positive, negative }
}

const toGlobOptions = (options: NormalizedOptions): GlobOptionsWithoutFileTypes => {
const globOptions: GlobOptionsWithoutFileTypes = { withFileTypes: false }
if (options.cwd) {
globOptions.cwd = options.cwd
}
if (options.ignore) {
// Node's glob implementation uses `exclude` for ignore patterns
globOptions.exclude = options.ignore
}
return globOptions
}

const filterFilesAsync = async (candidates: Candidate[], requireFiles: boolean) => {
if (!requireFiles) {
return candidates
}

const filtered = await Promise.all(
candidates.map(async (candidate) => {
try {
const stats = await statAsync(candidate.absolutePath)
return stats.isFile() ? candidate : null
} catch {
return null
}
}),
)

return filtered.filter(Boolean) as Candidate[]
}

const filterFilesSync = (candidates: Candidate[], requireFiles: boolean) => {
if (!requireFiles) {
return candidates
}

const filtered: Candidate[] = []
for (const candidate of candidates) {
try {
const stats = fs.statSync(candidate.absolutePath)
if (stats.isFile()) {
filtered.push(candidate)
}
} catch {
// Ignore files that cannot be stat'ed
}
}
return filtered
}

const formatResult = (candidate: Candidate, options: NormalizedOptions) => {
const output = options.absolute ? candidate.absolutePath : candidate.rawPath
return normalizeSlashes(output)
}

const collectMatchesAsync = async (pattern: string, options: NormalizedOptions) => {
const matches: Candidate[] = []
for await (const match of fsGlob(pattern, toGlobOptions(options))) {
matches.push({
rawPath: match as string,
absolutePath: toAbsolutePath(match as string, options.cwd),
})
}
const filtered = await filterFilesAsync(matches, options.onlyFiles)
return filtered.map((candidate) => formatResult(candidate, options))
}

const collectMatchesSync = (pattern: string, options: NormalizedOptions) => {
const matches = (fsGlobSync(pattern, toGlobOptions(options)) as string[]).map((match) => ({
rawPath: match,
absolutePath: toAbsolutePath(match, options.cwd),
}))
const filtered = filterFilesSync(matches, options.onlyFiles)
return filtered.map((candidate) => formatResult(candidate, options))
}

type GlobbyLike = {
(patterns: PatternInput, options?: NativeGlobbyOptions): Promise<string[]>
sync(patterns: PatternInput, options?: NativeGlobbyOptions): string[]
}

export const nativeGlobby: GlobbyLike = Object.assign(
async (patterns: PatternInput, options?: NativeGlobbyOptions) => {
const normalized = normalizeOptions(options)
const { positive, negative } = splitPatterns(patterns)
const expandedPositives = (
await Promise.all(positive.map((pattern) => expandPatternAsync(pattern, normalized)))
).flat()
const expandedNegatives = (
await Promise.all(negative.map((pattern) => expandPatternAsync(pattern, normalized)))
).flat()
const results = new Set<string>()

for (const pattern of expandedPositives) {
const matches = await collectMatchesAsync(pattern, normalized)
for (const match of matches) {
results.add(match)
}
}

for (const pattern of expandedNegatives) {
const matches = await collectMatchesAsync(pattern, normalized)
for (const match of matches) {
results.delete(match)
}
}

return Array.from(results)
},
{
sync(patterns: PatternInput, options?: NativeGlobbyOptions) {
const normalized = normalizeOptions(options)
const { positive, negative } = splitPatterns(patterns)
const expandedPositives = positive.flatMap((pattern) =>
expandPatternSync(pattern, normalized),
)
const expandedNegatives = negative.flatMap((pattern) =>
expandPatternSync(pattern, normalized),
)
const results = new Set<string>()

for (const pattern of expandedPositives) {
const matches = collectMatchesSync(pattern, normalized)
for (const match of matches) {
results.add(match)
}
}

for (const pattern of expandedNegatives) {
const matches = collectMatchesSync(pattern, normalized)
for (const match of matches) {
results.delete(match)
}
}

return Array.from(results)
},
},
)
Loading