diff --git a/.changeset/tall-teachers-carry.md b/.changeset/tall-teachers-carry.md new file mode 100644 index 0000000..3c4c5fc --- /dev/null +++ b/.changeset/tall-teachers-carry.md @@ -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" +``` diff --git a/packages/build/README.md b/packages/build/README.md index 3f60c5c..d4cb720 100644 --- a/packages/build/README.md +++ b/packages/build/README.md @@ -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 @@ -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: [ @@ -51,7 +53,7 @@ export default defineConfig({ ### Build -Just runĀ `vite build`. +Just run `vite build`. ```bash npm exec vite build diff --git a/packages/build/package.json b/packages/build/package.json index e8e5437..6ae7875 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -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" @@ -57,6 +61,9 @@ ], "cloudflare-workers": [ "./dist/adapter/cloudflare-workers/index.d.ts" + ], + "netlify-functions": [ + "./dist/adapter/netlify-functions/index.d.ts" ] } }, diff --git a/packages/build/src/adapter/netlify-functions/index.ts b/packages/build/src/adapter/netlify-functions/index.ts new file mode 100644 index 0000000..2854932 --- /dev/null +++ b/packages/build/src/adapter/netlify-functions/index.ts @@ -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', + } +} diff --git a/packages/build/src/base.ts b/packages/build/src/base.ts index 8327a63..ef9b9dd 100644 --- a/packages/build/src/base.ts +++ b/packages/build/src/base.ts @@ -25,7 +25,10 @@ export type BuildOptions = { } & Omit export const defaultOptions: Required< - Omit + Omit< + BuildOptions, + 'entryContentAfterHooks' | 'entryContentBeforeHooks' | 'entryContentDefaultExportHook' + > > = { entry: ['src/index.ts', './src/index.tsx', './app/server.ts'], output: 'index.js', @@ -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 { @@ -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, }) } diff --git a/packages/build/src/entry/index.ts b/packages/build/src/entry/index.ts index 7645932..6727285 100644 --- a/packages/build/src/entry/index.ts +++ b/packages/build/src/entry/index.ts @@ -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[] } @@ -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)} @@ -76,6 +86,5 @@ ${appStr} ${await hooksToString('mainApp', options.entryContentAfterHooks)} -export default mainApp` - return mainAppStr +${await hooksToString('mainApp', [defaultExportHook])}` } diff --git a/packages/build/test/adapter.test.ts b/packages/build/test/adapter.test.ts index a000fb1..ed67d87 100644 --- a/packages/build/test/adapter.test.ts +++ b/packages/build/test/adapter.test.ts @@ -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', () => { @@ -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' diff --git a/packages/dev-server/e2e-bun/mock/app.ts b/packages/dev-server/e2e-bun/mock/app.ts index f36d83b..d78f90a 100644 --- a/packages/dev-server/e2e-bun/mock/app.ts +++ b/packages/dev-server/e2e-bun/mock/app.ts @@ -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('

Hello Vite!

') }) diff --git a/packages/dev-server/e2e/mock/worker.ts b/packages/dev-server/e2e/mock/worker.ts index ac859b4..efc33c5 100644 --- a/packages/dev-server/e2e/mock/worker.ts +++ b/packages/dev-server/e2e/mock/worker.ts @@ -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('

Hello Vite!

') }) diff --git a/packages/ssg/test/app.ts b/packages/ssg/test/app.ts index 71da5a5..82eab42 100644 --- a/packages/ssg/test/app.ts +++ b/packages/ssg/test/app.ts @@ -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) })