@aws-blocks/core 0.1.12 → 0.1.17

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.
Files changed (61) hide show
  1. package/README.md +180 -17
  2. package/dist/cors.d.ts +27 -1
  3. package/dist/cors.d.ts.map +1 -1
  4. package/dist/cors.js +55 -2
  5. package/dist/cors.test.js +81 -2
  6. package/dist/errors.test.js +26 -1
  7. package/dist/hosting.d.ts.map +1 -1
  8. package/dist/hosting.js +26 -1
  9. package/dist/hosting.test.js +73 -0
  10. package/dist/lambda-handler.d.ts.map +1 -1
  11. package/dist/lambda-handler.js +4 -17
  12. package/dist/lambda-handler.test.js +59 -2
  13. package/dist/redact.d.ts +3 -2
  14. package/dist/redact.d.ts.map +1 -1
  15. package/dist/redact.js +4 -3
  16. package/dist/redact.test.js +9 -0
  17. package/dist/rpc.test.js +77 -1
  18. package/dist/scripts/console.d.ts.map +1 -1
  19. package/dist/scripts/console.js +30 -2
  20. package/dist/scripts/deploy-stream.d.ts +181 -0
  21. package/dist/scripts/deploy-stream.d.ts.map +1 -0
  22. package/dist/scripts/deploy-stream.js +332 -0
  23. package/dist/scripts/deploy-stream.test.d.ts +2 -0
  24. package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
  25. package/dist/scripts/deploy-stream.test.js +845 -0
  26. package/dist/scripts/deploy.d.ts.map +1 -1
  27. package/dist/scripts/deploy.js +16 -9
  28. package/dist/scripts/dev-server-cors.test.js +19 -1
  29. package/dist/scripts/dev-server-rpc.test.d.ts +2 -0
  30. package/dist/scripts/dev-server-rpc.test.d.ts.map +1 -0
  31. package/dist/scripts/dev-server-rpc.test.js +157 -0
  32. package/dist/scripts/dev-server.d.ts +8 -0
  33. package/dist/scripts/dev-server.d.ts.map +1 -1
  34. package/dist/scripts/dev-server.js +35 -8
  35. package/dist/scripts/sandbox.js +1 -1
  36. package/dist/telemetry/client.js +4 -4
  37. package/dist/telemetry/telemetry-send-worker.js +4 -0
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +10 -1
  41. package/src/cors.test.ts +96 -2
  42. package/src/cors.ts +59 -2
  43. package/src/errors.test.ts +29 -1
  44. package/src/hosting.test.ts +107 -0
  45. package/src/hosting.ts +27 -1
  46. package/src/lambda-handler.test.ts +71 -2
  47. package/src/lambda-handler.ts +4 -20
  48. package/src/redact.test.ts +12 -0
  49. package/src/redact.ts +4 -3
  50. package/src/rpc.test.ts +96 -1
  51. package/src/scripts/console.ts +29 -2
  52. package/src/scripts/deploy-stream.test.ts +1035 -0
  53. package/src/scripts/deploy-stream.ts +475 -0
  54. package/src/scripts/deploy.ts +18 -11
  55. package/src/scripts/dev-server-cors.test.ts +26 -1
  56. package/src/scripts/dev-server-rpc.test.ts +169 -0
  57. package/src/scripts/dev-server.ts +38 -8
  58. package/src/scripts/sandbox.ts +1 -1
  59. package/src/telemetry/client.ts +4 -4
  60. package/src/telemetry/telemetry-send-worker.ts +5 -0
  61. package/src/version.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/scripts/deploy.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,iBA0FlD"}
1
+ {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/scripts/deploy.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,wBAAsB,MAAM,CAAC,OAAO,EAAE,aAAa,iBAiGlD"}
@@ -8,7 +8,7 @@ import { ensureSecrets, loadProductionEnv } from './ensure-secrets.js';
8
8
  import { applyExternalMigrations } from './external-migrations-step.js';
9
9
  import { trackCommand } from '../telemetry/trackCommand.js';
10
10
  import { getCdkTelemetryEnv } from './cdk-telemetry-env.js';
11
- import { runSync } from './run-command.js';
11
+ import { runStreaming, buildCdkDeployArgs } from './deploy-stream.js';
12
12
  export async function deploy(options) {
13
13
  return trackCommand('deploy', async () => {
14
14
  console.log('🏗️ Preparing deployment...');
@@ -44,14 +44,14 @@ export async function deploy(options) {
44
44
  console.log(' (This may take a few minutes on first deploy)');
45
45
  console.log(' - Backend API (Lambda + API Gateway)');
46
46
  console.log(' - Frontend hosting (S3 + CloudFront)');
47
+ console.log(' Streaming CloudFormation events below; the deploy keeps running if this');
48
+ console.log(' process is backgrounded (press Ctrl-C, or send SIGTERM twice, to abort).');
47
49
  try {
48
- runSync("npx", [
49
- "cdk", "deploy",
50
- "--require-approval", "never",
51
- "--outputs-file", ".blocks-sandbox/outputs.json",
52
- "--context", `projectRoot=${options.projectRoot}`,
53
- ], {
54
- stdio: 'inherit',
50
+ await runStreaming("npx", buildCdkDeployArgs({
51
+ projectRoot: options.projectRoot,
52
+ outputsFile: '.blocks-sandbox/outputs.json',
53
+ }), {
54
+ label: 'cdk deploy',
55
55
  cwd: options.projectRoot,
56
56
  env: {
57
57
  ...process.env,
@@ -61,7 +61,14 @@ export async function deploy(options) {
61
61
  });
62
62
  }
63
63
  catch (error) {
64
- console.error('\n❌ Deployment failed.');
64
+ // Terminal verdict on stdout: a caller that only captures stdout (the case
65
+ // that produced phantom failures) must still be able to tell a failed
66
+ // deploy from a killed process. This banner deliberately moved off stderr,
67
+ // so grepping stderr for this exact string no longer matches — the failure
68
+ // *reason* is still there. The CDK CLI keeps error-level output on stderr
69
+ // even under `--ci`, and the entrypoint prints the error itself with
70
+ // `console.error(error)`.
71
+ console.log('\n❌ Deployment failed.');
65
72
  throw error;
66
73
  }
67
74
  const outputs = JSON.parse(readFileSync(join(options.projectRoot, '.blocks-sandbox', 'outputs.json'), 'utf-8'));
@@ -2,7 +2,8 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { describe, it } from 'node:test';
4
4
  import assert from 'node:assert';
5
- import { resolveDevCorsOrigin, LOCALHOST_PATTERN } from './dev-server.js';
5
+ import { resolveDevCorsOrigin, LOCALHOST_PATTERN, buildDevCorsHeaders } from './dev-server.js';
6
+ import { CORS_MAX_AGE } from '../cors.js';
6
7
  describe('resolveDevCorsOrigin — dev server CORS', () => {
7
8
  it('reflects localhost origin back as-is', () => {
8
9
  assert.strictEqual(resolveDevCorsOrigin('http://localhost:3000'), 'http://localhost:3000');
@@ -21,3 +22,20 @@ describe('resolveDevCorsOrigin — dev server CORS', () => {
21
22
  assert.strictEqual(resolveDevCorsOrigin('http://localhost.evil.com'), 'http://localhost:3000');
22
23
  });
23
24
  });
25
+ describe('buildDevCorsHeaders — dev server header set', () => {
26
+ it('shares one Max-Age value with the Lambda path so the two cannot drift', () => {
27
+ assert.strictEqual(buildDevCorsHeaders('http://localhost:3000')['Access-Control-Max-Age'], CORS_MAX_AGE);
28
+ });
29
+ it('sets Vary: Origin because Allow-Origin is reflected per request', () => {
30
+ assert.strictEqual(buildDevCorsHeaders('http://localhost:3000')['Vary'], 'Origin');
31
+ assert.strictEqual(buildDevCorsHeaders('https://evil.com')['Vary'], 'Origin');
32
+ });
33
+ it('reflects an allowed localhost origin', () => {
34
+ const headers = buildDevCorsHeaders('http://127.0.0.1:5173');
35
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], 'http://127.0.0.1:5173');
36
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], 'true');
37
+ });
38
+ it('does not reflect a non-localhost origin', () => {
39
+ assert.strictEqual(buildDevCorsHeaders('https://evil.com')['Access-Control-Allow-Origin'], 'http://localhost:3000');
40
+ });
41
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dev-server-rpc.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dev-server-rpc.test.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server-rpc.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,157 @@
1
+ import { afterEach, describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { spawn } from 'node:child_process';
4
+ import { createServer } from 'node:http';
5
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ async function getAvailablePort() {
11
+ const server = createServer();
12
+ await new Promise((resolve, reject) => {
13
+ server.once('error', reject);
14
+ server.listen(0, '127.0.0.1', resolve);
15
+ });
16
+ const address = server.address();
17
+ assert.ok(address && typeof address === 'object');
18
+ const { port } = address;
19
+ // TOCTOU: brief window between closing this probe and the dev server binding
20
+ // the port; port: 0 would require the dev server to expose its assigned port.
21
+ await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
22
+ return port;
23
+ }
24
+ describe('dev-server RPC integration', () => {
25
+ let devProcess = null;
26
+ let tempDir = null;
27
+ afterEach(async () => {
28
+ if (devProcess && devProcess.exitCode === null) {
29
+ devProcess.kill('SIGTERM');
30
+ await new Promise((resolve) => {
31
+ const timeout = setTimeout(() => {
32
+ devProcess?.kill('SIGKILL');
33
+ resolve();
34
+ }, 2_000);
35
+ devProcess?.once('exit', () => {
36
+ clearTimeout(timeout);
37
+ resolve();
38
+ });
39
+ });
40
+ }
41
+ if (tempDir)
42
+ rmSync(tempDir, { recursive: true, force: true });
43
+ });
44
+ it('returns success for a void handler in verbose mode', async () => {
45
+ const port = await getAvailablePort();
46
+ tempDir = join(tmpdir(), `dev-rpc-test-${process.pid}-${Date.now()}`);
47
+ mkdirSync(tempDir, { recursive: true });
48
+ writeFileSync(join(tempDir, 'backend.ts'), `
49
+ export const testApi = {
50
+ pingVoid: async () => undefined,
51
+ };
52
+ `);
53
+ // Polyfill process.loadEnvFile for Node <20.6; ENOENT means no .env file.
54
+ writeFileSync(join(tempDir, 'preload.mjs'), `
55
+ if (!process.loadEnvFile) {
56
+ process.loadEnvFile = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); };
57
+ }
58
+ `);
59
+ writeFileSync(join(tempDir, 'run-dev.ts'), `
60
+ import { startDevServer } from '${join(__dirname, 'dev-server.js').replace(/\\/g, '/')}';
61
+ startDevServer({ backendPath: '${join(tempDir, 'backend.ts').replace(/\\/g, '/')}', port: ${port} });
62
+ `);
63
+ const tsxBin = join(__dirname, '..', '..', '..', '..', 'node_modules', '.bin', 'tsx');
64
+ devProcess = spawn(tsxBin, ['--import', join(tempDir, 'preload.mjs'), join(tempDir, 'run-dev.ts')], {
65
+ cwd: tempDir,
66
+ env: {
67
+ ...process.env,
68
+ AWS_BLOCKS_DISABLE_TELEMETRY: '1',
69
+ // Empty is falsy, keeping verbose logging on even if the parent sets quiet mode.
70
+ BLOCKS_DEV_QUIET: '',
71
+ },
72
+ stdio: ['ignore', 'pipe', 'pipe'],
73
+ });
74
+ let stdout = '';
75
+ let stderr = '';
76
+ devProcess.stdout?.on('data', chunk => { stdout += chunk.toString(); });
77
+ devProcess.stderr?.on('data', chunk => { stderr += chunk.toString(); });
78
+ const deadline = Date.now() + 15_000;
79
+ let response;
80
+ let lastError;
81
+ while (!response && Date.now() < deadline) {
82
+ try {
83
+ response = await fetch(`http://127.0.0.1:${port}/aws-blocks/api`, {
84
+ method: 'POST',
85
+ headers: { 'Content-Type': 'application/json' },
86
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'testApi.pingVoid', params: [], id: 1 }),
87
+ });
88
+ }
89
+ catch (error) {
90
+ lastError = error;
91
+ await new Promise(resolve => setTimeout(resolve, 100));
92
+ }
93
+ }
94
+ assert.ok(response, `Dev server did not respond: ${String(lastError)}\nstdout: ${stdout}\nstderr: ${stderr}`);
95
+ assert.strictEqual(response.status, 200);
96
+ const payload = await response.json();
97
+ assert.strictEqual(payload.jsonrpc, '2.0');
98
+ assert.strictEqual(payload.id, 1);
99
+ assert.ok(!('error' in payload), `Unexpected RPC error: ${JSON.stringify(payload.error)}`);
100
+ const logDeadline = Date.now() + 1_000;
101
+ while (!stdout.includes('[rpc-ok] testApi.pingVoid') && Date.now() < logDeadline) {
102
+ await new Promise(resolve => setTimeout(resolve, 25));
103
+ }
104
+ assert.ok(stdout.includes('[rpc-ok] testApi.pingVoid'), `Missing verbose success log. stdout: ${stdout}`);
105
+ assert.ok(!stdout.includes('[rpc-err]'), `Unexpected RPC error log. stdout: ${stdout}`);
106
+ });
107
+ it('returns a JSON usage hint (not an empty body) for a GET on the API path', async () => {
108
+ const port = await getAvailablePort();
109
+ tempDir = join(tmpdir(), `dev-404-test-${process.pid}-${Date.now()}`);
110
+ mkdirSync(tempDir, { recursive: true });
111
+ writeFileSync(join(tempDir, 'backend.ts'), `
112
+ export const testApi = {
113
+ pingVoid: async () => undefined,
114
+ };
115
+ `);
116
+ writeFileSync(join(tempDir, 'preload.mjs'), `
117
+ if (!process.loadEnvFile) {
118
+ process.loadEnvFile = () => { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); };
119
+ }
120
+ `);
121
+ writeFileSync(join(tempDir, 'run-dev.ts'), `
122
+ import { startDevServer } from '${join(__dirname, 'dev-server.js').replace(/\\/g, '/')}';
123
+ startDevServer({ backendPath: '${join(tempDir, 'backend.ts').replace(/\\/g, '/')}', port: ${port} });
124
+ `);
125
+ const tsxBin = join(__dirname, '..', '..', '..', '..', 'node_modules', '.bin', 'tsx');
126
+ devProcess = spawn(tsxBin, ['--import', join(tempDir, 'preload.mjs'), join(tempDir, 'run-dev.ts')], {
127
+ cwd: tempDir,
128
+ env: { ...process.env, AWS_BLOCKS_DISABLE_TELEMETRY: '1', BLOCKS_DEV_QUIET: '1' },
129
+ stdio: ['ignore', 'pipe', 'pipe'],
130
+ });
131
+ let stdout = '';
132
+ let stderr = '';
133
+ devProcess.stdout?.on('data', chunk => { stdout += chunk.toString(); });
134
+ devProcess.stderr?.on('data', chunk => { stderr += chunk.toString(); });
135
+ // A GET on the API path (e.g. opening it in a browser) hits the API handler
136
+ // but not the POST branch — the exact case that used to return an empty 404.
137
+ const deadline = Date.now() + 15_000;
138
+ let response;
139
+ let lastError;
140
+ while (!response && Date.now() < deadline) {
141
+ try {
142
+ response = await fetch(`http://127.0.0.1:${port}/aws-blocks/api`, { method: 'GET' });
143
+ }
144
+ catch (error) {
145
+ lastError = error;
146
+ await new Promise(resolve => setTimeout(resolve, 100));
147
+ }
148
+ }
149
+ assert.ok(response, `Dev server did not respond: ${String(lastError)}\nstdout: ${stdout}\nstderr: ${stderr}`);
150
+ assert.strictEqual(response.status, 404);
151
+ assert.strictEqual(response.headers.get('content-type'), 'application/json');
152
+ const payload = await response.json();
153
+ assert.match(payload.error ?? '', /POST/, `404 body should hint at the POST requirement: ${JSON.stringify(payload)}`);
154
+ assert.strictEqual(payload.expected?.method, 'POST');
155
+ assert.strictEqual(payload.expected?.path, '/aws-blocks/api');
156
+ });
157
+ });
@@ -4,6 +4,14 @@ export declare const LOCALHOST_PATTERN: RegExp;
4
4
  * Reflects back origins matching localhost/127.0.0.1; otherwise returns the fallback.
5
5
  */
6
6
  export declare function resolveDevCorsOrigin(origin: string): string;
7
+ /**
8
+ * Build the CORS response headers the dev server sets on every request.
9
+ *
10
+ * Mirrors the Lambda path's cache posture — same {@link CORS_MAX_AGE} and the
11
+ * same `Vary: Origin` — so the reflected `Access-Control-Allow-Origin` can't be
12
+ * served across origins by a shared cache, and so the two sites can't drift.
13
+ */
14
+ export declare function buildDevCorsHeaders(requestOrigin: string): Record<string, string>;
7
15
  /** Shape of the client runtime config the browser fetches to discover the API URL. */
8
16
  export interface BlocksRuntimeConfig {
9
17
  apiUrl: string;
@@ -1 +1 @@
1
- {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server.ts"],"names":[],"mappings":"AAsCA,eAAO,MAAM,iBAAiB,QAAiD,CAAC;AAEhF;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASjF;AAED,sFAAsF;AACtF,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,OAAO,GAAG,SAAS,CAAC;CAClC;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,mBAAmB,CAKvF;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE/E;AAED,MAAM,WAAW,gBAAgB;IAC/B,2CAA2C;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAiBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,SAAS,SAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAa/F;AAYD,+DAA+D;AAC/D,MAAM,WAAW,qBAAqB;IACpC,oFAAoF;IACpF,WAAW,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,uFAAuF;AACvF,eAAO,MAAM,+BAA+B,EAAE,qBAK7C,CAAC;AAEF,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B,oDAAoD;IACpD,OAAO,EAAE,OAAO,CAAC;IACjB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,uBAAuB,CACrC,cAAc,EAAE,MAAM,EAAE,EACxB,GAAG,EAAE,MAAM,EACX,MAAM,GAAE,qBAAuD,GAC9D,eAAe,CAOjB;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAOnF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC,OAAO,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,EAC5E,OAAO,EAAE,OAAO,GACf,OAAO,CAOT;AAID,sCAAsC;AACtC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,OAAO,CAAC;IACjB,oFAAoF;IACpF,SAAS,EAAE,OAAO,CAAC;IACnB,yGAAyG;IACzG,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED,kFAAkF;AAClF,MAAM,WAAW,eAAe;IAC9B,4CAA4C;IAC5C,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1C,gDAAgD;IAChD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,EAAE,CAAC;IACrC,6EAA6E;IAC7E,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,KAAK,IAAI,CAAC;IACxD,kDAAkD;IAClD,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,OAAO,CAAC,eAAe,CAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAwB3G;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAW3F;AAED,gFAAgF;AAChF,MAAM,WAAW,mBAAmB;IAClC,sEAAsE;IACtE,WAAW,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,oFAAoF;AACpF,eAAO,MAAM,8BAA8B,EAAE,mBAG5C,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,MAAM,GAAE,mBAAoD,GAC3D;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAGrC;AAED,4GAA4G;AAC5G,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;IAClD,wEAAwE;IACxE,QAAQ,EAAE,MAAM,IAAI,CAAC;IACrB,wEAAwE;IACxE,aAAa,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,+EAA+E;IAC/E,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,oDAAoD;IACpD,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,aAAa,EACnB,MAAM,GAAE,mBAAoD,GAC3D,MAAM,IAAI,CAwBZ;AAID,8EAA8E;AAC9E,MAAM,WAAW,kBAAkB;IACjC,wCAAwC;IACxC,GAAG,EAAE,MAAM,CAAC;IACZ,mFAAmF;IACnF,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;CACd;AAED,mGAAmG;AACnG,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI,CAUtE;AAED,wGAAwG;AACxG,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAmC,GAAG,OAAO,CAQ3H;AAED,8GAA8G;AAC9G,MAAM,MAAM,iBAAiB,GAAG;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3F;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,kBAAkB,GAAG,IAAI,EACnC,IAAI,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EACnC,SAAS,EAAE,OAAO,EAClB,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,GAChC,iBAAiB,CASnB;AAED,wBAAsB,cAAc,CAAC,OAAO,EAAE,gBAAgB,iBAmiB7D"}
@@ -12,6 +12,7 @@ import { ApiError } from '../errors.js';
12
12
  import { BLOCKS_RPC_PREFIX, BLOCKS_SANDBOX_PREFIX } from '../constants.js';
13
13
  import { BLOCKS_SANDBOX_DIR } from '../common/constants.js';
14
14
  import { matchRoute, lockRouteRegistry } from '../raw-route.js';
15
+ import { CORS_MAX_AGE } from '../cors.js';
15
16
  import { registerBuiltinRoutes } from '../builtin-routes.js';
16
17
  import { parseRpcRequest, successResponse, errorResponseFromCatch, methodNotFoundResponse, } from '../rpc.js';
17
18
  import { redactToJson } from '../redact.js';
@@ -36,6 +37,23 @@ export const LOCALHOST_PATTERN = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
36
37
  export function resolveDevCorsOrigin(origin) {
37
38
  return LOCALHOST_PATTERN.test(origin) ? origin : 'http://localhost:3000';
38
39
  }
40
+ /**
41
+ * Build the CORS response headers the dev server sets on every request.
42
+ *
43
+ * Mirrors the Lambda path's cache posture — same {@link CORS_MAX_AGE} and the
44
+ * same `Vary: Origin` — so the reflected `Access-Control-Allow-Origin` can't be
45
+ * served across origins by a shared cache, and so the two sites can't drift.
46
+ */
47
+ export function buildDevCorsHeaders(requestOrigin) {
48
+ return {
49
+ 'Access-Control-Allow-Origin': resolveDevCorsOrigin(requestOrigin),
50
+ 'Vary': 'Origin',
51
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS',
52
+ 'Access-Control-Allow-Headers': 'Content-Type',
53
+ 'Access-Control-Allow-Credentials': 'true',
54
+ 'Access-Control-Max-Age': CORS_MAX_AGE,
55
+ };
56
+ }
39
57
  /**
40
58
  * Build the runtime config the browser fetches at `${BLOCKS_SANDBOX_PREFIX}/config.json`.
41
59
  * In sandbox mode the browser still targets the localhost front door (the dev
@@ -699,12 +717,9 @@ export async function startDevServer(options) {
699
717
  const method = req.method || 'GET';
700
718
  // CORS headers
701
719
  const requestOrigin = req.headers.origin || '';
702
- const allowedOrigin = resolveDevCorsOrigin(requestOrigin);
703
- res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
704
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS');
705
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
706
- res.setHeader('Access-Control-Allow-Credentials', 'true');
707
- res.setHeader('Access-Control-Max-Age', '86400');
720
+ for (const [name, value] of Object.entries(buildDevCorsHeaders(requestOrigin))) {
721
+ res.setHeader(name, value);
722
+ }
708
723
  if (method === 'OPTIONS') {
709
724
  res.writeHead(200);
710
725
  res.end();
@@ -1066,6 +1081,18 @@ function handleApiRequest(req, res, url, method, apis) {
1066
1081
  });
1067
1082
  return;
1068
1083
  }
1069
- res.writeHead(404);
1070
- res.end();
1084
+ // Anything that reaches here didn't match `POST /aws-blocks/api`. Returning a
1085
+ // bare empty 404 leaves a developer poking at the endpoint (e.g. opening it in
1086
+ // a browser, which sends a GET, or trying a REST-style URL) with no feedback.
1087
+ // Reply with a small JSON hint pointing at the one supported shape. When the
1088
+ // path was right but the method wasn't, say so explicitly.
1089
+ const wrongMethodOnApi = url.pathname === BLOCKS_RPC_PREFIX;
1090
+ const message = wrongMethodOnApi
1091
+ ? `The AWS Blocks JSON-RPC API expects POST, not ${method}.`
1092
+ : 'Not found. The AWS Blocks JSON-RPC API is served at POST /aws-blocks/api.';
1093
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1094
+ res.end(JSON.stringify({
1095
+ error: message,
1096
+ expected: { method: 'POST', path: BLOCKS_RPC_PREFIX },
1097
+ }));
1071
1098
  }
@@ -124,7 +124,7 @@ export async function startSandbox(options) {
124
124
  console.log("🌐 Starting local dev server (proxying to AWS)...");
125
125
  console.log(`\n Open http://localhost:${clientPort}\n`);
126
126
  const cdkWatch = spawnCommand("npx", [
127
- "cdk", "watch", "--hotswap",
127
+ "cdk", "watch",
128
128
  `--outputs-file`, `${outDir}/outputs.json`,
129
129
  `--context`, `projectRoot=${process.cwd()}`,
130
130
  `--context`, `sandboxMode=true`,
@@ -192,15 +192,15 @@ export function sendEvent(event) {
192
192
  const workerPath = path.join(dir, 'telemetry-send-worker.js');
193
193
  const child = spawn(process.execPath, [workerPath, endpoint], {
194
194
  detached: true,
195
- stdio: ['pipe', 'ignore', 'ignore'],
195
+ stdio: ['pipe', 'ignore', process.env.NODE_DEBUG?.includes('blocks-telemetry') ? 'inherit' : 'ignore'],
196
196
  // Clear NODE_OPTIONS so inherited flags (e.g. --conditions=cdk) don't interfere
197
197
  env: { ...process.env, NODE_OPTIONS: '' },
198
198
  });
199
- child.stdin.on('error', (err) => { debug('stdin write failed: %s', err.message); });
199
+ child.stdin?.on('error', (err) => { debug('stdin write failed: %s', err.message); });
200
200
  // Payload is small (<1KB JSON) so it fits in the kernel pipe buffer (~64KB)
201
201
  // and survives the parent closing its fd on exit.
202
- child.stdin.write(payload);
203
- child.stdin.end();
202
+ child.stdin?.write(payload);
203
+ child.stdin?.end();
204
204
  child.on('error', (err) => { debug('spawn failed: %s', err.message); });
205
205
  child.unref();
206
206
  debug('spawned telemetry subprocess (pid=%d)', child.pid);
@@ -1,5 +1,9 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ // NOTE: This worker is an intentional near-duplicate of
4
+ // packages/create-blocks-app/src/telemetry-send-worker.ts. It is kept separate
5
+ // because the worker must run with zero project/package imports. Any change
6
+ // here should be mirrored in both files.
3
7
  /**
4
8
  * Telemetry send worker — spawned as a detached subprocess.
5
9
  * Reads JSON payload from stdin, POSTs it to the endpoint (argv[2]).
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CORE_VERSION = "0.1.12";
1
+ export declare const CORE_VERSION = "0.1.17";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
- export const CORE_VERSION = '0.1.12';
2
+ export const CORE_VERSION = '0.1.17';
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "@aws-blocks/core",
3
- "version": "0.1.12",
3
+ "version": "0.1.17",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/aws-devtools-labs/aws-blocks.git",
7
+ "directory": "packages/core"
8
+ },
9
+ "homepage": "https://github.com/aws-devtools-labs/aws-blocks/tree/main/packages/core#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/aws-devtools-labs/aws-blocks/issues"
12
+ },
4
13
  "author": "Amazon Web Services",
5
14
  "license": "Apache-2.0",
6
15
  "type": "module",
package/src/cors.test.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { describe, it, beforeEach } from 'node:test';
4
4
  import assert from 'node:assert';
5
5
 
6
- import { parseCorsPatterns, _resetCorsPatterns } from './cors.js';
6
+ import { parseCorsPatterns, _resetCorsPatterns, buildCorsHeaders, CORS_MAX_AGE } from './cors.js';
7
7
  import { createLambdaHandler } from './lambda-handler.js';
8
8
  import { clearRouteRegistry } from './raw-route.js';
9
9
 
@@ -59,6 +59,95 @@ describe('parseCorsPatterns', () => {
59
59
  });
60
60
  });
61
61
 
62
+ // ── buildCorsHeaders unit tests ─────────────────────────────────────────────
63
+
64
+ describe('buildCorsHeaders', () => {
65
+ beforeEach(() => {
66
+ delete process.env.CORS_ALLOWED_ORIGINS;
67
+ delete process.env.CORS_HOSTING_ORIGINS;
68
+ _resetCorsPatterns();
69
+ });
70
+
71
+ it('reflects an origin that matches the configured allowlist', () => {
72
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
73
+ _resetCorsPatterns();
74
+
75
+ const headers = buildCorsHeaders('https://myapp.example.com');
76
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], 'https://myapp.example.com');
77
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], 'true');
78
+ });
79
+
80
+ it('never reflects an origin that is not on a configured allowlist', () => {
81
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
82
+ _resetCorsPatterns();
83
+
84
+ const headers = buildCorsHeaders('https://evil.example.com');
85
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], undefined);
86
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], undefined);
87
+ assert.deepStrictEqual(headers, { Vary: 'Origin' });
88
+ });
89
+
90
+ it('never reflects an origin when no allowlist is configured', () => {
91
+ const headers = buildCorsHeaders('https://evil.example.com');
92
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], undefined);
93
+ assert.deepStrictEqual(headers, { Vary: 'Origin' });
94
+ });
95
+
96
+ it('returns no reflection headers when there is no origin', () => {
97
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
98
+ _resetCorsPatterns();
99
+
100
+ assert.deepStrictEqual(buildCorsHeaders(''), { Vary: 'Origin' });
101
+ });
102
+
103
+ it('always sets Vary: Origin so shared caches key on the request origin', () => {
104
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
105
+ _resetCorsPatterns();
106
+
107
+ assert.strictEqual(buildCorsHeaders('https://myapp.example.com').Vary, 'Origin');
108
+ assert.strictEqual(buildCorsHeaders('https://evil.example.com').Vary, 'Origin');
109
+ assert.strictEqual(buildCorsHeaders('').Vary, 'Origin');
110
+ });
111
+
112
+ it('warns only once per distinct disallowed origin', () => {
113
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
114
+ _resetCorsPatterns();
115
+
116
+ const original = console.warn;
117
+ const lines: string[] = [];
118
+ console.warn = (msg: string) => { lines.push(msg); };
119
+ try {
120
+ buildCorsHeaders('https://evil.example.com');
121
+ buildCorsHeaders('https://evil.example.com');
122
+ buildCorsHeaders('https://evil.example.com');
123
+ assert.strictEqual(lines.length, 1, 'repeat requests from one origin should warn once');
124
+
125
+ buildCorsHeaders('https://other.example.com');
126
+ assert.strictEqual(lines.length, 2, 'a distinct origin should still warn');
127
+ assert.ok(lines[0].includes('https://evil.example.com'));
128
+ assert.ok(lines[1].includes('https://other.example.com'));
129
+ } finally {
130
+ console.warn = original;
131
+ }
132
+ });
133
+
134
+ it('does not warn for an allowed origin or an absent origin', () => {
135
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
136
+ _resetCorsPatterns();
137
+
138
+ const original = console.warn;
139
+ const lines: string[] = [];
140
+ console.warn = (msg: string) => { lines.push(msg); };
141
+ try {
142
+ buildCorsHeaders('https://myapp.example.com');
143
+ buildCorsHeaders('');
144
+ assert.strictEqual(lines.length, 0);
145
+ } finally {
146
+ console.warn = original;
147
+ }
148
+ });
149
+ });
150
+
62
151
  // ── isOriginAllowed + getCorsPatterns integration via handler ────────────────
63
152
 
64
153
  // These tests exercise the full CORS flow through createLambdaHandler to
@@ -159,7 +248,12 @@ describe('createLambdaHandler — CORS origin validation', () => {
159
248
  assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], 'true');
160
249
  assert.ok(result.headers['Access-Control-Allow-Methods']);
161
250
  assert.ok(result.headers['Access-Control-Allow-Headers']);
162
- assert.strictEqual(result.headers['Access-Control-Max-Age'], '86400');
251
+ assert.strictEqual(result.headers['Access-Control-Max-Age'], CORS_MAX_AGE);
252
+ assert.strictEqual(result.headers['Vary'], 'Origin');
253
+ });
254
+
255
+ it('pins Access-Control-Max-Age at the browser preflight cap', () => {
256
+ assert.strictEqual(CORS_MAX_AGE, '7200');
163
257
  });
164
258
 
165
259
  it('OPTIONS preflight with rejected origin returns 403', async () => {
package/src/cors.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ /**
5
+ * `Access-Control-Max-Age` for preflight responses, in seconds.
6
+ *
7
+ * Chromium caps the preflight cache at 7200s and silently clamps anything
8
+ * higher, so a larger value buys nothing while widening the window in which a
9
+ * stale per-origin grant can be served. Shared by the Lambda handler and the
10
+ * local dev server so the two can't drift.
11
+ */
12
+ export const CORS_MAX_AGE = '7200';
13
+
4
14
  /**
5
15
  * Parse a comma-separated CORS origin string into anchored RegExp patterns.
6
16
  *
@@ -72,20 +82,67 @@ export function isOriginAllowed(origin: string): boolean {
72
82
  return patterns.some(re => re.test(origin));
73
83
  }
74
84
 
85
+ /**
86
+ * Distinct origins already warned about, so a caller retrying — or a bot
87
+ * spraying bogus `Origin` values — can't amplify one log line per request now
88
+ * that this helper runs on every response path.
89
+ */
90
+ const warnedOrigins = new Set<string>();
91
+
92
+ /** Cap on {@link warnedOrigins} so an untrusted input can't grow it unbounded. */
93
+ const WARNED_ORIGINS_LIMIT = 100;
94
+
95
+ function warnDisallowedOriginOnce(origin: string): void {
96
+ if (warnedOrigins.has(origin)) return;
97
+ if (warnedOrigins.size < WARNED_ORIGINS_LIMIT) warnedOrigins.add(origin);
98
+ const example = 'CORS_ALLOWED_ORIGINS=https://myapp\\.com,^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$';
99
+ console.warn(
100
+ `[CORS] Origin "${origin}" is not allowed. Set the CORS_ALLOWED_ORIGINS environment variable to allow this origin. Example: ${example}`
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Build the CORS response headers for a request origin.
106
+ *
107
+ * Only reflects the origin when it matches the configured allowlist. When no
108
+ * allowlist is configured, or the origin is configured-but-not-allowed, no
109
+ * `Access-Control-Allow-Origin` / `Access-Control-Allow-Credentials` headers
110
+ * are emitted, so a disallowed origin is never reflected back.
111
+ *
112
+ * `Vary: Origin` is always emitted, including on the not-allowed path: the
113
+ * response headers depend on the request `Origin`, so any shared cache (CDN,
114
+ * forward proxy) must key on it or it can serve one origin's grant — or one
115
+ * origin's *absence* of a grant — to a different origin.
116
+ *
117
+ * @param origin - The `Origin` header value from the request (may be empty)
118
+ * @returns The CORS headers to merge into the response
119
+ */
120
+ export function buildCorsHeaders(origin: string): Record<string, string> {
121
+ const headers: Record<string, string> = { Vary: 'Origin' };
122
+ if (isOriginAllowed(origin)) {
123
+ headers['Access-Control-Allow-Origin'] = origin;
124
+ headers['Access-Control-Allow-Credentials'] = 'true';
125
+ } else if (origin) {
126
+ warnDisallowedOriginOnce(origin);
127
+ }
128
+ return headers;
129
+ }
130
+
75
131
  /**
76
132
  * Build a 403 Forbidden response for cross-origin requests from disallowed origins.
77
133
  */
78
134
  export function corsRejection(): { statusCode: number; headers: Record<string, string>; body: string } {
79
135
  return {
80
136
  statusCode: 403,
81
- headers: { 'Content-Type': 'application/json' },
137
+ headers: { 'Content-Type': 'application/json', Vary: 'Origin' },
82
138
  body: JSON.stringify({ error: 'Forbidden: cross-origin request rejected' }),
83
139
  };
84
140
  }
85
141
 
86
142
  /**
87
- * Reset the lazy CORS pattern cache. **For testing only.**
143
+ * Reset the lazy CORS pattern cache and the warned-origin set. **For testing only.**
88
144
  */
89
145
  export function _resetCorsPatterns(): void {
90
146
  _corsPatterns = undefined;
147
+ warnedOrigins.clear();
91
148
  }