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/remix-edge-adapter): support Hydrogen Vite sites #441

Merged
Show file tree
Hide file tree
Changes from 12 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
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ jobs:
node-version: 18
check-latest: true
- run: corepack enable
- name: Install Deno
uses: denoland/setup-deno@v1
with:
# Should satisfy the `DENO_VERSION_RANGE` from https://github.com/netlify/edge-bundler/blob/main/node/bridge.ts#L17
deno-version: v1
- name: Install
run: pnpm install
- name: Build
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,6 @@ build/
/playwright-report/
/blob-report/
/playwright/.cache/

# Generated by `deno types`
/packages/remix-edge-adapter/deno.d.ts
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ are three packages:
- `@netlify/remix-edge-adapter` - The Remix adapter for Netlify Edge Functions
- `@netlify/remix-runtime` - The Remix runtime for Netlify Edge Functions

## Hydrogen

Shopify Hydrogen sites are supported and automatically detected. However, only
[the edge adapter](./packages/remix-edge-adapter/README.md) is supported, and only when using Remix Vite.

## Development

### Installation
Expand Down
3 changes: 3 additions & 0 deletions packages/remix-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ The Remix Adapter for Netlify allows you to deploy your [Remix](https://remix.ru
It is strongly advised to use [the Netlify Remix template](https://github.com/netlify/remix-template) to create a Remix
site for deployment to Netlify. See [Remix on Netlify](https://docs.netlify.com/frameworks/remix/) for more details and
other options.

Please note that this adapter **does not support Hydrogen**. Hydrogen is only supported via Edge Functions. See
<https://github.com/netlify/hydrogen-template>.
52 changes: 35 additions & 17 deletions packages/remix-adapter/src/vite/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,35 @@ import { join, relative, sep } from 'node:path'
import { sep as posixSep } from 'node:path/posix'
import { version, name } from '../../package.json'

const SERVER_ID = 'virtual:netlify-server'
const RESOLVED_SERVER_ID = `\0${SERVER_ID}`
const NETLIFY_FUNCTIONS_DIR = '.netlify/functions-internal'

const FUNCTION_FILENAME = 'remix-server.mjs'
/**
* The chunk filename without an extension, i.e. in the Rollup config `input` format
*/
const FUNCTION_HANDLER_CHUNK = 'server'

const FUNCTION_HANDLER_MODULE_ID = 'virtual:netlify-server'
const RESOLVED_FUNCTION_HANDLER_MODULE_ID = `\0${FUNCTION_HANDLER_MODULE_ID}`

const toPosixPath = (path: string) => path.split(sep).join(posixSep)

// The virtual module that is the compiled server entrypoint.
const serverCode = /* js */ `
// The virtual module that is the compiled Vite SSR entrypoint (a Netlify Function handler)
const FUNCTION_HANDLER = /* js */ `
import { createRequestHandler } from "@netlify/remix-adapter";
import * as build from "virtual:remix/server-build";
export default createRequestHandler({ build });
export default createRequestHandler({
build,
getLoadContext: async (_req, ctx) => ctx,
});
`

// This is written to the functions directory. It just re-exports
// the compiled entrypoint, along with Netlify function config.
function generateNetlifyFunction(server: string) {
function generateNetlifyFunction(handlerPath: string) {
return /* js */ `
export { default } from "${server}";
export { default } from "${handlerPath}";

export const config = {
name: "Remix server handler",
generator: "${name}@${version}",
Expand All @@ -41,12 +53,18 @@ export function netlifyPlugin(): Plugin {
isSsr = isSsrBuild
if (command === 'build') {
if (isSsrBuild) {
// We need to add an extra entrypoint, as we need to compile
// We need to add an extra SSR entrypoint, as we need to compile
// the server entrypoint too. This is because it uses virtual
// modules.
// NOTE: the below is making various assumptions about the Remix Vite plugin's
// implementation details:
// https://github.com/remix-run/remix/blob/cc65962b1a96d1e134336aa9620ef1dad7c5efb1/packages/remix-dev/vite/plugin.ts#L1149-L1168
// TODO(serhalp) Stop making these assumptions or assert them explictly.
// TODO(serhalp) Unless I'm misunderstanding something, we should only need to *replace*
// the default Remix Vite SSR entrypoint, not add an additional one.
if (typeof config.build?.rollupOptions?.input === 'string') {
config.build.rollupOptions.input = {
server: SERVER_ID,
[FUNCTION_HANDLER_CHUNK]: FUNCTION_HANDLER_MODULE_ID,
index: config.build.rollupOptions.input,
}
if (config.build.rollupOptions.output && !Array.isArray(config.build.rollupOptions.output)) {
Expand All @@ -57,14 +75,14 @@ export function netlifyPlugin(): Plugin {
}
},
async resolveId(source) {
if (source === SERVER_ID) {
return RESOLVED_SERVER_ID
if (source === FUNCTION_HANDLER_MODULE_ID) {
return RESOLVED_FUNCTION_HANDLER_MODULE_ID
}
},
// See https://vitejs.dev/guide/api-plugin#virtual-modules-convention.
load(id) {
if (id === RESOLVED_SERVER_ID) {
return serverCode
if (id === RESOLVED_FUNCTION_HANDLER_MODULE_ID) {
return FUNCTION_HANDLER
}
},
async configResolved(config) {
Expand All @@ -74,14 +92,14 @@ export function netlifyPlugin(): Plugin {
async writeBundle() {
// Write the server entrypoint to the Netlify functions directory
if (currentCommand === 'build' && isSsr) {
const functionsDirectory = join(resolvedConfig.root, '.netlify/functions-internal')
const functionsDirectory = join(resolvedConfig.root, NETLIFY_FUNCTIONS_DIR)

await mkdir(functionsDirectory, { recursive: true })

const serverPath = join(resolvedConfig.build.outDir, 'server.js')
const relativeServerPath = toPosixPath(relative(functionsDirectory, serverPath))
const handlerPath = join(resolvedConfig.build.outDir, `${FUNCTION_HANDLER_CHUNK}.js`)
const relativeHandlerPath = toPosixPath(relative(functionsDirectory, handlerPath))

await writeFile(join(functionsDirectory, 'remix-server.mjs'), generateNetlifyFunction(relativeServerPath))
await writeFile(join(functionsDirectory, FUNCTION_FILENAME), generateNetlifyFunction(relativeHandlerPath))
}
},
}
Expand Down
30 changes: 30 additions & 0 deletions packages/remix-edge-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,36 @@
The Remix Edge Adapter for Netlify allows you to deploy your [Remix](https://remix.run) app to
[Netlify Edge Functions](https://docs.netlify.com/edge-functions/overview/).

## Usage

It is strongly advised to use [the Netlify Remix template](https://github.com/netlify/remix-template) to create a Remix
site for deployment to Netlify. See [Remix on Netlify](https://docs.netlify.com/frameworks/remix/) for more details and
other options.

However, if you are using **Remix Vite**, you can easily skip the template and deploy your site to Netlify by following
these steps:

1. Add dependencies on `@netlify/remix-edge-adapter` and `@netlify/remix-runtime`
2. Use the Netlify Remix edge Vite plugin in your Vite config:

```js
// vite.config.js
import { vitePlugin as remix } from "@remix-run/dev";
import { netlifyPlugin } from "@netlify/remix-edge-adapter/plugin";

export default defineConfig({
plugins: [remix(), netlifyPlugin(),
});
```

3. Add an `app/entry.jsx` (.tsx if using TypeScript) with these contents:

```js
// app.entry.jsx or .tsx
export { default } from 'virtual:netlify-server-entry'
```

### Hydrogen

Hydrogen Vite sites are supported and automatically detected. However, additional setup is required. See
<https://github.com/netlify/hydrogen-template> for details.
3 changes: 2 additions & 1 deletion packages/remix-edge-adapter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
],
"scripts": {
"prepack": "pnpm run build",
"postinstall": "deno types > deno.d.ts",
"build": "tsup-node src/index.ts src/vite/plugin.ts --format esm,cjs --dts --target node16 --clean",
"build:watch": "pnpm run build --watch"
},
Expand All @@ -59,10 +60,10 @@
"homepage": "https://github.com/netlify/remix-compute#readme",
"dependencies": {
"@netlify/remix-runtime": "2.3.0",
"@remix-run/dev": "^2.9.2",
"isbot": "^5.0.0"
},
"devDependencies": {
"@remix-run/dev": "^2.9.2",
"@remix-run/react": "^2.9.2",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
Expand Down
6 changes: 3 additions & 3 deletions packages/remix-edge-adapter/src/common/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AppLoadContext, ServerBuild } from '@netlify/remix-runtime'
import { createRequestHandler as createRemixRequestHandler } from '@netlify/remix-runtime'
import type { Context } from '@netlify/edge-functions'

type LoadContext = AppLoadContext & Context
export type LoadContext = AppLoadContext & Context

/**
* A function that returns the value to use as `context` in route `loader` and
Expand Down Expand Up @@ -49,10 +49,10 @@ export function createRequestHandler({

if (response.status === 404) {
// Check if there is a matching static file
const originResponse = await context.next({
const originResponse = await loadContext.next({
sendConditionalRequest: true,
})
if (originResponse.status !== 404) {
if (originResponse && originResponse?.status !== 404) {
return originResponse
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/remix-edge-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export type { GetLoadContextFunction, RequestHandler } from './common/server'
export { createRequestHandler } from './common/server'
export { config } from './classic-compiler/defaultRemixConfig'
export { default as handleRequest } from './common/entry.server'
export { createHydrogenAppLoadContext } from './vite/hydrogen'
64 changes: 64 additions & 0 deletions packages/remix-edge-adapter/src/vite/hydrogen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Context } from '@netlify/edge-functions'

/**
* The base Hydrogen templates expect a globally defined `ExecutionContext` type, which by default
* comes from Oxygen:
* https://github.com/Shopify/hydrogen/blob/92a53c477540ee22cc273e7f3cbd2fd0582c815f/templates/skeleton/env.d.ts#L3.
* We do the same thing to minimize differences.
*/
declare global {
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void
}
}

/**
* For convenience, this matches the function signature that Hydrogen includes by default in its templates:
* https://github.com/Shopify/hydrogen/blob/92a53c477540ee22cc273e7f3cbd2fd0582c815f/templates/skeleton/app/lib/context.ts.
*
* Remix expects the user to use module augmentation to modify their exported `AppLoadContext` type. See
* https://github.com/remix-run/remix/blob/5dc3b67dc31f3df7b1b0298ae4e9cac9c5ae1c06/packages/remix-server-runtime/data.ts#L15-L23
* Hydrogen follows this pattern. However, because of the way TypeScript module augmentation works,
* we can't access the final user-augmented type here, so we have to do this dance with generic types.
*/
type CreateAppLoadContext<E extends {}, C extends {}> = (
request: Request,
env: E,
executionContext: ExecutionContext,
) => Promise<C>

const executionContext: ExecutionContext = {
/**
* Hydrogen expects a `waitUntil` function like the one in the workerd runtime:
* https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil.
* Netlify Edge Functions don't have such a function, but Deno Deploy isolates make a best-effort
* attempt to wait for the event loop to drain, so just awaiting the promise here is equivalent.
*/
async waitUntil(p: Promise<unknown>): Promise<void> {
await p
},
}

/**
* In dev we run in a Node.js environment (via Remix Vite) but otherwise we run in a Deno (Netlify
* Edge Functions) environment.
*/
const getEnv = () => {
if (globalThis.Netlify) {
return globalThis.Netlify.env.toObject()
}
return process.env
}

export const createHydrogenAppLoadContext = async <E extends {}, C extends {}>(
request: Request,
netlifyContext: Context,
createAppLoadContext: CreateAppLoadContext<E, C>,
): Promise<Context & C & Record<string, unknown>> => {
const env = getEnv() as E
const userHydrogenContext = await createAppLoadContext(request, env, executionContext)

// NOTE: We use `Object.assign` here because a spread would access the getters on the
// `netlifyContext` fields, some of which throw a "not implemented" error in local dev.
return Object.assign(netlifyContext, userHydrogenContext)
}
Loading