Skip to content

Add --localhost-port to port for --use-localhost flag #5630

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

Merged
merged 12 commits into from
Apr 23, 2025
10 changes: 10 additions & 0 deletions .changeset/clever-dogs-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@shopify/app': minor
---

Improved how port selection works when using localhost development

Added a `--localhost-port` flag. Use this to specify that you want to develop using localhost on a specific port. For example: `shopify app dev --localhost-port=4000`

`shopify app dev --use-localhost` will always try to use port 3458. If port 3458 is not available the CLI will warn the user and select a different port.

57 changes: 15 additions & 42 deletions packages/app/src/cli/commands/app/dev.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import {appFlags} from '../../flags.js'
import {dev, DevOptions, TunnelMode} from '../../services/dev.js'
import {dev, DevOptions} from '../../services/dev.js'
import {showApiKeyDeprecationWarning} from '../../prompts/deprecation-warnings.js'
import {checkFolderIsValidApp} from '../../models/app/loader.js'
import AppCommand, {AppCommandOutput} from '../../utilities/app-command.js'
import {linkedAppContext} from '../../services/app-context.js'
import {storeContext} from '../../services/store-context.js'
import {generateCertificate} from '../../utilities/mkcert.js'
import {generateCertificatePrompt} from '../../prompts/dev.js'
import {getTunnelMode} from '../../services/dev/tunnel-mode.js'
import {Flags} from '@oclif/core'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {addPublicMetadata} from '@shopify/cli-kit/node/metadata'
import {renderInfo} from '@shopify/cli-kit/node/ui'

export default class Dev extends AppCommand {
static summary = 'Run the app.'
Expand Down Expand Up @@ -91,11 +89,16 @@ If you're using the Ruby app template, then you need to complete the following s
'use-localhost': Flags.boolean({
hidden: true,
description:
"Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not Webhooks, Flow Action, App Proxy or POS",
"Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing since the previous list was non exhaustive. This new appraoch matches the approach we take elsewhere

env: 'SHOPIFY_FLAG_USE_LOCALHOST',
default: false,
exclusive: ['tunnel-url'],
}),
'localhost-port': Flags.integer({
hidden: true,
description: 'Port to use for localhost.',
env: 'SHOPIFY_FLAG_LOCALHOST_PORT',
}),
theme: Flags.string({
hidden: false,
char: 't',
Expand Down Expand Up @@ -139,46 +142,17 @@ If you're using the Ruby app template, then you need to complete the following s
await showApiKeyDeprecationWarning()
}

let tunnel: TunnelMode = {mode: 'auto'}
let tunnelType = 'cloudflare'

if (flags['use-localhost']) {
tunnelType = 'use-localhost'
tunnel = {
mode: 'no-tunnel-use-localhost',
provideCertificate: async (appDirectory) => {
renderInfo({
headline: 'Localhost-based development is in developer preview.',
body: [
'`--use-localhost` is not compatible with Shopify features which directly invoke your app',
'(such as Webhooks, App proxy, and Flow actions), or those which require testing your app from another',
'device (such as POS). Please report any issues and provide feedback on the dev community:',
],
link: {
label: 'Create a feedback post',
url: 'https://community.shopify.dev/new-topic?category=shopify-cli-libraries&tags=app-dev-on-localhost',
},
})

return generateCertificate({
appDirectory,
onRequiresConfirmation: generateCertificatePrompt,
})
},
}
} else if (flags['tunnel-url']) {
tunnelType = 'custom'
tunnel = {
mode: 'provided',
url: flags['tunnel-url'],
}
}
const tunnelMode = await getTunnelMode({
useLocalhost: flags['use-localhost'],
tunnelUrl: flags['tunnel-url'],
localhostPort: flags['localhost-port'],
})

await addPublicMetadata(() => {
return {
cmd_app_dependency_installation_skipped: flags['skip-dependencies-installation'],
cmd_app_reset_used: flags.reset,
cmd_dev_tunnel_type: tunnelType,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed this value has not changed:

On this branch branch

pnpm shopify app dev --path='../app-test-use-localhost'
auto

pnpm shopify app dev --path='../app-test-use-localhost' --use-localhost
use-localhost

pnpm shopify app dev --path='../app-test-use-localhost' --tunnel-url="http://my-tunnel.com:3000"
custom

On main

pnpm shopify app dev --path='../app-test-use-localhost'
auto

pnpm shopify app dev --path='../app-test-use-localhost' --use-localhost
use-localhost

pnpm shopify app dev --path='../app-test-use-localhost' --tunnel-url="http://my-tunnel.com:3000"
custom

cmd_dev_tunnel_type: tunnelMode.mode,
}
})

Expand All @@ -190,7 +164,6 @@ If you're using the Ruby app template, then you need to complete the following s
forceRelink: flags.reset,
userProvidedConfigName: flags.config,
})

const store = await storeContext({
appContextResult,
storeFqdn: flags.store,
Expand All @@ -211,7 +184,7 @@ If you're using the Ruby app template, then you need to complete the following s
notify: flags.notify,
graphiqlPort: flags['graphiql-port'],
graphiqlKey: flags['graphiql-key'],
tunnel,
tunnel: tunnelMode,
}

await dev(devOptions)
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/cli/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const blocks = {

export const ports = {
graphiql: 3457,
localhost: 3458,
} as const

export const EsbuildEnvVarRegex = /^([a-zA-Z_$])([a-zA-Z0-9_$])*$/
2 changes: 2 additions & 0 deletions packages/app/src/cli/services/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {describe, expect, test, vi} from 'vitest'
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'

vi.mock('./dev/fetch.js')
vi.mock('@shopify/cli-kit/node/tcp')
vi.mock('../utilities/mkcert.js')

describe('developerPreviewController', () => {
test('does not refresh the tokens when they are still valid', async () => {
Expand Down
86 changes: 43 additions & 43 deletions packages/app/src/cli/services/dev.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ApplicationURLs,
FrontendURLOptions,
generateApplicationURLs,
generateFrontendURL,
getURLs,
Expand All @@ -25,14 +26,16 @@ import {canEnablePreviewMode} from './extensions/common.js'
import {fetchAppRemoteConfiguration} from './app/select-app.js'
import {patchAppConfigurationFile} from './app/patch-app-configuration-file.js'
import {DevSessionStatusManager} from './dev/processes/dev-session/dev-session-status-manager.js'
import {TunnelMode} from './dev/tunnel-mode.js'
import {PortDetail, renderPortWarnings} from './dev/port-warnings.js'
import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js'
import {Web, isCurrentAppSchema, getAppScopesArray, AppLinkedInterface} from '../models/app/app.js'
import {Organization, OrganizationApp, OrganizationStore} from '../models/organization.js'
import {getAnalyticsTunnelType} from '../utilities/analytics.js'
import {ports} from '../constants.js'
import metadata from '../metadata.js'
import {AppConfigurationUsedByCli} from '../models/extensions/specifications/types/app_config.js'
import {RemoteAwareExtensionSpecification} from '../models/extensions/specification.js'
import {ports} from '../constants.js'
import {Config} from '@oclif/core'
import {performActionWithRetryAfterRecovery} from '@shopify/cli-kit/common/retry'
import {AbortController} from '@shopify/cli-kit/node/abort'
Expand All @@ -46,22 +49,6 @@ import {OutputProcess, formatPackageManagerCommand, outputDebug} from '@shopify/
import {hashString} from '@shopify/cli-kit/node/crypto'
import {AbortError} from '@shopify/cli-kit/node/error'

interface NoTunnel {
mode: 'no-tunnel-use-localhost'
provideCertificate: (appDirectory: string) => Promise<{keyContent: string; certContent: string; certPath: string}>
}

interface AutoTunnel {
mode: 'auto'
}

interface TunnelProvided {
mode: 'provided'
url: string
}

export type TunnelMode = NoTunnel | AutoTunnel | TunnelProvided

export interface DevOptions {
app: AppLinkedInterface
remoteApp: OrganizationApp
Expand Down Expand Up @@ -92,12 +79,13 @@ export async function dev(commandOptions: DevOptions) {
}

async function prepareForDev(commandOptions: DevOptions): Promise<DevConfig> {
const {app, remoteApp, developerPlatformClient, store, specifications} = commandOptions
const {app, remoteApp, developerPlatformClient, store, specifications, tunnel} = commandOptions

// Be optimistic about tunnel creation and do it as early as possible
const tunnelPort = await getAvailableTCPPort()
let tunnelClient: TunnelClient | undefined
if (commandOptions.tunnel.mode === 'auto') {

if (tunnel.mode === 'auto') {
const tunnelPort = await getAvailableTCPPort()
tunnelClient = await startTunnelPlugin(commandOptions.commandConfig, tunnelPort, 'cloudflare')
}

Expand Down Expand Up @@ -133,28 +121,31 @@ async function prepareForDev(commandOptions: DevOptions): Promise<DevConfig> {
}

const graphiqlPort = commandOptions.graphiqlPort ?? (await getAvailableTCPPort(ports.graphiql))
const {graphiqlKey} = commandOptions

if (graphiqlPort !== (commandOptions.graphiqlPort ?? ports.graphiql)) {
renderWarning({
headline: [
'A random port will be used for GraphiQL because',
{command: `${ports.graphiql}`},
'is not available.',
],
body: [
'If you want to keep your session in GraphiQL, you can choose a different one by setting the',
{command: '--graphiql-port'},
'flag.',
],
const portDetails: PortDetail[] = [
{
for: 'GraphiQL',
flagToRemedy: '--graphiql-port',
requested: commandOptions.graphiqlPort ?? ports.graphiql,
actual: graphiqlPort,
},
]

if (tunnel.mode === 'use-localhost') {
portDetails.push({
for: 'localhost',
flagToRemedy: '--localhost-port',
requested: tunnel.requestedPort,
actual: tunnel.actualPort,
})
}

renderPortWarnings(portDetails)

const {webs, ...network} = await setupNetworkingOptions(
app.directory,
app.webs,
graphiqlPort,
commandOptions.tunnel,
tunnel,
tunnelClient,
remoteApp.configuration,
)
Expand Down Expand Up @@ -186,7 +177,7 @@ async function prepareForDev(commandOptions: DevOptions): Promise<DevConfig> {
network,
partnerUrlsUpdated,
graphiqlPort,
graphiqlKey,
graphiqlKey: commandOptions.graphiqlKey,
}
}

Expand Down Expand Up @@ -319,14 +310,22 @@ async function setupNetworkingOptions(

await validateCustomPorts(webs, graphiqlPort)

const frontendUrlOptions: FrontendURLOptions =
tunnelOptions.mode === 'use-localhost'
? {
noTunnelUseLocalhost: true,
port: tunnelOptions.actualPort,
}
: {
noTunnelUseLocalhost: false,
tunnelUrl: tunnelOptions.mode === 'custom' ? tunnelOptions.url : undefined,
tunnelClient,
}

// generateFrontendURL still uses the old naming of frontendUrl and frontendPort,
// we can rename them to proxyUrl and proxyPort when we delete dev.ts
const [{frontendUrl, frontendPort: proxyPort, usingLocalhost}, backendPort, currentUrls] = await Promise.all([
generateFrontendURL({
noTunnelUseLocalhost: tunnelOptions.mode === 'no-tunnel-use-localhost',
tunnelUrl: tunnelOptions.mode === 'provided' ? tunnelOptions.url : undefined,
tunnelClient,
}),
generateFrontendURL(frontendUrlOptions),
getBackendPort() ?? backendConfig?.configuration.port ?? getAvailableTCPPort(),
getURLs(remoteAppConfig),
])
Expand All @@ -342,12 +341,13 @@ async function setupNetworkingOptions(
frontendPort = frontendPort ?? (await getAvailableTCPPort())

let reverseProxyCert
if (tunnelOptions.mode === 'no-tunnel-use-localhost') {
if (tunnelOptions.mode === 'use-localhost') {
const {keyContent, certContent, certPath} = await tunnelOptions.provideCertificate(appDirectory)
reverseProxyCert = {
key: keyContent,
cert: certContent,
certPath,
port: tunnelOptions.actualPort,
}
}

Expand Down Expand Up @@ -514,7 +514,7 @@ async function validateCustomPorts(webConfigs: Web[], graphiqlPort: number) {
const portAvailable = await checkPortAvailability(graphiqlPort)
if (!portAvailable) {
const errorMessage = `Port ${graphiqlPort} is not available for serving GraphiQL.`
const tryMessage = ['Choose a different port by setting the', {command: '--graphiql-port'}, 'flag.']
const tryMessage = ['Choose a different port for the', {command: '--graphiql-port'}, 'flag.']
throw new AbortError(errorMessage, tryMessage)
}
})(),
Expand Down
Loading
Loading