Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:

test:
runs-on: ${{ matrix.os }}
timeout-minutes: 10
timeout-minutes: 15

strategy:
fail-fast: false
Expand Down
25 changes: 21 additions & 4 deletions docs/latest/advanced/builder.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ const builder = new Builder({
// Where to write generated files when doing a production build.
// (default: `<root>/_fresh/`)
outDir?: string;
// Path to static file directory. (Default: `<root>/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: `<root>/static/`)
staticDir?: string | string[];
// Path to island directory. (Default: `<root>/islands`)
islandDir?: string;
// Path to routes directory. (Default: `<root>/routes`)
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/latest/advanced/vite.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[\\/]+/],
Expand Down
25 changes: 25 additions & 0 deletions docs/latest/concepts/static-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 18 additions & 9 deletions packages/fresh/src/dev/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -103,11 +105,15 @@ export interface BuildOptions {
/**
* The final resolved Builder configuration.
*/
export type ResolvedBuildConfig = Required<Omit<BuildOptions, "sourceMap">> & {
mode: "development" | "production";
buildId: string;
sourceMap?: FreshBundleOptions["sourceMap"];
};
export type ResolvedBuildConfig =
& Required<Omit<BuildOptions, "sourceMap" | "staticDir">>
& {
/** 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<State = any> {
Expand All @@ -122,7 +128,10 @@ export class Builder<State = any> {
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);

Expand Down
100 changes: 100 additions & 0 deletions packages/fresh/src/dev/builder_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
await builder.listen(() => Promise.resolve<App<unknown>>(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_" });
Expand Down
97 changes: 55 additions & 42 deletions packages/fresh/src/dev/dev_build_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,50 +130,53 @@ export class MemoryBuildCache<State> implements DevBuildCache<State> {
}
}

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 filePathname = 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(filePathname, 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 filePathname = new URL(rel, "http://localhost").pathname;
this.addUnprocessedFile(filePathname, staticDir);
return this.readFile(filePathname);
}
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) {
throw err;
}
}
}
}
Expand Down Expand Up @@ -303,9 +306,13 @@ export class DiskBuildCache<State> implements DevBuildCache<State> {
}

async flush(): Promise<void> {
const { staticDir, outDir, target, root } = this.#config;
const { staticDir: staticDirs, outDir, target, root } = this.#config;

const seen = new Set<string>();

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,
Expand All @@ -332,12 +339,18 @@ export class DiskBuildCache<State> implements DevBuildCache<State> {
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);
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/fresh/src/dev/dev_build_cache_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Deno.test({
islandDir: "",
outDir: "",
routeDir: "",
staticDir: "",
staticDir: [""],
target: "latest",
};
const fileTransformer = new FileTransformer(createFakeFs({}), tmp);
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-tailwindcss-v3/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async function initTailwind(
config: ResolvedBuildConfig,
options: TailwindPluginOptions,
): Promise<postcss.Processor> {
const root = path.dirname(config.staticDir);
const root = config.root;

const configPath = await findTailwindConfigFile(root);
const url = path.toFileUrl(configPath).href;
Expand Down
Loading
Loading