Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 16 additions & 0 deletions tools/egg-bundler/docs/output-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ the chunks.
├── _root-of-the-server___<hash>.js.map
├── _turbopack__runtime.js # @utoo/pack runtime shim
├── _turbopack__runtime.js.map
├── app/port/binary.html # app runtime asset copied from <baseDir>/app/port/binary.html
├── app/port/login.html # app runtime asset copied from <baseDir>/app/port/login.html
├── tsconfig.json # written by PackRunner; SWC reads decorator options from here
├── package.json # written by PackRunner; `{ "type": "commonjs" }` so node parses *.js as CJS
└── bundle-manifest.json # written by Bundler; reference / debug metadata
Expand All @@ -40,6 +42,20 @@ is keyed by relKey, output-dir absolute paths, precomputed original app absolute
paths, and manifest `resolveCache` request aliases. Application code and plugins
may still use `fs` for resources such as config, views, or assets.

## Runtime assets

The bundler copies application runtime assets from `<baseDir>/app` into the
same relative path under `outputDir`, excluding manifest-known module files and
source-like files such as `.js`, `.ts`, `.mjs`, and `.cjs` outside static asset
directories. For example, `<baseDir>/app/port/binary.html` is emitted as
`<outputDir>/app/port/binary.html`. Since bundled workers start Egg with
`baseDir: outputDir`, existing reads such as
`fs.readFile(path.join(app.config.baseDir, 'app/port/binary.html'))` resolve to
the copied file in bundle mode and continue to resolve to the original file in
non-bundle mode. Static asset directories such as `app/public`, `app/assets`,
and `app/static` are copied verbatim so frontend `.js` and `.css` files remain
servable from the bundled app.

## `bundle-manifest.json`

A reference file produced by `Bundler` (not consumed at runtime). Shape:
Expand Down
86 changes: 84 additions & 2 deletions tools/egg-bundler/src/lib/Bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const TURBOPACK_IMPORT_META_OBJECT =
const LINE_SOURCE_MAP_URL = /(?:\r?\n)?\/\/# sourceMappingURL=([^\r\n]*)\s*$/;
const BLOCK_SOURCE_MAP_URL = /(?:\r?\n)?\/\*# sourceMappingURL=([\s\S]*?)\*\/\s*$/;
const UNSAFE_ALIAS_SPECIFIERS = new Set(['__proto__', 'constructor', 'prototype']);
const RUNTIME_ASSET_ROOTS = ['app'];
const FORCE_COPY_RUNTIME_ASSET_DIRS = ['app/public', 'app/assets', 'app/static'];
const BUNDLED_SOURCE_EXTENSIONS = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']);

type JsonRecord = Record<string, unknown>;

Expand Down Expand Up @@ -259,6 +262,11 @@ export class Bundler {
patchResult.deletedMapCount,
);

const runtimeAssets = await wrapStep('runtime asset copy', () =>
this.#copyRuntimeAssets(absBaseDir, absOutputDir, manifestLoader),
);
debug('copied %d runtime assets', runtimeAssets.length);

// Merge project name into output package.json so the framework's
// getAppname() finds it (it reads baseDir/package.json).
const outputPkgPath = path.join(absOutputDir, 'package.json');
Expand All @@ -282,14 +290,14 @@ export class Bundler {
framework,
entries: [{ name: 'worker', source: entries.workerEntry }],
externals: Object.keys(externalsMap).sort((a, b) => a.localeCompare(b)),
chunks: patchResult.outputFiles,
chunks: Array.from(new Set([...patchResult.outputFiles, ...runtimeAssets])).sort((a, b) => a.localeCompare(b)),
};
await wrapStep('write bundle-manifest', () =>
fs.writeFile(manifestPathAbs, JSON.stringify(bundleManifest, null, 2)),
);

// Re-enumerate files so bundle-manifest.json is included in the result.
const finalRelFiles = new Set<string>(patchResult.outputFiles);
const finalRelFiles = new Set<string>(bundleManifest.chunks);
finalRelFiles.add(BUNDLE_MANIFEST_FILENAME);
const files = Array.from(finalRelFiles)
.map((rel) => path.join(absOutputDir, rel))
Expand All @@ -302,6 +310,80 @@ export class Bundler {
};
}

async #copyRuntimeAssets(
baseDir: string,
outputDir: string,
manifestLoader: ManifestLoader,
): Promise<readonly string[]> {
const copied = new Set<string>();
const bundledSourceFiles = new Set<string>();
for (const filepath of manifestLoader.getAllDiscoveredFiles()) {
bundledSourceFiles.add(filepath);
}
for (const filepath of manifestLoader.getTeggDecoratedFiles()) {
bundledSourceFiles.add(filepath);
}
Comment thread
killagu marked this conversation as resolved.
for (const value of Object.values(manifestLoader.manifest.resolveCache)) {
if (typeof value === 'string') bundledSourceFiles.add(path.resolve(baseDir, value));
}

for (const root of RUNTIME_ASSET_ROOTS) {
const absRoot = path.join(baseDir, root);
await this.#copyRuntimeAssetsUnderRoot(baseDir, outputDir, absRoot, bundledSourceFiles, copied);
}

return Array.from(copied).sort((a, b) => a.localeCompare(b));
}

async #copyRuntimeAssetsUnderRoot(
baseDir: string,
outputDir: string,
dir: string,
bundledSourceFiles: ReadonlySet<string>,
copied: Set<string>,
): Promise<void> {
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
throw err;
}

entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const filepath = path.join(dir, entry.name);
if (this.#isInsideDir(outputDir, filepath)) continue;
// Dirent flags from withFileTypes are not followed here; symlinked app
// assets are skipped so a link cannot escape baseDir/app during copying.
if (entry.isSymbolicLink()) continue;
if (this.#shouldSkipRuntimeAssetEntry(entry.name)) continue;

if (entry.isDirectory()) {
await this.#copyRuntimeAssetsUnderRoot(baseDir, outputDir, filepath, bundledSourceFiles, copied);
continue;
}

if (!entry.isFile()) continue;
const rel = this.#sanitizeOutputRelativePath(path.relative(baseDir, filepath));
if (bundledSourceFiles.has(filepath) || !this.#shouldCopyRuntimeAsset(rel)) continue;

const target = path.join(outputDir, rel);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.copyFile(filepath, target);
copied.add(rel);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#shouldSkipRuntimeAssetEntry(name: string): boolean {
return name === 'node_modules' || name.startsWith('.');
}

#shouldCopyRuntimeAsset(rel: string): boolean {
if (FORCE_COPY_RUNTIME_ASSET_DIRS.some((dir) => rel === dir || rel.startsWith(`${dir}/`))) return true;
return !BUNDLED_SOURCE_EXTENSIONS.has(path.posix.extname(rel));
}

async #patchImportMetaOutput(
outputDir: string,
inputFiles: readonly string[],
Expand Down
65 changes: 65 additions & 0 deletions tools/egg-bundler/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,71 @@ const __dirname = "/generated/shadow";
expect(entrySource).toContain('__EGG_BUNDLE_MODULE_LOADER__');
expect(entrySource).toContain('startEgg');
});

it('copies app runtime assets next to the bundle while preserving baseDir-relative readFile paths', async () => {
await fs.mkdir(path.join(tmpApp, 'app/port'), { recursive: true });
await fs.writeFile(path.join(tmpApp, 'app/port/binary.html'), '<html>binary</html>\n');
await fs.writeFile(path.join(tmpApp, 'app/port/login.html'), '<html>login</html>\n');
await fs.writeFile(path.join(tmpApp, 'app/port/helper.ts'), 'export const helper = true;\n');
await fs.mkdir(path.join(tmpApp, 'app/public'), { recursive: true });
await fs.writeFile(path.join(tmpApp, 'app/public/client.js'), 'globalThis.clientAsset = true;\n');
await fs.writeFile(path.join(tmpApp, 'app/public/app.ts'), 'export const publicAsset = true;\n');
await fs.mkdir(path.join(tmpApp, 'app/controller'), { recursive: true });
await fs.writeFile(path.join(tmpApp, 'app/controller/home.ts'), 'export {};\n');
await fs.mkdir(path.join(tmpApp, 'app/public/node_modules/ignored'), { recursive: true });
await fs.writeFile(path.join(tmpApp, 'app/.env'), 'TOKEN=secret\n');
await fs.writeFile(path.join(tmpApp, 'app/public/.hidden'), 'secret\n');
await fs.writeFile(path.join(tmpApp, 'app/public/node_modules/ignored/client.js'), 'globalThis.ignored = true;\n');

const result = await bundle({
baseDir: tmpApp,
outputDir: tmpOutput,
pack: { buildFunc: makeMockBuild() },
});

await expect(fs.readFile(path.join(tmpOutput, 'app/port/binary.html'), 'utf8')).resolves.toBe(
'<html>binary</html>\n',
);
await expect(fs.readFile(path.join(tmpOutput, 'app/port/login.html'), 'utf8')).resolves.toBe(
'<html>login</html>\n',
);
await expect(fs.readFile(path.join(tmpOutput, 'app/public/client.js'), 'utf8')).resolves.toBe(
'globalThis.clientAsset = true;\n',
);
await expect(fs.readFile(path.join(tmpOutput, 'app/public/app.ts'), 'utf8')).resolves.toBe(
'export const publicAsset = true;\n',
);
await expect(fs.stat(path.join(tmpOutput, 'app/controller/home.ts'))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(path.join(tmpOutput, 'app/port/helper.ts'))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(path.join(tmpOutput, 'app/.env'))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(path.join(tmpOutput, 'app/public/.hidden'))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(fs.stat(path.join(tmpOutput, 'app/public/node_modules'))).rejects.toMatchObject({ code: 'ENOENT' });

const bm = JSON.parse(await fs.readFile(result.manifestPath, 'utf8')) as { chunks: string[] };
expect(bm.chunks).toEqual(
expect.arrayContaining([
'app/port/binary.html',
'app/port/login.html',
'app/public/app.ts',
'app/public/client.js',
]),
);
expect(result.files).toEqual(
expect.arrayContaining([
path.join(tmpOutput, 'app/port/binary.html'),
path.join(tmpOutput, 'app/port/login.html'),
path.join(tmpOutput, 'app/public/app.ts'),
path.join(tmpOutput, 'app/public/client.js'),
]),
);

// Non-bundle mode keeps using the original app baseDir; bundle mode uses
// outputDir as baseDir, and the copied asset keeps the same relative path.
await expect(fs.readFile(path.join(tmpApp, 'app/port/binary.html'), 'utf8')).resolves.toBe('<html>binary</html>\n');
await expect(fs.readFile(path.join(tmpOutput, 'app/port/binary.html'), 'utf8')).resolves.toBe(
'<html>binary</html>\n',
);
});
});

describe('bundle() integration — minimal-app (Phase 2: real @utoo/pack)', () => {
Expand Down
Loading