Skip to content

Commit 81e9bfa

Browse files
authored
feat(@netlify/remix-edge-adapter): support Hydrogen Vite sites (#441)
This adds support for Shopify Hydrogen sites that use Remix Vite. See https://github.com/netlify/hydrogen-template.
1 parent 68591ef commit 81e9bfa

File tree

118 files changed

+10710
-549
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

118 files changed

+10710
-549
lines changed

.github/workflows/test.yml

+5
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ jobs:
2121
node-version: 18
2222
check-latest: true
2323
- run: corepack enable
24+
- name: Install Deno
25+
uses: denoland/setup-deno@v1
26+
with:
27+
# Should satisfy the `DENO_VERSION_RANGE` from https://github.com/netlify/edge-bundler/blob/main/node/bridge.ts#L17
28+
deno-version: v1
2429
- name: Install
2530
run: pnpm install
2631
- name: Build

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,6 @@ build/
139139
/playwright-report/
140140
/blob-report/
141141
/playwright/.cache/
142+
143+
# Generated by `deno types`
144+
/packages/remix-edge-adapter/deno.d.ts

README.md

+5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ are three packages:
1414
- `@netlify/remix-edge-adapter` - The Remix adapter for Netlify Edge Functions
1515
- `@netlify/remix-runtime` - The Remix runtime for Netlify Edge Functions
1616

17+
## Hydrogen
18+
19+
Shopify Hydrogen sites are supported and automatically detected. However, only
20+
[the edge adapter](./packages/remix-edge-adapter/README.md) is supported, and only when using Remix Vite.
21+
1722
## Development
1823

1924
### Installation

packages/remix-adapter/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ The Remix Adapter for Netlify allows you to deploy your [Remix](https://remix.ru
66
It is strongly advised to use [the Netlify Remix template](https://github.com/netlify/remix-template) to create a Remix
77
site for deployment to Netlify. See [Remix on Netlify](https://docs.netlify.com/frameworks/remix/) for more details and
88
other options.
9+
10+
Please note that this adapter **does not support Hydrogen**. Hydrogen is only supported via Edge Functions. See
11+
<https://github.com/netlify/hydrogen-template>.

packages/remix-adapter/src/vite/plugin.ts

+35-17
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,35 @@ import { join, relative, sep } from 'node:path'
44
import { sep as posixSep } from 'node:path/posix'
55
import { version, name } from '../../package.json'
66

7-
const SERVER_ID = 'virtual:netlify-server'
8-
const RESOLVED_SERVER_ID = `\0${SERVER_ID}`
7+
const NETLIFY_FUNCTIONS_DIR = '.netlify/functions-internal'
8+
9+
const FUNCTION_FILENAME = 'remix-server.mjs'
10+
/**
11+
* The chunk filename without an extension, i.e. in the Rollup config `input` format
12+
*/
13+
const FUNCTION_HANDLER_CHUNK = 'server'
14+
15+
const FUNCTION_HANDLER_MODULE_ID = 'virtual:netlify-server'
16+
const RESOLVED_FUNCTION_HANDLER_MODULE_ID = `\0${FUNCTION_HANDLER_MODULE_ID}`
917

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

12-
// The virtual module that is the compiled server entrypoint.
13-
const serverCode = /* js */ `
20+
// The virtual module that is the compiled Vite SSR entrypoint (a Netlify Function handler)
21+
const FUNCTION_HANDLER = /* js */ `
1422
import { createRequestHandler } from "@netlify/remix-adapter";
1523
import * as build from "virtual:remix/server-build";
16-
export default createRequestHandler({ build });
24+
export default createRequestHandler({
25+
build,
26+
getLoadContext: async (_req, ctx) => ctx,
27+
});
1728
`
1829

1930
// This is written to the functions directory. It just re-exports
2031
// the compiled entrypoint, along with Netlify function config.
21-
function generateNetlifyFunction(server: string) {
32+
function generateNetlifyFunction(handlerPath: string) {
2233
return /* js */ `
23-
export { default } from "${server}";
34+
export { default } from "${handlerPath}";
35+
2436
export const config = {
2537
name: "Remix server handler",
2638
generator: "${name}@${version}",
@@ -41,12 +53,18 @@ export function netlifyPlugin(): Plugin {
4153
isSsr = isSsrBuild
4254
if (command === 'build') {
4355
if (isSsrBuild) {
44-
// We need to add an extra entrypoint, as we need to compile
56+
// We need to add an extra SSR entrypoint, as we need to compile
4557
// the server entrypoint too. This is because it uses virtual
4658
// modules.
59+
// NOTE: the below is making various assumptions about the Remix Vite plugin's
60+
// implementation details:
61+
// https://github.com/remix-run/remix/blob/cc65962b1a96d1e134336aa9620ef1dad7c5efb1/packages/remix-dev/vite/plugin.ts#L1149-L1168
62+
// TODO(serhalp) Stop making these assumptions or assert them explictly.
63+
// TODO(serhalp) Unless I'm misunderstanding something, we should only need to *replace*
64+
// the default Remix Vite SSR entrypoint, not add an additional one.
4765
if (typeof config.build?.rollupOptions?.input === 'string') {
4866
config.build.rollupOptions.input = {
49-
server: SERVER_ID,
67+
[FUNCTION_HANDLER_CHUNK]: FUNCTION_HANDLER_MODULE_ID,
5068
index: config.build.rollupOptions.input,
5169
}
5270
if (config.build.rollupOptions.output && !Array.isArray(config.build.rollupOptions.output)) {
@@ -57,14 +75,14 @@ export function netlifyPlugin(): Plugin {
5775
}
5876
},
5977
async resolveId(source) {
60-
if (source === SERVER_ID) {
61-
return RESOLVED_SERVER_ID
78+
if (source === FUNCTION_HANDLER_MODULE_ID) {
79+
return RESOLVED_FUNCTION_HANDLER_MODULE_ID
6280
}
6381
},
6482
// See https://vitejs.dev/guide/api-plugin#virtual-modules-convention.
6583
load(id) {
66-
if (id === RESOLVED_SERVER_ID) {
67-
return serverCode
84+
if (id === RESOLVED_FUNCTION_HANDLER_MODULE_ID) {
85+
return FUNCTION_HANDLER
6886
}
6987
},
7088
async configResolved(config) {
@@ -74,14 +92,14 @@ export function netlifyPlugin(): Plugin {
7492
async writeBundle() {
7593
// Write the server entrypoint to the Netlify functions directory
7694
if (currentCommand === 'build' && isSsr) {
77-
const functionsDirectory = join(resolvedConfig.root, '.netlify/functions-internal')
95+
const functionsDirectory = join(resolvedConfig.root, NETLIFY_FUNCTIONS_DIR)
7896

7997
await mkdir(functionsDirectory, { recursive: true })
8098

81-
const serverPath = join(resolvedConfig.build.outDir, 'server.js')
82-
const relativeServerPath = toPosixPath(relative(functionsDirectory, serverPath))
99+
const handlerPath = join(resolvedConfig.build.outDir, `${FUNCTION_HANDLER_CHUNK}.js`)
100+
const relativeHandlerPath = toPosixPath(relative(functionsDirectory, handlerPath))
83101

84-
await writeFile(join(functionsDirectory, 'remix-server.mjs'), generateNetlifyFunction(relativeServerPath))
102+
await writeFile(join(functionsDirectory, FUNCTION_FILENAME), generateNetlifyFunction(relativeHandlerPath))
85103
}
86104
},
87105
}

packages/remix-edge-adapter/README.md

+29
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,35 @@
33
The Remix Edge Adapter for Netlify allows you to deploy your [Remix](https://remix.run) app to
44
[Netlify Edge Functions](https://docs.netlify.com/edge-functions/overview/).
55

6+
## Usage
7+
68
It is strongly advised to use [the Netlify Remix template](https://github.com/netlify/remix-template) to create a Remix
79
site for deployment to Netlify. See [Remix on Netlify](https://docs.netlify.com/frameworks/remix/) for more details and
810
other options.
11+
12+
However, if you are using **Remix Vite**, you can instead deploy your existing site to Netlify by following these steps:
13+
14+
1. Add dependencies on `@netlify/remix-edge-adapter` and `@netlify/remix-runtime`
15+
2. Use the Netlify Remix edge Vite plugin in your Vite config:
16+
17+
```js
18+
// vite.config.js
19+
import { vitePlugin as remix } from "@remix-run/dev";
20+
import { netlifyPlugin } from "@netlify/remix-edge-adapter/plugin";
21+
22+
export default defineConfig({
23+
plugins: [remix(), netlifyPlugin(),
24+
});
25+
```
26+
27+
3. Add an `app/entry.jsx` (.tsx if using TypeScript) with these contents:
28+
29+
```js
30+
// app.entry.jsx or .tsx
31+
export { default } from 'virtual:netlify-server-entry'
32+
```
33+
34+
### Hydrogen
35+
36+
Hydrogen Vite sites are supported and automatically detected. However, additional setup is required. See
37+
<https://github.com/netlify/hydrogen-template> for details.

packages/remix-edge-adapter/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
],
4141
"scripts": {
4242
"prepack": "pnpm run build",
43+
"postinstall": "deno types > deno.d.ts",
4344
"build": "tsup-node src/index.ts src/vite/plugin.ts --format esm,cjs --dts --target node16 --clean",
4445
"build:watch": "pnpm run build --watch"
4546
},
@@ -59,10 +60,10 @@
5960
"homepage": "https://github.com/netlify/remix-compute#readme",
6061
"dependencies": {
6162
"@netlify/remix-runtime": "2.3.0",
63+
"@remix-run/dev": "^2.9.2",
6264
"isbot": "^5.0.0"
6365
},
6466
"devDependencies": {
65-
"@remix-run/dev": "^2.9.2",
6667
"@remix-run/react": "^2.9.2",
6768
"@types/react": "^18.0.27",
6869
"@types/react-dom": "^18.0.10",

packages/remix-edge-adapter/src/common/server.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { AppLoadContext, ServerBuild } from '@netlify/remix-runtime'
22
import { createRequestHandler as createRemixRequestHandler } from '@netlify/remix-runtime'
33
import type { Context } from '@netlify/edge-functions'
44

5-
type LoadContext = AppLoadContext & Context
5+
export type LoadContext = AppLoadContext & Context
66

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

5050
if (response.status === 404) {
5151
// Check if there is a matching static file
52-
const originResponse = await context.next({
52+
const originResponse = await loadContext.next({
5353
sendConditionalRequest: true,
5454
})
55-
if (originResponse.status !== 404) {
55+
if (originResponse && originResponse?.status !== 404) {
5656
return originResponse
5757
}
5858
}

packages/remix-edge-adapter/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export type { GetLoadContextFunction, RequestHandler } from './common/server'
44
export { createRequestHandler } from './common/server'
55
export { config } from './classic-compiler/defaultRemixConfig'
66
export { default as handleRequest } from './common/entry.server'
7+
export { createHydrogenAppLoadContext } from './vite/hydrogen'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Context } from '@netlify/edge-functions'
2+
3+
/**
4+
* The base Hydrogen templates expect a globally defined `ExecutionContext` type, which by default
5+
* comes from Oxygen:
6+
* https://github.com/Shopify/hydrogen/blob/92a53c477540ee22cc273e7f3cbd2fd0582c815f/templates/skeleton/env.d.ts#L3.
7+
* We do the same thing to minimize differences.
8+
*/
9+
declare global {
10+
interface ExecutionContext {
11+
waitUntil(promise: Promise<unknown>): void
12+
}
13+
}
14+
15+
/**
16+
* For convenience, this matches the function signature that Hydrogen includes by default in its templates:
17+
* https://github.com/Shopify/hydrogen/blob/92a53c477540ee22cc273e7f3cbd2fd0582c815f/templates/skeleton/app/lib/context.ts.
18+
*
19+
* Remix expects the user to use module augmentation to modify their exported `AppLoadContext` type. See
20+
* https://github.com/remix-run/remix/blob/5dc3b67dc31f3df7b1b0298ae4e9cac9c5ae1c06/packages/remix-server-runtime/data.ts#L15-L23
21+
* Hydrogen follows this pattern. However, because of the way TypeScript module augmentation works,
22+
* we can't access the final user-augmented type here, so we have to do this dance with generic types.
23+
*/
24+
type CreateAppLoadContext<E extends {}, C extends {}> = (
25+
request: Request,
26+
env: E,
27+
executionContext: ExecutionContext,
28+
) => Promise<C>
29+
30+
const executionContext: ExecutionContext = {
31+
/**
32+
* Hydrogen expects a `waitUntil` function like the one in the workerd runtime:
33+
* https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil.
34+
* Netlify Edge Functions don't have such a function, but Deno Deploy isolates make a best-effort
35+
* attempt to wait for the event loop to drain, so just awaiting the promise here is equivalent.
36+
*/
37+
async waitUntil(p: Promise<unknown>): Promise<void> {
38+
await p
39+
},
40+
}
41+
42+
/**
43+
* In dev we run in a Node.js environment (via Remix Vite) but otherwise we run in a Deno (Netlify
44+
* Edge Functions) environment.
45+
*/
46+
const getEnv = () => {
47+
if (globalThis.Netlify) {
48+
return globalThis.Netlify.env.toObject()
49+
}
50+
return process.env
51+
}
52+
53+
export const createHydrogenAppLoadContext = async <E extends {}, C extends {}>(
54+
request: Request,
55+
netlifyContext: Context,
56+
createAppLoadContext: CreateAppLoadContext<E, C>,
57+
): Promise<Context & C & Record<string, unknown>> => {
58+
const env = getEnv() as E
59+
const userHydrogenContext = await createAppLoadContext(request, env, executionContext)
60+
61+
// NOTE: We use `Object.assign` here because a spread would access the getters on the
62+
// `netlifyContext` fields, some of which throw a "not implemented" error in local dev.
63+
return Object.assign(netlifyContext, userHydrogenContext)
64+
}

0 commit comments

Comments
 (0)