@aws-blocks/core 0.1.7 → 0.1.10
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/dist/db-naming.d.ts +17 -5
- package/dist/db-naming.d.ts.map +1 -1
- package/dist/db-naming.js +18 -6
- package/dist/db-naming.test.js +44 -3
- package/dist/hosting.d.ts +49 -0
- package/dist/hosting.d.ts.map +1 -1
- package/dist/hosting.js +47 -8
- package/dist/hosting.test.js +60 -0
- package/dist/scripts/deploy.d.ts.map +1 -1
- package/dist/scripts/deploy.js +4 -2
- package/dist/scripts/ensure-secrets.d.ts +5 -2
- package/dist/scripts/ensure-secrets.d.ts.map +1 -1
- package/dist/scripts/ensure-secrets.js +14 -6
- package/dist/scripts/external-migrations-step.d.ts.map +1 -1
- package/dist/scripts/external-migrations-step.js +5 -1
- package/dist/scripts/index.d.ts +1 -1
- package/dist/scripts/index.d.ts.map +1 -1
- package/dist/scripts/index.js +1 -1
- package/dist/scripts/sandbox.d.ts.map +1 -1
- package/dist/scripts/sandbox.js +10 -1
- package/dist/scripts/stack-id.d.ts +25 -0
- package/dist/scripts/stack-id.d.ts.map +1 -1
- package/dist/scripts/stack-id.js +25 -0
- package/dist/scripts/stack-id.test.js +51 -1
- package/dist/telemetry/client.d.ts +3 -1
- package/dist/telemetry/client.d.ts.map +1 -1
- package/dist/telemetry/client.js +20 -24
- package/dist/telemetry/telemetry-send-worker.d.ts +2 -0
- package/dist/telemetry/telemetry-send-worker.d.ts.map +1 -0
- package/dist/telemetry/telemetry-send-worker.js +58 -0
- package/dist/telemetry/telemetry.test.js +77 -1
- package/dist/telemetry/trackCommand.d.ts +1 -1
- package/dist/telemetry/trackCommand.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/db-naming.test.ts +50 -5
- package/src/db-naming.ts +18 -6
- package/src/hosting.test.ts +79 -0
- package/src/hosting.ts +105 -12
- package/src/scripts/deploy.ts +4 -2
- package/src/scripts/ensure-secrets.ts +17 -6
- package/src/scripts/external-migrations-step.ts +5 -1
- package/src/scripts/index.ts +1 -1
- package/src/scripts/sandbox.ts +10 -1
- package/src/scripts/stack-id.test.ts +61 -1
- package/src/scripts/stack-id.ts +26 -0
- package/src/telemetry/client.ts +22 -30
- package/src/telemetry/telemetry-send-worker.ts +60 -0
- package/src/telemetry/telemetry.test.ts +91 -1
- package/src/telemetry/trackCommand.ts +3 -3
- package/src/version.ts +1 -1
package/src/hosting.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import {
|
|
27
27
|
detectFramework,
|
|
28
28
|
getAdapter,
|
|
29
|
+
normalizeBasePath,
|
|
29
30
|
type FrameworkAdapterFn,
|
|
30
31
|
} from '@aws-blocks/hosting/adapters';
|
|
31
32
|
import type {
|
|
@@ -150,6 +151,27 @@ export interface HostingProps {
|
|
|
150
151
|
/** Supply a custom adapter when using an unsupported framework. */
|
|
151
152
|
customAdapter?: FrameworkAdapterFn;
|
|
152
153
|
|
|
154
|
+
/**
|
|
155
|
+
* URL prefix the whole site is served under (Next.js `basePath`, Astro
|
|
156
|
+
* `base`, Nuxt `app.baseURL`). When set, CloudFront behaviors are prefixed
|
|
157
|
+
* with it and the bare root issues a 308 redirect to `/<basePath>/`.
|
|
158
|
+
*
|
|
159
|
+
* Declaring it here is the recommended source of truth: the value is
|
|
160
|
+
* caller-provided rather than reverse-engineered from build output, so it
|
|
161
|
+
* can't drift with framework/bundler internals. When omitted, the adapter
|
|
162
|
+
* falls back to detecting the framework's own base-path config from the
|
|
163
|
+
* build output.
|
|
164
|
+
*
|
|
165
|
+
* Format: leading slash, no trailing slash (e.g. `'/app'`). A trailing
|
|
166
|
+
* slash or bare `'/'` is normalized/ignored.
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ```ts
|
|
170
|
+
* new Hosting(stack, 'Web', { root, framework: 'nuxt', basePath: '/app' });
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
basePath?: string;
|
|
174
|
+
|
|
153
175
|
// ── Blocks backend integration ────────────────────────────────────
|
|
154
176
|
/**
|
|
155
177
|
* The Blocks backend stack (or any object with `apiUrl`).
|
|
@@ -204,6 +226,36 @@ export interface HostingProps {
|
|
|
204
226
|
countries: string[];
|
|
205
227
|
};
|
|
206
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Overrides for the adjustable AWS Service Quotas the CloudFront
|
|
231
|
+
* distribution draws on. Each field maps to a named AWS quota you can
|
|
232
|
+
* request an increase on:
|
|
233
|
+
*
|
|
234
|
+
* - `cacheBehaviors` — "Cache behaviors per distribution" (default 25).
|
|
235
|
+
* Consumed by routed paths, prerendered pages, per-pattern header
|
|
236
|
+
* rules, assetPrefix, and the error-page behavior.
|
|
237
|
+
* - `edgeFunctions` — Lambda@Edge associations per distribution
|
|
238
|
+
* (default 25). Consumed by `runtime: 'edge'` routes.
|
|
239
|
+
* - `headerPolicies` — "Response headers policies per AWS account"
|
|
240
|
+
* (default 20, account-wide).
|
|
241
|
+
*
|
|
242
|
+
* Omitted fields use the AWS default. Set a field ONLY to match a quota
|
|
243
|
+
* increase AWS has actually granted — synth cannot verify your real quota,
|
|
244
|
+
* so an over-set value does not raise the AWS ceiling; it just moves the
|
|
245
|
+
* failure from a clear synth error to an opaque CloudFormation rollback.
|
|
246
|
+
*
|
|
247
|
+
* @example
|
|
248
|
+
* ```ts
|
|
249
|
+
* // After AWS grants "Cache behaviors per distribution" = 50:
|
|
250
|
+
* new Hosting(stack, 'Web', { root, quotas: { cacheBehaviors: 50 } });
|
|
251
|
+
* ```
|
|
252
|
+
*/
|
|
253
|
+
quotas?: {
|
|
254
|
+
cacheBehaviors?: number;
|
|
255
|
+
edgeFunctions?: number;
|
|
256
|
+
headerPolicies?: number;
|
|
257
|
+
};
|
|
258
|
+
|
|
207
259
|
/**
|
|
208
260
|
* Build cache configuration. When enabled, provisions an S3 bucket for
|
|
209
261
|
* framework build caches (e.g. Next.js .next/cache) and exports the bucket
|
|
@@ -401,6 +453,23 @@ export class Hosting extends Construct {
|
|
|
401
453
|
manifest.buildId = generateBuildId();
|
|
402
454
|
}
|
|
403
455
|
|
|
456
|
+
// ── 4b'. basePath: prop is the source of truth ───────────────
|
|
457
|
+
// A caller-declared `basePath` overrides whatever the adapter
|
|
458
|
+
// detected from build output. This is the robust path: the value
|
|
459
|
+
// is provided rather than reverse-engineered from framework/bundler
|
|
460
|
+
// internals (which drift across versions). When the prop is omitted,
|
|
461
|
+
// the adapter's detected `manifest.basePath` (if any) stands.
|
|
462
|
+
if (props.basePath !== undefined) {
|
|
463
|
+
const normalized = normalizeBasePath(props.basePath);
|
|
464
|
+
if (normalized) {
|
|
465
|
+
manifest.basePath = normalized;
|
|
466
|
+
} else {
|
|
467
|
+
// Explicit '/' (or empty) means "no base path" — clear any value
|
|
468
|
+
// the adapter may have detected so the prop genuinely wins.
|
|
469
|
+
delete manifest.basePath;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
404
473
|
// ── 4c. Prevent duplicate error pages ────────────────────────
|
|
405
474
|
// The adapter may auto-detect error pages (e.g. SPA adapter finds
|
|
406
475
|
// 404.html in build output and sets manifest.errorPages). When the
|
|
@@ -470,11 +539,12 @@ export class Hosting extends Construct {
|
|
|
470
539
|
storage: props.retainOnDelete != null
|
|
471
540
|
? { retainOnDelete: props.retainOnDelete }
|
|
472
541
|
: undefined,
|
|
473
|
-
cdn: (props.contentSecurityPolicy || props.priceClass || props.geoRestriction)
|
|
542
|
+
cdn: (props.contentSecurityPolicy || props.priceClass || props.geoRestriction || props.quotas)
|
|
474
543
|
? {
|
|
475
544
|
contentSecurityPolicy: props.contentSecurityPolicy,
|
|
476
545
|
priceClass: props.priceClass,
|
|
477
546
|
geoRestriction: props.geoRestriction,
|
|
547
|
+
quotas: props.quotas,
|
|
478
548
|
}
|
|
479
549
|
: undefined,
|
|
480
550
|
logging: props.logging,
|
|
@@ -492,17 +562,26 @@ export class Hosting extends Construct {
|
|
|
492
562
|
}
|
|
493
563
|
|
|
494
564
|
// ── 7a. Inject Blocks env vars into compute functions ───────────
|
|
495
|
-
|
|
565
|
+
// Lambda@Edge functions (edge-runtime routes) do NOT support environment
|
|
566
|
+
// variables — they surface in computeFunctions as EdgeFunction/IVersion
|
|
567
|
+
// without an `addEnvironment` method. Skip any function that can't take
|
|
568
|
+
// env vars instead of crashing (`fn.addEnvironment is not a function`).
|
|
569
|
+
const canAddEnv = (
|
|
570
|
+
fn: unknown,
|
|
571
|
+
): fn is cdk.aws_lambda.Function =>
|
|
572
|
+
typeof (fn as { addEnvironment?: unknown })?.addEnvironment === 'function';
|
|
573
|
+
|
|
574
|
+
const primaryFunction = [...hosting.computeFunctions.values()].find(
|
|
575
|
+
canAddEnv,
|
|
576
|
+
);
|
|
496
577
|
|
|
497
578
|
for (const [, fn] of hosting.computeFunctions) {
|
|
579
|
+
if (!canAddEnv(fn)) continue; // Lambda@Edge: no env var support
|
|
498
580
|
if (props.api) {
|
|
499
|
-
|
|
581
|
+
fn.addEnvironment('BLOCKS_API_URL', props.api.apiUrl);
|
|
500
582
|
}
|
|
501
583
|
if (props.backendConfig) {
|
|
502
|
-
(
|
|
503
|
-
'BLOCKS_CONFIG',
|
|
504
|
-
JSON.stringify(props.backendConfig),
|
|
505
|
-
);
|
|
584
|
+
fn.addEnvironment('BLOCKS_CONFIG', JSON.stringify(props.backendConfig));
|
|
506
585
|
}
|
|
507
586
|
}
|
|
508
587
|
|
|
@@ -521,11 +600,25 @@ export class Hosting extends Construct {
|
|
|
521
600
|
cacheControl: [s3deploy.CacheControl.fromString('public, max-age=60, must-revalidate')],
|
|
522
601
|
});
|
|
523
602
|
|
|
524
|
-
// Ensure config deployment runs
|
|
525
|
-
// asset
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
603
|
+
// Ensure the config deployment runs AFTER the hosting construct's
|
|
604
|
+
// asset deployments. Those deployments upload the whole static dir —
|
|
605
|
+
// which includes the *placeholder* `.blocks-sandbox/config.json`
|
|
606
|
+
// (`{_placeholder:true}`) written during synth — to the same
|
|
607
|
+
// `builds/<id>/.blocks-sandbox/config.json` key this deployment writes
|
|
608
|
+
// the resolved config to. Without an ordering dependency the
|
|
609
|
+
// placeholder can land last and clobber the real config.
|
|
610
|
+
//
|
|
611
|
+
// We depend on EVERY BucketDeployment under the hosting construct
|
|
612
|
+
// rather than a single hard-coded child id: the real children are
|
|
613
|
+
// `AssetDeploymentImmutable` / `AssetDeploymentHtml` / `...Mutable`
|
|
614
|
+
// (and vary by deploy shape), so the previous
|
|
615
|
+
// `tryFindChild('AssetDeployment')` never matched and the dependency
|
|
616
|
+
// was silently never wired.
|
|
617
|
+
const assetDeployments = hosting.node
|
|
618
|
+
.findAll()
|
|
619
|
+
.filter((c): c is s3deploy.BucketDeployment => c instanceof s3deploy.BucketDeployment);
|
|
620
|
+
for (const dep of assetDeployments) {
|
|
621
|
+
configDeployment.node.addDependency(dep);
|
|
529
622
|
}
|
|
530
623
|
}
|
|
531
624
|
|
package/src/scripts/deploy.ts
CHANGED
|
@@ -25,8 +25,10 @@ export async function deploy(options: DeployOptions) {
|
|
|
25
25
|
|
|
26
26
|
process.env.BLOCKS_STAGE = 'production';
|
|
27
27
|
|
|
28
|
-
// Provision secrets for production
|
|
29
|
-
|
|
28
|
+
// Provision secrets for production. projectRoot must match the root cdk
|
|
29
|
+
// synth uses (passed as --context below) so the written parameter name
|
|
30
|
+
// equals the one the app resolves at synth.
|
|
31
|
+
const secrets = await ensureSecrets('production', options.projectRoot);
|
|
30
32
|
if (secrets.created.length > 0 || secrets.updated.length > 0) {
|
|
31
33
|
console.log(`🔐 Secrets provisioned: ${[...secrets.created, ...secrets.updated].join(', ')}`);
|
|
32
34
|
}
|
|
@@ -4,15 +4,20 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Pre-deploy secret provisioning.
|
|
6
6
|
*
|
|
7
|
-
* Writes the connection string to an SSM SecureString parameter.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Writes the connection string to an SSM SecureString parameter. The parameter
|
|
8
|
+
* name is stack-scoped (`/<stackName>-db-url` via `dbConnectionParameterName`),
|
|
9
|
+
* so two Blocks apps in the same account/region/stage never collide. The synth
|
|
10
|
+
* step names the parameter with the same function and the same inputs
|
|
11
|
+
* (`projectRoot` + stage), so the value written here is read back under the
|
|
12
|
+
* identical name — which is why this must be given the same `projectRoot` the
|
|
13
|
+
* deploy command passes to synth.
|
|
10
14
|
*
|
|
11
15
|
* On first deploy: creates the parameter.
|
|
12
16
|
* On subsequent deploys: updates if value changed, no-op otherwise.
|
|
13
17
|
*/
|
|
14
18
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
19
|
import { dbConnectionParameterName } from '../db-naming.js';
|
|
20
|
+
import { getStackName } from './stack-id.js';
|
|
16
21
|
|
|
17
22
|
const CONNECTION_STRING_PATTERN = /_(DB_URL|CONNECTION_STRING)$/;
|
|
18
23
|
|
|
@@ -32,9 +37,15 @@ export function findConnectionString(): { name: string; value: string } | null {
|
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
/**
|
|
35
|
-
* Ensure the connection string is stored in SSM
|
|
40
|
+
* Ensure the connection string is stored in SSM under this app's stack-scoped
|
|
41
|
+
* parameter name. `projectRoot` locates the committed `.blocks/config.json`
|
|
42
|
+
* that defines the stack name; it must match the root used at synth (the deploy
|
|
43
|
+
* commands pass it explicitly) so the written name equals the name the app reads.
|
|
36
44
|
*/
|
|
37
|
-
export async function ensureSecrets(
|
|
45
|
+
export async function ensureSecrets(
|
|
46
|
+
stage?: string,
|
|
47
|
+
projectRoot?: string,
|
|
48
|
+
): Promise<EnsureSecretsResult> {
|
|
38
49
|
const result: EnsureSecretsResult = { created: [], updated: [], unchanged: [] };
|
|
39
50
|
|
|
40
51
|
const conn = findConnectionString();
|
|
@@ -46,7 +57,7 @@ export async function ensureSecrets(stage?: 'sandbox' | 'production'): Promise<E
|
|
|
46
57
|
await import('@aws-sdk/client-ssm');
|
|
47
58
|
|
|
48
59
|
const client = new SSMClient();
|
|
49
|
-
const parameterName = dbConnectionParameterName(resolvedStage);
|
|
60
|
+
const parameterName = dbConnectionParameterName(getStackName({ sandbox: resolvedStage !== 'production', projectRoot }));
|
|
50
61
|
|
|
51
62
|
let isNew = false;
|
|
52
63
|
try {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
28
28
|
import { findConnectionString } from './ensure-secrets.js';
|
|
29
29
|
import { extractDbRef, dbConnectionParameterName } from '../db-naming.js';
|
|
30
|
+
import { getStackName } from './stack-id.js';
|
|
30
31
|
import { runSync } from './run-command.js';
|
|
31
32
|
|
|
32
33
|
const DEFAULT_MIGRATIONS_DIR = './migrations';
|
|
@@ -170,7 +171,10 @@ async function productionRefs(devRef: string): Promise<Set<string>> {
|
|
|
170
171
|
try {
|
|
171
172
|
const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
172
173
|
const res = await new SSMClient().send(
|
|
173
|
-
new GetParameterCommand({
|
|
174
|
+
new GetParameterCommand({
|
|
175
|
+
Name: dbConnectionParameterName(getStackName({ sandbox: false })),
|
|
176
|
+
WithDecryption: true,
|
|
177
|
+
}),
|
|
174
178
|
);
|
|
175
179
|
const v = res.Parameter?.Value;
|
|
176
180
|
const r = v ? safeRef(v) : null;
|
package/src/scripts/index.ts
CHANGED
|
@@ -19,4 +19,4 @@ export {
|
|
|
19
19
|
type BuildAndSendEventOptions,
|
|
20
20
|
} from '../telemetry/index.js';
|
|
21
21
|
export { telemetry, type TelemetryOptions } from './telemetry.js';
|
|
22
|
-
export { getStackId, getSandboxId } from './stack-id.js';
|
|
22
|
+
export { getStackId, getSandboxId, getStackName } from './stack-id.js';
|
package/src/scripts/sandbox.ts
CHANGED
|
@@ -51,7 +51,9 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
51
51
|
|
|
52
52
|
// Provision connection string to SSM SecureString.
|
|
53
53
|
// On first deploy, creates the parameter. On subsequent deploys, updates if changed.
|
|
54
|
-
|
|
54
|
+
// projectRoot is process.cwd() — the same value passed to cdk as --context
|
|
55
|
+
// projectRoot below — so the written name matches the name resolved at synth.
|
|
56
|
+
const secrets = await ensureSecrets('sandbox', process.cwd());
|
|
55
57
|
if (secrets.created.length > 0) {
|
|
56
58
|
console.log(`🔐 Created secrets: ${secrets.created.join(', ')}`);
|
|
57
59
|
}
|
|
@@ -75,6 +77,13 @@ export async function startSandbox(options: SandboxOptions) {
|
|
|
75
77
|
"npm",
|
|
76
78
|
[
|
|
77
79
|
"exec", "cdk", "--", "deploy",
|
|
80
|
+
// `--all`: an app that uses Lambda@Edge (e.g. a Next.js route with
|
|
81
|
+
// `export const runtime = 'edge'`) synthesizes a SECOND stack
|
|
82
|
+
// (`edge-lambda-stack-*`, region us-east-1) in addition to the main
|
|
83
|
+
// hosting stack. Without `--all`, CDK refuses with "specify which
|
|
84
|
+
// stacks to use". Deploying every stack in a sandbox app is the
|
|
85
|
+
// intended behavior, so select them all.
|
|
86
|
+
"--all",
|
|
78
87
|
"--require-approval", "never",
|
|
79
88
|
"--outputs-file", `${outDir}/outputs.json`,
|
|
80
89
|
"--context", `projectRoot=${process.cwd()}`,
|
|
@@ -7,7 +7,7 @@ import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
|
|
10
|
-
import { getStackId, getSandboxId } from './stack-id.js';
|
|
10
|
+
import { getStackId, getSandboxId, getStackName } from './stack-id.js';
|
|
11
11
|
|
|
12
12
|
describe('getStackId', () => {
|
|
13
13
|
let tmpDir: string;
|
|
@@ -61,3 +61,63 @@ describe('getSandboxId', () => {
|
|
|
61
61
|
assert.strictEqual(getSandboxId(tmpDir), 'alice-abc123');
|
|
62
62
|
});
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
describe('getStackName', () => {
|
|
66
|
+
let tmpDir: string;
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('production is <stackId>-prod', () => {
|
|
73
|
+
tmpDir = join(tmpdir(), `stack-name-prod-${Date.now()}`);
|
|
74
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
75
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'my-app-k7x2mf' }));
|
|
76
|
+
assert.strictEqual(getStackName({ sandbox: false, projectRoot: tmpDir }), 'my-app-k7x2mf-prod');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('sandbox is <stackId>-<sandboxId>', () => {
|
|
80
|
+
tmpDir = join(tmpdir(), `stack-name-sbx-${Date.now()}`);
|
|
81
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
82
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'my-app-k7x2mf' }));
|
|
83
|
+
mkdirSync(join(tmpDir, '.blocks-sandbox'), { recursive: true });
|
|
84
|
+
writeFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'alice-0d7e1c');
|
|
85
|
+
assert.strictEqual(getStackName({ sandbox: true, projectRoot: tmpDir }), 'my-app-k7x2mf-alice-0d7e1c');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('throws actionable error when config is missing (fail fast, no silent fallback)', () => {
|
|
89
|
+
tmpDir = join(tmpdir(), `stack-name-missing-${Date.now()}`);
|
|
90
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
91
|
+
assert.throws(() => getStackName({ sandbox: false, projectRoot: tmpDir }), /\.blocks\/config\.json not found/);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('getStackName sandbox id (get-or-create)', () => {
|
|
96
|
+
let tmpDir: string;
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('creates and persists sandbox-id.txt on first call when missing', () => {
|
|
103
|
+
tmpDir = join(tmpdir(), `stack-name-getorcreate-${Date.now()}`);
|
|
104
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
105
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'test-app' }));
|
|
106
|
+
// No .blocks-sandbox dir yet — getStackName creates the id rather than throwing.
|
|
107
|
+
const name = getStackName({ sandbox: true, projectRoot: tmpDir });
|
|
108
|
+
assert.match(name, /^test-app-[a-z0-9]+-[a-f0-9]{6}$/);
|
|
109
|
+
// Persisted so later callers/processes resolve the identical name.
|
|
110
|
+
const stored = readFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'utf-8').trim();
|
|
111
|
+
assert.strictEqual(name, `test-app-${stored}`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('reuses the same id across calls', () => {
|
|
115
|
+
tmpDir = join(tmpdir(), `stack-name-getorcreate-idem-${Date.now()}`);
|
|
116
|
+
mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
|
|
117
|
+
writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'test-app' }));
|
|
118
|
+
assert.strictEqual(
|
|
119
|
+
getStackName({ sandbox: true, projectRoot: tmpDir }),
|
|
120
|
+
getStackName({ sandbox: true, projectRoot: tmpDir }),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/scripts/stack-id.ts
CHANGED
|
@@ -38,6 +38,11 @@ export function getStackId(projectRoot?: string): string {
|
|
|
38
38
|
* Get or create a per-machine sandbox identifier.
|
|
39
39
|
* Stored in `.blocks-sandbox/sandbox-id.txt` (gitignored).
|
|
40
40
|
* Format: `<username(8)>-<random(6)>` — identifies the developer's sandbox.
|
|
41
|
+
*
|
|
42
|
+
* Get-or-create (lazy init): returns the existing id, or generates and persists
|
|
43
|
+
* one on first call. The file is the shared sync point — once written, every
|
|
44
|
+
* later caller and every process reads the same id, so the secret writer
|
|
45
|
+
* (`ensureSecrets`) and synth derive identical names.
|
|
41
46
|
*/
|
|
42
47
|
export function getSandboxId(projectRoot?: string): string {
|
|
43
48
|
const root = projectRoot || process.cwd();
|
|
@@ -52,6 +57,27 @@ export function getSandboxId(projectRoot?: string): string {
|
|
|
52
57
|
return id;
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The full CloudFormation stack name for a deployment.
|
|
62
|
+
*
|
|
63
|
+
* Single source of truth for the stack-name scheme (D-012): production is
|
|
64
|
+
* `<stackId>-prod`; a sandbox is `<stackId>-<sandboxId>`. The CDK templates name
|
|
65
|
+
* the stack with this function, and the external-DB connection-string parameter
|
|
66
|
+
* name (`dbConnectionParameterName`) is derived from it — so a deployed stack and
|
|
67
|
+
* the parameter holding its database credentials can never use divergent names.
|
|
68
|
+
*
|
|
69
|
+
* This function reads committed config (`.blocks/config.json`, throws if absent
|
|
70
|
+
* — D-012) and resolves the sandbox id via {@link getSandboxId} (get-or-create):
|
|
71
|
+
* the first caller materializes `.blocks-sandbox/sandbox-id.txt`, every later
|
|
72
|
+
* caller reads the same value. Because that file persists and is shared across
|
|
73
|
+
* processes, the secret writer (`ensureSecrets`) and synth resolve identical
|
|
74
|
+
* names. Production does not use the sandbox id.
|
|
75
|
+
*/
|
|
76
|
+
export function getStackName(opts: { sandbox: boolean; projectRoot?: string }): string {
|
|
77
|
+
const base = getStackId(opts.projectRoot);
|
|
78
|
+
return opts.sandbox ? `${base}-${getSandboxId(opts.projectRoot)}` : `${base}-prod`;
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
function getUsername(): string {
|
|
56
82
|
try {
|
|
57
83
|
return execSync('git config user.name', { encoding: 'utf-8' }).trim();
|
package/src/telemetry/client.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { request as httpsRequest } from 'node:https';
|
|
2
|
-
import { request as httpRequest } from 'node:http';
|
|
3
1
|
import { existsSync, readFileSync, mkdirSync, openSync, writeSync, closeSync, writeFileSync, constants } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
4
3
|
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { debuglog } from 'node:util';
|
|
6
6
|
import { CORE_VERSION } from '../version.js';
|
|
7
7
|
import { Scope } from '../common/index.js';
|
|
@@ -14,7 +14,6 @@ import type { BlocksTelemetryEvent, BuildAndSendEventOptions } from './types.js'
|
|
|
14
14
|
const debug = debuglog('blocks-telemetry');
|
|
15
15
|
|
|
16
16
|
const DEFAULT_ENDPOINT = 'https://blocks-telemetry.us-east-1.api.aws/metrics';
|
|
17
|
-
const TIMEOUT_MS = 500;
|
|
18
17
|
const TELEMETRY_VERSION = '1.0.0';
|
|
19
18
|
|
|
20
19
|
function getEndpoint(): string {
|
|
@@ -179,7 +178,9 @@ export function buildAndSendEvent(opts: BuildAndSendEventOptions): void {
|
|
|
179
178
|
/**
|
|
180
179
|
* Send a pre-built telemetry event to the collection endpoint.
|
|
181
180
|
*
|
|
182
|
-
*
|
|
181
|
+
* Spawns a detached subprocess that performs the HTTPS POST independently of
|
|
182
|
+
* the parent CLI process. This ensures the request completes even when the
|
|
183
|
+
* parent exits on failure paths before an in-process request would flush.
|
|
183
184
|
* Debug output available via `NODE_DEBUG=blocks-telemetry`.
|
|
184
185
|
*/
|
|
185
186
|
export function sendEvent(event: BlocksTelemetryEvent): void {
|
|
@@ -194,32 +195,23 @@ export function sendEvent(event: BlocksTelemetryEvent): void {
|
|
|
194
195
|
|
|
195
196
|
debug('sending event to %s (%d bytes)', endpoint, Buffer.byteLength(payload));
|
|
196
197
|
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
debug('event sent (status=%d)', res.statusCode);
|
|
215
|
-
res.resume();
|
|
216
|
-
},
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
req.on('error', (err) => { debug('send failed: %s', err.message); });
|
|
220
|
-
req.on('timeout', () => { debug('send timed out'); req.destroy(); });
|
|
221
|
-
req.write(payload);
|
|
222
|
-
req.end();
|
|
198
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
199
|
+
const workerPath = path.join(dir, 'telemetry-send-worker.js');
|
|
200
|
+
const child = spawn(process.execPath, [workerPath, endpoint], {
|
|
201
|
+
detached: true,
|
|
202
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
203
|
+
// Clear NODE_OPTIONS so inherited flags (e.g. --conditions=cdk) don't interfere
|
|
204
|
+
env: { ...process.env, NODE_OPTIONS: '' },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
child.stdin!.on('error', (err) => { debug('stdin write failed: %s', err.message); });
|
|
208
|
+
// Payload is small (<1KB JSON) so it fits in the kernel pipe buffer (~64KB)
|
|
209
|
+
// and survives the parent closing its fd on exit.
|
|
210
|
+
child.stdin!.write(payload);
|
|
211
|
+
child.stdin!.end();
|
|
212
|
+
child.on('error', (err) => { debug('spawn failed: %s', err.message); });
|
|
213
|
+
child.unref();
|
|
214
|
+
debug('spawned telemetry subprocess (pid=%d)', child.pid);
|
|
223
215
|
} catch {
|
|
224
216
|
// Telemetry must never throw or affect the user's command
|
|
225
217
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Telemetry send worker — spawned as a detached subprocess.
|
|
6
|
+
* Reads JSON payload from stdin, POSTs it to the endpoint (argv[2]).
|
|
7
|
+
*
|
|
8
|
+
* Uses only Node built-ins — no project imports — so the compiled .js
|
|
9
|
+
* runs with bare `node` (no tsx needed).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { request as httpsRequest } from 'node:https';
|
|
13
|
+
import { request as httpRequest } from 'node:http';
|
|
14
|
+
|
|
15
|
+
const TIMEOUT_MS = 500;
|
|
16
|
+
const debug = (process.env.NODE_DEBUG || '').includes('blocks-telemetry');
|
|
17
|
+
|
|
18
|
+
const endpoint = process.argv[2];
|
|
19
|
+
if (!endpoint) process.exit(1);
|
|
20
|
+
|
|
21
|
+
let payload = '';
|
|
22
|
+
process.stdin.setEncoding('utf-8');
|
|
23
|
+
process.stdin.on('data', (chunk: string) => { payload += chunk; });
|
|
24
|
+
process.stdin.on('end', () => {
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(endpoint);
|
|
27
|
+
const isHttps = url.protocol === 'https:';
|
|
28
|
+
const requestFn = isHttps ? httpsRequest : httpRequest;
|
|
29
|
+
|
|
30
|
+
const req = requestFn({
|
|
31
|
+
hostname: url.hostname,
|
|
32
|
+
port: url.port || (isHttps ? '443' : '80'),
|
|
33
|
+
path: url.pathname + url.search,
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
'Content-Type': 'application/json',
|
|
37
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
38
|
+
},
|
|
39
|
+
timeout: TIMEOUT_MS,
|
|
40
|
+
}, (res) => {
|
|
41
|
+
res.resume();
|
|
42
|
+
if (debug) process.stderr.write(`BLOCKS-TELEMETRY: sent (status=${res.statusCode})\n`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
req.on('error', (e) => {
|
|
47
|
+
if (debug) process.stderr.write(`BLOCKS-TELEMETRY: error: ${(e as Error).message}\n`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
req.on('timeout', () => {
|
|
51
|
+
if (debug) process.stderr.write(`BLOCKS-TELEMETRY: timed out\n`);
|
|
52
|
+
req.destroy();
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
req.write(payload);
|
|
56
|
+
req.end();
|
|
57
|
+
} catch {
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -11,7 +11,7 @@ import { isCI, detectOS, detectNodeVersion, detectPackageManager, detectAgent, c
|
|
|
11
11
|
import { trackCommand, classifyError } from './trackCommand.js';
|
|
12
12
|
import { buildAndSendEvent, buildEvent, sendEvent, getTelemetryFilePath } from './client.js';
|
|
13
13
|
import { getInstallationId, getProjectId, generateEventId } from './identifiers.js';
|
|
14
|
-
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { spawnSync, spawn as spawnChild } from 'node:child_process';
|
|
15
15
|
import type { BlocksTelemetryEvent } from './types.js';
|
|
16
16
|
import { Scope, OFFICIAL_BB_NAMES } from '../common/index.js';
|
|
17
17
|
import type { ScopeParent } from '../common/index.js';
|
|
@@ -533,6 +533,96 @@ describe('telemetry/client', () => {
|
|
|
533
533
|
});
|
|
534
534
|
});
|
|
535
535
|
|
|
536
|
+
describe('telemetry/send-worker', () => {
|
|
537
|
+
it('worker POSTs payload from stdin to endpoint', async () => {
|
|
538
|
+
const received: string[] = [];
|
|
539
|
+
|
|
540
|
+
const server: Server = await new Promise((resolve) => {
|
|
541
|
+
const s = createServer((req, res) => {
|
|
542
|
+
let body = '';
|
|
543
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
544
|
+
req.on('end', () => {
|
|
545
|
+
received.push(body);
|
|
546
|
+
res.writeHead(200);
|
|
547
|
+
res.end();
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
s.listen(0, '127.0.0.1', () => resolve(s));
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const addr = server.address() as { port: number };
|
|
554
|
+
const endpoint = `http://127.0.0.1:${addr.port}/collect`;
|
|
555
|
+
const payload = JSON.stringify({ test: true, command: 'dev' });
|
|
556
|
+
const workerPath = join(__dirname, 'telemetry-send-worker.js');
|
|
557
|
+
|
|
558
|
+
const exitCode = await new Promise<number | null>((resolve) => {
|
|
559
|
+
const proc = spawnChild(process.execPath, [workerPath, endpoint], {
|
|
560
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
561
|
+
env: { ...process.env, NODE_OPTIONS: '' },
|
|
562
|
+
});
|
|
563
|
+
proc.stdin!.write(payload);
|
|
564
|
+
proc.stdin!.end();
|
|
565
|
+
proc.on('close', (code) => resolve(code));
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
assert.strictEqual(exitCode, 0);
|
|
569
|
+
assert.strictEqual(received.length, 1);
|
|
570
|
+
assert.deepStrictEqual(JSON.parse(received[0]), { test: true, command: 'dev' });
|
|
571
|
+
|
|
572
|
+
server.close();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('worker exits with 1 on unreachable endpoint', async () => {
|
|
576
|
+
const payload = JSON.stringify({ test: true });
|
|
577
|
+
const workerPath = join(__dirname, 'telemetry-send-worker.js');
|
|
578
|
+
|
|
579
|
+
const exitCode = await new Promise<number | null>((resolve) => {
|
|
580
|
+
const proc = spawnChild(process.execPath, [workerPath, 'http://127.0.0.1:1/unreachable'], {
|
|
581
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
582
|
+
env: { ...process.env, NODE_OPTIONS: '' },
|
|
583
|
+
});
|
|
584
|
+
proc.stdin!.write(payload);
|
|
585
|
+
proc.stdin!.end();
|
|
586
|
+
proc.on('close', (code) => resolve(code));
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
assert.strictEqual(exitCode, 1);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('worker writes debug output to stderr when NODE_DEBUG is set', async () => {
|
|
593
|
+
const server: Server = await new Promise((resolve) => {
|
|
594
|
+
const s = createServer((req, res) => {
|
|
595
|
+
let body = '';
|
|
596
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
597
|
+
req.on('end', () => { res.writeHead(200); res.end(); });
|
|
598
|
+
});
|
|
599
|
+
s.listen(0, '127.0.0.1', () => resolve(s));
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const addr = server.address() as { port: number };
|
|
603
|
+
const endpoint = `http://127.0.0.1:${addr.port}/collect`;
|
|
604
|
+
const payload = JSON.stringify({ test: true });
|
|
605
|
+
const workerPath = join(__dirname, 'telemetry-send-worker.js');
|
|
606
|
+
|
|
607
|
+
const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => {
|
|
608
|
+
const proc = spawnChild(process.execPath, [workerPath, endpoint], {
|
|
609
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
610
|
+
env: { ...process.env, NODE_OPTIONS: '', NODE_DEBUG: 'blocks-telemetry' },
|
|
611
|
+
});
|
|
612
|
+
let stderr = '';
|
|
613
|
+
proc.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
614
|
+
proc.stdin!.write(payload);
|
|
615
|
+
proc.stdin!.end();
|
|
616
|
+
proc.on('close', (code) => resolve({ code, stderr }));
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
assert.strictEqual(result.code, 0);
|
|
620
|
+
assert.ok(result.stderr.includes('BLOCKS-TELEMETRY: sent (status=200)'), `Expected debug output, got: ${result.stderr}`);
|
|
621
|
+
|
|
622
|
+
server.close();
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
536
626
|
describe('telemetry/trackCommand integration', () => {
|
|
537
627
|
const originalEnv = { ...process.env };
|
|
538
628
|
|