Skip to content

Commit 576d4ea

Browse files
Create abstract client steps infrastracture to externalize the ap modules client configurations
1 parent b7dd050 commit 576d4ea

8 files changed

Lines changed: 239 additions & 8 deletions

File tree

packages/app/src/cli/models/extensions/extension-instance.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,17 +355,17 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
355355
return copyFilesForExtension(
356356
this,
357357
options,
358-
this.specification.buildConfig.filePatterns,
359-
this.specification.buildConfig.ignoredFilePatterns,
358+
this.specification.buildConfig.filePatterns ?? [],
359+
this.specification.buildConfig.ignoredFilePatterns ?? [],
360360
)
361+
case 'hosted_app_home':
361362
case 'none':
362363
break
363364
}
364365
}
365366

366367
async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string) {
367368
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)
368-
369369
await this.build(options)
370370

371371
const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId))

packages/app/src/cli/models/extensions/specification.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js'
22
import {ExtensionInstance} from './extension-instance.js'
33
import {blocks} from '../../constants.js'
4+
import {ClientSteps} from '../../services/build/client-steps.js'
45

56
import {Flag} from '../../utilities/developer-platform-client.js'
67
import {AppConfiguration} from '../app/app.js'
@@ -54,8 +55,9 @@ export interface BuildAsset {
5455
}
5556

5657
type BuildConfig =
57-
| {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none'}
58+
| {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home'}
5859
| {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]}
60+
5961
/**
6062
* Extension specification with all the needed properties and methods to load an extension.
6163
*/
@@ -69,6 +71,7 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
6971
surface: string
7072
registrationLimit: number
7173
experience: ExtensionExperience
74+
clientSteps?: ClientSteps
7275
buildConfig: BuildConfig
7376
dependency?: string
7477
graphQLType?: string
@@ -205,6 +208,7 @@ export function createExtensionSpecification<TConfiguration extends BaseConfigTy
205208
experience: spec.experience ?? 'extension',
206209
uidStrategy: spec.uidStrategy ?? (spec.experience === 'configuration' ? 'single' : 'uuid'),
207210
getDevSessionUpdateMessages: spec.getDevSessionUpdateMessages,
211+
clientSteps: spec.clientSteps,
208212
buildConfig: spec.buildConfig ?? {mode: 'none'},
209213
}
210214
const merged = {...defaults, ...spec}
@@ -247,6 +251,8 @@ export function createExtensionSpecification<TConfiguration extends BaseConfigTy
247251
export function createConfigExtensionSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(spec: {
248252
identifier: string
249253
schema: ZodSchemaType<TConfiguration>
254+
clientSteps?: ClientSteps
255+
buildConfig?: BuildConfig
250256
appModuleFeatures?: (config?: TConfiguration) => ExtensionFeature[]
251257
transformConfig: TransformationConfig | CustomTransformationConfig
252258
uidStrategy?: UidStrategy
@@ -264,18 +270,24 @@ export function createConfigExtensionSpecification<TConfiguration extends BaseCo
264270
transformRemoteToLocal: resolveReverseAppConfigTransform(spec.schema, spec.transformConfig),
265271
experience: 'configuration',
266272
uidStrategy: spec.uidStrategy ?? 'single',
273+
clientSteps: spec.clientSteps,
274+
buildConfig: spec.buildConfig ?? {mode: 'none'},
267275
getDevSessionUpdateMessages: spec.getDevSessionUpdateMessages,
268276
patchWithAppDevURLs: spec.patchWithAppDevURLs,
269277
})
270278
}
271279

272280
export function createContractBasedModuleSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(
273-
spec: Pick<CreateExtensionSpecType<TConfiguration>, 'identifier' | 'appModuleFeatures' | 'buildConfig'>,
281+
spec: Pick<
282+
CreateExtensionSpecType<TConfiguration>,
283+
'identifier' | 'appModuleFeatures' | 'clientSteps' | 'buildConfig'
284+
>,
274285
) {
275286
return createExtensionSpecification({
276287
identifier: spec.identifier,
277288
schema: zod.any({}) as unknown as ZodSchemaType<TConfiguration>,
278289
appModuleFeatures: spec.appModuleFeatures,
290+
clientSteps: spec.clientSteps,
279291
buildConfig: spec.buildConfig ?? {mode: 'none'},
280292
deployConfig: async (config, directory) => {
281293
let parsedConfig = configWithoutFirstClassFields(config)

packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,13 @@ function relativeUri(uri?: string, appUrl?: string) {
7777
}
7878

7979
function getCustomersDeletionUri(webhooks: WebhooksConfig) {
80-
return getComplianceUri(webhooks, 'customers/redact') || webhooks?.privacy_compliance?.customer_deletion_url
80+
return getComplianceUri(webhooks, 'customers/redact') ?? webhooks?.privacy_compliance?.customer_deletion_url
8181
}
8282

8383
function getCustomersDataRequestUri(webhooks: WebhooksConfig) {
84-
return getComplianceUri(webhooks, 'customers/data_request') || webhooks?.privacy_compliance?.customer_data_request_url
84+
return getComplianceUri(webhooks, 'customers/data_request') ?? webhooks?.privacy_compliance?.customer_data_request_url
8585
}
8686

8787
function getShopDeletionUri(webhooks: WebhooksConfig) {
88-
return getComplianceUri(webhooks, 'shop/redact') || webhooks?.privacy_compliance?.shop_deletion_url
88+
return getComplianceUri(webhooks, 'shop/redact') ?? webhooks?.privacy_compliance?.shop_deletion_url
8989
}

packages/app/src/cli/models/extensions/specifications/payments_app_extension.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const paymentExtensionSpec = createExtensionSpecification({
6969
)
7070
case CARD_PRESENT_TARGET:
7171
return cardPresentPaymentsAppExtensionDeployConfig(config as CardPresentPaymentsAppExtensionConfigType)
72+
case undefined:
7273
default:
7374
return {}
7475
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {executeStep, BuildContext, LifecycleStep} from './client-steps.js'
2+
import * as stepsIndex from './steps/index.js'
3+
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
4+
import {beforeEach, describe, expect, test, vi} from 'vitest'
5+
6+
vi.mock('./steps/index.js')
7+
8+
describe('executeStep', () => {
9+
let mockContext: BuildContext
10+
11+
beforeEach(() => {
12+
mockContext = {
13+
extension: {
14+
directory: '/test/dir',
15+
outputPath: '/test/output/index.js',
16+
} as ExtensionInstance,
17+
options: {
18+
stdout: {write: vi.fn()} as any,
19+
stderr: {write: vi.fn()} as any,
20+
app: {} as any,
21+
environment: 'production' as const,
22+
},
23+
stepResults: new Map(),
24+
}
25+
})
26+
27+
const step: LifecycleStep = {
28+
id: 'test-step',
29+
name: 'Test Step',
30+
type: 'include_assets',
31+
config: {},
32+
}
33+
34+
describe('success', () => {
35+
test('returns a successful StepResult with output', async () => {
36+
vi.mocked(stepsIndex.executeStepByType).mockResolvedValue({filesCopied: 3})
37+
38+
const result = await executeStep(step, mockContext)
39+
40+
expect(result.id).toBe('test-step')
41+
expect(result.success).toBe(true)
42+
if (result.success) expect(result.output).toEqual({filesCopied: 3})
43+
expect(result.duration).toBeGreaterThanOrEqual(0)
44+
})
45+
46+
test('logs step execution to stdout', async () => {
47+
vi.mocked(stepsIndex.executeStepByType).mockResolvedValue({})
48+
49+
await executeStep(step, mockContext)
50+
51+
expect(mockContext.options.stdout.write).toHaveBeenCalledWith('Executing step: Test Step\n')
52+
})
53+
})
54+
55+
describe('failure', () => {
56+
test('throws a wrapped error when the step fails', async () => {
57+
vi.mocked(stepsIndex.executeStepByType).mockRejectedValue(new Error('something went wrong'))
58+
59+
await expect(executeStep(step, mockContext)).rejects.toThrow(
60+
'Build step "Test Step" failed: something went wrong',
61+
)
62+
})
63+
64+
test('returns a failure result and logs a warning when continueOnError is true', async () => {
65+
vi.mocked(stepsIndex.executeStepByType).mockRejectedValue(new Error('something went wrong'))
66+
67+
const result = await executeStep({...step, continueOnError: true}, mockContext)
68+
69+
expect(result.success).toBe(false)
70+
if (!result.success) expect(result.error?.message).toBe('something went wrong')
71+
expect(mockContext.options.stderr.write).toHaveBeenCalledWith(
72+
'Warning: Step "Test Step" failed but continuing: something went wrong\n',
73+
)
74+
})
75+
})
76+
})
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {executeStepByType} from './steps/index.js'
2+
import type {ExtensionInstance} from '../../models/extensions/extension-instance.js'
3+
import type {ExtensionBuildOptions} from './extension.js'
4+
5+
/**
6+
* LifecycleStep represents a single step in the client-side build pipeline.
7+
* Pure configuration object — execution logic is separate (router pattern).
8+
*/
9+
export interface LifecycleStep {
10+
/** Unique identifier, used as the key in the stepResults map */
11+
readonly id: string
12+
13+
/** Human-readable name for logging */
14+
readonly name: string
15+
16+
/** Step type (determines which executor handles it) */
17+
readonly type:
18+
| 'include_assets'
19+
| 'build_theme'
20+
| 'bundle_theme'
21+
| 'bundle_ui'
22+
| 'copy_static_assets'
23+
| 'build_function'
24+
| 'create_tax_stub'
25+
| 'esbuild'
26+
| 'validate'
27+
| 'transform'
28+
| 'custom'
29+
30+
/** Step-specific configuration */
31+
readonly config: {[key: string]: unknown}
32+
33+
/** Whether to continue on error (default: false) */
34+
readonly continueOnError?: boolean
35+
}
36+
37+
/**
38+
* A group of steps scoped to a specific lifecycle phase.
39+
* Allows executing only the steps relevant to a given lifecycle (e.g. 'deploy').
40+
*/
41+
interface ClientLifecycleGroup {
42+
readonly lifecycle: 'deploy'
43+
readonly steps: ReadonlyArray<LifecycleStep>
44+
}
45+
46+
/**
47+
* The full client steps configuration for an extension.
48+
* Replaces the old buildConfig contract.
49+
*/
50+
export type ClientSteps = ReadonlyArray<ClientLifecycleGroup>
51+
52+
/**
53+
* Context passed through the step pipeline.
54+
* Each step can read from and write to the context.
55+
*/
56+
export interface BuildContext {
57+
readonly extension: ExtensionInstance
58+
readonly options: ExtensionBuildOptions
59+
readonly stepResults: Map<string, StepResult>
60+
[key: string]: unknown
61+
}
62+
63+
type StepResult = {
64+
readonly id: string
65+
readonly duration: number
66+
} & (
67+
| {
68+
readonly success: false
69+
readonly error: Error
70+
}
71+
| {
72+
readonly success: true
73+
readonly output: unknown
74+
}
75+
)
76+
77+
/**
78+
* Executes a single client step with error handling.
79+
*/
80+
export async function executeStep(step: LifecycleStep, context: BuildContext): Promise<StepResult> {
81+
const startTime = Date.now()
82+
83+
try {
84+
context.options.stdout.write(`Executing step: ${step.name}\n`)
85+
const output = await executeStepByType(step, context)
86+
87+
return {
88+
id: step.id,
89+
success: true,
90+
duration: Date.now() - startTime,
91+
output,
92+
}
93+
} catch (error) {
94+
const stepError = error as Error
95+
96+
if (step.continueOnError) {
97+
context.options.stderr.write(`Warning: Step "${step.name}" failed but continuing: ${stepError.message}\n`)
98+
return {
99+
id: step.id,
100+
success: false,
101+
duration: Date.now() - startTime,
102+
error: stepError,
103+
}
104+
}
105+
106+
throw new Error(`Build step "${step.name}" failed: ${stepError.message}`)
107+
}
108+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type {LifecycleStep, BuildContext} from '../client-steps.js'
2+
3+
/**
4+
* Routes step execution to the appropriate handler based on step type.
5+
* This implements the Command Pattern router, dispatching to type-specific executors.
6+
*
7+
* @param step - The build step configuration
8+
* @param context - The build context
9+
* @returns The output from the step execution
10+
* @throws Error if the step type is not implemented or unknown
11+
*/
12+
export async function executeStepByType(step: LifecycleStep, _context: BuildContext): Promise<unknown> {
13+
switch (step.type) {
14+
// Future step types (not implemented yet):
15+
case 'include_assets':
16+
case 'build_theme':
17+
case 'bundle_theme':
18+
case 'bundle_ui':
19+
case 'copy_static_assets':
20+
case 'build_function':
21+
case 'create_tax_stub':
22+
case 'esbuild':
23+
case 'validate':
24+
case 'transform':
25+
case 'custom':
26+
throw new Error(`Build step type "${step.type}" is not yet implemented.`)
27+
28+
default:
29+
throw new Error(`Unknown build step type: ${(step as {type: string}).type}`)
30+
}
31+
}

packages/app/src/cli/services/generate/fetch-extension-specifications.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ async function mergeLocalAndRemoteSpecs(
7979
const merged = {...localSpec, ...remoteSpec, loadedRemoteSpecs: true} as RemoteAwareExtensionSpecification &
8080
FlattenedRemoteSpecification
8181

82+
// Not all the specs are moved to remote definition yet
83+
merged.clientSteps ??= localSpec.clientSteps ?? []
84+
8285
// If configuration is inside an app.toml -- i.e. single UID mode -- we must be able to parse a partial slice.
8386
let handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties
8487
switch (merged.uidStrategy) {

0 commit comments

Comments
 (0)