@hs-x/codegen 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/index.d.ts +101 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +820 -35
- package/dist/index.js.map +1 -1
- package/package.json +6 -8
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
|
|
3
|
+
import { normalizeSyncSchedule } from '@hs-x/sdk';
|
|
4
|
+
export const CODEGEN_VERSION = '0.3.0';
|
|
4
5
|
export async function generateProjectArtifacts(options) {
|
|
5
6
|
const manifest = {
|
|
6
7
|
generatedAt: new Date().toISOString(),
|
|
@@ -17,7 +18,10 @@ export async function generateProjectArtifacts(options) {
|
|
|
17
18
|
await writeFile(join(outDir, 'refs.d.ts'), renderRefsTypes(options.workers, options));
|
|
18
19
|
await writeFile(join(outDir, 'refs.js'), renderRefsModule(options.workers, options));
|
|
19
20
|
if (options.resourceScope) {
|
|
20
|
-
await writeFile(join(outDir, 'alchemy.run.ts'), renderAlchemyProgram({
|
|
21
|
+
await writeFile(join(outDir, 'alchemy.run.ts'), renderAlchemyProgram({
|
|
22
|
+
...options.resourceScope,
|
|
23
|
+
workers: options.workers,
|
|
24
|
+
}));
|
|
21
25
|
}
|
|
22
26
|
return manifest;
|
|
23
27
|
}
|
|
@@ -51,6 +55,39 @@ export function workerCloudflareResourcePlan(scope, worker) {
|
|
|
51
55
|
export function cloudflareResourceName(scope, resource) {
|
|
52
56
|
return `hsx-${toResourceSlug(scope.hsXAccountId)}-${toResourceSlug(resource)}`;
|
|
53
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Translate a worker's declared sync schedules into a Cloudflare cron-trigger
|
|
60
|
+
* plan (ADR-023 S04). Only pull-capable syncs are scheduled — a push source is
|
|
61
|
+
* webhook-driven, and `event` / `manual` schedules produce no trigger. Two
|
|
62
|
+
* syncs that share a schedule collapse into a single cron entry that dispatches
|
|
63
|
+
* both: Cloudflare caps cron triggers per worker (5 on the base tier), so
|
|
64
|
+
* emitting one trigger per distinct cadence — rather than one per sync — both
|
|
65
|
+
* respects the cap and avoids duplicate scheduler wakeups. Schedules are
|
|
66
|
+
* normalized (and thus validated) here, so an unhonorable cadence that somehow
|
|
67
|
+
* reached deploy still throws rather than silently dropping the trigger.
|
|
68
|
+
*/
|
|
69
|
+
export function planWorkerCronTriggers(worker) {
|
|
70
|
+
const byCron = new Map();
|
|
71
|
+
for (const capability of worker.capabilities) {
|
|
72
|
+
if (capability.kind !== 'sync')
|
|
73
|
+
continue;
|
|
74
|
+
// Push sources ingest via the authenticated webhook route, never a cron.
|
|
75
|
+
if (capability.source?.kind === 'push')
|
|
76
|
+
continue;
|
|
77
|
+
if (capability.schedule === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
const normalized = normalizeSyncSchedule(capability.schedule);
|
|
80
|
+
if (normalized.kind !== 'cron')
|
|
81
|
+
continue;
|
|
82
|
+
const ids = byCron.get(normalized.cron) ?? new Set();
|
|
83
|
+
ids.add(capability.id);
|
|
84
|
+
byCron.set(normalized.cron, ids);
|
|
85
|
+
}
|
|
86
|
+
const dispatch = [...byCron.entries()]
|
|
87
|
+
.map(([cron, ids]) => ({ cron, syncIds: [...ids].sort() }))
|
|
88
|
+
.sort((a, b) => (a.cron < b.cron ? -1 : a.cron > b.cron ? 1 : 0));
|
|
89
|
+
return { crons: dispatch.map((entry) => entry.cron), dispatch };
|
|
90
|
+
}
|
|
54
91
|
export function renderCloudflareWorkerEntrypoint(options) {
|
|
55
92
|
return `// Generated by hs-x. Do not edit by hand.
|
|
56
93
|
import {
|
|
@@ -60,6 +97,7 @@ import {
|
|
|
60
97
|
createHubSpotTokenRefresh,
|
|
61
98
|
createKvRuntimeSealedTokenBlobStore,
|
|
62
99
|
createKvRuntimeTokenStateOwnerStore,
|
|
100
|
+
loggerRateLimitMetrics,
|
|
63
101
|
createRuntimeTokenService,
|
|
64
102
|
createSingleBucketRuntimeRateLimiter,
|
|
65
103
|
RUNTIME_VERSION,
|
|
@@ -76,6 +114,9 @@ function getRuntime(env: Record<string, unknown>) {
|
|
|
76
114
|
runtime ??= createRuntime({
|
|
77
115
|
worker,
|
|
78
116
|
env,
|
|
117
|
+
${options.requireActiveCardInstall === false ? ' cards: { requireActiveInstall: false },\n' : ''}
|
|
118
|
+
${renderAppEventBatching(options.appEventBatching)}
|
|
119
|
+
${renderRedaction(options.redaction)}
|
|
79
120
|
${renderLinkedHeartbeat(options.heartbeat)}
|
|
80
121
|
${renderLinkedCheckpoint(options.heartbeat)}
|
|
81
122
|
...(hostedBilling ? { billing: hostedBilling.billing } : {}),
|
|
@@ -113,7 +154,9 @@ ${options.heartbeat?.mode === 'linked' ? ' ...hsxInstalledPortalTelem
|
|
|
113
154
|
rateLimiter: createSingleBucketRuntimeRateLimiter({
|
|
114
155
|
capacity: 100,
|
|
115
156
|
refillPerSecond: 10,
|
|
157
|
+
${renderHubSpotRateLimitBucket(options.rateLimits)}
|
|
116
158
|
}),
|
|
159
|
+
${renderManagedHubSpotRateLimitOptions(options.rateLimits)}
|
|
117
160
|
},
|
|
118
161
|
}
|
|
119
162
|
: {}),
|
|
@@ -165,7 +208,41 @@ export default {
|
|
|
165
208
|
${options.heartbeat?.mode === 'anonymous' ? 'void emitAnonymousHeartbeat(env);' : ''}
|
|
166
209
|
return getRuntime(env).fetch(request, ctx);
|
|
167
210
|
},
|
|
168
|
-
};
|
|
211
|
+
${renderScheduledHandler(options.syncCron)}};
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* ADR-023 S04 scheduled handler. A Cloudflare cron fire carries the exact
|
|
216
|
+
* trigger string in `event.cron`; we map it to the syncs planned for that
|
|
217
|
+
* cadence and dispatch each through the runtime's `/sync/<id>/run` route with
|
|
218
|
+
* an empty body. That empty-body path is the install fan-out
|
|
219
|
+
* (`executeSyncDispatch`): it enumerates live installs and runs each active
|
|
220
|
+
* one exactly once under the S01 single-winner lease, isolating per-portal
|
|
221
|
+
* failures. `Promise.allSettled` keeps one sync's failure from aborting the
|
|
222
|
+
* others on the same tick. No trigger, no handler — a webhook/manual-only
|
|
223
|
+
* worker stays a pure `fetch` worker.
|
|
224
|
+
*/
|
|
225
|
+
function renderScheduledHandler(plan) {
|
|
226
|
+
if (!plan || plan.dispatch.length === 0)
|
|
227
|
+
return '';
|
|
228
|
+
const table = JSON.stringify(Object.fromEntries(plan.dispatch.map((entry) => [entry.cron, [...entry.syncIds]])));
|
|
229
|
+
return ` async scheduled(event: { readonly cron: string; readonly scheduledTime: number }, env: Record<string, unknown>, ctx: { waitUntil(promise: Promise<unknown>): void }) {
|
|
230
|
+
const dispatch: Record<string, readonly string[]> = ${table};
|
|
231
|
+
const syncIds = dispatch[event.cron] ?? [];
|
|
232
|
+
const runtime = getRuntime(env);
|
|
233
|
+
await Promise.allSettled(
|
|
234
|
+
syncIds.map((id) =>
|
|
235
|
+
runtime.fetch(
|
|
236
|
+
new Request("https://hsx.internal/sync/" + encodeURIComponent(id) + "/run", {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: { "content-type": "application/json" },
|
|
239
|
+
body: "{}",
|
|
240
|
+
}),
|
|
241
|
+
ctx,
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
},
|
|
169
246
|
`;
|
|
170
247
|
}
|
|
171
248
|
function renderLinkedCheckpoint(heartbeat) {
|
|
@@ -225,6 +302,47 @@ function renderHostedBilling(billing) {
|
|
|
225
302
|
runtimeToken: readRuntimeBillingToken(env, ${JSON.stringify(runtimeTokenBinding)}),
|
|
226
303
|
})`;
|
|
227
304
|
}
|
|
305
|
+
function renderAppEventBatching(batching) {
|
|
306
|
+
if (batching === undefined)
|
|
307
|
+
return '';
|
|
308
|
+
if (batching === false)
|
|
309
|
+
return ' appEventBatching: false,\n';
|
|
310
|
+
return ` appEventBatching: ${JSON.stringify(batching)},\n`;
|
|
311
|
+
}
|
|
312
|
+
function renderRedaction(redaction) {
|
|
313
|
+
if (!redaction || !redaction.fields || redaction.fields.length === 0)
|
|
314
|
+
return '';
|
|
315
|
+
return ` redaction: ${JSON.stringify(redaction)},\n`;
|
|
316
|
+
}
|
|
317
|
+
function renderHubSpotRateLimitBucket(rateLimits) {
|
|
318
|
+
const bucket = rateLimits?.bucket;
|
|
319
|
+
if (!bucket)
|
|
320
|
+
return '';
|
|
321
|
+
const rateLimiterBucket = {
|
|
322
|
+
...(bucket.capacity !== undefined ? { capacity: bucket.capacity } : {}),
|
|
323
|
+
...(bucket.refillPerSecond !== undefined ? { refillPerSecond: bucket.refillPerSecond } : {}),
|
|
324
|
+
...(bucket.searchCapacity !== undefined ? { searchCapacity: bucket.searchCapacity } : {}),
|
|
325
|
+
...(bucket.searchRefillPerSecond !== undefined
|
|
326
|
+
? { searchRefillPerSecond: bucket.searchRefillPerSecond }
|
|
327
|
+
: {}),
|
|
328
|
+
};
|
|
329
|
+
return Object.keys(rateLimiterBucket).length
|
|
330
|
+
? ` ...${JSON.stringify(rateLimiterBucket)},\n`
|
|
331
|
+
: '';
|
|
332
|
+
}
|
|
333
|
+
function renderManagedHubSpotRateLimitOptions(rateLimits) {
|
|
334
|
+
const lines = [];
|
|
335
|
+
if (rateLimits?.bucket?.requestedTokensPerCall !== undefined) {
|
|
336
|
+
lines.push(` requestedTokensPerCall: ${rateLimits.bucket.requestedTokensPerCall},`);
|
|
337
|
+
}
|
|
338
|
+
if (rateLimits?.retry !== undefined) {
|
|
339
|
+
lines.push(` retry: ${JSON.stringify(rateLimits.retry)},`);
|
|
340
|
+
}
|
|
341
|
+
if (rateLimits?.metrics !== undefined && rateLimits.metrics !== false) {
|
|
342
|
+
lines.push(' metrics: loggerRateLimitMetrics(),');
|
|
343
|
+
}
|
|
344
|
+
return lines.length ? `${lines.join('\n')}\n` : '';
|
|
345
|
+
}
|
|
228
346
|
// ADR-015/ADR-014 tenant data plane. Emitted ONLY for linked workers (the
|
|
229
347
|
// optional platform); an unlinked direct-to-HubSpot Worker gets none of this.
|
|
230
348
|
// Each option then activates at runtime only when its bindings are present, so
|
|
@@ -238,10 +356,14 @@ function renderTenantDataPlaneImports(heartbeat) {
|
|
|
238
356
|
return '';
|
|
239
357
|
return `import {
|
|
240
358
|
createControlPlaneRuntimeInstalledPortalTelemetryEmitter,
|
|
359
|
+
createD1RuntimeAppStore,
|
|
241
360
|
createD1RuntimeFlagConfigStore,
|
|
242
361
|
createD1RuntimeInstalledPortalUserStore,
|
|
243
362
|
createD1RuntimePlatformEventOccurrenceStore,
|
|
363
|
+
createD1RuntimeSyncPoisonStore,
|
|
364
|
+
createD1RuntimeSyncStateStore,
|
|
244
365
|
createD1RuntimeWebhookDedupStore,
|
|
366
|
+
createD1RuntimeWebhookDeliveryLog,
|
|
245
367
|
createKvRuntimeFlagSnapshotStore,
|
|
246
368
|
} from "@hs-x/runtime";
|
|
247
369
|
`;
|
|
@@ -250,9 +372,38 @@ function renderTenantDataPlaneOptions(heartbeat) {
|
|
|
250
372
|
if (heartbeat?.mode !== 'linked')
|
|
251
373
|
return '';
|
|
252
374
|
return ` // Webhook trigger dedup: durable per-delivery-id claims in tenant D1
|
|
253
|
-
// (migration 0004) — \`dedup: 'strict'\` holds across isolates.
|
|
375
|
+
// (migration 0004) — \`dedup: 'strict'\` holds across isolates. The
|
|
376
|
+
// delivery LOG (migration 0008) is the redacted history behind the
|
|
377
|
+
// dashboard delivery view — best-effort writes, never payload bodies.
|
|
378
|
+
...(env.TENANT_DB
|
|
379
|
+
? {
|
|
380
|
+
webhook: {
|
|
381
|
+
dedupStore: createD1RuntimeWebhookDedupStore(env.TENANT_DB as never),
|
|
382
|
+
deliveryLog: createD1RuntimeWebhookDeliveryLog(env.TENANT_DB as never),
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
: {}),
|
|
386
|
+
// ctx.store: durable, install-scoped app key/value storage in tenant D1
|
|
387
|
+
// (migration 0005). Falls back to per-isolate memory when TENANT_DB is absent.
|
|
254
388
|
...(env.TENANT_DB
|
|
255
|
-
? {
|
|
389
|
+
? { appStore: (installId) => createD1RuntimeAppStore(env.TENANT_DB as never, installId) }
|
|
390
|
+
: {}),
|
|
391
|
+
// ADR-023 durable sync execution state — safe cursor, single-winner fenced
|
|
392
|
+
// lease, chunk checkpoints (migration 0006) — plus the poison-row DLQ
|
|
393
|
+
// (migration 0007) for rows that fail validation or a permanent upsert, in
|
|
394
|
+
// tenant D1. When TENANT_DB is absent the runtime falls back to its
|
|
395
|
+
// per-isolate memory stores (dev). Install enumeration for scheduled runs
|
|
396
|
+
// rides installOAuth's owner store; delivery rides managedHubSpot.
|
|
397
|
+
...(env.TENANT_DB
|
|
398
|
+
? {
|
|
399
|
+
sync: {
|
|
400
|
+
store: createD1RuntimeSyncStateStore(env.TENANT_DB as never),
|
|
401
|
+
poisonStore: createD1RuntimeSyncPoisonStore(env.TENANT_DB as never),
|
|
402
|
+
// Durable cross-isolate replay protection for signed push
|
|
403
|
+
// deliveries — reuses the webhook dedup table (push:-namespaced).
|
|
404
|
+
dedupStore: createD1RuntimeWebhookDedupStore(env.TENANT_DB as never),
|
|
405
|
+
},
|
|
406
|
+
}
|
|
256
407
|
: {}),
|
|
257
408
|
// ADR-015 ctx.flags + Path-B evaluate endpoint (KV snapshot read).
|
|
258
409
|
...(isHsxFlagsKvConfigured(env)
|
|
@@ -477,7 +628,8 @@ export function generateServerlessWorkerProject(input) {
|
|
|
477
628
|
'hsproject.json': renderHubSpotProjectConfig(input.legacyApp.name),
|
|
478
629
|
'hsx.config.ts': renderServerlessAppConfig(input.legacyApp.name),
|
|
479
630
|
'src/app/app-hsmeta.json': renderServerlessAppMetadata(input.legacyApp.name),
|
|
480
|
-
'src/workers/functions.ts': renderServerlessWorker(portable),
|
|
631
|
+
'src/workers/functions.ts': renderServerlessWorker(portable, input.serverless.sources),
|
|
632
|
+
...renderPreservedServerlessHelpers(input.serverless.sources),
|
|
481
633
|
},
|
|
482
634
|
};
|
|
483
635
|
}
|
|
@@ -511,25 +663,47 @@ function renderServerlessAppMetadata(appName) {
|
|
|
511
663
|
},
|
|
512
664
|
}, null, 2)}\n`;
|
|
513
665
|
}
|
|
514
|
-
function renderServerlessWorker(portable) {
|
|
666
|
+
function renderServerlessWorker(portable, sources, cardCalls = new Map()) {
|
|
515
667
|
const tools = portable
|
|
516
668
|
.map(([name, fn]) => {
|
|
517
669
|
const slug = toKebabCase(name);
|
|
670
|
+
const source = fn.file ? sources?.[fn.file] : undefined;
|
|
671
|
+
const isCardBackend = cardCalls.has(name);
|
|
672
|
+
const inferredInput = mergeInferredMigrationFields(cardCalls.get(name), source ? inferLegacyParameterFields(source) : undefined);
|
|
673
|
+
const inputFields = [...inferredInput.entries()]
|
|
674
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
675
|
+
.map(([field, type]) => ` ${JSON.stringify(field)}: { type: ${JSON.stringify(type)} },`)
|
|
676
|
+
.join('\n');
|
|
677
|
+
const preservedImports = source ? collectLegacyLibImports(source) : [];
|
|
678
|
+
const preservedImportsNote = preservedImports.length > 0
|
|
679
|
+
? `\n // Preserved local helper(s): ${preservedImports
|
|
680
|
+
.map((path) => `./legacy/${path.slice(2)}`)
|
|
681
|
+
.join(', ')}. Update imports when porting the body.`
|
|
682
|
+
: '';
|
|
518
683
|
const secretsNote = fn.secrets && fn.secrets.length > 0
|
|
519
684
|
? `\n// Secrets used by the legacy function: ${fn.secrets.join(', ')}.\n// Store each with \`hs-x secrets\` so deploys inject them into this Worker.`
|
|
520
685
|
: '';
|
|
686
|
+
const stubReturn = isCardBackend ? 'return { ok: true };' : 'return ok({ ok: true });';
|
|
687
|
+
const outputSchema = isCardBackend
|
|
688
|
+
? ''
|
|
689
|
+
: ` output: {
|
|
690
|
+
ok: { type: "boolean" },
|
|
691
|
+
},
|
|
692
|
+
`;
|
|
521
693
|
return `${secretsNote}
|
|
522
|
-
worker
|
|
694
|
+
worker.${isCardBackend ? 'cardBackend' : 'tool'}(${JSON.stringify(slug)}, {
|
|
523
695
|
label: ${JSON.stringify(`Migrated: ${name}`)},
|
|
524
|
-
input: {
|
|
525
|
-
|
|
526
|
-
ok: { type: "boolean" },
|
|
696
|
+
input: {
|
|
697
|
+
${inputFields}
|
|
527
698
|
},
|
|
528
|
-
|
|
699
|
+
${outputSchema}
|
|
700
|
+
async handler({ input }) {
|
|
529
701
|
// TODO(migration): port the body of ${JSON.stringify(fn.file ?? name)} here.
|
|
702
|
+
${preservedImportsNote}
|
|
530
703
|
// The legacy handler signature was \`async (context) => result\`; HS-X
|
|
531
|
-
//
|
|
532
|
-
|
|
704
|
+
// exposes its former context.parameters values as input.
|
|
705
|
+
void input;
|
|
706
|
+
${stubReturn}
|
|
533
707
|
},
|
|
534
708
|
});`;
|
|
535
709
|
})
|
|
@@ -537,7 +711,8 @@ worker.tool(${JSON.stringify(slug)}, {
|
|
|
537
711
|
return `import { defineWorker, ok } from "@hs-x/sdk";
|
|
538
712
|
|
|
539
713
|
// Generated by \`hs-x migrate\` from a legacy serverless.json source.
|
|
540
|
-
// Each
|
|
714
|
+
// Each capability below replaces one appFunction; card-called functions use
|
|
715
|
+
// cardBackend while unclassified functions remain tools. Port the original
|
|
541
716
|
// bodies, then test them with \`hs-x dev\`.
|
|
542
717
|
const worker = defineWorker("functions");
|
|
543
718
|
${tools}
|
|
@@ -545,6 +720,320 @@ ${tools}
|
|
|
545
720
|
export default worker;
|
|
546
721
|
`;
|
|
547
722
|
}
|
|
723
|
+
function matchingDelimiter(source, start, open, close) {
|
|
724
|
+
let depth = 0;
|
|
725
|
+
let quote;
|
|
726
|
+
let escaped = false;
|
|
727
|
+
for (let index = start; index < source.length; index += 1) {
|
|
728
|
+
const char = source[index] ?? '';
|
|
729
|
+
if (quote) {
|
|
730
|
+
if (escaped) {
|
|
731
|
+
escaped = false;
|
|
732
|
+
}
|
|
733
|
+
else if (char === '\\') {
|
|
734
|
+
escaped = true;
|
|
735
|
+
}
|
|
736
|
+
else if (char === quote) {
|
|
737
|
+
quote = undefined;
|
|
738
|
+
}
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
742
|
+
quote = char;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (char === open)
|
|
746
|
+
depth += 1;
|
|
747
|
+
if (char === close) {
|
|
748
|
+
depth -= 1;
|
|
749
|
+
if (depth === 0)
|
|
750
|
+
return index;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return -1;
|
|
754
|
+
}
|
|
755
|
+
function topLevelColon(value) {
|
|
756
|
+
let braces = 0;
|
|
757
|
+
let brackets = 0;
|
|
758
|
+
let parens = 0;
|
|
759
|
+
let quote;
|
|
760
|
+
let escaped = false;
|
|
761
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
762
|
+
const char = value[index] ?? '';
|
|
763
|
+
if (quote) {
|
|
764
|
+
if (escaped)
|
|
765
|
+
escaped = false;
|
|
766
|
+
else if (char === '\\')
|
|
767
|
+
escaped = true;
|
|
768
|
+
else if (char === quote)
|
|
769
|
+
quote = undefined;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (char === '"' || char === "'" || char === '`')
|
|
773
|
+
quote = char;
|
|
774
|
+
else if (char === '{')
|
|
775
|
+
braces += 1;
|
|
776
|
+
else if (char === '}')
|
|
777
|
+
braces -= 1;
|
|
778
|
+
else if (char === '[')
|
|
779
|
+
brackets += 1;
|
|
780
|
+
else if (char === ']')
|
|
781
|
+
brackets -= 1;
|
|
782
|
+
else if (char === '(')
|
|
783
|
+
parens += 1;
|
|
784
|
+
else if (char === ')')
|
|
785
|
+
parens -= 1;
|
|
786
|
+
else if (char === ':' && braces === 0 && brackets === 0 && parens === 0)
|
|
787
|
+
return index;
|
|
788
|
+
}
|
|
789
|
+
return -1;
|
|
790
|
+
}
|
|
791
|
+
function splitTopLevelObject(value) {
|
|
792
|
+
const parts = [];
|
|
793
|
+
let start = 0;
|
|
794
|
+
let braces = 0;
|
|
795
|
+
let brackets = 0;
|
|
796
|
+
let parens = 0;
|
|
797
|
+
let quote;
|
|
798
|
+
let escaped = false;
|
|
799
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
800
|
+
const char = value[index] ?? '';
|
|
801
|
+
if (quote) {
|
|
802
|
+
if (escaped)
|
|
803
|
+
escaped = false;
|
|
804
|
+
else if (char === '\\')
|
|
805
|
+
escaped = true;
|
|
806
|
+
else if (char === quote)
|
|
807
|
+
quote = undefined;
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (char === '"' || char === "'" || char === '`')
|
|
811
|
+
quote = char;
|
|
812
|
+
else if (char === '{')
|
|
813
|
+
braces += 1;
|
|
814
|
+
else if (char === '}')
|
|
815
|
+
braces -= 1;
|
|
816
|
+
else if (char === '[')
|
|
817
|
+
brackets += 1;
|
|
818
|
+
else if (char === ']')
|
|
819
|
+
brackets -= 1;
|
|
820
|
+
else if (char === '(')
|
|
821
|
+
parens += 1;
|
|
822
|
+
else if (char === ')')
|
|
823
|
+
parens -= 1;
|
|
824
|
+
else if (char === ',' && braces === 0 && brackets === 0 && parens === 0) {
|
|
825
|
+
parts.push(value.slice(start, index));
|
|
826
|
+
start = index + 1;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
parts.push(value.slice(start));
|
|
830
|
+
return parts;
|
|
831
|
+
}
|
|
832
|
+
function inferMigrationExpressionType(expression) {
|
|
833
|
+
const value = expression.trim();
|
|
834
|
+
if (/^(?:["'`]|String\s*\(|toStringValue\s*\()/.test(value))
|
|
835
|
+
return 'string';
|
|
836
|
+
if (/^(?:[-+]?(?:\d+\.?\d*|\.\d+)\b|Number\s*\()/.test(value))
|
|
837
|
+
return 'number';
|
|
838
|
+
if (/^(?:true|false)\b/.test(value))
|
|
839
|
+
return 'boolean';
|
|
840
|
+
return 'json';
|
|
841
|
+
}
|
|
842
|
+
function parseMigrationParameterObject(objectSource) {
|
|
843
|
+
const fields = new Map();
|
|
844
|
+
for (const rawProperty of splitTopLevelObject(objectSource)) {
|
|
845
|
+
const property = rawProperty.trim();
|
|
846
|
+
if (!property || property.startsWith('...'))
|
|
847
|
+
continue;
|
|
848
|
+
const colon = topLevelColon(property);
|
|
849
|
+
const rawName = (colon >= 0 ? property.slice(0, colon) : property).trim();
|
|
850
|
+
const nameMatch = rawName.match(/^(?:([A-Za-z_$][\w$]*)|["']([^"']+)["'])$/);
|
|
851
|
+
const name = nameMatch?.[1] ?? nameMatch?.[2];
|
|
852
|
+
if (!name)
|
|
853
|
+
continue;
|
|
854
|
+
const expression = colon >= 0 ? property.slice(colon + 1) : property;
|
|
855
|
+
fields.set(name, inferMigrationExpressionType(expression));
|
|
856
|
+
}
|
|
857
|
+
return fields;
|
|
858
|
+
}
|
|
859
|
+
function mergeInferredMigrationField(prior, next) {
|
|
860
|
+
if (!prior || prior === next)
|
|
861
|
+
return next;
|
|
862
|
+
if (prior === 'json')
|
|
863
|
+
return next;
|
|
864
|
+
if (next === 'json')
|
|
865
|
+
return prior;
|
|
866
|
+
return 'json';
|
|
867
|
+
}
|
|
868
|
+
function mergeInferredMigrationFields(...maps) {
|
|
869
|
+
const merged = new Map();
|
|
870
|
+
for (const fields of maps) {
|
|
871
|
+
for (const [name, type] of fields ?? []) {
|
|
872
|
+
merged.set(name, mergeInferredMigrationField(merged.get(name), type));
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return merged;
|
|
876
|
+
}
|
|
877
|
+
function collectFrontendServerlessCalls(sources) {
|
|
878
|
+
let count = 0;
|
|
879
|
+
const fieldsByFunction = new Map();
|
|
880
|
+
for (const source of Object.values(sources ?? {})) {
|
|
881
|
+
let cursor = 0;
|
|
882
|
+
while (cursor < source.length) {
|
|
883
|
+
const callIndex = source.indexOf('runServerlessFunction', cursor);
|
|
884
|
+
if (callIndex < 0)
|
|
885
|
+
break;
|
|
886
|
+
const open = source.indexOf('(', callIndex + 'runServerlessFunction'.length);
|
|
887
|
+
if (open < 0 || !/^\s*$/.test(source.slice(callIndex + 21, open))) {
|
|
888
|
+
cursor = callIndex + 21;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const close = matchingDelimiter(source, open, '(', ')');
|
|
892
|
+
if (close < 0)
|
|
893
|
+
break;
|
|
894
|
+
const argument = source.slice(open + 1, close);
|
|
895
|
+
const name = argument.match(/\bname\s*:\s*(["'`])([^"'`]+)\1/)?.[2];
|
|
896
|
+
if (name) {
|
|
897
|
+
count += 1;
|
|
898
|
+
const current = fieldsByFunction.get(name) ?? new Map();
|
|
899
|
+
const parameters = /\bparameters\s*:/.exec(argument);
|
|
900
|
+
if (parameters) {
|
|
901
|
+
const afterColon = argument.slice((parameters.index ?? 0) + parameters[0].length);
|
|
902
|
+
const leading = afterColon.length - afterColon.trimStart().length;
|
|
903
|
+
if (afterColon[leading] === '{') {
|
|
904
|
+
const end = matchingDelimiter(afterColon, leading, '{', '}');
|
|
905
|
+
if (end >= 0) {
|
|
906
|
+
const inferred = parseMigrationParameterObject(afterColon.slice(leading + 1, end));
|
|
907
|
+
for (const [field, type] of inferred) {
|
|
908
|
+
current.set(field, mergeInferredMigrationField(current.get(field), type));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
fieldsByFunction.set(name, current);
|
|
914
|
+
}
|
|
915
|
+
cursor = close + 1;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return { count, fieldsByFunction };
|
|
919
|
+
}
|
|
920
|
+
function inferLegacyParameterFields(source) {
|
|
921
|
+
const fields = new Map();
|
|
922
|
+
const pattern = /\bcontext\.parameters(?:\?\.)?\.?([A-Za-z_$][\w$]*)/g;
|
|
923
|
+
for (const match of source.matchAll(pattern)) {
|
|
924
|
+
const name = match[1];
|
|
925
|
+
if (!name)
|
|
926
|
+
continue;
|
|
927
|
+
const before = source.slice(Math.max(0, (match.index ?? 0) - 80), match.index ?? 0);
|
|
928
|
+
const type = /Number\s*\([^)]*$/.test(before)
|
|
929
|
+
? 'number'
|
|
930
|
+
: /(?:String|toStringValue)\s*\([^)]*$/.test(before)
|
|
931
|
+
? 'string'
|
|
932
|
+
: 'json';
|
|
933
|
+
fields.set(name, mergeInferredMigrationField(fields.get(name), type));
|
|
934
|
+
}
|
|
935
|
+
return fields;
|
|
936
|
+
}
|
|
937
|
+
export const MIGRATED_RUNTIME_ORIGIN_PLACEHOLDER = 'https://runtime-origin.invalid';
|
|
938
|
+
export const MIGRATED_SERVERLESS_SHIM_PATH = 'src/app/cards/_hsx-migration-run-serverless.ts';
|
|
939
|
+
export function renderMigratedRunServerlessShim(runtimeOrigin, backendIds = {}) {
|
|
940
|
+
return `// Generated by hs-x migrate. The deploy command stamps the resolved
|
|
941
|
+
// workers.dev origin into this compatibility bridge before HubSpot upload.
|
|
942
|
+
import { hubspot } from "@hubspot/ui-extensions";
|
|
943
|
+
|
|
944
|
+
const RUNTIME_ORIGIN = ${JSON.stringify(stripTrailingSlash(runtimeOrigin))};
|
|
945
|
+
const BACKEND_IDS: Readonly<Record<string, string>> = ${JSON.stringify(backendIds, null, 2)};
|
|
946
|
+
|
|
947
|
+
export interface LegacyServerlessFunctionResult<TResponse = unknown> {
|
|
948
|
+
readonly status: "SUCCESS" | "ERROR";
|
|
949
|
+
readonly message?: string;
|
|
950
|
+
readonly response?: TResponse;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
export async function runServerlessFunction<TResponse = unknown>({
|
|
954
|
+
name,
|
|
955
|
+
parameters,
|
|
956
|
+
}: {
|
|
957
|
+
readonly name: string;
|
|
958
|
+
readonly parameters?: Record<string, unknown>;
|
|
959
|
+
}): Promise<LegacyServerlessFunctionResult<TResponse>> {
|
|
960
|
+
try {
|
|
961
|
+
const backendId = BACKEND_IDS[name] ?? name;
|
|
962
|
+
const response = await hubspot.fetch(
|
|
963
|
+
\`\${RUNTIME_ORIGIN}/_hsx/cards/\${encodeURIComponent(backendId)}\`,
|
|
964
|
+
{
|
|
965
|
+
method: "POST",
|
|
966
|
+
body: { input: parameters ?? {} },
|
|
967
|
+
},
|
|
968
|
+
);
|
|
969
|
+
const payload = (await response.json().catch(() => undefined)) as
|
|
970
|
+
| { ok?: boolean; result?: TResponse; message?: string; error?: string }
|
|
971
|
+
| undefined;
|
|
972
|
+
if (!response.ok || payload?.ok !== true) {
|
|
973
|
+
return {
|
|
974
|
+
status: "ERROR",
|
|
975
|
+
message: payload?.message ?? payload?.error ?? \`Backend request failed (\${response.status}).\`,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
return { status: "SUCCESS", response: payload.result };
|
|
979
|
+
} catch (error) {
|
|
980
|
+
return {
|
|
981
|
+
status: "ERROR",
|
|
982
|
+
message: error instanceof Error ? error.message : String(error),
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
`;
|
|
987
|
+
}
|
|
988
|
+
export function stampMigratedRunServerlessShimOrigin(source, runtimeOrigin) {
|
|
989
|
+
const value = JSON.stringify(stripTrailingSlash(runtimeOrigin));
|
|
990
|
+
return source.replace(/const RUNTIME_ORIGIN = ["'][^"']*["'];/, `const RUNTIME_ORIGIN = ${value};`);
|
|
991
|
+
}
|
|
992
|
+
function rewriteMigratedCardProvider(source, entrypoint) {
|
|
993
|
+
const provider = /(hubspot\.extend(?:<[^>]+>)?\s*\(\s*\(\s*\{)([^}]*)(\}\s*\)\s*=>)/m;
|
|
994
|
+
const match = provider.exec(source);
|
|
995
|
+
if (!match || !/\brunServerlessFunction\b/.test(match[2] ?? ''))
|
|
996
|
+
return source;
|
|
997
|
+
const rewrittenProps = (match[2] ?? '')
|
|
998
|
+
.split(',')
|
|
999
|
+
.map((part) => part.trim())
|
|
1000
|
+
.filter((part) => part && part !== 'runServerlessFunction')
|
|
1001
|
+
.join(', ');
|
|
1002
|
+
let rewritten = source.replace(provider, `$1${rewrittenProps}$3`);
|
|
1003
|
+
const beforeProp = rewritten;
|
|
1004
|
+
rewritten = rewritten.replace(/runServerlessFunction=\{\s*runServerlessFunction(\s+as\s+[^}]+)?\}/g, (_whole, cast) => `runServerlessFunction={hsxRunServerlessFunction${cast ?? ''}}`);
|
|
1005
|
+
if (rewritten === beforeProp)
|
|
1006
|
+
return source;
|
|
1007
|
+
const depth = Math.max(0, entrypoint.replace(/\\/g, '/').split('/').length - 1);
|
|
1008
|
+
const modulePath = `${depth === 0 ? './' : '../'.repeat(depth)}_hsx-migration-run-serverless`;
|
|
1009
|
+
return `import { runServerlessFunction as hsxRunServerlessFunction } from ${JSON.stringify(modulePath)};
|
|
1010
|
+
${rewritten}`;
|
|
1011
|
+
}
|
|
1012
|
+
function canRewriteMigratedCardProvider(sources, entrypoint) {
|
|
1013
|
+
const source = sources[entrypoint];
|
|
1014
|
+
return (typeof source === 'string' &&
|
|
1015
|
+
/hubspot\.extend(?:<[^>]+>)?\s*\(\s*\(\s*\{[^}]*\brunServerlessFunction\b[^}]*\}\s*\)\s*=>/m.test(source) &&
|
|
1016
|
+
/runServerlessFunction=\{\s*runServerlessFunction(?:\s+as\s+[^}]+)?\}/.test(source));
|
|
1017
|
+
}
|
|
1018
|
+
function collectLegacyLibImports(source) {
|
|
1019
|
+
const imports = new Set();
|
|
1020
|
+
const pattern = /(?:require\s*\(\s*|from\s+)(['"])(\.\/lib\/[^'"]+)\1/g;
|
|
1021
|
+
for (const match of source.matchAll(pattern)) {
|
|
1022
|
+
if (match[2])
|
|
1023
|
+
imports.add(match[2]);
|
|
1024
|
+
}
|
|
1025
|
+
return [...imports].sort();
|
|
1026
|
+
}
|
|
1027
|
+
function renderPreservedServerlessHelpers(sources) {
|
|
1028
|
+
const files = {};
|
|
1029
|
+
for (const [rawPath, contents] of Object.entries(sources ?? {})) {
|
|
1030
|
+
const path = rawPath.replace(/\\/g, '/').replace(/^\.\/+/, '');
|
|
1031
|
+
if (!path.startsWith('lib/') || path.split('/').includes('..'))
|
|
1032
|
+
continue;
|
|
1033
|
+
files[`src/workers/legacy/${path}`] = contents;
|
|
1034
|
+
}
|
|
1035
|
+
return files;
|
|
1036
|
+
}
|
|
548
1037
|
/** Read scope a card needs for the object it renders on (validator's map). */
|
|
549
1038
|
function cardReadScope(objectType) {
|
|
550
1039
|
const t = objectType.toLowerCase();
|
|
@@ -576,6 +1065,36 @@ export function generateMigratedHsxProject(input) {
|
|
|
576
1065
|
};
|
|
577
1066
|
const scopes = new Set(['crm.objects.contacts.read']);
|
|
578
1067
|
const permittedFetch = new Set();
|
|
1068
|
+
const migratedCardRuntimeDependencies = collectMigratedCardRuntimeDependencies(input.projectCards ?? []);
|
|
1069
|
+
const callsByCard = new Map();
|
|
1070
|
+
const providerRewritableCards = new Set();
|
|
1071
|
+
const allCardCalls = new Map();
|
|
1072
|
+
for (const projectCard of input.projectCards ?? []) {
|
|
1073
|
+
const scan = collectFrontendServerlessCalls(projectCard.sources);
|
|
1074
|
+
callsByCard.set(projectCard, scan);
|
|
1075
|
+
if (projectCard.entrypoint &&
|
|
1076
|
+
canRewriteMigratedCardProvider(projectCard.sources ?? {}, projectCard.entrypoint)) {
|
|
1077
|
+
providerRewritableCards.add(projectCard);
|
|
1078
|
+
}
|
|
1079
|
+
for (const [name, fields] of scan.fieldsByFunction) {
|
|
1080
|
+
allCardCalls.set(name, mergeInferredMigrationFields(allCardCalls.get(name), fields));
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
const portableServerlessNames = new Set(Object.entries(input.serverless?.appFunctions ?? {})
|
|
1084
|
+
.filter(([, fn]) => typeof fn.file === 'string' && fn.file.length > 0 && fn.endpoint === undefined)
|
|
1085
|
+
.map(([name]) => name));
|
|
1086
|
+
const routedCardCalls = new Map([...allCardCalls].filter(([name]) => portableServerlessNames.has(name)));
|
|
1087
|
+
const shimBackendNames = new Set();
|
|
1088
|
+
for (const projectCard of providerRewritableCards) {
|
|
1089
|
+
for (const name of callsByCard.get(projectCard)?.fieldsByFunction.keys() ?? []) {
|
|
1090
|
+
if (portableServerlessNames.has(name))
|
|
1091
|
+
shimBackendNames.add(name);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if (shimBackendNames.size > 0) {
|
|
1095
|
+
permittedFetch.add(MIGRATED_RUNTIME_ORIGIN_PLACEHOLDER);
|
|
1096
|
+
files[MIGRATED_SERVERLESS_SHIM_PATH] = renderMigratedRunServerlessShim(MIGRATED_RUNTIME_ORIGIN_PLACEHOLDER, Object.fromEntries([...shimBackendNames].map((name) => [name, toKebabCase(name)])));
|
|
1097
|
+
}
|
|
579
1098
|
const card = input.legacyCard;
|
|
580
1099
|
if (card && typeof card.fetchUrl === 'string' && card.fetchUrl.length > 0) {
|
|
581
1100
|
const cardSlug = toKebabCase(card.title || card.id);
|
|
@@ -590,7 +1109,7 @@ export function generateMigratedHsxProject(input) {
|
|
|
590
1109
|
scopes.add(scope);
|
|
591
1110
|
}
|
|
592
1111
|
if (!files['src/app/cards/package.json']) {
|
|
593
|
-
files['src/app/cards/package.json'] = renderMigratedCardsPackage();
|
|
1112
|
+
files['src/app/cards/package.json'] = renderMigratedCardsPackage(migratedCardRuntimeDependencies);
|
|
594
1113
|
}
|
|
595
1114
|
files[`src/app/cards/${componentName}.tsx`] = renderMigratedCardComponent(componentName, card.fetchUrl);
|
|
596
1115
|
files[`src/app/cards/${cardSlug}-hsmeta.json`] = renderMigratedCardMetadata({
|
|
@@ -607,14 +1126,24 @@ export function generateMigratedHsxProject(input) {
|
|
|
607
1126
|
const name = projectCard.name || projectCard.uid || 'Migrated Card';
|
|
608
1127
|
const slug = toKebabCase(projectCard.uid || name);
|
|
609
1128
|
const sources = projectCard.sources ?? {};
|
|
610
|
-
// Carry the React component tree over wholesale; identical paths from
|
|
611
|
-
// sibling cards sharing a directory dedupe naturally.
|
|
612
|
-
for (const [rel, contents] of Object.entries(sources)) {
|
|
613
|
-
files[`src/app/cards/${rel}`] = contents;
|
|
614
|
-
}
|
|
615
1129
|
const entryFile = Object.keys(sources).includes(projectCard.entrypoint)
|
|
616
1130
|
? projectCard.entrypoint
|
|
617
1131
|
: (Object.keys(sources).find((f) => /\.(t|j)sx?$/.test(f)) ?? projectCard.entrypoint);
|
|
1132
|
+
// Carry the React component tree over wholesale; identical paths from
|
|
1133
|
+
// sibling cards sharing a directory dedupe naturally. For a card that
|
|
1134
|
+
// called portable serverless functions, rewrite only the provider in the
|
|
1135
|
+
// entrypoint and leave every hook/call site untouched.
|
|
1136
|
+
const cardCalls = callsByCard.get(projectCard);
|
|
1137
|
+
const cardHasRoutedBackend = providerRewritableCards.has(projectCard) &&
|
|
1138
|
+
[...(cardCalls?.fieldsByFunction.keys() ?? [])].some((name) => portableServerlessNames.has(name));
|
|
1139
|
+
for (const [rel, contents] of Object.entries(sources)) {
|
|
1140
|
+
if (rel === 'package.json' || rel.endsWith('/package.json'))
|
|
1141
|
+
continue;
|
|
1142
|
+
files[`src/app/cards/${rel}`] =
|
|
1143
|
+
rel === entryFile && cardHasRoutedBackend
|
|
1144
|
+
? rewriteMigratedCardProvider(contents, entryFile)
|
|
1145
|
+
: contents;
|
|
1146
|
+
}
|
|
618
1147
|
const objectTypes = projectCard.objectTypes?.length ? projectCard.objectTypes : ['contacts'];
|
|
619
1148
|
for (const objectType of objectTypes) {
|
|
620
1149
|
const scope = cardReadScope(objectType);
|
|
@@ -622,7 +1151,7 @@ export function generateMigratedHsxProject(input) {
|
|
|
622
1151
|
scopes.add(scope);
|
|
623
1152
|
}
|
|
624
1153
|
if (!files['src/app/cards/package.json']) {
|
|
625
|
-
files['src/app/cards/package.json'] = renderMigratedCardsPackage();
|
|
1154
|
+
files['src/app/cards/package.json'] = renderMigratedCardsPackage(migratedCardRuntimeDependencies);
|
|
626
1155
|
}
|
|
627
1156
|
files[`src/app/cards/${slug}-hsmeta.json`] = `${JSON.stringify({
|
|
628
1157
|
uid: slug,
|
|
@@ -641,7 +1170,8 @@ export function generateMigratedHsxProject(input) {
|
|
|
641
1170
|
.filter(([, fn]) => typeof fn.file === 'string' && fn.file.length > 0 && fn.endpoint === undefined)
|
|
642
1171
|
.sort(([a], [b]) => a.localeCompare(b));
|
|
643
1172
|
if (portable.length > 0) {
|
|
644
|
-
files['src/workers/functions.ts'] = renderServerlessWorker(portable);
|
|
1173
|
+
files['src/workers/functions.ts'] = renderServerlessWorker(portable, input.serverless.sources, routedCardCalls);
|
|
1174
|
+
Object.assign(files, renderPreservedServerlessHelpers(input.serverless.sources));
|
|
645
1175
|
}
|
|
646
1176
|
}
|
|
647
1177
|
if (input.webhooks) {
|
|
@@ -667,11 +1197,22 @@ export function generateMigratedHsxProject(input) {
|
|
|
667
1197
|
// Workers, or upload — the original run-003 blocker; the composed generator
|
|
668
1198
|
// must emit it like the single-kind generators did.
|
|
669
1199
|
files['package.json'] = renderMigratedRootPackage(input.legacyApp.name);
|
|
670
|
-
files['hsx.config.ts'] = renderComposedAppConfig(appConfig.name ?? input.legacyApp.name, appConfig, [...scopes].sort(), [...permittedFetch].sort())
|
|
1200
|
+
files['hsx.config.ts'] = renderComposedAppConfig(appConfig.name ?? input.legacyApp.name, appConfig, [...scopes].sort(), [...permittedFetch].sort(), (input.projectCards ?? []).map((projectCard) => {
|
|
1201
|
+
const name = projectCard.name || projectCard.uid || 'Migrated Card';
|
|
1202
|
+
const calledBackend = [...(callsByCard.get(projectCard)?.fieldsByFunction.keys() ?? [])].find((candidate) => providerRewritableCards.has(projectCard) && portableServerlessNames.has(candidate));
|
|
1203
|
+
return {
|
|
1204
|
+
id: toKebabCase(projectCard.uid || name),
|
|
1205
|
+
name,
|
|
1206
|
+
location: projectCard.location ?? 'crm.record.tab',
|
|
1207
|
+
objectTypes: projectCard.objectTypes?.length ? projectCard.objectTypes : ['contacts'],
|
|
1208
|
+
entrypoint: `./src/app/cards/${projectCard.entrypoint ?? 'Card.tsx'}`,
|
|
1209
|
+
...(calledBackend ? { backend: toKebabCase(calledBackend) } : {}),
|
|
1210
|
+
};
|
|
1211
|
+
}));
|
|
671
1212
|
files['src/app/app-hsmeta.json'] = renderComposedAppMetadata(appConfig.name ?? input.legacyApp.name, appConfig, [...scopes].sort(), [...permittedFetch].sort());
|
|
672
1213
|
return { files };
|
|
673
1214
|
}
|
|
674
|
-
function renderComposedAppConfig(appName, appConfig, scopes, permittedFetch) {
|
|
1215
|
+
function renderComposedAppConfig(appName, appConfig, scopes, permittedFetch, cards = []) {
|
|
675
1216
|
const distribution = appConfig.distribution === 'marketplace' ? 'marketplace' : 'private';
|
|
676
1217
|
const auth = appConfig.authType === 'static' || !appConfig.redirectUrls?.length ? 'static' : 'oauth';
|
|
677
1218
|
const description = appConfig.description
|
|
@@ -690,14 +1231,25 @@ function renderComposedAppConfig(appName, appConfig, scopes, permittedFetch) {
|
|
|
690
1231
|
img.length > 0 ? ` img: ${JSON.stringify(img)},` : undefined,
|
|
691
1232
|
].filter((entry) => entry !== undefined);
|
|
692
1233
|
const permitted = permittedEntries.length > 0 ? `\n permittedUrls: {\n${permittedEntries.join('\n')}\n },` : '';
|
|
693
|
-
|
|
1234
|
+
const cardDeclarations = cards.length > 0
|
|
1235
|
+
? `\n cards: [\n${cards
|
|
1236
|
+
.map((cardDefinition) => ` card({
|
|
1237
|
+
id: ${JSON.stringify(cardDefinition.id)},
|
|
1238
|
+
name: ${JSON.stringify(cardDefinition.name)},
|
|
1239
|
+
location: ${JSON.stringify(cardDefinition.location)},
|
|
1240
|
+
objectTypes: ${JSON.stringify(cardDefinition.objectTypes)},
|
|
1241
|
+
entrypoint: ${JSON.stringify(cardDefinition.entrypoint)},${cardDefinition.backend ? `\n backend: ${JSON.stringify(cardDefinition.backend)},` : ''}
|
|
1242
|
+
}),`)
|
|
1243
|
+
.join('\n')}\n ],`
|
|
1244
|
+
: '';
|
|
1245
|
+
return `import { ${cards.length > 0 ? 'card, ' : ''}defineApp } from "@hs-x/sdk";
|
|
694
1246
|
|
|
695
1247
|
export default defineApp({
|
|
696
1248
|
name: ${JSON.stringify(appName.replace(/\s+Legacy$/i, ''))},${description}
|
|
697
1249
|
distribution: ${JSON.stringify(distribution)},
|
|
698
1250
|
auth: ${JSON.stringify(auth)},
|
|
699
1251
|
platformVersion: "2026.03",
|
|
700
|
-
scopes: ${JSON.stringify(scopes)},${optional}${permitted}
|
|
1252
|
+
scopes: ${JSON.stringify(scopes)},${optional}${permitted}${cardDeclarations}
|
|
701
1253
|
});
|
|
702
1254
|
`;
|
|
703
1255
|
}
|
|
@@ -877,6 +1429,29 @@ export function generateHubSpotRuntimeProject(options) {
|
|
|
877
1429
|
renderAppEventMetadata(event);
|
|
878
1430
|
}
|
|
879
1431
|
const cards = options.cards ?? [];
|
|
1432
|
+
const linkedCards = cards.filter((card) => card.backend);
|
|
1433
|
+
if (linkedCards.length > 0) {
|
|
1434
|
+
// C03 (ADR-018): a card that declares a `backend` is validated against the
|
|
1435
|
+
// worker capabilities and the app's fetch allowlist BEFORE any file is
|
|
1436
|
+
// emitted, so a broken link fails codegen rather than shipping a card whose
|
|
1437
|
+
// `hubspot.fetch` is dead. The effective allowlist mirrors the app-hsmeta
|
|
1438
|
+
// computation (the runtime origin is always auto-permitted).
|
|
1439
|
+
const effectiveFetch = [
|
|
1440
|
+
options.runtimeBaseUrl,
|
|
1441
|
+
...(options.permittedUrls?.fetch ?? []).filter((url) => url !== options.runtimeBaseUrl),
|
|
1442
|
+
];
|
|
1443
|
+
const errors = validateCardBackends({
|
|
1444
|
+
cards,
|
|
1445
|
+
workers: options.workers,
|
|
1446
|
+
runtimeOrigin: options.runtimeBaseUrl,
|
|
1447
|
+
fetchAllowlist: effectiveFetch,
|
|
1448
|
+
});
|
|
1449
|
+
if (errors.length > 0) {
|
|
1450
|
+
throw new Error(`Card backend linkage is invalid:\n${errors
|
|
1451
|
+
.map((error) => ` - [${error.code}] ${error.message}`)
|
|
1452
|
+
.join('\n')}`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
880
1455
|
for (const card of cards) {
|
|
881
1456
|
files[`src/app/cards/${toKebabCase(card.id)}-hsmeta.json`] = renderRuntimeCardMetadata(card);
|
|
882
1457
|
}
|
|
@@ -884,6 +1459,16 @@ export function generateHubSpotRuntimeProject(options) {
|
|
|
884
1459
|
// A single package.json is shared by all cards (HubSpot's convention).
|
|
885
1460
|
files['src/app/cards/package.json'] = renderCardsPackage();
|
|
886
1461
|
}
|
|
1462
|
+
if (linkedCards.length > 0) {
|
|
1463
|
+
// The typed, secret-free client the card components import to reach their
|
|
1464
|
+
// backend. Server/client separation is the file boundary: this module holds
|
|
1465
|
+
// only the public runtime origin + dispatch path.
|
|
1466
|
+
files['src/app/cards/_hsx-backend.ts'] = renderCardBackendClient({
|
|
1467
|
+
cards: linkedCards,
|
|
1468
|
+
workers: options.workers,
|
|
1469
|
+
runtimeOrigin: options.runtimeBaseUrl,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
887
1472
|
return { files };
|
|
888
1473
|
}
|
|
889
1474
|
/** The component filename a card's hsmeta `entrypoint` points at, e.g. `DealSummary`. */
|
|
@@ -918,6 +1503,150 @@ function renderCardsPackage() {
|
|
|
918
1503
|
},
|
|
919
1504
|
}, null, 2)}\n`;
|
|
920
1505
|
}
|
|
1506
|
+
function originOf(url) {
|
|
1507
|
+
try {
|
|
1508
|
+
return new URL(url).origin;
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
return url.replace(/\/+$/, '');
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
function stripTrailingSlash(url) {
|
|
1515
|
+
return url.replace(/\/+$/, '');
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Cross-check every card that declares a `backend` against the worker
|
|
1519
|
+
* capabilities and the app's fetch allowlist. Pure and side-effect-free so both
|
|
1520
|
+
* codegen (which throws) and callers doing author-time linting can consume it.
|
|
1521
|
+
*
|
|
1522
|
+
* Errors:
|
|
1523
|
+
* - `card.backend.missing` — no `card-backend` capability with that id.
|
|
1524
|
+
* - `card.backend.permission-missing` — the runtime origin the generated
|
|
1525
|
+
* `hubspot.fetch` targets is not in `permittedUrls.fetch`, so HubSpot would
|
|
1526
|
+
* block the call.
|
|
1527
|
+
* - `card.backend.scope-mismatch` — the backend declares `objectTypes` and the
|
|
1528
|
+
* card renders on an object type the backend does not serve.
|
|
1529
|
+
*/
|
|
1530
|
+
export function validateCardBackends(input) {
|
|
1531
|
+
const backends = new Map();
|
|
1532
|
+
for (const capability of cardBackendCapabilities(input.workers)) {
|
|
1533
|
+
backends.set(capability.id, capability);
|
|
1534
|
+
}
|
|
1535
|
+
const runtimeOrigin = originOf(input.runtimeOrigin);
|
|
1536
|
+
const originPermitted = input.fetchAllowlist.some((url) => originOf(url) === runtimeOrigin);
|
|
1537
|
+
const diagnostics = [];
|
|
1538
|
+
for (const card of input.cards) {
|
|
1539
|
+
if (!card.backend)
|
|
1540
|
+
continue;
|
|
1541
|
+
const backendId = card.backend;
|
|
1542
|
+
const backend = backends.get(backendId);
|
|
1543
|
+
if (!backend) {
|
|
1544
|
+
diagnostics.push({
|
|
1545
|
+
code: 'card.backend.missing',
|
|
1546
|
+
severity: 'error',
|
|
1547
|
+
cardId: card.id,
|
|
1548
|
+
backendId,
|
|
1549
|
+
message: `Card "${card.id}" declares backend "${backendId}", but no cardBackend capability with that id is defined in any worker.`,
|
|
1550
|
+
});
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
if (!originPermitted) {
|
|
1554
|
+
diagnostics.push({
|
|
1555
|
+
code: 'card.backend.permission-missing',
|
|
1556
|
+
severity: 'error',
|
|
1557
|
+
cardId: card.id,
|
|
1558
|
+
backendId,
|
|
1559
|
+
message: `Card "${card.id}" calls backend "${backendId}" at ${runtimeOrigin}, but that origin is not in permittedUrls.fetch; HubSpot would block the UI extension's hubspot.fetch. Add it to permittedUrls.fetch.`,
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
// A backend that declares `objectTypes` constrains which cards may call it;
|
|
1563
|
+
// a backend without them serves any object and skips this check.
|
|
1564
|
+
if (backend.objectTypes && backend.objectTypes.length > 0) {
|
|
1565
|
+
const served = new Set(backend.objectTypes.map(toHubSpotObjectType));
|
|
1566
|
+
const unserved = card.objectTypes.filter((type) => !served.has(toHubSpotObjectType(type)));
|
|
1567
|
+
if (unserved.length > 0) {
|
|
1568
|
+
diagnostics.push({
|
|
1569
|
+
code: 'card.backend.scope-mismatch',
|
|
1570
|
+
severity: 'error',
|
|
1571
|
+
cardId: card.id,
|
|
1572
|
+
backendId,
|
|
1573
|
+
message: `Card "${card.id}" renders on ${unserved.join(', ')}, but backend "${backendId}" only serves ${backend.objectTypes.join(', ')}. Add the object type(s) to the cardBackend or narrow the card.`,
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return diagnostics;
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* The typed, secret-free client a UI-extension card imports to call its backend.
|
|
1582
|
+
* One exported callable per referenced `card-backend`, each wrapping
|
|
1583
|
+
* `hubspot.fetch(<runtimeOrigin>/_hsx/cards/<id>)`. The server/client boundary
|
|
1584
|
+
* is the file boundary: this module never contains handler source, credentials,
|
|
1585
|
+
* signing keys, or env — only the public origin and dispatch path.
|
|
1586
|
+
*/
|
|
1587
|
+
export function renderCardBackendClient(input) {
|
|
1588
|
+
const backends = new Map();
|
|
1589
|
+
for (const capability of cardBackendCapabilities(input.workers)) {
|
|
1590
|
+
backends.set(capability.id, capability);
|
|
1591
|
+
}
|
|
1592
|
+
const referenced = new Map();
|
|
1593
|
+
for (const card of input.cards) {
|
|
1594
|
+
if (!card.backend)
|
|
1595
|
+
continue;
|
|
1596
|
+
const backend = backends.get(card.backend);
|
|
1597
|
+
if (backend)
|
|
1598
|
+
referenced.set(backend.id, backend);
|
|
1599
|
+
}
|
|
1600
|
+
const callables = [...referenced.values()]
|
|
1601
|
+
.map((backend) => {
|
|
1602
|
+
const fn = toIdentifier(backend.id);
|
|
1603
|
+
const path = cardBackendDispatchPath(backend.id);
|
|
1604
|
+
const label = backend.label ? ` (${backend.label})` : '';
|
|
1605
|
+
return `/** Call the \`${backend.id}\` card backend${label}. */
|
|
1606
|
+
export function ${fn}<TResponse = unknown>(
|
|
1607
|
+
body?: Record<string, unknown>,
|
|
1608
|
+
): Promise<CardBackendResponse<TResponse>> {
|
|
1609
|
+
return callCardBackend<TResponse>(${JSON.stringify(path)}, body);
|
|
1610
|
+
}
|
|
1611
|
+
`;
|
|
1612
|
+
})
|
|
1613
|
+
.join('\n');
|
|
1614
|
+
return `// Generated by hs-x. Do not edit.
|
|
1615
|
+
//
|
|
1616
|
+
// Typed card→backend client for the UI-extension bundle. It references only the
|
|
1617
|
+
// public runtime origin and the ADR-018 card dispatch path — nothing from the
|
|
1618
|
+
// server side crosses this file boundary. Import a callable into a card
|
|
1619
|
+
// component and call it like a fetch; HS-X authenticates the request on the
|
|
1620
|
+
// runtime (v3 signature + active-install binding).
|
|
1621
|
+
import { hubspot } from '@hubspot/ui-extensions';
|
|
1622
|
+
|
|
1623
|
+
const RUNTIME_ORIGIN = ${JSON.stringify(stripTrailingSlash(input.runtimeOrigin))};
|
|
1624
|
+
|
|
1625
|
+
export interface CardBackendResponse<TData = unknown> {
|
|
1626
|
+
readonly ok: boolean;
|
|
1627
|
+
readonly status: number;
|
|
1628
|
+
readonly data: TData;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function callCardBackend<TData>(
|
|
1632
|
+
path: string,
|
|
1633
|
+
body?: Record<string, unknown>,
|
|
1634
|
+
): Promise<CardBackendResponse<TData>> {
|
|
1635
|
+
// hubspot.fetch serializes \`body\` itself (its contract types \`body\` as an
|
|
1636
|
+
// object, not a string). Passing a pre-stringified body double-encodes it —
|
|
1637
|
+
// the runtime then parses a JSON string, not a record, and rejects it with
|
|
1638
|
+
// 400 invalid_payload. So hand the object straight through.
|
|
1639
|
+
const response = await hubspot.fetch(\`\${RUNTIME_ORIGIN}\${path}\`, {
|
|
1640
|
+
method: 'POST',
|
|
1641
|
+
...(body === undefined ? {} : { body }),
|
|
1642
|
+
});
|
|
1643
|
+
const data = (await response.json().catch(() => undefined)) as TData;
|
|
1644
|
+
return { ok: response.ok, status: response.status, data };
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
${callables}`;
|
|
1648
|
+
}
|
|
1649
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
921
1650
|
// HubSpot project names become part of URLs and must agree with the scaffolded
|
|
922
1651
|
// package.json name and the deploy's project identity. Slugify to a lowercase,
|
|
923
1652
|
// url-safe form so e.g. "HSX E2E 006" -> "hsx-e2e-006" rather than a spaced name
|
|
@@ -959,9 +1688,14 @@ function renderRuntimeAppMetadata(options) {
|
|
|
959
1688
|
conditionallyRequiredScopes: [],
|
|
960
1689
|
},
|
|
961
1690
|
permittedUrls: {
|
|
962
|
-
fetch
|
|
963
|
-
|
|
964
|
-
|
|
1691
|
+
// The worker URL is always fetch-permitted (UI extensions and the
|
|
1692
|
+
// runtime call it); author-declared fetch hosts are merged in.
|
|
1693
|
+
fetch: [
|
|
1694
|
+
options.runtimeBaseUrl,
|
|
1695
|
+
...(options.permittedUrls?.fetch ?? []).filter((url) => url !== options.runtimeBaseUrl),
|
|
1696
|
+
],
|
|
1697
|
+
iframe: [...(options.permittedUrls?.iframe ?? [])],
|
|
1698
|
+
img: [...(options.permittedUrls?.img ?? [])],
|
|
965
1699
|
},
|
|
966
1700
|
support: {
|
|
967
1701
|
supportEmail: 'support@example.com',
|
|
@@ -1035,7 +1769,9 @@ function renderAppObjectMetadata(object) {
|
|
|
1035
1769
|
...(object.appPrefix ? { appPrefix: object.appPrefix } : {}),
|
|
1036
1770
|
primaryDisplayLabelPropertyName: object.primaryDisplayLabelPropertyName,
|
|
1037
1771
|
...(object.secondaryDisplayLabelPropertyNames
|
|
1038
|
-
? {
|
|
1772
|
+
? {
|
|
1773
|
+
secondaryDisplayLabelPropertyNames: object.secondaryDisplayLabelPropertyNames,
|
|
1774
|
+
}
|
|
1039
1775
|
: {}),
|
|
1040
1776
|
...(object.requiredProperties ? { requiredProperties: object.requiredProperties } : {}),
|
|
1041
1777
|
...(object.searchableProperties
|
|
@@ -1191,14 +1927,24 @@ function hubSpotWorkflowInputFields(fields) {
|
|
|
1191
1927
|
else if (options) {
|
|
1192
1928
|
typeDefinition.options = options;
|
|
1193
1929
|
}
|
|
1194
|
-
const
|
|
1930
|
+
const declaredValueTypes = Array.isArray(record.supportedValueTypes)
|
|
1195
1931
|
? record.supportedValueTypes.filter((value) => typeof value === 'string')
|
|
1196
|
-
: [
|
|
1932
|
+
: [];
|
|
1933
|
+
// HubSpot rejects an input field that declares more than one
|
|
1934
|
+
// supportedValueType ("must have exactly one supportedValueType"), so fail
|
|
1935
|
+
// here with a clear message rather than letting the HubSpot build error out.
|
|
1936
|
+
if (declaredValueTypes.length > 1) {
|
|
1937
|
+
throw new Error(`Workflow action input field "${name}" declares ${declaredValueTypes.length} supportedValueTypes (${declaredValueTypes.join(', ')}). HubSpot requires exactly one per field — choose STATIC_VALUE (user enters a value) or OBJECT_PROPERTY (user picks a property or a prior action's output).`);
|
|
1938
|
+
}
|
|
1939
|
+
const supportedValueTypes = declaredValueTypes.length === 1 ? declaredValueTypes : ['STATIC_VALUE'];
|
|
1940
|
+
// Match HubSpot's platform default: input fields are optional unless the
|
|
1941
|
+
// author opts in. (HubSpot's custom-action schema treats isRequired as
|
|
1942
|
+
// false-by-default.) Honor either `isRequired` or the `required` alias.
|
|
1197
1943
|
const isRequired = typeof record.isRequired === 'boolean'
|
|
1198
1944
|
? record.isRequired
|
|
1199
1945
|
: typeof record.required === 'boolean'
|
|
1200
1946
|
? record.required
|
|
1201
|
-
:
|
|
1947
|
+
: false;
|
|
1202
1948
|
return {
|
|
1203
1949
|
typeDefinition,
|
|
1204
1950
|
supportedValueTypes,
|
|
@@ -1337,7 +2083,45 @@ function renderMigratedRootPackage(appName) {
|
|
|
1337
2083
|
},
|
|
1338
2084
|
}, null, 2)}\n`;
|
|
1339
2085
|
}
|
|
1340
|
-
function
|
|
2086
|
+
function collectMigratedCardRuntimeDependencies(cards) {
|
|
2087
|
+
const importedPackages = new Set();
|
|
2088
|
+
const declaredDependencies = new Map();
|
|
2089
|
+
for (const card of cards) {
|
|
2090
|
+
for (const [file, contents] of Object.entries(card.sources ?? {})) {
|
|
2091
|
+
if (file === 'package.json' || file.endsWith('/package.json')) {
|
|
2092
|
+
try {
|
|
2093
|
+
const pkg = JSON.parse(contents);
|
|
2094
|
+
for (const [name, version] of Object.entries({
|
|
2095
|
+
...(pkg.dependencies ?? {}),
|
|
2096
|
+
...(pkg.optionalDependencies ?? {}),
|
|
2097
|
+
})) {
|
|
2098
|
+
if (typeof version === 'string')
|
|
2099
|
+
declaredDependencies.set(name, version);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
catch {
|
|
2103
|
+
// Invalid legacy package metadata is ignored; the normalized base
|
|
2104
|
+
// dependencies still produce a valid card package.
|
|
2105
|
+
}
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
for (const match of contents.matchAll(/(?:\bfrom\s+|\bimport\s*\(\s*|\brequire\s*\(\s*)(["'])([^"'./][^"']*)\1/g)) {
|
|
2109
|
+
const specifier = match[2];
|
|
2110
|
+
if (!specifier)
|
|
2111
|
+
continue;
|
|
2112
|
+
const segments = specifier.split('/');
|
|
2113
|
+
importedPackages.add(specifier.startsWith('@') ? segments.slice(0, 2).join('/') : (segments[0] ?? specifier));
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
return Object.fromEntries([...declaredDependencies]
|
|
2118
|
+
.filter(([name]) => importedPackages.has(name) &&
|
|
2119
|
+
name !== '@hubspot/ui-extensions-dev-server' &&
|
|
2120
|
+
name !== '@hubspot/ui-extensions' &&
|
|
2121
|
+
name !== 'react')
|
|
2122
|
+
.sort(([a], [b]) => a.localeCompare(b)));
|
|
2123
|
+
}
|
|
2124
|
+
function renderMigratedCardsPackage(runtimeDependencies = {}) {
|
|
1341
2125
|
return `${JSON.stringify({
|
|
1342
2126
|
name: 'hs-x-migrated-cards',
|
|
1343
2127
|
version: '0.1.0',
|
|
@@ -1345,6 +2129,7 @@ function renderMigratedCardsPackage() {
|
|
|
1345
2129
|
dependencies: {
|
|
1346
2130
|
'@hubspot/ui-extensions': 'latest',
|
|
1347
2131
|
react: '^18.2.0',
|
|
2132
|
+
...runtimeDependencies,
|
|
1348
2133
|
},
|
|
1349
2134
|
}, null, 2)}\n`;
|
|
1350
2135
|
}
|