Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"packageManager": "pnpm@10.17.1",
"devDependencies": {
"@changesets/changelog-github": "^0.5.2",
"@changesets/cli": "^2.29.8",
"@changesets/cli": "^2.31.0",
"eslint-plugin-prettier": "catalog:",
"prettier": "catalog:"
}
Expand Down
6 changes: 2 additions & 4 deletions packages/b2c-cli/src/commands/mrt/bundle/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,9 @@ export default class MrtBundleDeploy extends MrtCommand<typeof MrtBundleDeploy>
}),
'ssr-only': Flags.string({
description: 'Glob patterns for server-only files (comma-separated or JSON array, only for local builds)',
default: 'ssr.js,ssr.mjs,server/**/*',
}),
'ssr-shared': Flags.string({
description: 'Glob patterns for shared files (comma-separated or JSON array, only for local builds)',
default: 'static/**/*,client/**/*',
}),
'node-version': Flags.string({
char: 'n',
Expand Down Expand Up @@ -237,8 +235,8 @@ export default class MrtBundleDeploy extends MrtCommand<typeof MrtBundleDeploy>
}

const buildDir = this.flags['build-dir'];
const ssrOnly = parseGlobPatterns(this.flags['ssr-only']);
const ssrShared = parseGlobPatterns(this.flags['ssr-shared']);
const ssrOnly = this.flags['ssr-only'] ? parseGlobPatterns(this.flags['ssr-only']) : undefined;
const ssrShared = this.flags['ssr-shared'] ? parseGlobPatterns(this.flags['ssr-shared']) : undefined;

// Build SSR parameters from flags
const ssrParameters: Record<string, unknown> = parseSsrParams(this.flags['ssr-param']);
Expand Down
143 changes: 143 additions & 0 deletions packages/b2c-cli/src/commands/mrt/bundle/save.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/
import {writeFileSync} from 'node:fs';
import {gzipSync} from 'node:zlib';
import path from 'node:path';
import {Flags} from '@oclif/core';
import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli';
import {createBundle, getDefaultMessage, DEFAULT_SSR_PARAMETERS} from '@salesforce/b2c-tooling-sdk/operations/mrt';
import {t, withDocs} from '../../../i18n/index.js';

export interface SaveBundleResult {
filePath: string;
projectSlug: string;
message: string;
ssrOnlyCount: number;
ssrSharedCount: number;
}

/**
* Save a bundle to a local directory without uploading it to Managed Runtime.
*/
export default class MrtBundleSave extends BaseCommand<typeof MrtBundleSave> {
static description = withDocs(
t('commands.mrt.bundle.save.description', 'Save a Managed Runtime bundle to a local directory'),
'/cli/mrt.html#b2c-mrt-bundle-save',
);

static enableJsonFlag = true;

static examples = [
'<%= config.bin %> <%= command.id %> --project my-storefront --save-dir ./artifacts',
'<%= config.bin %> <%= command.id %> --project my-storefront --save-dir ./artifacts --gzip',
'<%= config.bin %> <%= command.id %> --project my-storefront --save-dir ./artifacts --build-dir ./dist',
'<%= config.bin %> <%= command.id %> --project my-storefront --save-dir ./artifacts --json',
];

static flags = {
...BaseCommand.baseFlags,
project: Flags.string({
char: 'p',
description: 'MRT project slug (or set MRT_PROJECT env var)',
env: 'MRT_PROJECT',
default: async () => process.env.SFCC_MRT_PROJECT || undefined,
}),
'save-dir': Flags.string({
char: 's',
description: 'Directory to save the bundle to',
required: true,
}),
'build-dir': Flags.string({
char: 'b',
description: 'Path to the build directory',
default: 'build',
}),
'ssr-only': Flags.string({
description: 'Glob patterns for server-only files (comma-separated or JSON array)',
}),
'ssr-shared': Flags.string({
description: 'Glob patterns for shared files (comma-separated or JSON array)',
}),
'node-version': Flags.string({
char: 'n',
description: `Node.js version for SSR runtime (default: ${DEFAULT_SSR_PARAMETERS.SSRFunctionNodeVersion})`,
}),
gzip: Flags.boolean({
char: 'g',
description: 'Gzip the bundle (saves as bundle.tgz instead of bundle.tar)',
default: false,
}),
};

async run(): Promise<SaveBundleResult> {
const project = this.flags.project;

if (!project) {
this.error('MRT project is required. Provide --project flag or set MRT_PROJECT.');
}

const saveDir = this.flags['save-dir'];
const buildDir = this.flags['build-dir'];
const ssrOnly = this.flags['ssr-only'] ? parseGlobPatterns(this.flags['ssr-only']) : undefined;
const ssrShared = this.flags['ssr-shared'] ? parseGlobPatterns(this.flags['ssr-shared']) : undefined;
const ssrParameters: Record<string, unknown> = {};

if (this.flags['node-version']) {
ssrParameters.SSRFunctionNodeVersion = this.flags['node-version'];
}

const message = getDefaultMessage();

this.log(t('commands.mrt.bundle.save.creating', 'Creating bundle for {{project}}...', {project}));

const bundle = await createBundle({
projectSlug: project,
buildDirectory: buildDir,
message,
ssrOnly,
ssrShared,
ssrParameters,
});

const gzip = this.flags.gzip;
const fileName = gzip ? 'bundle.tgz' : 'bundle.tar';
const filePath = path.resolve(saveDir, fileName);

let data = Buffer.from(bundle.data, 'base64');
if (gzip) {
data = gzipSync(data);
}

writeFileSync(filePath, data);

if (!this.jsonEnabled()) {
this.log(t('commands.mrt.bundle.save.success', 'Bundle saved to {{filePath}}', {filePath}));
}

return {
filePath,
projectSlug: project,
message: bundle.message,
ssrOnlyCount: bundle.ssr_only.length,
ssrSharedCount: bundle.ssr_shared.length,
};
}
}

function parseGlobPatterns(value: string): string[] {
const trimmed = value.trim();
if (trimmed.startsWith('[')) {
const parsed: unknown = JSON.parse(trimmed);
if (!Array.isArray(parsed) || !parsed.every((item) => typeof item === 'string')) {
throw new Error(`Invalid glob pattern array: expected an array of strings`);
}
return parsed.map((s: string) => s.trim()).filter(Boolean);
}
return trimmed
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
2 changes: 2 additions & 0 deletions packages/b2c-tooling-sdk/src/cli/mrt-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,13 @@ export abstract class MrtCommand<T extends typeof Command> extends BaseCommand<T
default: async () => process.env.SFCC_MRT_ENVIRONMENT || process.env.MRT_TARGET || undefined,
}),
'cloud-origin': Flags.string({
char: 'o',
description: `MRT cloud origin URL (or set mrtOrigin in dw.json; default: ${DEFAULT_MRT_ORIGIN})`,
env: 'MRT_CLOUD_ORIGIN',
default: async () => process.env.SFCC_MRT_CLOUD_ORIGIN || undefined,
}),
'credentials-file': Flags.string({
char: 'c',
description: 'Path to MRT credentials file (overrides default ~/.mobify)',
env: 'MRT_CREDENTIALS_FILE',
}),
Expand Down
50 changes: 43 additions & 7 deletions packages/b2c-tooling-sdk/src/operations/mrt/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ import tar from 'tar-fs';
import {Minimatch} from 'minimatch';
import {getLogger} from '../../logging/logger.js';

/**
* Shape of config.server.ts exported from an MRT app.
* Used to define ssrOnly, ssrShared, and ssrParameters for bundle creation.
*/
export interface MrtServerConfig {
ssrOnly: string[];
ssrShared: string[];
ssrParameters?: Record<string, unknown>;
}

export const DEFAULT_SSR_ONLY = ['ssr.js', 'ssr.mjs', 'server/**/*'];
export const DEFAULT_SSR_SHARED = ['static/**/*', 'client/**/*'];

/**
* Default SSR parameters applied to all bundles.
* These can be overridden by providing ssrParameters in CreateBundleOptions.
Expand Down Expand Up @@ -49,15 +62,17 @@ export interface CreateBundleOptions {

/**
* Glob patterns for files that should only run on the server.
* If omitted, loaded from build/config.server.js if present.
* @example ['ssr.js', 'ssr/*.js']
*/
ssrOnly: string[];
ssrOnly?: string[];

/**
* Glob patterns for files shared between client and server.
* If omitted, loaded from build/config.server.js if present.
* @example ['static/**\/*', '**\/*.js']
*/
ssrShared: string[];
ssrShared?: string[];

/**
* Path to the build directory containing the application build output.
Expand Down Expand Up @@ -170,15 +185,39 @@ export function getDefaultMessage(): string {
* });
* ```
*/
async function loadServerConfig(buildPath: string): Promise<MrtServerConfig | null> {
const configPath = path.join(buildPath, 'config.server.js');
try {
await stat(configPath);
} catch {
return null;
}
try {
const mod = await import(configPath);
const config: MrtServerConfig = mod.config ?? mod.default?.config ?? mod.default;
return config ?? null;
} catch {
return null;
}
}

export async function createBundle(options: CreateBundleOptions): Promise<Bundle> {
const logger = getLogger();
const {ssrOnly, ssrShared, projectSlug} = options;
const {projectSlug} = options;
const buildDirectory = options.buildDirectory || 'build';
const message = options.message || getDefaultMessage();
const buildPath = path.isAbsolute(buildDirectory) ? buildDirectory : path.join(process.cwd(), buildDirectory);

const serverConfig = await loadServerConfig(buildPath);

const ssrOnly = options.ssrOnly ?? serverConfig?.ssrOnly ?? DEFAULT_SSR_ONLY;
const ssrShared = options.ssrShared ?? serverConfig?.ssrShared ?? DEFAULT_SSR_SHARED;
const ssrParamsFromConfig = serverConfig?.ssrParameters ?? {};

// Merge default SSR parameters with provided ones (provided values take precedence)
// Merge: defaults < config.server.js < explicit options (explicit values win)
const ssrParameters = {
...DEFAULT_SSR_PARAMETERS,
...ssrParamsFromConfig,
...options.ssrParameters,
};

Expand All @@ -189,9 +228,6 @@ export async function createBundle(options: CreateBundleOptions): Promise<Bundle
throw new Error('ssrOnly and ssrShared patterns are required and cannot be empty');
}

// Verify build directory exists
const buildPath = path.isAbsolute(buildDirectory) ? buildDirectory : path.join(process.cwd(), buildDirectory);

try {
await stat(buildPath);
} catch {
Expand Down
11 changes: 9 additions & 2 deletions packages/b2c-tooling-sdk/src/operations/mrt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,15 @@
*/

// Bundle creation
export {createBundle, createGlobFilter, getDefaultMessage, DEFAULT_SSR_PARAMETERS} from './bundle.js';
export type {CreateBundleOptions, Bundle} from './bundle.js';
export {
createBundle,
createGlobFilter,
getDefaultMessage,
DEFAULT_SSR_PARAMETERS,
DEFAULT_SSR_ONLY,
DEFAULT_SSR_SHARED,
} from './bundle.js';
export type {CreateBundleOptions, Bundle, MrtServerConfig} from './bundle.js';

// Push and bundle operations
export {pushBundle, uploadBundle, listBundles, downloadBundle, deleteBundle, bulkDeleteBundles} from './push.js';
Expand Down
11 changes: 11 additions & 0 deletions packages/mrt-reference-app/.c8rc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"all": true,
"src": ["src"],
"exclude": [
"src/**/*.test.ts",
"src/dev/**",
"**/*.d.ts"
],
"reporter": ["text", "text-summary", "html", "lcov"],
"report-dir": "coverage"
}
6 changes: 6 additions & 0 deletions packages/mrt-reference-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
*.tsbuildinfo
build/
build/**.*
6 changes: 6 additions & 0 deletions packages/mrt-reference-app/.mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"node-option": ["import=tsx", "conditions=development"],
"timeout": 30000,
"recursive": true,
"extension": ["ts"]
}
1 change: 1 addition & 0 deletions packages/mrt-reference-app/build/loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// This file is intentionally empty
Loading
Loading