Skip to content

Commit

Permalink
feat(@netlify/remix-edge-adapter): support Hydrogen Vite sites (#441)
Browse files Browse the repository at this point in the history
This adds support for Shopify Hydrogen sites that use Remix Vite. See https://github.com/netlify/hydrogen-template.
  • Loading branch information
serhalp authored Sep 5, 2024
1 parent 68591ef commit 81e9bfa
Show file tree
Hide file tree
Showing 118 changed files with 10,710 additions and 549 deletions.
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
29 changes: 29 additions & 0 deletions packages/remix-edge-adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,35 @@
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 instead deploy your existing 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

0 comments on commit 81e9bfa

Please sign in to comment.