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

Tunnel support via @shopify/plugin-cloudflare #154

Merged
merged 21 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@ module.exports = {
'./examples/*/tsconfig.json',
'./preset/tsconfig.json'
]
},
"rules": {
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/consistent-type-assertions": "off"
}
}
7 changes: 7 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,10 @@ Specifies the file name of the snippet that loads your assets.
- **Default:** `false`

Specifies whether to append version numbers to your production-ready asset URLs in [`snippetFile`](/guide/configuration.html#snippetfile).

## tunnel

- **Type:** `boolean | string`
- **Default:** `false`

Enables the creation of Cloudflare tunnels during dev, allowing previews from any device.
21 changes: 21 additions & 0 deletions docs/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,24 @@ export default {
]
}
```

## Use ngrok for tunneling during theme development

If you are experiencing Cloudflare tunnel errors with the Shopify Vite Plugin, you can use ngrok as a workaround.
First, create an ngrok account and install the ngrok CLI, then follow their instructions to set up your access token.
Next, run the command `ngrok http 3000` (or any other port number you prefer) and take note of the URL
provided by ngrok, which ends with `ngrok-free.app`. Keep ngrok running. Finally, configure the plugin.

::: code-group

```js [vite.config.js]
import shopify from 'vite-plugin-shopify'

export default {
plugins: [
shopify({
tunnel: 'https://123abc.ngrok-free.app:3000' // [!code ++]
})
]
}
```
10 changes: 3 additions & 7 deletions examples/vite-shopify-example/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { defineConfig } from 'vite'
import shopify from 'vite-plugin-shopify'
import pageReload from 'vite-plugin-page-reload'
import basicSsl from '@vitejs/plugin-basic-ssl'
// import basicSsl from '@vitejs/plugin-basic-ssl'
import { resolve } from 'node:path'

export default defineConfig({
server: {
host: true,
https: true,
port: 3000
},
publicDir: 'public',
resolve: {
alias: {
Expand All @@ -18,8 +13,8 @@ export default defineConfig({
}
},
plugins: [
basicSsl(),
shopify({
tunnel: true,
snippetFile: 'vite.liquid',
additionalEntrypoints: [
'frontend/foo.ts', // relative to sourceCodeDir
Expand All @@ -31,6 +26,7 @@ export default defineConfig({
pageReload('/tmp/theme.update', {
delay: 2000
})
// basicSsl()
],
build: {
sourcemap: true
Expand Down
4 changes: 3 additions & 1 deletion packages/vite-plugin-shopify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ export default {
// Specifies the file name of the snippet that loads your assets
snippetFile: 'vite-tag.liquid',
// Specifies whether to append version numbers to your production-ready asset URLs in `snippetFile`
versionNumbers: false
versionNumbers: false,
// Enables the creation of Cloudflare tunnels during dev, allowing previews from any device
tunnel: false
})
]
}
Expand Down
2 changes: 2 additions & 0 deletions packages/vite-plugin-shopify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"vite": "^5.0.0"
},
"dependencies": {
"@shopify/cli-kit": "^3.67.2",
"@shopify/plugin-cloudflare": "^3.67.2",
"debug": "^4.3.4",
"fast-glob": "^3.2.11"
},
Expand Down
8 changes: 2 additions & 6 deletions packages/vite-plugin-shopify/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export default function shopifyConfig (options: Required<Options>): Plugin {
const port = config.server?.port ?? 5173
const https = config.server?.https
const origin = config.server?.origin ?? '__shopify_vite_placeholder__'
const socketProtocol = https === undefined ? 'ws' : 'wss'
const defaultAliases: Record<string, string> = {
'~': path.resolve(options.sourceCodeDir),
'@': path.resolve(options.sourceCodeDir)
Expand Down Expand Up @@ -48,7 +47,7 @@ export default function shopifyConfig (options: Required<Options>): Plugin {
// Provide import alias to source code dir for convenience
alias: Array.isArray(config.resolve?.alias)
? [
...config.resolve?.alias ?? [],
...(config.resolve?.alias ?? []),
...Object.keys(defaultAliases).map(alias => ({
find: alias,
replacement: defaultAliases[alias]
Expand All @@ -67,10 +66,7 @@ export default function shopifyConfig (options: Required<Options>): Plugin {
hmr: config.server?.hmr === false
? false
: {
host: typeof host === 'string' ? host : undefined,
port,
protocol: socketProtocol,
...config.server?.hmr === true ? {} : config.server?.hmr
...(config.server?.hmr === true ? {} : config.server?.hmr)
}
}
}
Expand Down
124 changes: 114 additions & 10 deletions packages/vite-plugin-shopify/src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,26 @@ import path from 'node:path'
import { AddressInfo } from 'node:net'
import { Manifest, Plugin, ResolvedConfig, normalizePath } from 'vite'
import createDebugger from 'debug'
import startTunnel from '@shopify/plugin-cloudflare/hooks/tunnel'
import { renderInfo, isTTY } from '@shopify/cli-kit/node/ui'

import { CSS_EXTENSIONS_REGEX, KNOWN_CSS_EXTENSIONS } from './constants'
import type { Options, DevServerUrl } from './types'
import type { Options, DevServerUrl, FrontendURLResult } from './types'
import type { TunnelClient } from '@shopify/cli-kit/node/plugins/tunnel'

const debug = createDebugger('vite-plugin-shopify:html')

// Plugin for generating vite-tag liquid theme snippet with entry points for JS and CSS assets
export default function shopifyHTML (options: Required<Options>): Plugin {
let config: ResolvedConfig
let viteDevServerUrl: DevServerUrl
let tunnelClient: TunnelClient | undefined
let tunnelUrl: string | undefined

const viteTagSnippetPath = path.resolve(options.themeRoot, `snippets/${options.snippetFile}`)
const viteTagSnippetName = options.snippetFile.replace(/\.[^.]+$/, '')
const viteTagSnippetPrefix = (config: ResolvedConfig): string =>
viteTagDisclaimer + viteTagEntryPath(config.resolve.alias, options.entrypointsDir, viteTagSnippetName)

return {
name: 'vite-plugin-shopify-html',
Expand All @@ -26,29 +33,73 @@ export default function shopifyHTML (options: Required<Options>): Plugin {
},
transform (code) {
if (config.command === 'serve') {
return code.replace(/__shopify_vite_placeholder__/g, viteDevServerUrl)
return code.replace(/__shopify_vite_placeholder__/g, tunnelUrl ?? viteDevServerUrl)
}
},
configureServer ({ config, middlewares, httpServer }) {
const tunnelConfig = resolveTunnelConfig(options)

if (tunnelConfig.frontendPort !== -1) {
config.server.port = tunnelConfig.frontendPort
}

httpServer?.once('listening', () => {
const address = httpServer?.address()

const isAddressInfo = (x: string | AddressInfo | null | undefined): x is AddressInfo => typeof x === 'object'

if (isAddressInfo(address)) {
viteDevServerUrl = resolveDevServerUrl(address, config)
const reactPlugin = config.plugins.find(plugin =>
plugin.name === 'vite:react-babel' || plugin.name === 'vite:react-refresh'
)

debug({ address, viteDevServerUrl })
debug({ address, viteDevServerUrl, tunnelConfig })

const reactPlugin = config.plugins.find(plugin => plugin.name === 'vite:react-babel' || plugin.name === 'vite:react-refresh')
setTimeout(() => {
void (async (): Promise<void> => {
if (options.tunnel === false) {
return
}

const viteTagSnippetContent = viteTagDisclaimer + viteTagEntryPath(config.resolve.alias, options.entrypointsDir, viteTagSnippetName) + viteTagSnippetDev(viteDevServerUrl, options.entrypointsDir, reactPlugin)
if (tunnelConfig.frontendUrl !== '') {
tunnelUrl = tunnelConfig.frontendUrl
isTTY() && renderInfo({ body: `${viteDevServerUrl} is tunneled to ${tunnelUrl}` })
return
}

const hook = await startTunnel({
config: null,
provider: 'cloudflare',
port: address.port
})
tunnelClient = hook.valueOrAbort()
tunnelUrl = await pollTunnelUrl(tunnelClient)
isTTY() && renderInfo({ body: `${viteDevServerUrl} is tunneled to ${tunnelUrl}` })
const viteTagSnippetContent = viteTagSnippetPrefix(config) + viteTagSnippetDev(
tunnelUrl, options.entrypointsDir, reactPlugin
)

// Write vite-tag with a Cloudflare Tunnel URL
fs.writeFileSync(viteTagSnippetPath, viteTagSnippetContent)
})()
}, 100)

const viteTagSnippetContent = viteTagSnippetPrefix(config) + viteTagSnippetDev(
tunnelConfig.frontendUrl !== ''
? tunnelConfig.frontendUrl
: viteDevServerUrl, options.entrypointsDir, reactPlugin
)

// Write vite-tag snippet for development server
fs.writeFileSync(viteTagSnippetPath, viteTagSnippetContent)
}
})

httpServer?.on('close', () => {
tunnelClient?.stopTunnel()
})

// Serve the dev-server-index.html page
return () => middlewares.use((req, res, next) => {
if (req.url === '/index.html') {
Expand Down Expand Up @@ -133,7 +184,7 @@ export default function shopifyHTML (options: Required<Options>): Plugin {
}
})

const viteTagSnippetContent = viteTagDisclaimer + viteTagEntryPath(config.resolve.alias, options.entrypointsDir, viteTagSnippetName) + assetTags.join('\n') + '\n{% endif %}\n'
const viteTagSnippetContent = viteTagSnippetPrefix(config) + assetTags.join('\n') + '\n{% endif %}\n'

// Write vite-tag snippet for production build
fs.writeFileSync(viteTagSnippetPath, viteTagSnippetContent)
Expand Down Expand Up @@ -204,8 +255,8 @@ const viteTagSnippetDev = (assetHost: string, entrypointsDir: string, reactPlugi
assign is_css = true
endif
%}${reactPlugin === undefined
? ''
: `
? ''
: `
<script src="${assetHost}/@id/__x00__vite-plugin-shopify:react-refresh" type="module"></script>`}
<script src="${assetHost}/@vite/client" type="module"></script>
{% if is_css == true %}
Expand All @@ -220,8 +271,8 @@ const viteTagSnippetDev = (assetHost: string, entrypointsDir: string, reactPlugi
*/
function resolveDevServerUrl (address: AddressInfo, config: ResolvedConfig): DevServerUrl {
const configHmrProtocol = typeof config.server.hmr === 'object' ? config.server.hmr.protocol : null
const clientProtocol = configHmrProtocol !== null ? (configHmrProtocol === 'wss' ? 'https' : 'http') : null
const serverProtocol = config.server.https !== undefined ? 'https' : 'http'
const clientProtocol = configHmrProtocol ? (configHmrProtocol === 'wss' ? 'https' : 'http') : null
const serverProtocol = config.server.https ? 'https' : 'http'
const protocol = clientProtocol ?? serverProtocol

const configHmrHost = typeof config.server.hmr === 'object' ? config.server.hmr.host : null
Expand All @@ -243,3 +294,56 @@ function isIpv6 (address: AddressInfo): boolean {
// @ts-expect-error-next-line
address.family === 6
}

function resolveTunnelConfig (options: Required<Options>): FrontendURLResult {
let frontendPort = -1
let frontendUrl = ''
let usingLocalhost = false

if (options.tunnel === false) {
usingLocalhost = true
return { frontendUrl, frontendPort, usingLocalhost }
}

if (options.tunnel === true) {
return { frontendUrl, frontendPort, usingLocalhost }
}

const matches = options.tunnel.match(/(https:\/\/[^:]+):([0-9]+)/)
if (matches === null) {
throw new Error(`Invalid tunnel URL: ${options.tunnel}`)
}
frontendPort = Number(matches[2])
frontendUrl = matches[1]
return { frontendUrl, frontendPort, usingLocalhost }
}

/**
* Poll the tunnel provider every 0.5 until an URL or error is returned.
*/
async function pollTunnelUrl (tunnelClient: TunnelClient): Promise<string> {
return await new Promise<string>((resolve, reject) => {
let retries = 0
const pollTunnelStatus = async (): Promise<void> => {
const result = tunnelClient.getTunnelStatus()
debug(`Polling tunnel status for ${tunnelClient.provider} (attempt ${retries}): ${result.status}`)
if (result.status === 'error') {
return reject(result.message) // Changed AbortError to standard Error
}
if (result.status === 'connected') {
resolve(result.url)
} else {
retries += 1
startPolling()
}
}

const startPolling = (): void => {
setTimeout(() => {
void pollTunnelStatus()
}, 500)
}

void pollTunnelStatus()
})
}
4 changes: 3 additions & 1 deletion packages/vite-plugin-shopify/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ export const resolveOptions = (
const additionalEntrypoints = options.additionalEntrypoints ?? []
const snippetFile = options.snippetFile ?? 'vite-tag.liquid'
const versionNumbers = options.versionNumbers ?? false
const tunnel = options.tunnel ?? false

return {
themeRoot,
sourceCodeDir,
entrypointsDir,
additionalEntrypoints,
snippetFile,
versionNumbers
versionNumbers,
tunnel
}
}
13 changes: 13 additions & 0 deletions packages/vite-plugin-shopify/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ export interface Options {
* @default false
*/
versionNumbers?: boolean

/**
* Enables the creation of Cloudflare tunnels during dev, allowing previews from any device.
*
* @default false
*/
tunnel?: boolean | string
}

export type DevServerUrl = `${'http' | 'https'}://${string}:${number}`

export interface FrontendURLResult {
frontendUrl: string
frontendPort: number
usingLocalhost: boolean
}
Loading
Loading