Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/lucky-buckets-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@shopify/cli-hydrogen': patch
---

Add deploy flags and environment variables for configuring Oxygen's client assets directory and worker directory. The worker directory must contain an `index.js` or `index.mjs` entry point.

Deployments now resolve output directories from explicit flags, Vite output directories, then `dist/client` and `dist/server` fallbacks. Custom output deployments and `0.0.0-preview-*` Hydrogen versions default to `node --run build` instead of the Hydrogen build pipeline when no build command is provided.
9 changes: 9 additions & 0 deletions .changeset/soft-clouds-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@shopify/mini-oxygen': minor
---

- Make the Oxygen Vite plugin self-sufficient by inferring a compatibility date from the resolved Hydrogen package when one is not provided.
- Worker-specific resolve conditions now apply only to the SSR worker environment so browser/client modules continue using Vite's normal client conditions.
- `oxygen.json` is now emitted only during SSR builds.
- The Oxygen Vite plugin now loads Vite environment variables as Mini Oxygen bindings when no env bindings are provided by plugin options or the Hydrogen CLI.
- `vite preview` can now run the built Oxygen worker in Mini Oxygen, with an optional `previewEntry` override for custom worker output paths.
20 changes: 19 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -388,13 +388,31 @@
"type": "option"
},
"build-command": {
"description": "Specify a build command to run before deploying. If not specified, `shopify hydrogen build` will be used.",
"description": "Specify a build command to run before deploying. If not specified, the Hydrogen build pipeline will be used. When custom output directories are configured, defaults to `node --run build`.",
"name": "build-command",
"required": false,
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"assets-dir": {
"description": "Directory containing the client assets to deploy, relative to the project root. Defaults to the detected Vite client output directory, then falls back to `dist/client`.",
"env": "SHOPIFY_HYDROGEN_FLAG_ASSETS_DIR",
"name": "assets-dir",
"required": false,
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"worker-dir": {
"description": "Directory containing the Oxygen worker entry point (`index.js` or `index.mjs`), relative to the project root. Defaults to the detected Vite server output directory, then falls back to `dist/server`.",
"env": "SHOPIFY_HYDROGEN_FLAG_WORKER_DIR",
"name": "worker-dir",
"required": false,
"hasDynamicHelp": false,
"multiple": false,
"type": "option"
},
"lockfile-check": {
"description": "Checks that there is exactly one valid lockfile in the project. Defaults to `true`. Deactivate with `--no-lockfile-check`.",
"env": "SHOPIFY_HYDROGEN_FLAG_LOCKFILE_CHECK",
Expand Down
145 changes: 143 additions & 2 deletions packages/cli/src/commands/hydrogen/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import {
} from '@shopify/cli-kit/node/git';
import {createRequire} from 'node:module';

import {deploymentLogger, getHydrogenVersion, runDeploy} from './deploy.js';
import {
deploymentLogger,
getHydrogenVersion,
resolveDeploymentOutputDirs,
runDeploy,
} from './deploy.js';
import {getOxygenDeploymentData} from '../../lib/get-oxygen-deployment-data.js';
import {execAsync} from '../../lib/process.js';
import {createEnvironmentCliChoiceLabel} from '../../lib/common.js';
Expand Down Expand Up @@ -159,6 +164,7 @@ describe('deploy', async () => {
};

beforeEach(async () => {
await createHydrogenDependencyPackageJson('2000.1.1');
process.exit = vi.fn() as any;
vi.mocked(login).mockResolvedValue({
session: ADMIN_SESSION,
Expand All @@ -182,7 +188,6 @@ describe('deploy', async () => {
oxygenDeploymentToken: 'some-encoded-token',
environments: [],
});

vi.mocked(parseToken).mockReturnValue(mockToken);
});

Expand Down Expand Up @@ -211,6 +216,95 @@ describe('deploy', async () => {
expect(vi.mocked(renderSuccess)).toHaveBeenCalled;
});

it('calls createDeploy with configured deploy output paths', async () => {
const {buildFunction: _, ...hooks} = expectedHooks;

await runDeploy({
...deployParams,
assetsDir: 'custom/client',
workerDir: 'custom/server',
});

expect(vi.mocked(createDeploy)).toHaveBeenCalledWith({
config: {
...expectedConfig,
assetsDir: 'custom/client',
workerDir: 'custom/server',
buildCommand: 'node --run build',
},
hooks,
logger: deploymentLogger,
});
expect(vi.mocked(runBuild)).not.toHaveBeenCalled();
});

describe('resolveDeploymentOutputDirs', () => {
const root = '/project';

it('uses Vite output dirs when available', async () => {
await expect(
resolveDeploymentOutputDirs({
root,
viteConfig: {
clientOutDir: '/project/vite/client',
serverOutDir: '/project/vite/server',
},
}),
).resolves.toEqual({
assetsDir: 'vite/client',
workerDir: 'vite/server',
});
});

it('uses configured output paths before Vite output dirs', async () => {
await expect(
resolveDeploymentOutputDirs({
root,
viteConfig: {
clientOutDir: '/project/vite/client',
serverOutDir: '/project/vite/server',
},
assetsDir: 'custom/client',
workerDir: 'custom/server',
}),
).resolves.toEqual({
assetsDir: 'custom/client',
workerDir: 'custom/server',
});
});

it('uses configured output paths before fallback output', async () => {
await expect(
resolveDeploymentOutputDirs({
root,
assetsDir: 'custom/client',
}),
).resolves.toEqual({
assetsDir: 'custom/client',
workerDir: 'dist/server',
});
});

it('falls back to dist output when Vite output is unavailable', async () => {
await expect(resolveDeploymentOutputDirs({root})).resolves.toEqual({
assetsDir: 'dist/client',
workerDir: 'dist/server',
});
});

it('uses configured worker dirs as directories', async () => {
await expect(
resolveDeploymentOutputDirs({
root,
workerDir: 'custom/server',
}),
).resolves.toEqual({
assetsDir: 'dist/client',
workerDir: 'custom/server',
});
});
});

it('calls createDeploy with overridden variables in environment file', async () => {
vi.mocked(readAndParseDotEnv).mockResolvedValue({
path: 'fake-env-file',
Expand Down Expand Up @@ -678,6 +772,53 @@ describe('deploy', async () => {
});
});

it('uses the default build command for Hydrogen preview versions', async () => {
const hydrogenVersion = '0.0.0-preview-20260625000000';
await createHydrogenDependencyPackageJson(hydrogenVersion);

const {buildFunction: _, ...hooks} = expectedHooks;

await runDeploy(deployParams);

expect(vi.mocked(createDeploy)).toHaveBeenCalledWith({
config: {
...expectedConfig,
buildCommand: 'node --run build',
metadata: {
...expectedConfig.metadata,
hydrogenVersion,
},
},
hooks,
logger: deploymentLogger,
});
expect(vi.mocked(runBuild)).not.toHaveBeenCalled();
});

it('deploys custom app output with a custom build command and output directories', async () => {
const params = {
...deployParams,
buildCommand: 'custom-framework build',
assetsDir: 'xyz/client',
workerDir: 'xyz/server',
};
const {buildFunction: _, ...hooks} = expectedHooks;

await runDeploy(params);

expect(vi.mocked(createDeploy)).toHaveBeenCalledWith({
config: {
...expectedConfig,
assetsDir: 'xyz/client',
workerDir: 'xyz/server',
buildCommand: 'custom-framework build',
},
hooks,
logger: deploymentLogger,
});
expect(vi.mocked(runBuild)).not.toHaveBeenCalled();
});

it('writes a file with JSON content in CI environments', async () => {
vi.mocked(ciPlatform).mockReturnValue({
isCI: true,
Expand Down
96 changes: 83 additions & 13 deletions packages/cli/src/commands/hydrogen/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {packageManagers} from '../../lib/package-managers.js';
import {setupResourceCleanup} from '../../lib/resource-cleanup.js';

const DEPLOY_OUTPUT_FILE_HANDLE = 'h2_deploy_log.json';
const DEFAULT_BUILD_COMMAND = 'node --run build';

export const deploymentLogger: Logger = (
message: string,
Expand Down Expand Up @@ -118,7 +119,19 @@ export default class Deploy extends Command {
}),
'build-command': Flags.string({
description:
'Specify a build command to run before deploying. If not specified, `shopify hydrogen build` will be used.',
'Specify a build command to run before deploying. If not specified, the Hydrogen build pipeline will be used. When custom output directories are configured, defaults to `node --run build`.',
required: false,
}),
'assets-dir': Flags.string({
description:
'Directory containing the client assets to deploy, relative to the project root. Defaults to the detected Vite client output directory, then falls back to `dist/client`.',
env: 'SHOPIFY_HYDROGEN_FLAG_ASSETS_DIR',
required: false,
}),
'worker-dir': Flags.string({
description:
'Directory containing the Oxygen worker entry point (`index.js` or `index.mjs`), relative to the project root. Defaults to the detected Vite server output directory, then falls back to `dist/server`.',
env: 'SHOPIFY_HYDROGEN_FLAG_WORKER_DIR',
required: false,
}),
...commonFlags.lockfileCheck,
Expand Down Expand Up @@ -213,6 +226,8 @@ interface OxygenDeploymentOptions {
metadataUser?: string;
metadataVersion?: string;
entry?: string;
assetsDir?: string;
workerDir?: string;
}

interface GitCommit {
Expand Down Expand Up @@ -257,10 +272,12 @@ export async function runDeploy(
jsonOutput,
path: root,
shop,
assetsDir: assetsDirFlag,
metadataUrl,
metadataUser,
metadataVersion,
entry: ssrEntry,
workerDir: workerDirFlag,
} = options;
let {metadataDescription} = options;

Expand Down Expand Up @@ -465,18 +482,29 @@ export async function runDeploy(
let workerDir = 'dist/worker';

const isClassicCompiler = await isClassicProject(root);

if (!isClassicCompiler) {
const metadataHydrogenVersion = await getHydrogenVersion({appPath: root});
const shouldUseDefaultBuildCommand =
!buildCommand &&
!isClassicCompiler &&
(assetsDirFlag ||
workerDirFlag ||
isHydrogenPreviewVersion(metadataHydrogenVersion));

if (isClassicCompiler) {
assetsDir = assetsDirFlag ?? assetsDir;
workerDir = workerDirFlag ?? workerDir;
} else {
const viteConfig = await getViteConfig(root, ssrEntry).catch(() => null);
if (viteConfig) {
assetsDir = relativePath(root, viteConfig.clientOutDir);
workerDir = relativePath(root, viteConfig.serverOutDir);
} else {
workerDir = 'dist/server';
}
}
const outputDirs = await resolveDeploymentOutputDirs({
root,
viteConfig,
assetsDir: assetsDirFlag,
workerDir: workerDirFlag,
});

const metadataHydrogenVersion = await getHydrogenVersion({appPath: root});
assetsDir = outputDirs.assetsDir;
workerDir = outputDirs.workerDir;
}

const config: DeploymentConfig = {
assetsDir,
Expand Down Expand Up @@ -589,7 +617,7 @@ Continue?`.value,
},
};

if (buildCommand) {
if (buildCommand || shouldUseDefaultBuildCommand) {
if (forceClientSourcemap) {
console.log('');
renderInfo({
Expand All @@ -598,7 +626,7 @@ Continue?`.value,
body: 'Client sourcemaps will not be generated.',
});
}
config.buildCommand = buildCommand;
config.buildCommand = buildCommand ?? DEFAULT_BUILD_COMMAND;
} else {
hooks.buildFunction = async (
assetPath: string | undefined,
Expand Down Expand Up @@ -700,6 +728,48 @@ Continue?`.value,
return deployPromise;
}

type DeploymentOutputResolverOptions = {
root: string;
viteConfig?: {
clientOutDir: string;
serverOutDir: string;
} | null;
assetsDir?: string;
workerDir?: string;
};

export async function resolveDeploymentOutputDirs({
root,
viteConfig,
assetsDir,
workerDir,
}: DeploymentOutputResolverOptions): Promise<{
assetsDir: string;
workerDir: string;
}> {
const fallbackOutputDirs = {
assetsDir: 'dist/client',
workerDir: 'dist/server',
};
const viteOutputDirs = viteConfig
? {
assetsDir: relativePath(root, viteConfig.clientOutDir),
workerDir: relativePath(root, viteConfig.serverOutDir),
}
: undefined;

return {
assetsDir:
assetsDir ?? viteOutputDirs?.assetsDir ?? fallbackOutputDirs.assetsDir,
workerDir:
workerDir ?? viteOutputDirs?.workerDir ?? fallbackOutputDirs.workerDir,
};
}

function isHydrogenPreviewVersion(version?: string) {
return version?.startsWith('0.0.0-preview-') ?? false;
}

/**
* Gets the current @shopify/hydrogen version from the package's package.json
*/
Expand Down
Loading
Loading