@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.
- package/README.md +180 -17
- package/dist/cors.d.ts +27 -1
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +55 -2
- package/dist/cors.test.js +81 -2
- package/dist/errors.test.js +26 -1
- package/dist/hosting.d.ts.map +1 -1
- package/dist/hosting.js +26 -1
- package/dist/hosting.test.js +73 -0
- package/dist/lambda-handler.d.ts.map +1 -1
- package/dist/lambda-handler.js +4 -17
- package/dist/lambda-handler.test.js +59 -2
- package/dist/redact.d.ts +3 -2
- package/dist/redact.d.ts.map +1 -1
- package/dist/redact.js +4 -3
- package/dist/redact.test.js +9 -0
- package/dist/rpc.test.js +77 -1
- package/dist/scripts/console.d.ts.map +1 -1
- package/dist/scripts/console.js +30 -2
- package/dist/scripts/deploy-stream.d.ts +181 -0
- package/dist/scripts/deploy-stream.d.ts.map +1 -0
- package/dist/scripts/deploy-stream.js +332 -0
- package/dist/scripts/deploy-stream.test.d.ts +2 -0
- package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
- package/dist/scripts/deploy-stream.test.js +845 -0
- package/dist/scripts/deploy.d.ts.map +1 -1
- package/dist/scripts/deploy.js +16 -9
- package/dist/scripts/dev-server-cors.test.js +19 -1
- package/dist/scripts/dev-server-rpc.test.d.ts +2 -0
- package/dist/scripts/dev-server-rpc.test.d.ts.map +1 -0
- package/dist/scripts/dev-server-rpc.test.js +157 -0
- package/dist/scripts/dev-server.d.ts +8 -0
- package/dist/scripts/dev-server.d.ts.map +1 -1
- package/dist/scripts/dev-server.js +35 -8
- package/dist/scripts/sandbox.js +1 -1
- package/dist/telemetry/client.js +4 -4
- package/dist/telemetry/telemetry-send-worker.js +4 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -1
- package/src/cors.test.ts +96 -2
- package/src/cors.ts +59 -2
- package/src/errors.test.ts +29 -1
- package/src/hosting.test.ts +107 -0
- package/src/hosting.ts +27 -1
- package/src/lambda-handler.test.ts +71 -2
- package/src/lambda-handler.ts +4 -20
- package/src/redact.test.ts +12 -0
- package/src/redact.ts +4 -3
- package/src/rpc.test.ts +96 -1
- package/src/scripts/console.ts +29 -2
- package/src/scripts/deploy-stream.test.ts +1035 -0
- package/src/scripts/deploy-stream.ts +475 -0
- package/src/scripts/deploy.ts +18 -11
- package/src/scripts/dev-server-cors.test.ts +26 -1
- package/src/scripts/dev-server-rpc.test.ts +169 -0
- package/src/scripts/dev-server.ts +38 -8
- package/src/scripts/sandbox.ts +1 -1
- package/src/telemetry/client.ts +4 -4
- package/src/telemetry/telemetry-send-worker.ts +5 -0
- 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,
|
|
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"}
|
package/dist/scripts/deploy.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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 @@
|
|
|
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":"
|
|
1
|
+
{"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/scripts/dev-server.ts"],"names":[],"mappings":"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"}
|
|
@@ -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
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
1070
|
-
|
|
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
|
}
|
package/dist/scripts/sandbox.js
CHANGED
|
@@ -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",
|
|
127
|
+
"cdk", "watch",
|
|
128
128
|
`--outputs-file`, `${outDir}/outputs.json`,
|
|
129
129
|
`--context`, `projectRoot=${process.cwd()}`,
|
|
130
130
|
`--context`, `sandboxMode=true`,
|
package/dist/telemetry/client.js
CHANGED
|
@@ -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
|
|
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
|
|
203
|
-
child.stdin
|
|
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.
|
|
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.
|
|
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.
|
|
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'],
|
|
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
|
}
|