From 5c37d4590291e064f6ccb23d55693cf2725ee36e Mon Sep 17 00:00:00 2001 From: Souta Date: Sat, 22 Aug 2026 17:42:07 +0900 Subject: [PATCH 1/2] fix(dev): resolve public assets dynamically in the worker --- build.config.ts | 1 + src/build/virtual/public-assets.ts | 69 +++++++++++++++++++ src/runtime/meta.ts | 1 + .../server/routes/fetch-public-asset.ts | 9 +++ test/presets/nitro-dev.test.ts | 14 ++++ test/presets/vercel.test.ts | 5 ++ test/tests.ts | 9 ++- 7 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 test/fixture/server/routes/fetch-public-asset.ts diff --git a/build.config.ts b/build.config.ts index f2c95cefa6..09abead11f 100644 --- a/build.config.ts +++ b/build.config.ts @@ -31,6 +31,7 @@ const tracePkgs = [ "defu", // used by open-api runtime "destr", // used by node-server and deno-server "get-port-please", // used by dev server + "mime", // used by dev public assets runtime "rendu", // used by HTML renderer template "scule", // used by runtime config "source-map", // used by dev error runtime diff --git a/src/build/virtual/public-assets.ts b/src/build/virtual/public-assets.ts index 334ad422f0..26922168cf 100644 --- a/src/build/virtual/public-assets.ts +++ b/src/build/virtual/public-assets.ts @@ -94,6 +94,75 @@ export default function publicAssets(nitro: Nitro) { ]) ); + if (nitro.options.dev) { + // Assets are resolved dynamically in dev: the build-time snapshot in + // `public-assets-data` is always empty (nothing is copied to + // `output.publicDir` in dev) and would not follow file changes anyway. + const publicAssetDirs = nitro.options.publicAssets.map((dir) => ({ + baseURL: withTrailingSlash(joinURL(nitro.options.baseURL, dir.baseURL || "/")), + dir: dir.dir, + })); + + return /* js */ ` +import { statSync, promises as fsp } from 'node:fs' +import { resolve } from 'node:path' +import mime from 'mime' + +const publicAssetDirs = ${JSON.stringify(publicAssetDirs)} +export const publicAssetBases = ${JSON.stringify(publicAssetBases)} + +export function isPublicAssetURL(id = '') { + if (getAsset(id)) { + return true + } + for (const base in publicAssetBases) { + if (id.startsWith(base)) { return true } + } + return false +} + +export function getPublicAssetMeta(id = '') { + for (const base in publicAssetBases) { + if (id.startsWith(base)) { return publicAssetBases[base] } + } + return {} +} + +export function getAsset (id) { + for (const { baseURL, dir } of publicAssetDirs) { + if (!id.startsWith(baseURL)) { continue } + const fullPath = resolve(dir, id.slice(baseURL.length)) + if (fullPath !== dir && !fullPath.startsWith(dir + '/')) { continue } + let stat + try { + stat = statSync(fullPath) + } catch { + continue + } + if (!stat.isFile()) { continue } + let type = mime.getType(id.replace(/\\.(gz|br|zst)$/, '')) || 'text/plain' + if (type.startsWith('text')) { type += '; charset=utf-8' } + let encoding + if (id.endsWith('.gz')) { encoding = 'gzip' } + else if (id.endsWith('.br')) { encoding = 'br' } + else if (id.endsWith('.zst')) { encoding = 'zstd' } + return { + type, + encoding, + mtime: stat.mtime.toJSON(), + size: stat.size, + path: fullPath, + } + } +} + +export function readAsset (id) { + const asset = getAsset(id) + return asset ? fsp.readFile(asset.path) : Promise.resolve(null) +} +`; + } + // prettier-ignore type _serveStaticAsKey = Exclude | "true" | "false"; // prettier-ignore diff --git a/src/runtime/meta.ts b/src/runtime/meta.ts index 3fa1493c50..1f12372969 100644 --- a/src/runtime/meta.ts +++ b/src/runtime/meta.ts @@ -20,6 +20,7 @@ export const runtimeDependencies: string[] = [ "h3", // dep "rou3", // sub-dep of h3 "hookable", // traced + "mime", // traced "ocache", // dep "ohash", // traced "rendu", // traced diff --git a/test/fixture/server/routes/fetch-public-asset.ts b/test/fixture/server/routes/fetch-public-asset.ts new file mode 100644 index 0000000000..7939976d2b --- /dev/null +++ b/test/fixture/server/routes/fetch-public-asset.ts @@ -0,0 +1,9 @@ +import { serverFetch } from "nitro"; + +export default async () => { + const res = await serverFetch("/build/test.txt"); + return { + status: res.status, + body: await res.text(), + }; +}; diff --git a/test/presets/nitro-dev.test.ts b/test/presets/nitro-dev.test.ts index b9aa335836..e6122bdb8a 100644 --- a/test/presets/nitro-dev.test.ts +++ b/test/presets/nitro-dev.test.ts @@ -2,6 +2,20 @@ import type { OpenAPI3 } from "../../src/types/openapi-ts.ts"; import { describe, expect, it } from "vitest"; import { setupTest, testNitro } from "../tests.ts"; +describe("nitro:preset:nitro-dev (serve static)", async () => { + const ctx = await setupTest("nitro-dev", { + config: { serveStatic: true }, + outDirSuffix: "-serve-static", + }); + + it("serves public assets via internal fetch", async () => { + const res = await ctx.fetch("/fetch-public-asset"); + const data = await res.json(); + expect(data.status).toBe(200); + expect(data.body).toBe("Works!\n"); + }); +}); + describe("nitro:preset:nitro-dev", async () => { const ctx = await setupTest("nitro-dev"); testNitro( diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index af993289fa..fa7626963d 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -312,6 +312,10 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/file", "src": "/file", }, + { + "dest": "/fetch-public-asset", + "src": "/fetch-public-asset", + }, { "dest": "/fetch", "src": "/fetch", @@ -524,6 +528,7 @@ describe("nitro:preset:vercel:web", async () => { "functions/errors/captured.func (symlink)", "functions/errors/stack.func (symlink)", "functions/errors/throw.func (symlink)", + "functions/fetch-public-asset.func (symlink)", "functions/fetch.func (symlink)", "functions/file.func (symlink)", "functions/icon.png.func (symlink)", diff --git a/test/tests.ts b/test/tests.ts index bc0355ff3c..04d2326d59 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -131,7 +131,8 @@ export async function setupTest( if (ctx.isDev) { // Setup development server const devServer = createDevServer(ctx.nitro); - const server = await devServer.listen({}); + // Use a random port so multiple dev contexts can coexist + const server = await devServer.listen({ port: 0 }); ctx.server = { url: server.url!, close: () => server.close(), @@ -481,6 +482,12 @@ export function testNitro( expect(headers["content-type"]).toBe("text/plain; charset=utf-8"); }); + it("serve static asset via internal fetch", async () => { + const { data } = await callHandler({ url: "/fetch-public-asset" }); + expect(data.status).toBe(200); + expect(data.body).toBe("Works!\n"); + }); + it("stores content-type for prerendered routes", async () => { const { data, headers } = await callHandler({ url: "/api/param/prerender4", From c8942ad961c678069eb7ce503b3569f4b0d86242 Mon Sep 17 00:00:00 2001 From: Souta Date: Sat, 22 Aug 2026 18:28:09 +0900 Subject: [PATCH 2/2] fix(dev): make asset containment check platform-neutral --- src/build/virtual/public-assets.ts | 8 +++----- test/tests.ts | 1 - 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/build/virtual/public-assets.ts b/src/build/virtual/public-assets.ts index 26922168cf..e52bb6fec3 100644 --- a/src/build/virtual/public-assets.ts +++ b/src/build/virtual/public-assets.ts @@ -95,9 +95,6 @@ export default function publicAssets(nitro: Nitro) { ); if (nitro.options.dev) { - // Assets are resolved dynamically in dev: the build-time snapshot in - // `public-assets-data` is always empty (nothing is copied to - // `output.publicDir` in dev) and would not follow file changes anyway. const publicAssetDirs = nitro.options.publicAssets.map((dir) => ({ baseURL: withTrailingSlash(joinURL(nitro.options.baseURL, dir.baseURL || "/")), dir: dir.dir, @@ -105,7 +102,7 @@ export default function publicAssets(nitro: Nitro) { return /* js */ ` import { statSync, promises as fsp } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, relative, isAbsolute, sep } from 'node:path' import mime from 'mime' const publicAssetDirs = ${JSON.stringify(publicAssetDirs)} @@ -132,7 +129,8 @@ export function getAsset (id) { for (const { baseURL, dir } of publicAssetDirs) { if (!id.startsWith(baseURL)) { continue } const fullPath = resolve(dir, id.slice(baseURL.length)) - if (fullPath !== dir && !fullPath.startsWith(dir + '/')) { continue } + const relativePath = relative(dir, fullPath) + if (relativePath.split(sep)[0] === '..' || isAbsolute(relativePath)) { continue } let stat try { stat = statSync(fullPath) diff --git a/test/tests.ts b/test/tests.ts index 04d2326d59..1ac6b418c7 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -131,7 +131,6 @@ export async function setupTest( if (ctx.isDev) { // Setup development server const devServer = createDevServer(ctx.nitro); - // Use a random port so multiple dev contexts can coexist const server = await devServer.listen({ port: 0 }); ctx.server = { url: server.url!,