Skip to content

Commit 38d87eb

Browse files
bartlomiejuclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 895bcac commit 38d87eb

5 files changed

Lines changed: 174 additions & 53 deletions

File tree

packages/fresh/src/dev/builder.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,15 @@ export interface BuildOptions {
5757
*/
5858
outDir?: string;
5959
/**
60-
* The directory to serve static files from.
60+
* The directory (or directories) to serve static files from.
6161
*
62-
* This can be an absolute path, a file URL or a relative path.
62+
* Each entry can be an absolute path, a file URL or a relative path.
6363
* Relative paths are resolved against the `root` option.
64+
* When multiple directories are specified, they are searched in order
65+
* and the first match wins.
6466
* @default "static"
6567
*/
66-
staticDir?: string;
68+
staticDir?: string | string[];
6769
/**
6870
* The directory which contains islands.
6971
*
@@ -103,11 +105,15 @@ export interface BuildOptions {
103105
/**
104106
* The final resolved Builder configuration.
105107
*/
106-
export type ResolvedBuildConfig = Required<Omit<BuildOptions, "sourceMap">> & {
107-
mode: "development" | "production";
108-
buildId: string;
109-
sourceMap?: FreshBundleOptions["sourceMap"];
110-
};
108+
export type ResolvedBuildConfig =
109+
& Required<Omit<BuildOptions, "sourceMap" | "staticDir">>
110+
& {
111+
/** Always normalized to an array of absolute paths. */
112+
staticDir: string[];
113+
mode: "development" | "production";
114+
buildId: string;
115+
sourceMap?: FreshBundleOptions["sourceMap"];
116+
};
111117

112118
// deno-lint-ignore no-explicit-any
113119
export class Builder<State = any> {
@@ -122,7 +128,9 @@ export class Builder<State = any> {
122128
const root = parseDirPath(options?.root ?? ".", Deno.cwd());
123129
const serverEntry = parseDirPath(options?.serverEntry ?? "main.ts", root);
124130
const outDir = parseDirPath(options?.outDir ?? "_fresh", root);
125-
const staticDir = parseDirPath(options?.staticDir ?? "static", root);
131+
const rawStaticDir = options?.staticDir ?? "static";
132+
const staticDir = (Array.isArray(rawStaticDir) ? rawStaticDir : [rawStaticDir])
133+
.map((d) => parseDirPath(d, root));
126134
const islandDir = parseDirPath(options?.islandDir ?? "islands", root);
127135
const routeDir = parseDirPath(options?.routeDir ?? "routes", root);
128136

packages/fresh/src/dev/builder_test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,106 @@ export const app = new App()
492492
},
493493
);
494494

495+
integrationTest("Builder - multiple staticDir in dev", async () => {
496+
const root = path.join(import.meta.dirname!, "..", "..");
497+
await using _tmp = await withTmpDir({ dir: root, prefix: "tmp_builder_" });
498+
const tmp = _tmp.dir;
499+
500+
const app = new App()
501+
.use(staticFiles())
502+
.get("/", () => new Response("no"));
503+
504+
await writeFiles(tmp, {
505+
"static_a/a.txt": "from a",
506+
"static_a/shared.txt": "from a",
507+
"static_b/b.txt": "from b",
508+
"static_b/shared.txt": "from b",
509+
});
510+
511+
const builder = new Builder({
512+
root: tmp,
513+
staticDir: ["static_a", "static_b"],
514+
});
515+
516+
const controller = new AbortController();
517+
const waiter = Promise.withResolvers<void>();
518+
await builder.listen(() => Promise.resolve<App<unknown>>(app), {
519+
signal: controller.signal,
520+
async onListen(addr) {
521+
try {
522+
const base = `http://localhost:${addr.port}`;
523+
524+
// File only in first dir
525+
let res = await fetch(`${base}/a.txt`);
526+
expect(await res.text()).toEqual("from a");
527+
528+
// File only in second dir
529+
res = await fetch(`${base}/b.txt`);
530+
expect(await res.text()).toEqual("from b");
531+
532+
// File in both dirs — first dir wins
533+
res = await fetch(`${base}/shared.txt`);
534+
expect(await res.text()).toEqual("from a");
535+
536+
// File in neither dir
537+
res = await fetch(`${base}/missing.txt`);
538+
expect(res.status).toEqual(404);
539+
540+
controller.abort();
541+
waiter.resolve();
542+
} catch (err) {
543+
waiter.reject(err);
544+
}
545+
},
546+
});
547+
548+
await waiter.promise;
549+
});
550+
551+
integrationTest(
552+
"Builder - multiple staticDir in prod",
553+
async () => {
554+
const root = path.join(import.meta.dirname!, "..", "..");
555+
await using _tmp = await withTmpDir({
556+
dir: root,
557+
prefix: "tmp_builder_",
558+
});
559+
const tmp = _tmp.dir;
560+
561+
await writeFiles(tmp, {
562+
"main.ts": `import { App, staticFiles } from "fresh";
563+
export const app = new App()
564+
.use(staticFiles());`,
565+
"static_a/a.txt": "from a",
566+
"static_a/shared.txt": "from a",
567+
"static_b/b.txt": "from b",
568+
"static_b/shared.txt": "from b",
569+
});
570+
571+
await new Builder({
572+
root: tmp,
573+
staticDir: ["static_a", "static_b"],
574+
}).build();
575+
576+
await withChildProcessServer(
577+
{ cwd: tmp, args: ["serve", "-A", "--port=0", "_fresh/server.js"] },
578+
async (address) => {
579+
// File only in first dir
580+
let res = await fetch(`${address}/a.txt`);
581+
expect(await res.text()).toEqual("from a");
582+
583+
// File only in second dir
584+
res = await fetch(`${address}/b.txt`);
585+
expect(await res.text()).toEqual("from b");
586+
587+
// File in both dirs — first dir wins
588+
res = await fetch(`${address}/shared.txt`);
589+
expect(await res.text()).toEqual("from a");
590+
},
591+
);
592+
},
593+
);
594+
495595
integrationTest("Builder - custom server entry", async () => {
496596
const root = path.join(import.meta.dirname!, "..", "..");
497597
await using _tmp = await withTmpDir({ dir: root, prefix: "tmp_builder_" });

packages/fresh/src/dev/dev_build_cache.ts

Lines changed: 55 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -130,50 +130,53 @@ export class MemoryBuildCache<State> implements DevBuildCache<State> {
130130
}
131131
}
132132

133-
let entry = pathname.startsWith("/") ? pathname.slice(1) : pathname;
134-
entry = path.join(this.#config.staticDir, entry);
135-
const relative = path.relative(this.#config.staticDir, entry);
136-
if (relative.startsWith("..")) {
137-
throw new Error(
138-
`Processed file resolved outside of static dir ${entry}`,
133+
const stripped = pathname.startsWith("/") ? pathname.slice(1) : pathname;
134+
135+
for (const staticDir of this.#config.staticDir) {
136+
const entry = path.join(staticDir, stripped);
137+
const relative = path.relative(staticDir, entry);
138+
if (relative.startsWith("..")) {
139+
throw new Error(
140+
`Processed file resolved outside of static dir ${entry}`,
141+
);
142+
}
143+
144+
// Might be a file that we still need to process
145+
const transformed = await this.#transformer.process(
146+
entry,
147+
"development",
148+
this.#config.target,
139149
);
140-
}
141150

142-
// Might be a file that we still need to process
143-
const transformed = await this.#transformer.process(
144-
entry,
145-
"development",
146-
this.#config.target,
147-
);
151+
if (transformed !== null) {
152+
for (let i = 0; i < transformed.length; i++) {
153+
const file = transformed[i];
154+
const rel = path.relative(staticDir, file.path);
155+
if (rel.startsWith("..")) {
156+
throw new Error(
157+
`Processed file resolved outside of static dir ${file.path}`,
158+
);
159+
}
160+
const filePn = new URL(rel, "http://localhost").pathname;
148161

149-
if (transformed !== null) {
150-
for (let i = 0; i < transformed.length; i++) {
151-
const file = transformed[i];
152-
const relative = path.relative(this.#config.staticDir, file.path);
153-
if (relative.startsWith("..")) {
154-
throw new Error(
155-
`Processed file resolved outside of static dir ${file.path}`,
156-
);
162+
this.addProcessedFile(filePn, file.content, null);
157163
}
158-
const pathname = new URL(relative, "http://localhost").pathname;
159-
160-
this.addProcessedFile(pathname, file.content, null);
161-
}
162-
if (this.#processedFiles.has(pathname)) {
163-
return this.readFile(pathname);
164-
}
165-
} else {
166-
try {
167-
const filePath = path.join(this.#config.staticDir, pathname);
168-
const relative = path.relative(this.#config.staticDir, filePath);
169-
if (!relative.startsWith("..") && (await Deno.stat(filePath)).isFile) {
170-
const pathname = new URL(relative, "http://localhost").pathname;
171-
this.addUnprocessedFile(pathname, this.#config.staticDir);
164+
if (this.#processedFiles.has(pathname)) {
172165
return this.readFile(pathname);
173166
}
174-
} catch (err) {
175-
if (!(err instanceof Deno.errors.NotFound)) {
176-
throw err;
167+
} else {
168+
try {
169+
const filePath = path.join(staticDir, pathname);
170+
const rel = path.relative(staticDir, filePath);
171+
if (!rel.startsWith("..") && (await Deno.stat(filePath)).isFile) {
172+
const filePn = new URL(rel, "http://localhost").pathname;
173+
this.addUnprocessedFile(filePn, staticDir);
174+
return this.readFile(filePn);
175+
}
176+
} catch (err) {
177+
if (!(err instanceof Deno.errors.NotFound)) {
178+
throw err;
179+
}
177180
}
178181
}
179182
}
@@ -303,9 +306,13 @@ export class DiskBuildCache<State> implements DevBuildCache<State> {
303306
}
304307

305308
async flush(): Promise<void> {
306-
const { staticDir, outDir, target, root } = this.#config;
309+
const { staticDir: staticDirs, outDir, target, root } = this.#config;
310+
311+
const seen = new Set<string>();
312+
313+
for (const staticDir of staticDirs) {
314+
if (!(await fsAdapter.isDirectory(staticDir))) continue;
307315

308-
if (await fsAdapter.isDirectory(staticDir)) {
309316
const entries = fsAdapter.walk(staticDir, {
310317
includeDirs: false,
311318
includeFiles: true,
@@ -332,12 +339,18 @@ export class DiskBuildCache<State> implements DevBuildCache<State> {
332339
const file = result[i];
333340
assertInDir(file.path, staticDir);
334341
const pathname = `/${path.relative(staticDir, file.path)}`;
335-
await this.addProcessedFile(pathname, file.content, null);
342+
if (!seen.has(pathname)) {
343+
seen.add(pathname);
344+
await this.addProcessedFile(pathname, file.content, null);
345+
}
336346
}
337347
} else {
338348
const relative = path.relative(staticDir, entry.path);
339349
const pathname = `/${relative}`;
340-
this.addUnprocessedFile(pathname, staticDir);
350+
if (!seen.has(pathname)) {
351+
seen.add(pathname);
352+
this.addUnprocessedFile(pathname, staticDir);
353+
}
341354
}
342355
}
343356
}

packages/fresh/src/dev/dev_build_cache_test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Deno.test({
2222
islandDir: "",
2323
outDir: "",
2424
routeDir: "",
25-
staticDir: "",
25+
staticDir: [""],
2626
target: "latest",
2727
};
2828
const fileTransformer = new FileTransformer(createFakeFs({}), tmp);

packages/plugin-tailwindcss-v3/src/mod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ async function initTailwind(
106106
config: ResolvedBuildConfig,
107107
options: TailwindPluginOptions,
108108
): Promise<postcss.Processor> {
109-
const root = path.dirname(config.staticDir);
109+
const root = config.root;
110110

111111
const configPath = await findTailwindConfigFile(root);
112112
const url = path.toFileUrl(configPath).href;

0 commit comments

Comments
 (0)