Skip to content
Merged
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: 2 additions & 0 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ esbuild
"typescript",
// pino-pretty uses worker threads and must be external
"pino-pretty",
// esbuild must be external to access its binary executable
"esbuild",
],
})
.catch((e) => {
Expand Down
41 changes: 40 additions & 1 deletion src/core/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ export interface ReleaseInfo {
createdAt: string;
}

/**
* UI extension format for release API (single target instead of array)
*/
export interface ReleaseUiExtension {
name: string;
handle: string;
source: string;
type: string;
target?: string;
}

export interface ExtensionSecurityReport {
extensionName: string;
extensionDir: string;
Expand Down Expand Up @@ -848,9 +859,10 @@ export function applicationReleaseEffect(
);
}

// Load configuration to get actions and subscriptions
// Load configuration to get actions, subscriptions, and extensions
let actions: ActionConfig[] = [];
let subscriptions: SubscriptionConfig[] = [];
let uiExtensions: ReleaseUiExtension[] = [];

const configResult = yield* getConfigFileEffect({
configPath: input.configPath,
Expand All @@ -860,6 +872,32 @@ export function applicationReleaseEffect(
if (configResult && !isConfigValidationErrorResult(configResult)) {
actions = configResult.actions || [];
subscriptions = configResult.subscriptions?.webhook || [];

// Extract UI extensions from config
const extensions = yield* getExtensionsFromConfigEffect({
configPath: input.configPath,
env: input.env as Environment,
}).pipe(Effect.orElseSucceed(() => []));

// Validate that only one target is specified per extension (API limitation)
for (const ext of extensions) {
if (ext.targets && ext.targets.length > 1) {
yield* Effect.fail(
new ValidationError({
message: `UI extension "${ext.name}" has ${ext.targets.length} targets, but only one target is supported per extension during release`,
}),
);
}
}

// Map extensions to release format
uiExtensions = extensions.map((ext) => ({
name: ext.name,
handle: ext.handle,
source: ext.source,
type: ext.type,
target: ext.targets?.[0]?.target,
}));
}

const releaseData = {
Expand All @@ -868,6 +906,7 @@ export function applicationReleaseEffect(
description: input.description,
actions,
subscriptions,
uiExtensions,
};

const result = yield* callCreateRelease(releaseData, { accessToken });
Expand Down
18 changes: 18 additions & 0 deletions src/services/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ export const CreateReleaseMutation = graphql(`
version
description
createdAt
uiExtensions {
id
name
handle
type
source
target
}
}
}
`);
Expand Down Expand Up @@ -167,12 +175,22 @@ export const subscriptionInput = type({
url: "string",
});

export const uiExtensionInput = type({
name: "string",
handle: "string",
source: "string",
// Must match ExtensionType from src/core/extension/bundler-config.ts
type: '"embed" | "checkout" | "blocks"',
target: "string?",
});

export const releaseInput = type({
applicationId: "string",
version: "string",
description: "string?",
actions: actionInput.array().optional(),
subscriptions: subscriptionInput.array().optional(),
uiExtensions: uiExtensionInput.array().optional(),
});

export function createApplicationEffect(
Expand Down
38 changes: 30 additions & 8 deletions src/services/extension/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,20 +214,35 @@ export function bundleExtensionEffect(
// Run esbuild
const buildResult = yield* Effect.tryPromise({
try: () => esbuild.build(config),
catch: (error) =>
new ConfigurationError({
message: `ESBUILD_ERROR: ${error instanceof Error ? error.message : String(error)}`,
userMessage: "Failed to bundle extension",
}),
catch: (error) => {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({
type: "esbuild_error",
extension: pkg.name,
entryPath,
config,
error: errorMessage,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

let me know if error logging is showing info that shouldn't be user-facing. We couldn't debug the esbuild issue until this logged a full error message from esbuild

});
return new ConfigurationError({
message: `ESBUILD_ERROR: ${errorMessage}`,
userMessage: `Failed to bundle extension: ${errorMessage}`,
});
},
});

// Extract output files
const outputFiles = buildResult.outputFiles;
if (!outputFiles || outputFiles.length === 0) {
logger.error({
type: "bundle_error",
extension: pkg.name,
reason: "no_output_files",
buildResult,
});
return yield* Effect.fail(
new ConfigurationError({
message: "No output files generated by esbuild",
userMessage: "Failed to bundle extension",
userMessage: "Failed to bundle extension: No output files generated by esbuild",
}),
);
}
Expand All @@ -236,10 +251,17 @@ export function bundleExtensionEffect(
const mapFile = outputFiles.find((f) => f.path.endsWith(".mjs.map"));

if (!mjsFile) {
const fileList = outputFiles.map(f => f.path).join(", ");
logger.error({
type: "bundle_error",
extension: pkg.name,
reason: "no_mjs_file",
outputFiles: fileList,
});
return yield* Effect.fail(
new ConfigurationError({
message: "No .mjs file generated by esbuild",
userMessage: "Failed to bundle extension",
message: `No .mjs file generated by esbuild. Output files: ${fileList}`,
userMessage: `Failed to bundle extension: No .mjs file generated. Got: ${fileList}`,
}),
);
}
Expand Down
38 changes: 38 additions & 0 deletions tests/integration/application-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,44 @@ describe("Application Service", () => {
expect(release?.createRelease?.createdAt).toBeTruthy();
});

test("creates application release with UI extensions", async () => {
const input = {
applicationId: "app-1",
version: "2.1.0",
description: "Release with UI extensions",
actions: [],
subscriptions: [],
uiExtensions: [
{
name: "product-embed",
handle: "product-embed-handle",
source: "./extensions/embed/index.tsx",
type: "embed",
target: "products.view",
},
{
name: "checkout-extension",
handle: "checkout-ext-handle",
source: "./extensions/checkout/index.tsx",
type: "checkout",
target: "checkout.summary",
},
],
};

const release = await runEffect(
createReleaseEffect(input, {
accessToken: "test-token-123",
}),
);

expect(release?.createRelease?.version).toBe(input.version);
expect(release?.createRelease?.description).toBe(input.description);
expect(release?.createRelease?.id).toBeTruthy();
expect(release?.createRelease?.createdAt).toBeTruthy();
expect(release?.createRelease?.uiExtensions).toBeDefined();
});

test("enables application", async () => {
const result = await runEffect(
enableApplicationEffect(
Expand Down
13 changes: 12 additions & 1 deletion tests/setup/fixtures/application-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,21 @@ export const applicationFixtures = {
publicKey: "public-key-data",
}),

createReleaseResponse: (input: { version: string; description: string }) => ({
createReleaseResponse: (input: {
version: string;
description: string;
uiExtensions?: Array<{
name: string;
handle: string;
source: string;
type: string;
target?: string;
}>;
}) => ({
id: `release-${Date.now()}`,
version: input.version,
description: input.description,
createdAt: new Date().toISOString(),
uiExtensions: input.uiExtensions || [],
}),
};