Skip to content

Commit

Permalink
Tunnel support via @shopify/plugin-cloudflare (#154)
Browse files Browse the repository at this point in the history
  • Loading branch information
montalvomiguelo authored Nov 6, 2024
1 parent a94abe8 commit dbeb35d
Show file tree
Hide file tree
Showing 15 changed files with 2,466 additions and 123 deletions.
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

0 comments on commit dbeb35d

Please sign in to comment.