diff --git a/packages/core/package.json b/packages/core/package.json index 5cb1429b6a..d497864127 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,6 +41,7 @@ "dependencies": { "@eggjs/extend2": "workspace:*", "@eggjs/koa": "workspace:*", + "@eggjs/loader-fs": "workspace:*", "@eggjs/path-matching": "workspace:*", "@eggjs/router": "workspace:*", "@eggjs/typings": "workspace:*", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6954cec221..5d3f2c07a6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,7 +11,7 @@ export * from './singleton.ts'; export * from './loader/egg_loader.ts'; export * from './loader/file_loader.ts'; export * from './loader/context_loader.ts'; -export * from './loader/loader_fs.ts'; +export * from '@eggjs/loader-fs'; export * from './loader/manifest.ts'; export * from './utils/sequencify.ts'; export * from './utils/timing.ts'; diff --git a/packages/core/src/loader/egg_loader.ts b/packages/core/src/loader/egg_loader.ts index 57b892ea55..ab8b3840c7 100644 --- a/packages/core/src/loader/egg_loader.ts +++ b/packages/core/src/loader/egg_loader.ts @@ -5,6 +5,7 @@ import { debuglog, inspect } from 'node:util'; import { extend } from '@eggjs/extend2'; import { Request, Response, Application, Context as KoaContext } from '@eggjs/koa'; +import { RealLoaderFS, type LoaderFS } from '@eggjs/loader-fs'; import { pathMatching, type PathMatchingOptions } from '@eggjs/path-matching'; import { isESM, isSupportTypeScript } from '@eggjs/utils'; import type { Logger } from 'egg-logger'; @@ -24,7 +25,6 @@ import { sequencify } from '../utils/sequencify.ts'; import { Timing } from '../utils/timing.ts'; import { type ContextLoaderOptions, ContextLoader } from './context_loader.ts'; import { type FileLoaderOptions, CaseStyle, FULLPATH, FileLoader } from './file_loader.ts'; -import { RealLoaderFS, type LoaderFS } from './loader_fs.ts'; import { ManifestStore, type StartupManifest } from './manifest.ts'; const debug = debuglog('egg/core/loader/egg_loader'); diff --git a/packages/core/src/loader/file_loader.ts b/packages/core/src/loader/file_loader.ts index 444e31f4c1..4b6c475971 100644 --- a/packages/core/src/loader/file_loader.ts +++ b/packages/core/src/loader/file_loader.ts @@ -2,11 +2,11 @@ import assert from 'node:assert'; import path from 'node:path'; import { debuglog } from 'node:util'; +import { RealLoaderFS, type LoaderFS } from '@eggjs/loader-fs'; import { isSupportTypeScript } from '@eggjs/utils'; import { isClass, isGeneratorFunction, isAsyncFunction, isPrimitive } from 'is-type-of'; import utils from '../utils/index.ts'; -import { RealLoaderFS, type LoaderFS } from './loader_fs.ts'; import type { ManifestStore } from './manifest.ts'; const debug = debuglog('egg/core/file_loader'); diff --git a/packages/core/src/loader/loader_fs.ts b/packages/core/src/loader/loader_fs.ts deleted file mode 100644 index 8446c60c1a..0000000000 --- a/packages/core/src/loader/loader_fs.ts +++ /dev/null @@ -1,43 +0,0 @@ -import fs, { type Stats } from 'node:fs'; - -import globby from 'globby'; -import { readJSONSync } from 'utility'; - -import utils from '../utils/index.ts'; - -export type LoaderFSGlobOptions = globby.GlobbyOptions; - -export interface LoaderFS { - exists(filepath: string): boolean; - stat(filepath: string): Stats; - realpath(filepath: string): string; - readJSON(filepath: string): T; - glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[]; - loadFile(filepath: string): Promise; -} - -export class RealLoaderFS implements LoaderFS { - exists(filepath: string): boolean { - return fs.existsSync(filepath); - } - - stat(filepath: string): Stats { - return fs.statSync(filepath); - } - - realpath(filepath: string): string { - return fs.realpathSync(filepath); - } - - readJSON(filepath: string): T { - return readJSONSync(filepath) as T; - } - - glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] { - return globby.sync(patterns, options); - } - - async loadFile(filepath: string): Promise { - return utils.loadFile(filepath); - } -} diff --git a/packages/core/test/loader/file_loader.test.ts b/packages/core/test/loader/file_loader.test.ts index fd4f4e314b..273dd1c0c3 100644 --- a/packages/core/test/loader/file_loader.test.ts +++ b/packages/core/test/loader/file_loader.test.ts @@ -1,12 +1,12 @@ import assert from 'node:assert/strict'; import path from 'node:path'; +import { RealLoaderFS, type LoaderFSGlobOptions } from '@eggjs/loader-fs'; import { isClass } from 'is-type-of'; import yaml from 'js-yaml'; import { describe, it, expect } from 'vitest'; import { FileLoader, CaseStyle } from '../../src/loader/file_loader.ts'; -import { RealLoaderFS, type LoaderFSGlobOptions } from '../../src/loader/loader_fs.ts'; import { ManifestStore } from '../../src/loader/manifest.ts'; import { getFilepath } from '../helper.ts'; diff --git a/packages/loader-fs/package.json b/packages/loader-fs/package.json new file mode 100644 index 0000000000..692a81b18d --- /dev/null +++ b/packages/loader-fs/package.json @@ -0,0 +1,51 @@ +{ + "name": "@eggjs/loader-fs", + "version": "1.0.0-beta.9", + "description": "Loader-facing filesystem boundary for Egg loaders", + "keywords": [ + "egg", + "filesystem", + "loader" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/packages/loader-fs", + "license": "MIT", + "author": "fengmk2 ", + "repository": { + "type": "git", + "url": "git+https://github.com/eggjs/egg.git", + "directory": "packages/loader-fs" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": "./dist/index.js", + "./package.json": "./package.json" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "@eggjs/utils": "workspace:*", + "globby": "catalog:", + "utility": "catalog:" + }, + "devDependencies": { + "@eggjs/tsconfig": "workspace:*", + "typescript": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/loader-fs/src/index.ts b/packages/loader-fs/src/index.ts new file mode 100644 index 0000000000..5379e6c1bc --- /dev/null +++ b/packages/loader-fs/src/index.ts @@ -0,0 +1,73 @@ +import fs, { type Stats } from 'node:fs'; +import BuiltinModule from 'node:module'; +import path from 'node:path'; +import { debuglog } from 'node:util'; + +import { importModule } from '@eggjs/utils'; +import globby from 'globby'; +import { readJSONSync } from 'utility'; + +const debug = debuglog('egg/loader-fs'); + +export type LoaderFSGlobOptions = globby.GlobbyOptions; + +export interface LoaderFS { + exists(filepath: string): boolean; + stat(filepath: string): Stats; + realpath(filepath: string): string; + readJSON(filepath: string): T; + glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[]; + loadFile(filepath: string): Promise; +} + +interface ModuleConstructorWithExtensions { + _extensions: Record; +} + +const ModuleConstructor = + typeof module !== 'undefined' && module.constructor.length > 1 + ? (module.constructor as unknown as ModuleConstructorWithExtensions) + : (BuiltinModule as unknown as ModuleConstructorWithExtensions); +const extensionNames = Object.keys(ModuleConstructor._extensions).concat(['.cjs', '.mjs']); + +export class RealLoaderFS implements LoaderFS { + exists(filepath: string): boolean { + return fs.existsSync(filepath); + } + + stat(filepath: string): Stats { + return fs.statSync(filepath); + } + + realpath(filepath: string): string { + return fs.realpathSync(filepath); + } + + readJSON(filepath: string): T { + return readJSONSync(filepath) as T; + } + + glob(patterns: string | string[], options?: LoaderFSGlobOptions): string[] { + return globby.sync(patterns, options); + } + + async loadFile(filepath: string): Promise { + debug('[loadFile:start] filepath: %s', filepath); + try { + const extname = path.extname(filepath); + if (extname && !extensionNames.includes(extname) && extname !== '.ts') { + return fs.readFileSync(filepath); + } + return await importModule(filepath, { importDefaultOnly: true }); + } catch (e) { + if (!(e instanceof Error)) { + console.trace(e); + throw e; + } + const err = new Error(`[egg/core] load file: ${filepath}, error: ${e.message}`); + err.cause = e; + debug('[loadFile] handle %s error: %s', filepath, e); + throw err; + } + } +} diff --git a/packages/loader-fs/test/fixtures/loadfile/no-js.yml b/packages/loader-fs/test/fixtures/loadfile/no-js.yml new file mode 100644 index 0000000000..20e9ff3fea --- /dev/null +++ b/packages/loader-fs/test/fixtures/loadfile/no-js.yml @@ -0,0 +1 @@ +foo: bar diff --git a/packages/loader-fs/test/fixtures/loadfile/null.js b/packages/loader-fs/test/fixtures/loadfile/null.js new file mode 100644 index 0000000000..087be1fe9f --- /dev/null +++ b/packages/loader-fs/test/fixtures/loadfile/null.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = null; diff --git a/packages/loader-fs/test/fixtures/loadfile/object.js b/packages/loader-fs/test/fixtures/loadfile/object.js new file mode 100644 index 0000000000..d0bcc4dbfb --- /dev/null +++ b/packages/loader-fs/test/fixtures/loadfile/object.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = { a: 1 }; diff --git a/packages/loader-fs/test/fixtures/loadfile/package.json b/packages/loader-fs/test/fixtures/loadfile/package.json new file mode 100644 index 0000000000..5bbefffbab --- /dev/null +++ b/packages/loader-fs/test/fixtures/loadfile/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/packages/core/test/loader/loader_fs.test.ts b/packages/loader-fs/test/index.test.ts similarity index 59% rename from packages/core/test/loader/loader_fs.test.ts rename to packages/loader-fs/test/index.test.ts index f029e433c0..a71567c9a0 100644 --- a/packages/core/test/loader/loader_fs.test.ts +++ b/packages/loader-fs/test/index.test.ts @@ -1,17 +1,18 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import globby from 'globby'; import { describe, it } from 'vitest'; -import { RealLoaderFS } from '../../src/loader/loader_fs.ts'; -import utils from '../../src/utils/index.ts'; -import { getFilepath } from '../helper.ts'; +import { RealLoaderFS } from '../src/index.ts'; -describe('test/loader/loader_fs.test.ts', () => { +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('test/index.test.ts', () => { const loaderFS = new RealLoaderFS(); - const baseDir = getFilepath('loadfile'); + const baseDir = path.join(__dirname, 'fixtures/loadfile'); it('should wrap exists/stat/realpath with node fs behavior', () => { const filepath = path.join(baseDir, 'object.js'); @@ -26,11 +27,11 @@ describe('test/loader/loader_fs.test.ts', () => { const packagePath = path.join(baseDir, 'package.json'); const patterns = ['*.js', '!null.js']; - assert.deepEqual(await loaderFS.readJSON(packagePath), JSON.parse(fs.readFileSync(packagePath, 'utf8'))); + assert.deepEqual(loaderFS.readJSON(packagePath), JSON.parse(fs.readFileSync(packagePath, 'utf8'))); assert.deepEqual(loaderFS.glob(patterns, { cwd: baseDir }).sort(), globby.sync(patterns, { cwd: baseDir }).sort()); - assert.deepEqual( - await loaderFS.loadFile(path.join(baseDir, 'object.js')), - await utils.loadFile(path.join(baseDir, 'object.js')), - ); + assert.deepEqual(await loaderFS.loadFile(path.join(baseDir, 'object.js')), { a: 1 }); + const noJsFile = await loaderFS.loadFile(path.join(baseDir, 'no-js.yml')); + assert.equal(Buffer.isBuffer(noJsFile), true); + assert.equal((noJsFile as Buffer).toString().replace(/\r\n/g, '\n'), 'foo: bar\n'); }); }); diff --git a/packages/loader-fs/tsconfig.json b/packages/loader-fs/tsconfig.json new file mode 100644 index 0000000000..4082f16a5d --- /dev/null +++ b/packages/loader-fs/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/packages/loader-fs/tsdown.config.ts b/packages/loader-fs/tsdown.config.ts new file mode 100644 index 0000000000..6dfb63bdb8 --- /dev/null +++ b/packages/loader-fs/tsdown.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 2eb2bc54cd..c3c41bbd5c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,9 @@ { "path": "./packages/core" }, + { + "path": "./packages/loader-fs" + }, { "path": "./packages/errors" }, diff --git a/wiki/index.md b/wiki/index.md index 2ecc1cd6b5..5bbc91a884 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -20,6 +20,7 @@ Read this file before exploring raw sources. - [Core Package](./packages/core.md) - Loader, lifecycle, and application core primitives used by Egg runtime packages. - [Egg Bundler](./packages/egg-bundler.md) - Tooling package that bundles Egg applications and backs `egg-bin bundle`. +- [Loader FS Package](./packages/loader-fs.md) - Shared loader-facing filesystem boundary and default real filesystem implementation. - [Onerror Plugin](./packages/onerror.md) - Default Egg error-handling plugin and configurable response negotiation layer. - [Typings Package](./packages/typings.md) - Shared TypeScript type surface for cross-package Egg typings. - [Utils Package](./packages/utils.md) - Shared utility package for module loading and bundled module-loader integration. diff --git a/wiki/log.md b/wiki/log.md index 50d72d6823..a509fead83 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2,9 +2,15 @@ Dates use the workspace-local Asia/Shanghai calendar date. +## [2026-05-10] package | extract LoaderFS package boundary + +- sources touched: `packages/loader-fs/src/index.ts`, `packages/core/src/index.ts`, `packages/core/src/loader/file_loader.ts`, `packages/core/src/loader/context_loader.ts`, `packages/core/src/loader/egg_loader.ts` +- pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/packages/core.md`, `wiki/packages/loader-fs.md` +- note: Moved `LoaderFS` and `RealLoaderFS` into `@eggjs/loader-fs` so core, tegg, and bundled runtimes can share the loader-facing boundary without a core dependency. + ## [2026-05-07] package | document core LoaderFS boundary -- sources touched: `packages/core/src/index.ts`, `packages/core/src/loader/loader_fs.ts`, `packages/core/src/loader/file_loader.ts`, `packages/core/src/loader/context_loader.ts`, `packages/core/src/loader/egg_loader.ts` +- sources touched: `packages/core/src/index.ts`, `packages/core/src/loader/file_loader.ts`, `packages/core/src/loader/context_loader.ts`, `packages/core/src/loader/egg_loader.ts` - pages updated: `wiki/index.md`, `wiki/log.md`, `wiki/packages/core.md` - note: Recorded `LoaderFS` as the minimal loader filesystem boundary and `RealLoaderFS` as the default implementation for existing non-bundled behavior. diff --git a/wiki/packages/core.md b/wiki/packages/core.md index 4bf8ae0332..a9bbf6f4ee 100644 --- a/wiki/packages/core.md +++ b/wiki/packages/core.md @@ -4,11 +4,10 @@ type: package summary: Loader, lifecycle, and application core primitives used by Egg runtime packages. source_files: - packages/core/src/index.ts - - packages/core/src/loader/loader_fs.ts - packages/core/src/loader/file_loader.ts - packages/core/src/loader/context_loader.ts - packages/core/src/loader/egg_loader.ts -updated_at: 2026-05-07 +updated_at: 2026-05-10 status: active --- @@ -20,14 +19,11 @@ support, lifecycle, and base context classes. ## LoaderFS -`LoaderFS` is the minimal filesystem boundary for loader-facing file access. It -covers `exists`, `stat`, `realpath`, `readJSON`, `glob`, and `loadFile` without -trying to polyfill the full Node.js `fs` module. - -`RealLoaderFS` is the default implementation. It preserves normal non-bundled -runtime behavior by delegating to `fs.existsSync`, `fs.statSync`, -`fs.realpathSync`, `utility.readJSONSync`, `globby.sync`, and the existing -`utils.loadFile()` helper. +`@eggjs/core` depends on `@eggjs/loader-fs` for the minimal loader-facing +filesystem boundary. It re-exports `LoaderFS`, `LoaderFSGlobOptions`, and +`RealLoaderFS` for core loader consumers, but the implementation lives in the +standalone package so tegg and bundled runtimes can share the same small +boundary without depending on core. `EggLoaderOptions`, `FileLoaderOptions`, and `ContextLoaderOptions` can carry a custom `loaderFS`. `EggLoader` passes its loader FS into `loadToApp()` and diff --git a/wiki/packages/loader-fs.md b/wiki/packages/loader-fs.md new file mode 100644 index 0000000000..236accda6a --- /dev/null +++ b/wiki/packages/loader-fs.md @@ -0,0 +1,27 @@ +--- +title: Loader FS Package +type: package +summary: Shared loader-facing filesystem boundary and default real filesystem implementation. +source_files: + - packages/loader-fs/src/index.ts + - packages/loader-fs/test/index.test.ts +updated_at: 2026-05-10 +status: active +--- + +# Loader FS Package + +`@eggjs/loader-fs` owns the small filesystem boundary shared by Egg loader +runtimes. It is not a full Node.js `fs` polyfill; it only covers the loader +operations needed by Egg-style file discovery and loading: `exists`, `stat`, +`realpath`, `readJSON`, `glob`, and `loadFile`. + +`RealLoaderFS` is the default implementation. It preserves normal non-bundled +runtime behavior by delegating to `fs.existsSync`, `fs.statSync`, +`fs.realpathSync`, `utility.readJSONSync`, `globby.sync`, and Egg's standard +module loading semantics through `@eggjs/utils` `importModule()`. + +`@eggjs/core` consumes and re-exports this package for `EggLoader`, +`FileLoader`, and `ContextLoader` options. Future manifest-backed or bundled +loaders can provide another `LoaderFS` implementation without making tegg or +runtime packages depend on `@eggjs/core`.