diff --git a/src/core/builder/build.middleware.ts b/src/core/builder/build.middleware.ts index 9ac988749..c365f8fb6 100644 --- a/src/core/builder/build.middleware.ts +++ b/src/core/builder/build.middleware.ts @@ -2,6 +2,7 @@ import type { IMiddlewareContribution } from '../../server/interfaces'; import { Request, Response } from 'express'; import { join } from 'path'; import { existsSync } from 'fs'; +import { sendFileAllowingDotfiles } from '../../server/utils'; const buildMaps: Record = {}; const destMaps: Record = {}; @@ -35,7 +36,7 @@ export default { if (dest && file) { const path = join(dest, file); if (existsSync(path)) { - return res.sendFile(path); + return sendFileAllowingDotfiles(res, path); } } diff --git a/src/core/preview/scripting-routes.ts b/src/core/preview/scripting-routes.ts index 5fe0748b2..fd9f3cc3d 100644 --- a/src/core/preview/scripting-routes.ts +++ b/src/core/preview/scripting-routes.ts @@ -9,12 +9,7 @@ import { mergeGraphicsConfigWithModules, normalizeIncludeModulesWithGraphics, } from '../engine/graphics-config'; - -function sendQuickPackChunk(res: Response, filePath: string): void { - // QuickPack may emit chunks under project temp paths used by smoke workspaces. - // The path is resolved by the loader, not by raw URL-to-file joining. - res.sendFile(filePath, { dotfiles: 'allow' }); -} +import { sendFileAllowingDotfiles } from '../../server/utils'; /** * 动态预览的共享资源路由。 @@ -53,7 +48,7 @@ export const scriptingRoutes = [ const relPath = decodeURIComponent(rawPath.substring('/external'.length)); const resourcePath = join(facet.engineDistRoot, 'external', relPath); if (await pathExists(resourcePath) && (await stat(resourcePath)).isFile()) { - res.sendFile(resourcePath, { dotfiles: 'allow' }); + sendFileAllowingDotfiles(res, resourcePath); } else { next(); } @@ -206,7 +201,7 @@ export const scriptingRoutes = [ const file = info?.library?.['.js']; if (file && await pathExists(file) && (await stat(file)).isFile()) { res.set('Cache-Control', 'no-store'); - res.sendFile(file, { dotfiles: 'allow' }); + sendFileAllowingDotfiles(res, file); } else { console.warn(`[Preview Server] Plugin script not found: ${relPath}`); next(); @@ -226,7 +221,7 @@ export const scriptingRoutes = [ relPath = decodeURIComponent(relPath); const resourcePath = join(facet.engineDistRoot, relPath); if (await pathExists(resourcePath) && (await stat(resourcePath)).isFile()) { - res.sendFile(resourcePath, { dotfiles: 'allow' }); + sendFileAllowingDotfiles(res, resourcePath); } else { next(); } @@ -343,7 +338,7 @@ export const scriptingRoutes = [ const { default: scripting } = await import('../../core/scripting'); const effectBinPath = join(scripting.projectPath, 'temp', 'asset-db', 'effect', 'effect.bin'); if (await pathExists(effectBinPath) && (await stat(effectBinPath)).isFile()) { - res.sendFile(effectBinPath); + sendFileAllowingDotfiles(res, effectBinPath); } else { next(); } @@ -426,7 +421,7 @@ export const scriptingRoutes = [ if (packResource.type === 'json') { res.json(packResource.json); } else if (packResource.type === 'chunk') { - sendQuickPackChunk(res, packResource.chunk.path); + sendFileAllowingDotfiles(res, packResource.chunk.path); } else { console.warn(`[Preview Server] Unknown pack resource type for ${fullUrl}:`, packResource); next(new Error('Unknown pack resource type')); @@ -446,7 +441,7 @@ export const scriptingRoutes = [ try { const packResource = await facet.loadPackResource(url); if (packResource.type === 'chunk') { - sendQuickPackChunk(res, packResource.chunk.path); + sendFileAllowingDotfiles(res, packResource.chunk.path); } else if (packResource.type === 'json') { res.json(packResource.json); } else { @@ -501,7 +496,7 @@ export const scriptingRoutes = [ } if (await pathExists(resourcePath) && (await stat(resourcePath)).isFile()) { - res.sendFile(resourcePath, { dotfiles: 'allow' }); + sendFileAllowingDotfiles(res, resourcePath); } else { console.warn(`[Preview Server] Engine resource NOT FOUND on disk: ${resourcePath}`); next(); @@ -538,7 +533,7 @@ export const scriptingRoutes = [ } } if (await pathExists(resourcePath) && (await stat(resourcePath)).isFile()) { - return res.sendFile(resourcePath, { dotfiles: 'allow' }); + return sendFileAllowingDotfiles(res, resourcePath); } } next(); @@ -550,7 +545,7 @@ export const scriptingRoutes = [ const relPath = req.path.substring('/static/web'.length); const resourcePath = join(GlobalPaths.workspace, 'static', 'web', relPath); if (await pathExists(resourcePath) && (await stat(resourcePath)).isFile()) { - res.sendFile(resourcePath); + sendFileAllowingDotfiles(res, resourcePath); } else { console.warn(`[Preview Server] Static resource not found: ${resourcePath}`); next(); @@ -566,12 +561,12 @@ export const scriptingRoutes = [ if (relPath.startsWith('/extras/')) { const extraPath = join(GlobalPaths.workspace, 'node_modules', '@cocos', 'systemjs', 'dist', relPath); if (await pathExists(extraPath) && (await stat(extraPath)).isFile()) { - return res.sendFile(extraPath); + return sendFileAllowingDotfiles(res, extraPath); } } const resourcePath = join(facet.systemJsHomeDir, relPath); if (await pathExists(resourcePath) && (await stat(resourcePath)).isFile()) { - res.sendFile(resourcePath); + sendFileAllowingDotfiles(res, resourcePath); } else { console.warn(`[Preview Server] SystemJS resource not found: ${resourcePath}`); next(); @@ -594,7 +589,7 @@ export const scriptingRoutes = [ } if (await pathExists(finalPath) && (await stat(finalPath)).isFile()) { - res.sendFile(finalPath, { dotfiles: 'allow' }); + sendFileAllowingDotfiles(res, finalPath); } else { next(); } diff --git a/src/core/preview/test/scripting-routes.test.ts b/src/core/preview/test/scripting-routes.test.ts index be51a0f5a..c707b3f47 100644 --- a/src/core/preview/test/scripting-routes.test.ts +++ b/src/core/preview/test/scripting-routes.test.ts @@ -2,6 +2,8 @@ const mockGetModules = jest.fn(); const mockGetConfigPath = jest.fn(); const mockPathExists = jest.fn(); const mockReadJSON = jest.fn(); +const mockStat = jest.fn(); +const mockWaitForProgrammingFacet = jest.fn(); jest.mock('../../engine', () => ({ Engine: { @@ -18,10 +20,20 @@ jest.mock('../../configuration', () => ({ jest.mock('fs-extra', () => ({ pathExists: mockPathExists, readJSON: mockReadJSON, - stat: jest.fn(), + stat: mockStat, readFile: jest.fn(), })); +jest.mock('../../scripting/programming/FacetInstance', () => ({ + waitForProgrammingFacet: mockWaitForProgrammingFacet, +})); + +jest.mock('../../scripting', () => ({ + __esModule: true, + default: { projectPath: '/workspace/.hidden-project' }, +})); + +import { join } from 'path'; import { scriptingRoutes } from '../scripting-routes'; describe('preview scripting routes', () => { @@ -30,6 +42,10 @@ describe('preview scripting routes', () => { mockGetModules.mockReturnValue(['base', 'custom-pipeline']); mockGetConfigPath.mockResolvedValue('E:/project/cocos.config.json'); mockPathExists.mockResolvedValue(true); + mockStat.mockResolvedValue({ isFile: () => true }); + mockWaitForProgrammingFacet.mockResolvedValue({ + systemJsHomeDir: '/workspace/.hidden', + }); }); it('normalizes disk graphics settings when serving engine modules', async () => { @@ -58,4 +74,40 @@ describe('preview scripting routes', () => { expect(res.json).toHaveBeenCalledWith(['base', 'legacy-pipeline']); }); + + it('allows files under a dot-prefixed directory', async () => { + const route = scriptingRoutes.find((item) => item.url instanceof RegExp && item.url.source === '^\\/scripting\\/systemjs'); + const res = { + sendFile: jest.fn(), + }; + const next = jest.fn(); + + expect(route).toBeDefined(); + + await route!.handler({ path: '/scripting/systemjs/example.js' } as any, res as any, next); + + expect(res.sendFile).toHaveBeenCalledWith( + '/workspace/.hidden/example.js', + { dotfiles: 'allow' }, + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('serves effect settings from a dot-prefixed project path', async () => { + const route = scriptingRoutes.find((item) => item.url === '/scripting/engine/effect-settings'); + const res = { + sendFile: jest.fn(), + }; + const next = jest.fn(); + + expect(route).toBeDefined(); + + await route!.handler({} as any, res as any, next); + + expect(res.sendFile).toHaveBeenCalledWith( + join('/workspace/.hidden-project', 'temp', 'asset-db', 'effect', 'effect.bin'), + { dotfiles: 'allow' }, + ); + expect(next).not.toHaveBeenCalled(); + }); }); diff --git a/src/core/scene/scene.scripting.middleware.ts b/src/core/scene/scene.scripting.middleware.ts index 076b77c70..ca2e8a02b 100644 --- a/src/core/scene/scene.scripting.middleware.ts +++ b/src/core/scene/scene.scripting.middleware.ts @@ -4,6 +4,7 @@ import { basename, join } from 'path'; import ejs from 'ejs'; import { GlobalPaths } from '../../global'; import { scriptingRoutes } from '../preview/scripting-routes'; +import { sendFileAllowingDotfiles } from '../../server/utils'; import { pathExists } from 'fs-extra'; export default { @@ -57,7 +58,7 @@ export default { const effectBinPath = join(scripting.projectPath, 'temp', 'cli', 'asset-db', 'effect', 'effect.bin'); if (await pathExists(effectBinPath)) { res.setHeader('Content-Type', 'application/octet-stream'); - res.sendFile(effectBinPath); + sendFileAllowingDotfiles(res, effectBinPath); } else { res.status(404).send('effect.bin not found'); } diff --git a/src/server/utils/index.ts b/src/server/utils/index.ts index 9c0226190..6f305196a 100644 --- a/src/server/utils/index.ts +++ b/src/server/utils/index.ts @@ -1,4 +1,6 @@ import net from 'net'; +import type { Response } from 'express'; + /** * 获取当前系统可用端口 @@ -25,3 +27,12 @@ export async function getAvailablePort(preferredPort: number): Promise { }); }); } + +/** + * 以允许点目录/点文件的方式返回文件。 + * express/send 默认忽略路径中以 `.` 开头的段(dotfiles: 'ignore'), + * 项目或资源位于点目录(如 ~/.projects/x)下时会被误判 404,统一走此 helper。 + */ +export function sendFileAllowingDotfiles(res: Response, filePath: string): void { + res.sendFile(filePath, { dotfiles: 'allow' }); +}