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 index 8446c60c1a..b1e0107ebe 100644 --- a/packages/core/src/loader/loader_fs.ts +++ b/packages/core/src/loader/loader_fs.ts @@ -1,43 +1,2 @@ -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); - } -} +export { RealLoaderFS } from '@eggjs/loader-fs'; +export type { LoaderFS, LoaderFSGlobOptions } from '@eggjs/loader-fs'; 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/core/test/loader/loader_fs.test.ts b/packages/core/test/loader/loader_fs.test.ts index f029e433c0..6db1600dbe 100644 --- a/packages/core/test/loader/loader_fs.test.ts +++ b/packages/core/test/loader/loader_fs.test.ts @@ -2,10 +2,10 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { RealLoaderFS } from '@eggjs/loader-fs'; 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'; diff --git a/packages/loader-fs/CHANGELOG.md b/packages/loader-fs/CHANGELOG.md new file mode 100644 index 0000000000..1c52c9dcf6 --- /dev/null +++ b/packages/loader-fs/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +> [!IMPORTANT] +> Moving forwards we are using the GitHub releases page at in combination with [release.yml](https://github.com/eggjs/egg/actions/workflows/release.yml) for publishing releases and their changelogs. + +--- + +## 1.0.0+ + +### Features + +- Initial shared `LoaderFS` and `RealLoaderFS` package. diff --git a/packages/loader-fs/README.md b/packages/loader-fs/README.md new file mode 100644 index 0000000000..264197a271 --- /dev/null +++ b/packages/loader-fs/README.md @@ -0,0 +1,14 @@ +# @eggjs/loader-fs + +Loader-facing filesystem abstraction for Egg loaders and bundled runtimes. + +## Usage + +```ts +import { RealLoaderFS, type LoaderFS } from '@eggjs/loader-fs'; + +const loaderFS: LoaderFS = new RealLoaderFS(); +``` + +`LoaderFS` intentionally covers only the operations required by loader code: +`exists`, `stat`, `realpath`, `readJSON`, `glob`, and `loadFile`. diff --git a/packages/loader-fs/package.json b/packages/loader-fs/package.json new file mode 100644 index 0000000000..7e6af4b7a3 --- /dev/null +++ b/packages/loader-fs/package.json @@ -0,0 +1,57 @@ +{ + "name": "@eggjs/loader-fs", + "version": "1.0.0-beta.9", + "description": "Loader-facing filesystem abstraction for Egg", + "keywords": [ + "egg", + "fs", + "loader" + ], + "homepage": "https://github.com/eggjs/egg/tree/next/packages/loader-fs", + "bugs": { + "url": "https://github.com/eggjs/egg/issues" + }, + "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", + "test": "vitest run" + }, + "dependencies": { + "@eggjs/utils": "workspace:*", + "globby": "catalog:", + "utility": "catalog:" + }, + "devDependencies": { + "@eggjs/tsconfig": "workspace:*", + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "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..304e4fdd84 --- /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'); + +type CommonJSModuleConstructor = { + _extensions?: Record; +}; + +// Guard against poorly mocked module constructors. +const Module = typeof module !== 'undefined' && module.constructor.length > 1 ? module.constructor : BuiltinModule; + +const extensions = (Module as unknown as CommonJSModuleConstructor)._extensions ?? {}; +const extensionNames = Object.keys(extensions).concat(['.js', '.cjs', '.mjs']); + +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 { + 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); + } + const message = e instanceof Error ? e.message : String(e); + const err = new Error(`[@eggjs/loader-fs] load file: ${filepath}, error: ${message}`); + err.cause = e; + debug('[loadFile] handle %s error: %s', filepath, e); + throw err; + } + } +} 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/loader-fs/test/fixtures/loadfile/plain.yml b/packages/loader-fs/test/fixtures/loadfile/plain.yml new file mode 100644 index 0000000000..a208b30732 --- /dev/null +++ b/packages/loader-fs/test/fixtures/loadfile/plain.yml @@ -0,0 +1 @@ +name: loader-fs diff --git a/packages/loader-fs/test/loader_fs.test.ts b/packages/loader-fs/test/loader_fs.test.ts new file mode 100644 index 0000000000..4b7a1fe8e7 --- /dev/null +++ b/packages/loader-fs/test/loader_fs.test.ts @@ -0,0 +1,37 @@ +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/index.ts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('test/loader_fs.test.ts', () => { + const loaderFS = new RealLoaderFS(); + const baseDir = path.join(__dirname, 'fixtures/loadfile'); + + it('should wrap exists/stat/realpath with node fs behavior', () => { + const filepath = path.join(baseDir, 'object.js'); + + assert.equal(loaderFS.exists(filepath), fs.existsSync(filepath)); + assert.equal(loaderFS.exists(path.join(baseDir, 'not-exists.js')), false); + assert.equal(loaderFS.stat(filepath).isFile(), fs.statSync(filepath).isFile()); + assert.equal(loaderFS.realpath(baseDir), fs.realpathSync(baseDir)); + }); + + it('should wrap readJSON/glob/loadFile with current loader behavior', async () => { + const packagePath = path.join(baseDir, 'package.json'); + const patterns = ['*.js', '!null.js']; + const yamlPath = path.join(baseDir, 'plain.yml'); + + 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')), { a: 1 }); + assert.deepEqual(await loaderFS.loadFile(yamlPath), fs.readFileSync(yamlPath)); + }); +}); 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..db00302e83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,9 @@ { "path": "./packages/typings" }, + { + "path": "./packages/loader-fs" + }, { "path": "./packages/core" }, diff --git a/wiki/index.md b/wiki/index.md index 2ecc1cd6b5..a9449c7a06 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 for Egg loaders and future bundled runtimes. - [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..cb4ae3398b 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2,6 +2,12 @@ Dates use the workspace-local Asia/Shanghai calendar date. +## [2026-05-10] package | extract shared LoaderFS package + +- sources touched: `packages/loader-fs/src/index.ts`, `packages/loader-fs/package.json`, `packages/core/src/index.ts`, `packages/core/src/loader/file_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 the loader-facing `LoaderFS` / `RealLoaderFS` boundary into `@eggjs/loader-fs` while keeping `@eggjs/core` as a consumer and re-exporter. + ## [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` diff --git a/wiki/packages/core.md b/wiki/packages/core.md index 4bf8ae0332..36191710ad 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,10 @@ 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` consumes and re-exports `LoaderFS` and `RealLoaderFS` from +`@eggjs/loader-fs`. The abstraction remains the minimal filesystem boundary for +loader-facing file access: `exists`, `stat`, `realpath`, `readJSON`, `glob`, and +`loadFile`, without trying to polyfill the full Node.js `fs` module. `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..85120ebdde --- /dev/null +++ b/wiki/packages/loader-fs.md @@ -0,0 +1,29 @@ +--- +title: Loader FS Package +type: package +summary: Shared loader-facing filesystem boundary for Egg loaders and future bundled runtimes. +source_files: + - packages/loader-fs/src/index.ts + - packages/loader-fs/package.json +updated_at: 2026-05-10 +status: active +--- + +# Loader FS Package + +`@eggjs/loader-fs` owns the small filesystem interface used by loader code. It +exports `LoaderFS`, `LoaderFSGlobOptions`, and `RealLoaderFS`. + +`LoaderFS` intentionally covers only loader-facing operations: `exists`, `stat`, +`realpath`, `readJSON`, `glob`, and `loadFile`. It is not a Node.js `fs` +polyfill. + +`RealLoaderFS` is the default implementation for normal non-bundled runtime. It +delegates file checks to Node `fs`, JSON reads to `utility.readJSONSync`, glob +discovery to `globby.sync`, and module loading to the same `@eggjs/utils` +`importModule()` path used by the core loader. + +`@eggjs/core` depends on this package and re-exports its public API so existing +core consumers can still import the loader filesystem boundary from core while +tegg and later bundled runtime packages can depend on the smaller package +directly.