Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(netlify): support included/excluded files #325

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
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
23 changes: 23 additions & 0 deletions .changeset/itchy-donuts-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@astrojs/netlify': minor
---

Adds `includedFiles` and `excludedFiles` configuration options. These allow extra files to be deployed in the SSR function bundle.

When an Astro site using `server` or `hybrid` rendering is deployed to Netlify, the generated functions trace the server dependencies and include any that may be needed in SSR. However, sometimes you may want to include extra files that are not detected as dependencies, such as files that are loaded using `fs` functions. Also, you may sometimes want to specifically exclude dependencies that are bundled automatically. For example, you may have a Node module that includes a large binary.

The `includedFiles` and `excludedFiles` options allow you specify these inclusions and exclusions as an array of file paths relative to the site root. Both options support glob patterns, so you can include/exclude multiple files at once.

If you are specifying files using filesystem functions, resolve the path using `path.resolve()` or `process.cwd()`, which will give you the site root. At runtime, compiled source files will be in a different location and you cannot rely on relative file paths.

```js
import netlify from '@astrojs/netlify';
import { defineConfig } from 'astro/config';

export default defineConfig({
output: 'server',
adapter: netlify({
includedFiles: ['src/address-data/**/*.csv', 'src/include-this.txt'],
excludedFiles: ['node_modules/chonky-module/not-this-massive-file.mp4'],
})
});
7 changes: 7 additions & 0 deletions .changeset/shiny-geese-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/netlify': major
---

Changes SSR working directory to site root rather than repo root.

During SSR, the working directory is now the site root, rather than the repo root. This change allows you to use paths in your SSR functions that match the paths used for `includedFiles`.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ package-lock.json
# exclude IntelliJ/WebStorm stuff
.idea

.astro/
1 change: 1 addition & 0 deletions packages/netlify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"files": ["dist"],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test-fn": "astro-scripts test \"test/functions/*.test.js\"",
"test-static": "astro-scripts test \"test/static/*.test.js\"",
"test": "pnpm run test-fn && pnpm run test-static",
Expand Down
39 changes: 35 additions & 4 deletions packages/netlify/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { randomUUID } from 'node:crypto';
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import type { IncomingMessage } from 'node:http';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { emptyDir } from '@astrojs/internal-helpers/fs';
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
import type { Context } from '@netlify/functions';
import type { AstroConfig, AstroIntegration, AstroIntegrationLogger, RouteData } from 'astro';
import { build } from 'esbuild';
import glob from 'fast-glob';
import { copyDependenciesToFunction } from './lib/nft.js';
import type { Args } from './ssr-function.js';

const { version: packageVersion } = JSON.parse(
await readFile(new URL('../package.json', import.meta.url), 'utf8')
);
Expand Down Expand Up @@ -185,6 +185,18 @@ export interface NetlifyIntegrationConfig {
* @default {true}
*/
imageCDN?: boolean;

/**
* Extra files to include in the SSR function bundle. Paths are relative to the project root.
* Glob patterns are supported.
*/
includedFiles?: string[];

/**
* Files to exclude from the SSR function bundle. Paths are relative to the project root.
* Glob patterns are supported.
*/
excludedFiles?: string[];
florian-lefebvre marked this conversation as resolved.
Show resolved Hide resolved
}

export default function netlifyIntegration(
Expand Down Expand Up @@ -237,18 +249,37 @@ export default function netlifyIntegration(
}
}

async function getFilesByGlob(
include: Array<string> = [],
exclude: Array<string> = []
): Promise<Array<URL>> {
const files = await glob(include, {
cwd: fileURLToPath(rootDir),
absolute: true,
ignore: exclude,
});
return files.map((file) => pathToFileURL(file));
ematipico marked this conversation as resolved.
Show resolved Hide resolved
}

async function writeSSRFunction({
notFoundContent,
logger,
}: { notFoundContent?: string; logger: AstroIntegrationLogger }) {
const entry = new URL('./entry.mjs', ssrBuildDir());

const includedFiles = await getFilesByGlob(
integrationConfig?.includedFiles,
integrationConfig?.excludedFiles
);

const excludedFiles = await getFilesByGlob(integrationConfig?.excludedFiles);

const { handler } = await copyDependenciesToFunction(
{
entry,
outDir: ssrOutputDir(),
includeFiles: [],
excludeFiles: [],
includeFiles: includedFiles,
excludeFiles: excludedFiles,
logger,
},
TRACE_CACHE
Expand Down
18 changes: 18 additions & 0 deletions packages/netlify/src/ssr-function.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { fileURLToPath } from 'node:url';
import type { Context } from '@netlify/functions';
import type { SSRManifest } from 'astro';
import { App } from 'astro/app';
Expand All @@ -17,14 +18,31 @@ export interface Args {

const clientAddressSymbol = Symbol.for('astro.clientAddress');

function getSiteRoot() {
// The entrypoint is created in `.netlify/build`, so we can find the root by going one level up
const url = import.meta.url;
const index = url.lastIndexOf('.netlify/build/');
return fileURLToPath(index !== -1 ? url.substring(0, index) : url);
}

export const createExports = (manifest: SSRManifest, { middlewareSecret }: Args) => {
const app = new App(manifest);

const root = getSiteRoot();

function createHandler(integrationConfig: {
cacheOnDemandPages: boolean;
notFoundContent?: string;
}) {
return async function handler(request: Request, context: Context) {
// Change working directory to the site root, rather than the repo root
// This allows relative paths to match those set in `includedFiles` in astro.config.mjs
try {
process.chdir(root);
} catch (err) {
console.warn('Failed to chdir to root', err);
}

const routeData = app.match(request);
if (!routeData && typeof integrationConfig.notFoundContent !== 'undefined') {
return new Response(integrationConfig.notFoundContent, {
Expand Down
11 changes: 11 additions & 0 deletions packages/netlify/test/functions/fixtures/excludes/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import netlify from '@astrojs/netlify';
import { defineConfig } from 'astro/config';

export default defineConfig({
output: 'server',
adapter: netlify({
// A long way to the root directory!
excludedFiles: ['../../../../../../node_modules/.pnpm/cowsay@*/**'],
}),
site: "http://example.com",
});
14 changes: 14 additions & 0 deletions packages/netlify/test/functions/fixtures/excludes/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@test/netlify-excludes",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/netlify": "workspace:",
"astro": "^4.15.1",
"cowsay": "1.6.0"
},
"scripts": {
"build": "astro build",
"dev": "astro dev"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
export const prerender = false
const header = Astro.request.headers.get("x-test")
---

<p>This is my custom 404 page</p>
<p>x-test: {header}</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---

async function moo() {
const cow = await import('cowsay')
return cow.say({ text: 'Moo!' });
}

if(Astro.url.searchParams.get('moo')) {
await moo();
}

---
<html>
<head><title>Testing</title></head>
<body>
<h1>Hi</h1>
</body>
</html>
11 changes: 11 additions & 0 deletions packages/netlify/test/functions/fixtures/includes/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import netlify from '@astrojs/netlify';
import { defineConfig } from 'astro/config';

export default defineConfig({
output: 'server',
adapter: netlify({
includedFiles: ['files/**/*.csv', 'files/include-this.txt'],
excludedFiles: ['files/subdirectory/not-this.csv'],
ematipico marked this conversation as resolved.
Show resolved Hide resolved
}),
site: "http://example.com",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1,2,3
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1,2,3
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1,2,3
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build]
command = "pnpm run --filter @test/netlify-includes... build"
publish = "dist"
14 changes: 14 additions & 0 deletions packages/netlify/test/functions/fixtures/includes/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@test/netlify-includes",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/netlify": "workspace:",
"astro": "^4.15.1",
"cowsay": "1.6.0"
},
"scripts": {
"build": "astro build",
"dev": "astro dev"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
export const prerender = false
const header = Astro.request.headers.get("x-test")
---

<p>This is my custom 404 page</p>
<p>x-test: {header}</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
import { promises as fs } from 'fs';
const cwd = process.cwd();
const file = await fs.readFile('files/include-this.txt', 'utf-8');

async function moo() {
const cow = await import('cowsay')
return cow.say({ text: 'Moo!' });
}
if(Astro.url.searchParams.get('moo')) {
await moo();
}


---
<html>
<head><title>Testing</title></head>
<body>
<h1>{file}</h1>
<p>{cwd}</p>
</body>
</html>
87 changes: 87 additions & 0 deletions packages/netlify/test/functions/includes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import * as assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { before, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { loadFixture } from '@astrojs/test-utils';
import * as cheerio from 'cheerio';

describe(
'Included files',
() => {
let fixture;
const root = new URL('./fixtures/includes/', import.meta.url);
const expectedCwd = new URL(
'.netlify/v1/functions/ssr/packages/netlify/test/functions/fixtures/includes/',
root
);

before(async () => {
fixture = await loadFixture({ root });
await fixture.build();
});

it('Includes files', async () => {
const filesRoot = new URL('./files/', expectedCwd);
const expectedFiles = ['include-this.txt', 'also-this.csv', 'subdirectory/and-this.csv'];

for (const file of expectedFiles) {
assert.ok(existsSync(new URL(file, filesRoot)), `Expected file ${file} to exist`);
}

const notExpectedFiles = ['subdirectory/not-this.csv', 'subdirectory/or-this.txt'];

for (const file of notExpectedFiles) {
assert.ok(!existsSync(new URL(file, filesRoot)), `Expected file ${file} to not exist`);
}
});

it('Can load included files correctly', async () => {
const entryURL = new URL(
'./fixtures/includes/.netlify/v1/functions/ssr/ssr.mjs',
import.meta.url
);
const { default: handler } = await import(entryURL);
const resp = await handler(new Request('http://example.com/'), {});
const html = await resp.text();
const $ = cheerio.load(html);
assert.equal($('h1').text(), 'hello');
assert.equal($('p').text(), fileURLToPath(expectedCwd).slice(0, -1));
});

it('Includes traced node modules with symlinks', async () => {
const expected = new URL(
'.netlify/v1/functions/ssr/node_modules/.pnpm/[email protected]/node_modules/cowsay/cows/happy-whale.cow',
root
);
console.log(expected.href);
assert.ok(existsSync(expected, 'Expected excluded file to exist in default build'));
});
},
{
timeout: 120000,
}
);

describe(
'Excluded files',
() => {
let fixture;
const root = new URL('./fixtures/excludes/', import.meta.url);

before(async () => {
fixture = await loadFixture({ root });
await fixture.build();
});

it('Excludes traced node modules', async () => {
const expected = new URL(
'.netlify/v1/functions/ssr/node_modules/.pnpm/[email protected]/node_modules/cowsay/cows/happy-whale.cow',
root
);
assert.ok(!existsSync(expected, 'Expected excluded file to not exist in build'));
});
},
{
timeout: 120000,
}
);
Loading