1+ import { createReadStream } from 'fs' ;
2+ import fs from 'fs/promises' ;
3+ import path from 'path' ;
4+ import { Readable } from 'stream' ;
5+
6+ import { safeJoin , validatePath , detectMimeType } from '@korix/file-adapter' ;
7+ import { type FileAdapter , type FileInfo , type FileStats , type ReadOptions } from '@korix/file-adapter' ;
8+ import etag from 'etag' ;
9+ import { lookup as mimeTypesLookup } from 'mime-types' ;
10+
11+
12+ /**
13+ * Options for creating a Node.js file adapter.
14+ */
15+ export type NodeFileAdapterOptions = {
16+ /**
17+ * Root directory for file operations.
18+ * All file paths will be resolved relative to this directory.
19+ */
20+ root : string ;
21+ } ;
22+
23+ /**
24+ * Creates a Node.js file adapter implementation.
25+ *
26+ * This adapter provides secure file operations using the Node.js fs module,
27+ * with built-in path traversal protection and web-compatible stream handling.
28+ *
29+ * @param options - Configuration options for the adapter
30+ * @returns FileAdapter implementation for Node.js
31+ *
32+ * @example
33+ * ```typescript
34+ * const adapter = createNodeFileAdapter({ root: './public' });
35+ *
36+ * // Check if file exists
37+ * const exists = await adapter.exists('index.html');
38+ *
39+ * // Get file statistics
40+ * const stats = await adapter.stat('index.html');
41+ *
42+ * // Read file content
43+ * const fileInfo = await adapter.read('index.html');
44+ * console.log(fileInfo.contentType); // 'text/html'
45+ * ```
46+ */
47+ export function createNodeFileAdapter ( options : NodeFileAdapterOptions ) : FileAdapter {
48+ const { root } = options ;
49+
50+ // Resolve and normalize the root path
51+ const resolvedRoot = path . resolve ( root ) ;
52+
53+ /**
54+ * Safely resolves a relative path within the root directory.
55+ *
56+ * @param relativePath - The relative path to resolve
57+ * @returns The absolute path within the root directory
58+ * @throws Error if the path is unsafe or would escape the root
59+ */
60+ function resolveSafePath ( relativePath : string ) : string {
61+ validatePath ( relativePath ) ;
62+ return safeJoin ( resolvedRoot , relativePath ) ;
63+ }
64+
65+ /**
66+ * Converts Node.js fs.Stats to our FileStats format.
67+ *
68+ * @param stats - Node.js fs.Stats object
69+ * @returns FileStats object
70+ */
71+ function convertStats ( stats : Awaited < ReturnType < typeof fs . stat > > ) : FileStats {
72+ return {
73+ size : stats . size ,
74+ mtime : stats . mtime ,
75+ isFile : stats . isFile ( ) ,
76+ isDirectory : stats . isDirectory ( ) ,
77+ } ;
78+ }
79+
80+ /**
81+ * Detects the MIME type for a file.
82+ * Uses the mime-types library for comprehensive detection,
83+ * falling back to the built-in detector.
84+ *
85+ * @param filePath - The file path to detect MIME type for
86+ * @returns The detected MIME type
87+ */
88+ function detectMimeTypeForFile ( filePath : string ) : string {
89+ // Try mime-types library first for comprehensive detection
90+ const mimeType = mimeTypesLookup ( filePath ) ;
91+ if ( mimeType ) {
92+ return mimeType ;
93+ }
94+
95+ // Fall back to built-in detector
96+ return detectMimeType ( filePath ) ;
97+ }
98+
99+ return {
100+ async exists ( filePath : string ) : Promise < boolean > {
101+ // This will throw if path is invalid
102+ const absolutePath = resolveSafePath ( filePath ) ;
103+
104+ try {
105+ await fs . access ( absolutePath ) ;
106+ return true ;
107+ } catch {
108+ return false ;
109+ }
110+ } ,
111+
112+ async stat ( filePath : string ) : Promise < FileStats > {
113+ const absolutePath = resolveSafePath ( filePath ) ;
114+
115+ try {
116+ const stats = await fs . stat ( absolutePath ) ;
117+ return convertStats ( stats ) ;
118+ } catch ( error : any ) {
119+ if ( error . code === 'ENOENT' ) {
120+ throw new Error ( `File not found: ${ filePath } ` ) ;
121+ }
122+ throw new Error ( `Failed to stat file: ${ filePath } - ${ error . message } ` ) ;
123+ }
124+ } ,
125+
126+ async read ( filePath : string , options ?: ReadOptions ) : Promise < FileInfo > {
127+ const absolutePath = resolveSafePath ( filePath ) ;
128+
129+ try {
130+ // Get file statistics first
131+ const stats = await fs . stat ( absolutePath ) ;
132+
133+ // Ensure it's a file, not a directory
134+ if ( ! stats . isFile ( ) ) {
135+ throw new Error ( `Path is not a file: ${ filePath } ` ) ;
136+ }
137+
138+ // Detect content type
139+ const contentType = detectMimeTypeForFile ( filePath ) ;
140+
141+ // Generate ETag based on file stats
142+ const etagValue = etag ( stats ) ;
143+
144+ // Create readable stream
145+ let readableStream : NodeJS . ReadableStream ;
146+
147+ if ( options ?. range ) {
148+ // Handle range requests (for future implementation)
149+ const { start, end } = options . range ;
150+ readableStream = createReadStream ( absolutePath , { start, end } ) ;
151+ } else {
152+ // Read entire file
153+ readableStream = createReadStream ( absolutePath ) ;
154+ }
155+
156+ // Convert Node.js stream to Web API ReadableStream
157+ const webStream = Readable . toWeb ( readableStream ) as ReadableStream < Uint8Array > ;
158+
159+ return {
160+ body : webStream ,
161+ size : stats . size ,
162+ mtime : stats . mtime ,
163+ contentType,
164+ etag : etagValue ,
165+ } ;
166+ } catch ( error : any ) {
167+ if ( error . code === 'ENOENT' ) {
168+ throw new Error ( `File not found: ${ filePath } ` ) ;
169+ }
170+ if ( error . code === 'EACCES' ) {
171+ throw new Error ( `Permission denied: ${ filePath } ` ) ;
172+ }
173+ if ( error . code === 'EISDIR' ) {
174+ throw new Error ( `Path is a directory: ${ filePath } ` ) ;
175+ }
176+
177+ // Re-throw our custom errors
178+ if ( error . message . includes ( 'Path is not a file:' ) ||
179+ error . message . includes ( 'Unsafe path detected:' ) ||
180+ error . message . includes ( 'Path cannot be empty' ) ) {
181+ throw error ;
182+ }
183+
184+ throw new Error ( `Failed to read file: ${ filePath } - ${ error . message } ` ) ;
185+ }
186+ } ,
187+ } ;
188+ }
0 commit comments