Skip to content

Commit

Permalink
feat(build): Add netlify-functions adapter
Browse files Browse the repository at this point in the history
Why?
====

So we can build a bundle targetting the netlify-functions runtime.

How?
====

* Extend `EntryContentOptions` with `entryContentDefaultExportHook`
  to allow customizing the way the default export of the bundle is
  built.

* Alter `getEntryContent` to use the `entryContentDefaultExportHook`
  for defining the default export, defaulting to the previous
  behavior if not defined.

* Add `netlifyFunctionsBuildPlugin` as a new exported adapter that
  wraps the hono app in the `hono/netlify` `handle()` adapter and
  defines a `config` export to make the hono app respond to the
  root path in Netlify.
  • Loading branch information
chadxz committed Jan 23, 2025
1 parent c40d227 commit a780f59
Show file tree
Hide file tree
Showing 10 changed files with 152 additions and 9 deletions.
61 changes: 61 additions & 0 deletions .changeset/tall-teachers-carry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
'@hono/vite-build': minor
---

Added a new Netlify Functions build adapter.

This adapter can be imported from `@hono/vite-build/netlify-functions` and will
compile your Hono app to comply with the requirements of the Netlify Functions
runtime.

* The default export will have the `hono/netlify` adapter applied to it.
* A `config` object will be exported, setting the function path to `'/*'` and
`preferStatic` to `true`.

Please note, this is for the Netlify Functions runtime, not the Netlify Edge
Functions runtime.

Example:

```ts
// vite.config.ts
import { defineConfig } from "vite";
import devServer from "@hono/vite-dev-server";
import build from "@hono/vite-build/netlify-functions";

export default defineConfig({
plugins: [
devServer({
entry: "./src/index.ts",
}),
build({
entry: "./src/index.ts",
output: "functions/server/index.js"
})
],
});
```

If you also have a `public/publish` directory for your assets that should be
published to the corresponding Netlify site, then after running a build, you
would end up with a directory structure like:

```
dist/
functions/
server/
index.js
publish/
robots.txt
....
```

then you can use a netlify.toml that looks like:

```toml
# https://ntl.fyi/file-based-build-config
[build]
command = "vite build"
functions = "dist/functions"
publish = "dist/publish"
```
4 changes: 3 additions & 1 deletion packages/build/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Here are the modules included:
- `@hono/vite-build/cloudflare-workers`
- `@hono/vite-build/bun`
- `@hono/vite-build/node`
- `@hono/vite-build/netlify-functions`

## Usage

Expand Down Expand Up @@ -36,6 +37,7 @@ import build from '@hono/vite-build/bun'
// import build from '@hono/vite-build/cloudflare-pages'
// import build from '@hono/vite-build/cloudflare-workers'
// import build from '@hono/vite-build/node'
// import build from '@hono/vite-build/netlify-functions'

export default defineConfig({
plugins: [
Expand All @@ -51,7 +53,7 @@ export default defineConfig({

### Build

Just run `vite build`.
Just run `vite build`.

```bash
npm exec vite build
Expand Down
7 changes: 7 additions & 0 deletions packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
"types": "./dist/adapter/cloudflare-workers/index.d.ts",
"import": "./dist/adapter/cloudflare-workers/index.js"
},
"./netlify-functions": {
"types": "./dist/adapter/netlify-functions/index.d.ts",
"import": "./dist/adapter/netlify-functions/index.js"
},
"./deno": {
"types": "./dist/adapter/deno/index.d.ts",
"import": "./dist/adapter/deno/index.js"
Expand All @@ -57,6 +61,9 @@
],
"cloudflare-workers": [
"./dist/adapter/cloudflare-workers/index.d.ts"
],
"netlify-functions": [
"./dist/adapter/netlify-functions/index.d.ts"
]
}
},
Expand Down
21 changes: 21 additions & 0 deletions packages/build/src/adapter/netlify-functions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Plugin } from 'vite'
import type { BuildOptions } from '../../base.js'
import buildPlugin from '../../base.js'

export type NetlifyFunctionsBuildOptions = BuildOptions

export default function netlifyFunctionsBuildPlugin(
pluginOptions?: NetlifyFunctionsBuildOptions
): Plugin {
return {
...buildPlugin({
...{
entryContentBeforeHooks: [() => 'import { handle } from "hono/netlify"'],
entryContentAfterHooks: [() => 'export const config = { path: "/*", preferStatic: true }'],
entryContentDefaultExportHook: (appName) => `export default handle(${appName})`,
},
...pluginOptions,
}),
name: '@hono/vite-build/netlify-functions',
}
}
7 changes: 5 additions & 2 deletions packages/build/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export type BuildOptions = {
} & Omit<GetEntryContentOptions, 'entry'>

export const defaultOptions: Required<
Omit<BuildOptions, 'entryContentAfterHooks' | 'entryContentBeforeHooks'>
Omit<
BuildOptions,
'entryContentAfterHooks' | 'entryContentBeforeHooks' | 'entryContentDefaultExportHook'
>
> = {
entry: ['src/index.ts', './src/index.tsx', './app/server.ts'],
output: 'index.js',
Expand All @@ -46,7 +49,6 @@ const buildPlugin = (options: BuildOptions): Plugin => {
const virtualEntryId = 'virtual:build-entry-module'
const resolvedVirtualEntryId = '\0' + virtualEntryId
let config: ResolvedConfig

const output = options.output ?? defaultOptions.output

return {
Expand Down Expand Up @@ -94,6 +96,7 @@ const buildPlugin = (options: BuildOptions): Plugin => {
entry: Array.isArray(entry) ? entry : [entry],
entryContentBeforeHooks: options.entryContentBeforeHooks,
entryContentAfterHooks: options.entryContentAfterHooks,
entryContentDefaultExportHook: options.entryContentDefaultExportHook,
staticPaths,
})
}
Expand Down
15 changes: 12 additions & 3 deletions packages/build/src/entry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export type GetEntryContentOptions = {
entry: string[]
entryContentBeforeHooks?: EntryContentHook[]
entryContentAfterHooks?: EntryContentHook[]
/**
* Explicitly specify the default export for the app. Make sure your export
* incorporates the app passed as the `appName` argument.
*
* @default `export default ${appName}`
*/
entryContentDefaultExportHook?: EntryContentHook
staticPaths?: string[]
}

Expand Down Expand Up @@ -67,7 +74,10 @@ export const getEntryContent = async (options: GetEntryContentOptions) => {
throw new Error("Can't import modules from [${globStr}]")
}`

const mainAppStr = `import { Hono } from 'hono'
const defaultExportHook =
options.entryContentDefaultExportHook ?? (() => 'export default mainApp')

return `import { Hono } from 'hono'
const mainApp = new Hono()
${await hooksToString('mainApp', options.entryContentBeforeHooks)}
Expand All @@ -76,6 +86,5 @@ ${appStr}
${await hooksToString('mainApp', options.entryContentAfterHooks)}
export default mainApp`
return mainAppStr
${await hooksToString('mainApp', [defaultExportHook])}`
}
38 changes: 38 additions & 0 deletions packages/build/test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'
import bunBuildPlugin from '../src/adapter/bun'
import cloudflarePagesPlugin from '../src/adapter/cloudflare-pages'
import denoBuildPlugin from '../src/adapter/deno'
import netlifyFunctionsPlugin from '../src/adapter/netlify-functions'
import nodeBuildPlugin from '../src/adapter/node'

describe('Build Plugin with Bun Adapter', () => {
Expand Down Expand Up @@ -41,6 +42,43 @@ describe('Build Plugin with Bun Adapter', () => {
})
})

describe('Build Plugin with Netlify Functions Adapter', () => {
const testDir = './test/mocks/app-static-files'
const entry = './src/server.ts'

afterEach(() => {
rmSync(`${testDir}/dist`, { recursive: true, force: true })
})

it('Should build the project correctly with the plugin', async () => {
const outputFile = `${testDir}/dist/index.js`

await build({
root: testDir,
plugins: [
netlifyFunctionsPlugin({
entry,
minify: false,
}),
],
})

expect(existsSync(outputFile)).toBe(true)

const output = readFileSync(outputFile, 'utf-8')
expect(output).toContain('Hello World')
expect(output).toContain('{ path: "/*", preferStatic: true }')
expect(output).toContain('handle(mainApp)')

const outputFooTxt = readFileSync(`${testDir}/dist/foo.txt`, 'utf-8')
expect(outputFooTxt).toContain('foo')

const outputJsClientJs = readFileSync(`${testDir}/dist/js/client.js`, 'utf-8')
// eslint-disable-next-line quotes
expect(outputJsClientJs).toContain("console.log('foo')")
})
})

describe('Build Plugin with Cloudflare Pages Adapter', () => {
const testDir = './test/mocks/app-static-files'

Expand Down
3 changes: 2 additions & 1 deletion packages/dev-server/e2e-bun/mock/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ app.get('/', (c) => {
})

app.get('/with-nonce', (c) => {
c.header('content-security-policy', 'script-src-elem \'self\' \'nonce-ZMuLoN/taD7JZTUXfl5yvQ==\';')
// eslint-disable-next-line quotes -- allowing using double-quotes bc of embedded single quotes
c.header('content-security-policy', "script-src-elem 'self' 'nonce-ZMuLoN/taD7JZTUXfl5yvQ==';")
return c.html('<h1>Hello Vite!</h1>')
})

Expand Down
3 changes: 2 additions & 1 deletion packages/dev-server/e2e/mock/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ app.get('/', (c) => {
})

app.get('/with-nonce', (c) => {
c.header('content-security-policy', 'script-src-elem \'self\' \'nonce-ZMuLoN/taD7JZTUXfl5yvQ==\';')
// eslint-disable-next-line quotes -- allowing using double-quotes bc of embedded single quotes
c.header('content-security-policy', "script-src-elem 'self' 'nonce-ZMuLoN/taD7JZTUXfl5yvQ==';")
return c.html('<h1>Hello Vite!</h1>')
})

Expand Down
2 changes: 1 addition & 1 deletion packages/ssg/test/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ app.get('/', (c) => {
})

app.get('/dynamic-import', async (c) => {
// @ts-expect-error
// @ts-expect-error this is a test
const module = await import('./sample.js')
return c.text('Dynamic import works: ' + module.default)
})
Expand Down

0 comments on commit a780f59

Please sign in to comment.