Skip to content

Commit 2cab957

Browse files
authored
Merge pull request #134 from bufferings/cursor/implement-core-file-plugins-2a95
Implement core file plugins
2 parents e0170ac + 61e23ad commit 2cab957

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+3544
-3
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { koriConfig } from '@korix/eslint-config';
2+
3+
export default [
4+
...koriConfig,
5+
{
6+
languageOptions: {
7+
parserOptions: {
8+
project: './tsconfig.json',
9+
tsconfigRootDir: import.meta.dirname,
10+
},
11+
},
12+
},
13+
];
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{
2+
"name": "@korix/file-adapter-node",
3+
"version": "0.1.0",
4+
"description": "Node.js file adapter implementation for Kori framework",
5+
"type": "module",
6+
"author": "mitz (@bufferings)",
7+
"license": "MIT",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/bufferings/kori"
11+
},
12+
"homepage": "https://github.com/bufferings/kori",
13+
"bugs": "https://github.com/bufferings/kori/issues",
14+
"engines": {
15+
"node": ">=18.0.0"
16+
},
17+
"files": [
18+
"dist"
19+
],
20+
"publishConfig": {
21+
"access": "public",
22+
"exports": {
23+
".": {
24+
"import": {
25+
"types": "./dist/index.d.ts",
26+
"default": "./dist/index.js"
27+
},
28+
"require": {
29+
"types": "./dist/index.d.cts",
30+
"default": "./dist/index.cjs"
31+
}
32+
}
33+
}
34+
},
35+
"exports": {
36+
".": {
37+
"development": "./src/index.ts",
38+
"import": {
39+
"types": "./dist/index.d.ts",
40+
"default": "./dist/index.js"
41+
},
42+
"require": {
43+
"types": "./dist/index.d.cts",
44+
"default": "./dist/index.cjs"
45+
}
46+
}
47+
},
48+
"scripts": {
49+
"clean": "rimraf dist *.tsbuildinfo .turbo .tsup",
50+
"typecheck": "tsc --noEmit",
51+
"lint": "eslint .",
52+
"lint:fix": "eslint . --fix",
53+
"build": "tsdown --tsconfig tsconfig.build.json",
54+
"test": "vitest run",
55+
"test:watch": "vitest",
56+
"sync:version": "ks sync-version && prettier --write src/version.ts"
57+
},
58+
"dependencies": {
59+
"mime-types": "^2.1.35",
60+
"etag": "^1.8.1"
61+
},
62+
"peerDependencies": {
63+
"@korix/file-adapter": "workspace:*"
64+
},
65+
"devDependencies": {
66+
"@korix/eslint-config": "workspace:*",
67+
"@korix/file-adapter": "workspace:*",
68+
"@korix/script": "workspace:*",
69+
"@types/mime-types": "^2.1.4",
70+
"@types/etag": "^1.8.3",
71+
"@types/node": "catalog:",
72+
"eslint": "catalog:",
73+
"rimraf": "catalog:",
74+
"tsdown": "catalog:",
75+
"typescript": "catalog:",
76+
"vitest": "catalog:"
77+
}
78+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { createNodeFileAdapter, type NodeFileAdapterOptions } from './node-file-adapter.js';
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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

Comments
 (0)