Skip to content

Commit

Permalink
feat(remix-edge-adapter): use site entrypoint for Hydrogen sites
Browse files Browse the repository at this point in the history
It appears to be (nearly?) imposssible to provide an out-of-the-box Netlify
experience for Hydrogen sites that use Remix Vite. We may be able to
solve for this at some point, but for now our only option is to expect
these sites to contain a `server.ts` (or similar) file at the root. This
is what Hydrogen templates do and what the Netlify Hydrogen template
will do.

There may be value in supporting this for Remix sites as well, but I
haven't heard of a use case so I didn't bother supporting it here.
  • Loading branch information
serhalp committed Aug 30, 2024
1 parent fcce736 commit e1ffb39
Show file tree
Hide file tree
Showing 108 changed files with 10,641 additions and 5 deletions.
49 changes: 44 additions & 5 deletions packages/remix-edge-adapter/src/vite/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Plugin, ResolvedConfig } from 'vite'
import { writeFile, mkdir, readdir } from 'node:fs/promises'
import { writeFile, mkdir, readdir, access } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { sep as posixSep } from 'node:path/posix'
import { version, name } from '../../package.json'
Expand Down Expand Up @@ -44,6 +44,33 @@ function generateEdgeFunction(handlerPath: string, exclude: Array<string> = [])
};`
}

// Note: these are checked in order. The first match is used.
const ALLOWED_USER_EDGE_FUNCTION_HANDLER_FILENAMES = [
'server.ts',
'server.mts',
'server.cts',
'server.mjs',
'server.cjs',
'server.js',
]
const findUserEdgeFunctionHandlerFile = async (root: string) => {
for (const filename of ALLOWED_USER_EDGE_FUNCTION_HANDLER_FILENAMES) {
try {
await access(join(root, filename))
return filename
} catch {}
}

throw new Error(
'Your Hydrogen site must include a `server.ts` (or js/mjs/cjs/mts/cts) file at the root to deploy to Netlify. See https://github.com/netlify/hydrogen-template.',
)
}

const getEdgeFunctionHandlerModuleId = async (root: string, isHydrogenSite: boolean) => {
if (!isHydrogenSite) return EDGE_FUNCTION_HANDLER_MODULE_ID
return findUserEdgeFunctionHandlerFile(root)
}

export function netlifyPlugin(): Plugin {
let resolvedConfig: ResolvedConfig
let currentCommand: string
Expand All @@ -63,6 +90,16 @@ export function netlifyPlugin(): Plugin {
// Only externalize Node builtins
noExternal: /^(?!node:).*$/,
}
}
}
},
configResolved: {
order: 'pre',
async handler(config) {
resolvedConfig = config
const isHydrogenSite = resolvedConfig.plugins.find((plugin) => plugin.name === 'hydrogen:main') != null

if (currentCommand === 'build' && isSsr) {
// We need to add an extra entrypoint, as we need to compile
// the server entrypoint too. This is because it uses virtual
// modules. It also avoids the faff of dealing with npm modules in Deno.
Expand All @@ -73,6 +110,11 @@ export function netlifyPlugin(): Plugin {
// 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') {
const edgeFunctionHandlerModuleId = await getEdgeFunctionHandlerModuleId(
resolvedConfig.root,
isHydrogenSite,
)

config.build.rollupOptions.input = {
[EDGE_FUNCTION_HANDLER_CHUNK]: edgeFunctionHandlerModuleId,
index: config.build.rollupOptions.input,
Expand All @@ -82,10 +124,7 @@ export function netlifyPlugin(): Plugin {
}
}
}
}
},
async configResolved(config) {
resolvedConfig = config
},
},

resolveId: {
Expand Down
12 changes: 12 additions & 0 deletions tests/e2e/fixtures/hydrogen-vite-site-no-entrypoint/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules
/.cache
/build
/dist
/public/build
/.mf
.env
.env.*
.shopify

# Local Netlify folder
.netlify
24 changes: 24 additions & 0 deletions tests/e2e/fixtures/hydrogen-vite-site-no-entrypoint/.graphqlrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getSchema } from '@shopify/hydrogen-codegen'

/**
* GraphQL Config
* @see https://the-guild.dev/graphql/config/docs/user/usage
* @type {IGraphQLConfig}
*/
export default {
projects: {
default: {
schema: getSchema('storefront'),
documents: ['./*.{ts,tsx,js,jsx}', './app/**/*.{ts,tsx,js,jsx}', '!./app/graphql/**/*.{ts,tsx,js,jsx}'],
},

customer: {
schema: getSchema('customer-account'),
documents: ['./app/graphql/customer-account/*.{ts,tsx,js,jsx}'],
},

// Add your own GraphQL projects here for CMS, Shopify Admin API, etc.
},
}

/** @typedef {import('graphql-config').IGraphQLConfig} IGraphQLConfig */
52 changes: 52 additions & 0 deletions tests/e2e/fixtures/hydrogen-vite-site-no-entrypoint/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Hydrogen template: Skeleton

Hydrogen is Shopify’s stack for headless commerce. Hydrogen is designed to dovetail with [Remix](https://remix.run/),
Shopify’s full stack web framework. This template contains a **minimal setup** of components, queries and tooling to get
started with Hydrogen.

[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/hydrogen-template#SESSION_SECRET=mock%20token&PUBLIC_STORE_DOMAIN=mock.shop)

- [Check out Hydrogen docs](https://shopify.dev/custom-storefronts/hydrogen)
- [Get familiar with Remix](https://remix.run/docs/)

## What's included

- Remix 2
- Hydrogen
- Shopify CLI
- ESLint
- Prettier
- GraphQL generator
- TypeScript and JavaScript flavors
- Minimal setup of components and routes

## Getting started

**Requirements:**

- Node.js version 18.0.0 or higher
- Netlify CLI 17.0.0 or higher

```bash
npm install -g netlify-cli@latest
```

[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/hydrogen-template#SESSION_SECRET=mock%20token&PUBLIC_STORE_DOMAIN=mock.shop)

To create a new project, either click the "Deploy to Netlify" button above, or run the following command:

```bash
npx create-remix@latest --template=netlify/hydrogen-template
```

## Local development

```bash
npm run dev
```

## Building for production

```bash
npm run build
```
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { CartForm, type OptimisticCartLineInput } from '@shopify/hydrogen'

export function AddToCartButton({
analytics,
children,
disabled,
lines,
onClick,
}: {
analytics?: unknown
children: React.ReactNode
disabled?: boolean
lines: Array<OptimisticCartLineInput>
onClick?: () => void
}) {
return (
<CartForm route="/cart" inputs={{ lines }} action={CartForm.ACTIONS.LinesAdd}>
{(fetcher: FetcherWithComponents<any>) => (
<>
<input name="analytics" type="hidden" value={JSON.stringify(analytics)} />
<button type="submit" onClick={onClick} disabled={disabled ?? fetcher.state !== 'idle'}>
{children}
</button>
</>
)}
</CartForm>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createContext, type ReactNode, useContext, useState } from 'react'

type AsideType = 'search' | 'cart' | 'mobile' | 'closed'
type AsideContextValue = {
type: AsideType
open: (mode: AsideType) => void
close: () => void
}

/**
* A side bar component with Overlay
* @example
* ```jsx
* <Aside type="search" heading="SEARCH">
* <input type="search" />
* ...
* </Aside>
* ```
*/
export function Aside({
children,
heading,
type,
}: {
children?: React.ReactNode
type: AsideType
heading: React.ReactNode
}) {
const { type: activeType, close } = useAside()
const expanded = type === activeType

return (
<div aria-modal className={`overlay ${expanded ? 'expanded' : ''}`} role="dialog">
<button className="close-outside" onClick={close} />
<aside>
<header>
<h3>{heading}</h3>
<button className="close reset" onClick={close}>
&times;
</button>
</header>
<main>{children}</main>
</aside>
</div>
)
}

const AsideContext = createContext<AsideContextValue | null>(null)

Aside.Provider = function AsideProvider({ children }: { children: ReactNode }) {
const [type, setType] = useState<AsideType>('closed')

return (
<AsideContext.Provider
value={{
type,
open: setType,
close: () => setType('closed'),
}}
>
{children}
</AsideContext.Provider>
)
}

export function useAside() {
const aside = useContext(AsideContext)
if (!aside) {
throw new Error('useAside must be used within an AsideProvider')
}
return aside
}
Loading

0 comments on commit e1ffb39

Please sign in to comment.