From 38d87eb8c6ec4ff327b69328ff47d2c9f2fd35f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 8 Apr 2026 19:35:56 +0200 Subject: [PATCH 1/6] feat: support multiple staticDir entries Allow `staticDir` to accept an array of directories. When multiple directories are specified, they are searched in order and the first match wins. This is useful for separating generated assets from hand-authored static files. Closes #3105 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/dev/builder.ts | 26 +++-- packages/fresh/src/dev/builder_test.ts | 100 ++++++++++++++++++ packages/fresh/src/dev/dev_build_cache.ts | 97 +++++++++-------- .../fresh/src/dev/dev_build_cache_test.ts | 2 +- packages/plugin-tailwindcss-v3/src/mod.ts | 2 +- 5 files changed, 174 insertions(+), 53 deletions(-) diff --git a/packages/fresh/src/dev/builder.ts b/packages/fresh/src/dev/builder.ts index 69b3711583c..63a622c701a 100644 --- a/packages/fresh/src/dev/builder.ts +++ b/packages/fresh/src/dev/builder.ts @@ -57,13 +57,15 @@ export interface BuildOptions { */ outDir?: string; /** - * The directory to serve static files from. + * The directory (or directories) to serve static files from. * - * This can be an absolute path, a file URL or a relative path. + * Each entry can be an absolute path, a file URL or a relative path. * Relative paths are resolved against the `root` option. + * When multiple directories are specified, they are searched in order + * and the first match wins. * @default "static" */ - staticDir?: string; + staticDir?: string | string[]; /** * The directory which contains islands. * @@ -103,11 +105,15 @@ export interface BuildOptions { /** * The final resolved Builder configuration. */ -export type ResolvedBuildConfig = Required> & { - mode: "development" | "production"; - buildId: string; - sourceMap?: FreshBundleOptions["sourceMap"]; -}; +export type ResolvedBuildConfig = + & Required> + & { + /** Always normalized to an array of absolute paths. */ + staticDir: string[]; + mode: "development" | "production"; + buildId: string; + sourceMap?: FreshBundleOptions["sourceMap"]; + }; // deno-lint-ignore no-explicit-any export class Builder { @@ -122,7 +128,9 @@ export class Builder { const root = parseDirPath(options?.root ?? ".", Deno.cwd()); const serverEntry = parseDirPath(options?.serverEntry ?? "main.ts", root); const outDir = parseDirPath(options?.outDir ?? "_fresh", root); - const staticDir = parseDirPath(options?.staticDir ?? "static", root); + const rawStaticDir = options?.staticDir ?? "static"; + const staticDir = (Array.isArray(rawStaticDir) ? rawStaticDir : [rawStaticDir]) + .map((d) => parseDirPath(d, root)); const islandDir = parseDirPath(options?.islandDir ?? "islands", root); const routeDir = parseDirPath(options?.routeDir ?? "routes", root); diff --git a/packages/fresh/src/dev/builder_test.ts b/packages/fresh/src/dev/builder_test.ts index d0b460d1406..d26289772fb 100644 --- a/packages/fresh/src/dev/builder_test.ts +++ b/packages/fresh/src/dev/builder_test.ts @@ -492,6 +492,106 @@ export const app = new App() }, ); +integrationTest("Builder - multiple staticDir in dev", async () => { + const root = path.join(import.meta.dirname!, "..", ".."); + await using _tmp = await withTmpDir({ dir: root, prefix: "tmp_builder_" }); + const tmp = _tmp.dir; + + const app = new App() + .use(staticFiles()) + .get("/", () => new Response("no")); + + await writeFiles(tmp, { + "static_a/a.txt": "from a", + "static_a/shared.txt": "from a", + "static_b/b.txt": "from b", + "static_b/shared.txt": "from b", + }); + + const builder = new Builder({ + root: tmp, + staticDir: ["static_a", "static_b"], + }); + + const controller = new AbortController(); + const waiter = Promise.withResolvers(); + await builder.listen(() => Promise.resolve>(app), { + signal: controller.signal, + async onListen(addr) { + try { + const base = `http://localhost:${addr.port}`; + + // File only in first dir + let res = await fetch(`${base}/a.txt`); + expect(await res.text()).toEqual("from a"); + + // File only in second dir + res = await fetch(`${base}/b.txt`); + expect(await res.text()).toEqual("from b"); + + // File in both dirs — first dir wins + res = await fetch(`${base}/shared.txt`); + expect(await res.text()).toEqual("from a"); + + // File in neither dir + res = await fetch(`${base}/missing.txt`); + expect(res.status).toEqual(404); + + controller.abort(); + waiter.resolve(); + } catch (err) { + waiter.reject(err); + } + }, + }); + + await waiter.promise; +}); + +integrationTest( + "Builder - multiple staticDir in prod", + async () => { + const root = path.join(import.meta.dirname!, "..", ".."); + await using _tmp = await withTmpDir({ + dir: root, + prefix: "tmp_builder_", + }); + const tmp = _tmp.dir; + + await writeFiles(tmp, { + "main.ts": `import { App, staticFiles } from "fresh"; +export const app = new App() + .use(staticFiles());`, + "static_a/a.txt": "from a", + "static_a/shared.txt": "from a", + "static_b/b.txt": "from b", + "static_b/shared.txt": "from b", + }); + + await new Builder({ + root: tmp, + staticDir: ["static_a", "static_b"], + }).build(); + + await withChildProcessServer( + { cwd: tmp, args: ["serve", "-A", "--port=0", "_fresh/server.js"] }, + async (address) => { + // File only in first dir + let res = await fetch(`${address}/a.txt`); + expect(await res.text()).toEqual("from a"); + + // File only in second dir + res = await fetch(`${address}/b.txt`); + expect(await res.text()).toEqual("from b"); + + // File in both dirs — first dir wins + res = await fetch(`${address}/shared.txt`); + expect(await res.text()).toEqual("from a"); + }, + ); + }, +); + integrationTest("Builder - custom server entry", async () => { const root = path.join(import.meta.dirname!, "..", ".."); await using _tmp = await withTmpDir({ dir: root, prefix: "tmp_builder_" }); diff --git a/packages/fresh/src/dev/dev_build_cache.ts b/packages/fresh/src/dev/dev_build_cache.ts index 220353b28b2..f66ee3abc01 100644 --- a/packages/fresh/src/dev/dev_build_cache.ts +++ b/packages/fresh/src/dev/dev_build_cache.ts @@ -130,50 +130,53 @@ export class MemoryBuildCache implements DevBuildCache { } } - let entry = pathname.startsWith("/") ? pathname.slice(1) : pathname; - entry = path.join(this.#config.staticDir, entry); - const relative = path.relative(this.#config.staticDir, entry); - if (relative.startsWith("..")) { - throw new Error( - `Processed file resolved outside of static dir ${entry}`, + const stripped = pathname.startsWith("/") ? pathname.slice(1) : pathname; + + for (const staticDir of this.#config.staticDir) { + const entry = path.join(staticDir, stripped); + const relative = path.relative(staticDir, entry); + if (relative.startsWith("..")) { + throw new Error( + `Processed file resolved outside of static dir ${entry}`, + ); + } + + // Might be a file that we still need to process + const transformed = await this.#transformer.process( + entry, + "development", + this.#config.target, ); - } - // Might be a file that we still need to process - const transformed = await this.#transformer.process( - entry, - "development", - this.#config.target, - ); + if (transformed !== null) { + for (let i = 0; i < transformed.length; i++) { + const file = transformed[i]; + const rel = path.relative(staticDir, file.path); + if (rel.startsWith("..")) { + throw new Error( + `Processed file resolved outside of static dir ${file.path}`, + ); + } + const filePn = new URL(rel, "http://localhost").pathname; - if (transformed !== null) { - for (let i = 0; i < transformed.length; i++) { - const file = transformed[i]; - const relative = path.relative(this.#config.staticDir, file.path); - if (relative.startsWith("..")) { - throw new Error( - `Processed file resolved outside of static dir ${file.path}`, - ); + this.addProcessedFile(filePn, file.content, null); } - const pathname = new URL(relative, "http://localhost").pathname; - - this.addProcessedFile(pathname, file.content, null); - } - if (this.#processedFiles.has(pathname)) { - return this.readFile(pathname); - } - } else { - try { - const filePath = path.join(this.#config.staticDir, pathname); - const relative = path.relative(this.#config.staticDir, filePath); - if (!relative.startsWith("..") && (await Deno.stat(filePath)).isFile) { - const pathname = new URL(relative, "http://localhost").pathname; - this.addUnprocessedFile(pathname, this.#config.staticDir); + if (this.#processedFiles.has(pathname)) { return this.readFile(pathname); } - } catch (err) { - if (!(err instanceof Deno.errors.NotFound)) { - throw err; + } else { + try { + const filePath = path.join(staticDir, pathname); + const rel = path.relative(staticDir, filePath); + if (!rel.startsWith("..") && (await Deno.stat(filePath)).isFile) { + const filePn = new URL(rel, "http://localhost").pathname; + this.addUnprocessedFile(filePn, staticDir); + return this.readFile(filePn); + } + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) { + throw err; + } } } } @@ -303,9 +306,13 @@ export class DiskBuildCache implements DevBuildCache { } async flush(): Promise { - const { staticDir, outDir, target, root } = this.#config; + const { staticDir: staticDirs, outDir, target, root } = this.#config; + + const seen = new Set(); + + for (const staticDir of staticDirs) { + if (!(await fsAdapter.isDirectory(staticDir))) continue; - if (await fsAdapter.isDirectory(staticDir)) { const entries = fsAdapter.walk(staticDir, { includeDirs: false, includeFiles: true, @@ -332,12 +339,18 @@ export class DiskBuildCache implements DevBuildCache { const file = result[i]; assertInDir(file.path, staticDir); const pathname = `/${path.relative(staticDir, file.path)}`; - await this.addProcessedFile(pathname, file.content, null); + if (!seen.has(pathname)) { + seen.add(pathname); + await this.addProcessedFile(pathname, file.content, null); + } } } else { const relative = path.relative(staticDir, entry.path); const pathname = `/${relative}`; - this.addUnprocessedFile(pathname, staticDir); + if (!seen.has(pathname)) { + seen.add(pathname); + this.addUnprocessedFile(pathname, staticDir); + } } } } diff --git a/packages/fresh/src/dev/dev_build_cache_test.ts b/packages/fresh/src/dev/dev_build_cache_test.ts index 7020eb30c39..120ff7b899a 100644 --- a/packages/fresh/src/dev/dev_build_cache_test.ts +++ b/packages/fresh/src/dev/dev_build_cache_test.ts @@ -22,7 +22,7 @@ Deno.test({ islandDir: "", outDir: "", routeDir: "", - staticDir: "", + staticDir: [""], target: "latest", }; const fileTransformer = new FileTransformer(createFakeFs({}), tmp); diff --git a/packages/plugin-tailwindcss-v3/src/mod.ts b/packages/plugin-tailwindcss-v3/src/mod.ts index 7ff05e10094..2e7bf0ea73d 100644 --- a/packages/plugin-tailwindcss-v3/src/mod.ts +++ b/packages/plugin-tailwindcss-v3/src/mod.ts @@ -106,7 +106,7 @@ async function initTailwind( config: ResolvedBuildConfig, options: TailwindPluginOptions, ): Promise { - const root = path.dirname(config.staticDir); + const root = config.root; const configPath = await findTailwindConfigFile(root); const url = path.toFileUrl(configPath).href; From cd436bafd169cfb258758b872f84a23c3838775b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 8 Apr 2026 19:54:26 +0200 Subject: [PATCH 2/6] fix: formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/dev/builder.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/fresh/src/dev/builder.ts b/packages/fresh/src/dev/builder.ts index 63a622c701a..c78ec430fae 100644 --- a/packages/fresh/src/dev/builder.ts +++ b/packages/fresh/src/dev/builder.ts @@ -129,8 +129,9 @@ export class Builder { const serverEntry = parseDirPath(options?.serverEntry ?? "main.ts", root); const outDir = parseDirPath(options?.outDir ?? "_fresh", root); const rawStaticDir = options?.staticDir ?? "static"; - const staticDir = (Array.isArray(rawStaticDir) ? rawStaticDir : [rawStaticDir]) - .map((d) => parseDirPath(d, root)); + const staticDir = + (Array.isArray(rawStaticDir) ? rawStaticDir : [rawStaticDir]) + .map((d) => parseDirPath(d, root)); const islandDir = parseDirPath(options?.islandDir ?? "islands", root); const routeDir = parseDirPath(options?.routeDir ?? "routes", root); From 79e6aedfd61fa58138d44b8146fa59ef17a03ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 8 Apr 2026 19:55:07 +0200 Subject: [PATCH 3/6] docs: document multiple staticDir support Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/latest/advanced/builder.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/latest/advanced/builder.md b/docs/latest/advanced/builder.md index 9d5c75b20f4..2170343568f 100644 --- a/docs/latest/advanced/builder.md +++ b/docs/latest/advanced/builder.md @@ -40,8 +40,10 @@ const builder = new Builder({ // Where to write generated files when doing a production build. // (default: `/_fresh/`) outDir?: string; - // Path to static file directory. (Default: `/static/`) - staticDir?: string; + // Path to static file directory, or an array of directories. + // When multiple directories are specified they are searched in order + // and the first match wins. (Default: `/static/`) + staticDir?: string | string[]; // Path to island directory. (Default: `/islands`) islandDir?: string; // Path to routes directory. (Default: `/routes`) @@ -93,8 +95,23 @@ builder.onTransformStaticFile({ }); ``` -> [info]: Only static files in `static/` or the value you set `staticDir` to -> will be processed. The builder won't process anything else. +> [info]: Only static files in `static/` or the directories you set `staticDir` +> to will be processed. The builder won't process anything else. + +### Multiple static directories + +You can pass an array to `staticDir` to serve files from multiple directories. +When the same filename exists in more than one directory, the first directory in +the array takes precedence. + +```ts dev.ts +const builder = new Builder({ + staticDir: ["static", "generated"], +}); +``` + +This is useful when you have a build step that generates assets into a separate +directory and you want to keep them apart from hand-authored static files. ## Testing From 18c64d41c5703cd780ba27fa5dc89e97d538c4f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Wed, 8 Apr 2026 20:03:41 +0200 Subject: [PATCH 4/6] feat: support multiple staticDir in Vite plugin Extend the Vite plugin with the same staticDir array support added to the Builder API. The first directory maps to Vite's native publicDir; extra directories are served manually in the dev server and walked during production builds with first-match-wins semantics. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/latest/advanced/vite.md | 4 + docs/latest/concepts/static-files.md | 25 +++++++ packages/plugin-vite/src/mod.ts | 9 ++- .../plugin-vite/src/plugins/dev_server.ts | 73 +++++++++++++------ .../src/plugins/server_snapshot.ts | 35 ++++++--- packages/plugin-vite/src/utils.ts | 14 +++- 6 files changed, 123 insertions(+), 37 deletions(-) diff --git a/docs/latest/advanced/vite.md b/docs/latest/advanced/vite.md index 646e7cf3c08..cbabf0ff43f 100644 --- a/docs/latest/advanced/vite.md +++ b/docs/latest/advanced/vite.md @@ -27,6 +27,10 @@ export default defineConfig({ islandsDir: "./islands", // Path to routes directory. Default: ./routes routeDir: "./routes", + // Static file directory or directories. Default: "static" + // When multiple directories are given, they are searched in + // order and the first match wins. + staticDir: ["static", "generated"], // Optional regex to ignore folders when crawling the routes and // island directory. ignore: [/[\\/]+some-folder[\\/]+/], diff --git a/docs/latest/concepts/static-files.md b/docs/latest/concepts/static-files.md index 71f93b84588..4b587444e98 100644 --- a/docs/latest/concepts/static-files.md +++ b/docs/latest/concepts/static-files.md @@ -47,6 +47,31 @@ pipeline, optimizes it, and adds a content hash to the filename for cache busting. Keeping these files outside `static/` ensures they're only included once in your build output. +## Multiple static directories + +You can serve files from more than one directory by passing an array to the +`staticDir` option. When the same filename exists in multiple directories, the +first directory in the array takes precedence. + +```ts vite.config.ts +import { defineConfig } from "vite"; +import { fresh } from "@fresh/plugin-vite"; + +export default defineConfig({ + plugins: [ + fresh({ + staticDir: ["static", "generated"], + }), + ], +}); +``` + +This is useful when you have a build step that generates assets into a separate +directory and you want to keep them apart from hand-authored static files. + +> [info]: If you're using the [Builder](/docs/advanced/builder) API instead of +> Vite, the same `staticDir` option accepts a string or an array of strings. + ## Caching headers By default, Fresh adds caching headers for the `src` and `srcset` attributes on diff --git a/packages/plugin-vite/src/mod.ts b/packages/plugin-vite/src/mod.ts index d9adb534d84..cd411cacfdc 100644 --- a/packages/plugin-vite/src/mod.ts +++ b/packages/plugin-vite/src/mod.ts @@ -55,11 +55,13 @@ export type { * ``` */ export function fresh(config?: FreshViteConfig): Plugin[] { + const rawStaticDir = config?.staticDir ?? "static"; const fConfig: ResolvedFreshViteConfig = { serverEntry: config?.serverEntry ?? "main.ts", clientEntry: config?.clientEntry ?? "client.ts", islandsDir: config?.islandsDir ?? "islands", routeDir: config?.routeDir ?? "routes", + staticDir: Array.isArray(rawStaticDir) ? rawStaticDir : [rawStaticDir], ignore: config?.ignore ?? [TEST_FILE_PATTERN], islandSpecifiers: new Map(), namer: new UniqueNamer(), @@ -111,7 +113,7 @@ export function fresh(config?: FreshViteConfig): Plugin[] { noDiscovery: true, }, - publicDir: pathWithRoot("static", config.root), + publicDir: pathWithRoot(fConfig.staticDir[0], config.root), builder: { async buildApp(builder) { @@ -208,6 +210,9 @@ export function fresh(config?: FreshViteConfig): Plugin[] { fConfig.islandsDir = pathWithRoot(fConfig.islandsDir, vConfig.root); fConfig.routeDir = pathWithRoot(fConfig.routeDir, vConfig.root); + fConfig.staticDir = fConfig.staticDir.map((d) => + pathWithRoot(d, vConfig.root) + ); config?.islandSpecifiers?.map((spec) => { const specName = specToName(spec); @@ -233,7 +238,7 @@ export function fresh(config?: FreshViteConfig): Plugin[] { clientEntryPlugin(fConfig), ...clientSnapshot(fConfig), buildIdPlugin(), - ...devServer(), + ...devServer(fConfig), prefresh({ include: [/\.[cm]?[tj]sx?$/], exclude: [/node_modules/, /[\\/]+deno[\\/]+npm[\\/]+/], diff --git a/packages/plugin-vite/src/plugins/dev_server.ts b/packages/plugin-vite/src/plugins/dev_server.ts index bce42e9205b..0411c535250 100644 --- a/packages/plugin-vite/src/plugins/dev_server.ts +++ b/packages/plugin-vite/src/plugins/dev_server.ts @@ -1,10 +1,16 @@ import type { DevEnvironment, Plugin } from "vite"; import * as path from "@std/path"; +import { contentType as getStdContentType } from "@std/media-types/content-type"; import { ASSET_CACHE_BUST_KEY } from "fresh/internal"; import { createRequest, sendResponse } from "@remix-run/node-fetch-server"; import { hashCode } from "../shared.ts"; +import type { ResolvedFreshViteConfig } from "../utils.ts"; -export function devServer(): Plugin[] { +function getContentType(ext: string): string { + return getStdContentType(ext) ?? "application/octet-stream"; +} + +export function devServer(freshConfig: ResolvedFreshViteConfig): Plugin[] { let publicDir = ""; return [ { @@ -53,30 +59,55 @@ export function devServer(): Plugin[] { const decodedPathname = decodeURIComponent(url.pathname.slice(1)); - // Check if it's a static file first - const staticFilePath = path.join(publicDir, decodedPathname); - try { - const stat = await Deno.stat(staticFilePath); - if (stat.isFile) { - return next(); + // Check if it's a static file first. + // Vite handles publicDir (the first static dir) natively, + // but we also need to check extra static dirs. + const extraStaticDirs = freshConfig.staticDir.slice(1); + const allStaticDirs = [publicDir, ...extraStaticDirs]; + let handledStatic = false; + for (const dir of allStaticDirs) { + const staticFilePath = path.join(dir, decodedPathname); + try { + const stat = await Deno.stat(staticFilePath); + if (stat.isFile) { + if (dir === publicDir) { + // Vite serves publicDir files natively + return next(); + } + // Serve files from extra dirs manually + const file = await Deno.open(staticFilePath, { read: true }); + const { ext } = path.parse(staticFilePath); + const contentType = getContentType(ext); + nodeRes.setHeader("Content-Type", contentType); + // deno-lint-ignore no-explicit-any + for await (const chunk of file.readable as any) { + nodeRes.write(chunk); + } + nodeRes.end(); + handledStatic = true; + break; + } + } catch { + // Ignore } - } catch { - // Ignore } + if (handledStatic) return; // Check if it's a static/index.html file - const staticFilePathIndex = path.join( - publicDir, - decodedPathname, - "index.html", - ); - try { - const content = await Deno.readTextFile(staticFilePathIndex); - nodeRes.setHeader("Content-Type", "text/html; charset=utf-8"); - nodeRes.end(content); - return; - } catch { - // Ignore + for (const dir of allStaticDirs) { + const staticFilePathIndex = path.join( + dir, + decodedPathname, + "index.html", + ); + try { + const content = await Deno.readTextFile(staticFilePathIndex); + nodeRes.setHeader("Content-Type", "text/html; charset=utf-8"); + nodeRes.end(content); + return; + } catch { + // Ignore + } } try { diff --git a/packages/plugin-vite/src/plugins/server_snapshot.ts b/packages/plugin-vite/src/plugins/server_snapshot.ts index 1cc1042c66d..836b189408e 100644 --- a/packages/plugin-vite/src/plugins/server_snapshot.ts +++ b/packages/plugin-vite/src/plugins/server_snapshot.ts @@ -32,7 +32,6 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { let clientOutDir = ""; let serverOutDir = ""; let root = ""; - let publicDir = ""; const islands = new Map(); const islandsByFile = new Set(); @@ -56,7 +55,6 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { configResolved(config) { root = config.root; - publicDir = pathWithRoot(config.publicDir, config.root); clientOutDir = pathWithRoot( config.environments.client.build.outDir, config.root, @@ -274,9 +272,14 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { } } - if (await fsAdapter.isDirectory(publicDir)) { + // Walk all static directories. First directory wins for + // duplicate pathnames. + const seenStaticPaths = new Set(); + for (const dir of options.staticDir) { + if (!(await fsAdapter.isDirectory(dir))) continue; + const entries = await fsAdapter.walk( - publicDir, + dir, { followSymlinks: false, includeDirs: false, @@ -286,11 +289,16 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { ); for await (const entry of entries) { - const relative = path.relative(publicDir, entry.path); + const relative = path.relative(dir, entry.path); + if (seenStaticPaths.has(relative)) continue; + seenStaticPaths.add(relative); + const filePath = path.join(clientOutDir, relative); try { - await Deno.mkdir(path.dirname(filePath), { recursive: true }); + await Deno.mkdir(path.dirname(filePath), { + recursive: true, + }); } catch (err) { if (!(err instanceof Deno.errors.AlreadyExists)) { throw err; @@ -306,15 +314,18 @@ export function serverSnapshot(options: ResolvedFreshViteConfig): Plugin[] { if (path.basename(relative) === "index.html") { const htmlRelative = path.relative( - publicDir, + dir, path.dirname(entry.path), ); - staticFiles.push({ - filePath, - hash: null, - pathname: htmlRelative, - }); + if (!seenStaticPaths.has(htmlRelative)) { + seenStaticPaths.add(htmlRelative); + staticFiles.push({ + filePath, + hash: null, + pathname: htmlRelative, + }); + } } } } diff --git a/packages/plugin-vite/src/utils.ts b/packages/plugin-vite/src/utils.ts index 1853280d0fe..0a866ef347f 100644 --- a/packages/plugin-vite/src/utils.ts +++ b/packages/plugin-vite/src/utils.ts @@ -45,6 +45,12 @@ export interface FreshViteConfig { islandsDir?: string; /** Path to routes directory. Default: `./routes` */ routeDir?: string; + /** + * The directory (or directories) to serve static files from. + * When multiple directories are specified, they are searched in order + * and the first match wins. Default: `"static"` + */ + staticDir?: string | string[]; /** * Ignore file paths matching any of the provided regexes when * crawling the islands and routes directories. @@ -67,6 +73,10 @@ export interface FreshViteConfig { export type ResolvedFreshViteConfig = & Required< - Omit + Omit > - & { islandSpecifiers: Map; namer: UniqueNamer }; + & { + staticDir: string[]; + islandSpecifiers: Map; + namer: UniqueNamer; + }; From 4dfa9aa85ed1c92ac93430f573ec67d48c117651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 09:24:06 +0200 Subject: [PATCH 5/6] fix: rename filePn to filePathname to pass typos check The CI typo checker flags "Pn" as a misspelling of "On". Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/dev/dev_build_cache.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/fresh/src/dev/dev_build_cache.ts b/packages/fresh/src/dev/dev_build_cache.ts index f66ee3abc01..242995d26b5 100644 --- a/packages/fresh/src/dev/dev_build_cache.ts +++ b/packages/fresh/src/dev/dev_build_cache.ts @@ -157,9 +157,9 @@ export class MemoryBuildCache implements DevBuildCache { `Processed file resolved outside of static dir ${file.path}`, ); } - const filePn = new URL(rel, "http://localhost").pathname; + const filePathname = new URL(rel, "http://localhost").pathname; - this.addProcessedFile(filePn, file.content, null); + this.addProcessedFile(filePathname, file.content, null); } if (this.#processedFiles.has(pathname)) { return this.readFile(pathname); @@ -169,9 +169,9 @@ export class MemoryBuildCache implements DevBuildCache { const filePath = path.join(staticDir, pathname); const rel = path.relative(staticDir, filePath); if (!rel.startsWith("..") && (await Deno.stat(filePath)).isFile) { - const filePn = new URL(rel, "http://localhost").pathname; - this.addUnprocessedFile(filePn, staticDir); - return this.readFile(filePn); + const filePathname = new URL(rel, "http://localhost").pathname; + this.addUnprocessedFile(filePathname, staticDir); + return this.readFile(filePathname); } } catch (err) { if (!(err instanceof Deno.errors.NotFound)) { From b12f3f21cd0ad851ee7161c962a4b9539bd32621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 9 Apr 2026 09:43:04 +0200 Subject: [PATCH 6/6] ci: increase test job timeout to 15 minutes Windows runners were occasionally timing out at 10 minutes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 627407566bb..6490f0efca9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: test: runs-on: ${{ matrix.os }} - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false