@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
@@ -1134,4 +1134,77 @@ describe('Hosting', () => {
1134
1134
  `found DependsOn=${JSON.stringify(dependsOn)}`);
1135
1135
  });
1136
1136
  });
1137
+ describe('config.json stale-placeholder guard (#173)', () => {
1138
+ // Helper: pull every BucketDeployment custom resource's Properties.
1139
+ const bucketDeployments = (stack) => {
1140
+ const crs = Template.fromStack(stack).findResources('Custom::CDKBucketDeployment');
1141
+ return Object.values(crs).map((cr) => cr.Properties);
1142
+ };
1143
+ it('uploads the placeholder config.json with a no-cache directive, never the 1-year mutable cache-control', () => {
1144
+ // The build-time placeholder (`{_placeholder:true}`) is written into the
1145
+ // static dir. If it inherits the mutable asset tier's
1146
+ // `s-maxage=31536000` and an edge caches it during the deploy window, the
1147
+ // edge serves the placeholder for up to a year — breaking every client
1148
+ // API call. It must instead be uploaded as a no-cache path so the edge
1149
+ // never caches it long-term.
1150
+ createSpaBuildOutput(tmpDir);
1151
+ const app = new App();
1152
+ const stack = new Stack(app, 'PlaceholderCacheStack');
1153
+ new Hosting(stack, 'Hosting', { root: tmpDir, api: MOCK_API });
1154
+ const deployments = bucketDeployments(stack);
1155
+ const cc = (p) => p.SystemMetadata?.['cache-control'];
1156
+ const includes = (p) => p.Include ?? [];
1157
+ const excludes = (p) => p.Exclude ?? [];
1158
+ // A deployment must upload `.blocks-sandbox/config.json` with the
1159
+ // no-cache directive (this is the placeholder upload).
1160
+ const noCacheDeploy = deployments.find((p) => includes(p).includes('.blocks-sandbox/config.json') &&
1161
+ cc(p) === 'no-cache, no-store, must-revalidate');
1162
+ assert.ok(noCacheDeploy, 'placeholder .blocks-sandbox/config.json must be uploaded with ' +
1163
+ '"no-cache, no-store, must-revalidate"');
1164
+ // No deployment may cover the placeholder with the 1-year mutable
1165
+ // cache-control: the mutable/other tier must EXCLUDE it.
1166
+ const leaks = deployments.filter((p) => {
1167
+ const directive = cc(p);
1168
+ return (typeof directive === 'string' &&
1169
+ directive.includes('s-maxage=31536000') &&
1170
+ !excludes(p).includes('.blocks-sandbox/config.json'));
1171
+ });
1172
+ assert.strictEqual(leaks.length, 0, 'no mutable-tier deployment may apply s-maxage=31536000 to ' +
1173
+ '.blocks-sandbox/config.json');
1174
+ });
1175
+ it('registers the placeholder as a no-cache path even for a static-only site (no api)', () => {
1176
+ // The placeholder is always written (step 5), so its no-cache
1177
+ // registration must not depend on `props.api`. This guards against a
1178
+ // regression that moves the registration inside an `if (props.api)`
1179
+ // block, which would reopen the stale-placeholder window for
1180
+ // static-only sites.
1181
+ createSpaBuildOutput(tmpDir);
1182
+ const app = new App();
1183
+ const stack = new Stack(app, 'StaticOnlyPlaceholderStack');
1184
+ new Hosting(stack, 'Hosting', { root: tmpDir });
1185
+ Template.fromStack(stack).hasResourceProperties('Custom::CDKBucketDeployment', Match.objectLike({
1186
+ Include: Match.arrayWith(['.blocks-sandbox/config.json']),
1187
+ SystemMetadata: Match.objectLike({
1188
+ 'cache-control': 'no-cache, no-store, must-revalidate',
1189
+ }),
1190
+ }));
1191
+ });
1192
+ it('invalidates the post-rewrite cache key (/builds/<id>/.blocks-sandbox/*)', () => {
1193
+ // The viewer-request skew-protection function rewrites the URI to
1194
+ // `/builds/<buildId>/.blocks-sandbox/config.json` BEFORE the cache
1195
+ // lookup, so the real edge cache key lives under `/builds/<id>/`.
1196
+ // Invalidating only `/.blocks-sandbox/*` never matches it.
1197
+ createSpaBuildOutput(tmpDir);
1198
+ const app = new App();
1199
+ const stack = new Stack(app, 'InvalidationPathStack');
1200
+ new Hosting(stack, 'Hosting', { root: tmpDir, api: MOCK_API });
1201
+ // Assert via Template + Match so a CDK property rename fails loudly here
1202
+ // rather than silently skipping a structural-heuristic lookup.
1203
+ Template.fromStack(stack).hasResourceProperties('Custom::CDKBucketDeployment', Match.objectLike({
1204
+ DistributionPaths: Match.arrayWith([
1205
+ Match.stringLikeRegexp('^/builds/.+/\\.blocks-sandbox/\\*$'),
1206
+ ]),
1207
+ }));
1208
+ });
1209
+ });
1137
1210
  });
@@ -1 +1 @@
1
- {"version":3,"file":"lambda-handler.d.ts","sourceRoot":"","sources":["../src/lambda-handler.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAerD,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAElE;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,2BAAkC,CAAC;AAG9D;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;CAErB,CAAC;AAoDX;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAWlF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,GAAG,CAwD7C;AAiBD;;;;;;;GAOG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,UAAU,CAWpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAIrE;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,wBAAwB,IAAI,MAAM,CAAC;CACpC;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,IA0BtD,OAAO,GAAG,EAAE,UAAU,aAAa,kBAkElD"}
1
+ {"version":3,"file":"lambda-handler.d.ts","sourceRoot":"","sources":["../src/lambda-handler.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAerD,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAElE;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,2BAAkC,CAAC;AAG9D;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;CAErB,CAAC;AAqCX;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAWlF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,GAAG,CAwD7C;AAiBD;;;;;;;GAOG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,QAAQ,CAAC;AAErE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,GAAG,GAAG,UAAU,CAWpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,GAAG,GAAG,OAAO,CAEzD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,CAIrE;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,wBAAwB,IAAI,MAAM,CAAC;CACpC;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,IA0BtD,OAAO,GAAG,EAAE,UAAU,aAAa,kBAiElD"}
@@ -8,7 +8,7 @@ import { matchRoute, lockRouteRegistry } from './raw-route.js';
8
8
  import { registerBuiltinRoutes } from './builtin-routes.js';
9
9
  import { loadConfigToProcessEnv } from './common/config.js';
10
10
  import { parseRpcRequest, successResponse, errorResponse, errorResponseFromCatch, methodNotFoundResponse, } from './rpc.js';
11
- import { getCorsPatterns, isOriginAllowed, corsRejection } from './cors.js';
11
+ import { getCorsPatterns, isOriginAllowed, corsRejection, buildCorsHeaders, CORS_MAX_AGE } from './cors.js';
12
12
  export { parseCorsPatterns, _resetCorsPatterns } from './cors.js';
13
13
  /**
14
14
  * AsyncLocalStorage that carries the inbound HTTP request cookies through
@@ -28,18 +28,6 @@ globalThis.__BLOCKS_REQUEST_COOKIES_STORE__ = requestCookies;
28
28
  export const EventSourceMapping = {
29
29
  SQS: 'aws:sqs',
30
30
  };
31
- // ── CORS helpers (private to handler) ───────────────────────────────────────
32
- function buildCorsHeaders(origin) {
33
- const headers = {};
34
- if (isOriginAllowed(origin)) {
35
- headers['Access-Control-Allow-Origin'] = origin;
36
- headers['Access-Control-Allow-Credentials'] = 'true';
37
- }
38
- else if (origin) {
39
- console.warn(`[CORS] Origin "${origin}" is not allowed. Set the CORS_ALLOWED_ORIGINS environment variable to allow this origin. Example: CORS_ALLOWED_ORIGINS=https://myapp\\.com,^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$`);
40
- }
41
- return headers;
42
- }
43
31
  // ── Helpers ─────────────────────────────────────────────────────────────────
44
32
  /**
45
33
  * Decode the Lambda event body and return both the raw string and a ReadableStream.
@@ -330,7 +318,7 @@ export function createLambdaHandler(backendFactory) {
330
318
  // Timeout won the race — build a 504 response. Format depends on
331
319
  // whether the request targeted an RPC endpoint (structured JSON-RPC
332
320
  // error envelope) or a plain HTTP path (simple error JSON).
333
- const origin = event.headers?.origin || event.headers?.Origin || '*';
321
+ const origin = event.headers?.origin || event.headers?.Origin || '';
334
322
  const requestPath = getRequestPath(event);
335
323
  const isRpcPath = requestPath === BLOCKS_RPC_PREFIX || requestPath.startsWith(BLOCKS_RPC_PREFIX + '/');
336
324
  const body = isRpcPath
@@ -340,8 +328,7 @@ export function createLambdaHandler(backendFactory) {
340
328
  statusCode: 504,
341
329
  headers: {
342
330
  'Content-Type': 'application/json',
343
- 'Access-Control-Allow-Origin': origin,
344
- 'Access-Control-Allow-Credentials': 'true',
331
+ ...buildCorsHeaders(origin),
345
332
  },
346
333
  body,
347
334
  };
@@ -414,7 +401,7 @@ function createHandler(backend) {
414
401
  ...corsHeaders,
415
402
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS',
416
403
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
417
- 'Access-Control-Max-Age': '86400',
404
+ 'Access-Control-Max-Age': CORS_MAX_AGE,
418
405
  },
419
406
  body: '',
420
407
  };
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { describe, it, beforeEach } from 'node:test';
4
4
  import assert from 'node:assert';
5
- import { createLambdaHandler, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost } from './lambda-handler.js';
5
+ import { createLambdaHandler, _resetCorsPatterns, requestCookies, isApiGatewayHttpEvent, computeHttpDeadlineMs, classifyEvent, buildEventUrl, isLoopbackForwardedHost } from './lambda-handler.js';
6
6
  import { registerRoute, clearRouteRegistry } from './raw-route.js';
7
7
  import { decodeRpcResponse } from './rpc.js';
8
8
  beforeEach(() => {
@@ -499,7 +499,7 @@ describe('createLambdaHandler — timeout guard for API Gateway events', () => {
499
499
  assert.strictEqual(body.jsonrpc, '2.0');
500
500
  assert.strictEqual(body.error.code, 504);
501
501
  });
502
- it('504 response includes CORS headers from event origin', async () => {
502
+ it('504 response includes CORS headers for an allowed origin', async () => {
503
503
  const backend = {
504
504
  api: () => ({
505
505
  async echo() {
@@ -508,6 +508,9 @@ describe('createLambdaHandler — timeout guard for API Gateway events', () => {
508
508
  },
509
509
  }),
510
510
  };
511
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
512
+ delete process.env.CORS_HOSTING_ORIGINS;
513
+ _resetCorsPatterns();
511
514
  const event = makeEvent({
512
515
  headers: {
513
516
  'Content-Type': 'application/json',
@@ -519,6 +522,60 @@ describe('createLambdaHandler — timeout guard for API Gateway events', () => {
519
522
  assert.strictEqual(result.statusCode, 504);
520
523
  assert.strictEqual(result.headers['Access-Control-Allow-Origin'], 'https://myapp.example.com');
521
524
  assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], 'true');
525
+ delete process.env.CORS_ALLOWED_ORIGINS;
526
+ _resetCorsPatterns();
527
+ });
528
+ it('504 response omits CORS headers when no allowlist is configured', async () => {
529
+ const backend = {
530
+ api: () => ({
531
+ async echo() {
532
+ await new Promise(resolve => setTimeout(resolve, 5_000));
533
+ return {};
534
+ },
535
+ }),
536
+ };
537
+ delete process.env.CORS_ALLOWED_ORIGINS;
538
+ delete process.env.CORS_HOSTING_ORIGINS;
539
+ _resetCorsPatterns();
540
+ const event = makeEvent({
541
+ headers: {
542
+ 'Content-Type': 'application/json',
543
+ origin: 'https://evil.example.com',
544
+ },
545
+ });
546
+ const ctx = makeLambdaContext(50);
547
+ const result = await invokeWithContext(backend, event, ctx);
548
+ assert.strictEqual(result.statusCode, 504);
549
+ assert.strictEqual(result.headers['Access-Control-Allow-Origin'], undefined);
550
+ assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], undefined);
551
+ delete process.env.CORS_ALLOWED_ORIGINS;
552
+ _resetCorsPatterns();
553
+ });
554
+ it('disallowed origin with a configured allowlist is rejected with 403 before the timeout path', async () => {
555
+ const backend = {
556
+ api: () => ({
557
+ async echo() {
558
+ await new Promise(resolve => setTimeout(resolve, 5_000));
559
+ return {};
560
+ },
561
+ }),
562
+ };
563
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
564
+ delete process.env.CORS_HOSTING_ORIGINS;
565
+ _resetCorsPatterns();
566
+ const event = makeEvent({
567
+ headers: {
568
+ 'Content-Type': 'application/json',
569
+ origin: 'https://evil.example.com',
570
+ },
571
+ });
572
+ const ctx = makeLambdaContext(50);
573
+ const result = await invokeWithContext(backend, event, ctx);
574
+ assert.strictEqual(result.statusCode, 403);
575
+ assert.strictEqual(result.headers['Access-Control-Allow-Origin'], undefined);
576
+ assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], undefined);
577
+ delete process.env.CORS_ALLOWED_ORIGINS;
578
+ _resetCorsPatterns();
522
579
  });
523
580
  });
524
581
  describe('createLambdaHandler — async events bypass timeout guard', () => {
package/dist/redact.d.ts CHANGED
@@ -44,8 +44,9 @@ export declare const REDACTED: "[REDACTED]";
44
44
  export declare function redactForLogging(value: unknown, seen?: WeakSet<object>): unknown;
45
45
  /**
46
46
  * Convenience for log call sites: redact `value` and serialize it to a JSON
47
- * string. Returns a safe placeholder instead of throwing if serialization
48
- * fails (e.g. a BigInt slips through), so logging can never crash a request.
47
+ * string. Returns `'undefined'` when JSON serialization produces no output,
48
+ * or a safe placeholder if serialization throws (e.g. a BigInt slips through),
49
+ * so logging can never crash a request.
49
50
  */
50
51
  export declare function redactToJson(value: unknown): string;
51
52
  //# sourceMappingURL=redact.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"redact.d.ts","sourceRoot":"","sources":["../src/redact.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,sDAAsD;AACtD,eAAO,MAAM,QAAQ,EAAG,YAAqB,CAAC;AAwC9C;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,GAAE,OAAO,CAAC,MAAM,CAAiB,GAAG,OAAO,CA2C/F;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAMnD"}
1
+ {"version":3,"file":"redact.d.ts","sourceRoot":"","sources":["../src/redact.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,sDAAsD;AACtD,eAAO,MAAM,QAAQ,EAAG,YAAqB,CAAC;AAwC9C;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,GAAE,OAAO,CAAC,MAAM,CAAiB,GAAG,OAAO,CA2C/F;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAMnD"}
package/dist/redact.js CHANGED
@@ -121,12 +121,13 @@ export function redactForLogging(value, seen = new WeakSet()) {
121
121
  }
122
122
  /**
123
123
  * Convenience for log call sites: redact `value` and serialize it to a JSON
124
- * string. Returns a safe placeholder instead of throwing if serialization
125
- * fails (e.g. a BigInt slips through), so logging can never crash a request.
124
+ * string. Returns `'undefined'` when JSON serialization produces no output,
125
+ * or a safe placeholder if serialization throws (e.g. a BigInt slips through),
126
+ * so logging can never crash a request.
126
127
  */
127
128
  export function redactToJson(value) {
128
129
  try {
129
- return JSON.stringify(redactForLogging(value));
130
+ return JSON.stringify(redactForLogging(value)) ?? 'undefined';
130
131
  }
131
132
  catch {
132
133
  return '[unserializable]';
@@ -179,6 +179,15 @@ describe('redactToJson', () => {
179
179
  // BigInt is not JSON-serializable and survives redaction (not a sensitive key).
180
180
  assert.equal(redactToJson({ big: 10n }), '[unserializable]');
181
181
  });
182
+ it('returns a string for undefined', () => {
183
+ assert.equal(redactToJson(undefined), 'undefined');
184
+ });
185
+ it('returns a string for function values', () => {
186
+ assert.equal(redactToJson(() => { }), 'undefined');
187
+ });
188
+ it('returns a string for symbol values', () => {
189
+ assert.equal(redactToJson(Symbol('x')), 'undefined');
190
+ });
182
191
  it('does not leak secrets even when truncation would apply downstream', () => {
183
192
  const json = redactToJson([{ action: 'signIn', username: 'u', password: 'p'.repeat(50) }]);
184
193
  assert.ok(!json.includes('pppp'));
package/dist/rpc.test.js CHANGED
@@ -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 { parseRpcRequest, RpcErrorCode } from './rpc.js';
5
+ import { decodeRpcResponse, errorResponseFromCatch, parseRpcRequest, RpcErrorCode } from './rpc.js';
6
+ import { ApiError, isBlocksError } from './errors.js';
6
7
  describe('-32600 Invalid Request error shape', () => {
7
8
  it('returns proper JSON-RPC 2.0 envelope with error code', () => {
8
9
  const result = parseRpcRequest(JSON.stringify({ method: 'ns.method', id: 1 }));
@@ -58,3 +59,78 @@ describe('-32600 Invalid Request error shape', () => {
58
59
  }
59
60
  });
60
61
  });
62
+ describe('params decoding', () => {
63
+ it('uses an array of params as positional args', () => {
64
+ const result = parseRpcRequest(JSON.stringify({ jsonrpc: '2.0', method: 'api.greet', params: ['World', 42], id: 1 }));
65
+ assert.strictEqual(result.ok, true);
66
+ if (result.ok) {
67
+ assert.deepStrictEqual(result.request.args, ['World', 42]);
68
+ assert.strictEqual(result.request.apiNamespace, 'api');
69
+ assert.strictEqual(result.request.method, 'greet');
70
+ }
71
+ });
72
+ it('flattens an object of named params in key order', () => {
73
+ const result = parseRpcRequest(JSON.stringify({ jsonrpc: '2.0', method: 'api.greet', params: { name: 'World', times: 42 }, id: 1 }));
74
+ assert.strictEqual(result.ok, true);
75
+ if (result.ok)
76
+ assert.deepStrictEqual(result.request.args, ['World', 42]);
77
+ });
78
+ it('yields no args when params is omitted', () => {
79
+ const result = parseRpcRequest(JSON.stringify({ jsonrpc: '2.0', method: 'api.ping', id: 7 }));
80
+ assert.strictEqual(result.ok, true);
81
+ if (result.ok)
82
+ assert.deepStrictEqual(result.request.args, []);
83
+ });
84
+ });
85
+ describe('batch requests (top-level JSON array body)', () => {
86
+ it('rejects an array body as Invalid Request with a null id', () => {
87
+ const result = parseRpcRequest(JSON.stringify([
88
+ { jsonrpc: '2.0', method: 'api.greet', params: ['a'], id: 1 },
89
+ { jsonrpc: '2.0', method: 'api.greet', params: ['b'], id: 2 },
90
+ ]));
91
+ assert.strictEqual(result.ok, false);
92
+ if (!result.ok) {
93
+ const parsed = JSON.parse(result.response);
94
+ assert.strictEqual(parsed.error.code, RpcErrorCode.InvalidRequest);
95
+ assert.strictEqual(parsed.error.data.name, 'InvalidRequest');
96
+ assert.strictEqual(parsed.id, null);
97
+ }
98
+ });
99
+ it('reports a parse error for a body that is not JSON at all', () => {
100
+ const result = parseRpcRequest('{oops');
101
+ assert.strictEqual(result.ok, false);
102
+ if (!result.ok) {
103
+ const parsed = JSON.parse(result.response);
104
+ assert.strictEqual(parsed.error.code, RpcErrorCode.ParseError);
105
+ assert.strictEqual(parsed.id, null);
106
+ }
107
+ });
108
+ });
109
+ describe('ApiError status ↔ JSON-RPC error code', () => {
110
+ it('encodes the HTTP status as the error code, with name and retriable in data', () => {
111
+ const encoded = errorResponseFromCatch(new ApiError('Username already taken', 409, { name: 'ConditionalCheckFailedException', retriable: true }), 1);
112
+ const parsed = JSON.parse(encoded);
113
+ assert.strictEqual(parsed.error.code, 409);
114
+ assert.strictEqual(parsed.error.message, 'Username already taken');
115
+ assert.strictEqual(parsed.error.data.name, 'ConditionalCheckFailedException');
116
+ assert.strictEqual(parsed.error.data.retriable, true);
117
+ });
118
+ it('encodes a non-ApiError throw as code 500 with no data.name', () => {
119
+ const parsed = JSON.parse(errorResponseFromCatch(new Error('plain'), 2));
120
+ assert.strictEqual(parsed.error.code, 500);
121
+ assert.strictEqual(parsed.error.data, undefined);
122
+ });
123
+ it('round-trips status, name and retriable back into an ApiError on the client', () => {
124
+ const wire = JSON.parse(errorResponseFromCatch(new ApiError('Username already taken', 409, { name: 'ConditionalCheckFailedException', retriable: true }), 1));
125
+ assert.throws(() => decodeRpcResponse(wire), (e) => {
126
+ assert.ok(e instanceof ApiError);
127
+ assert.strictEqual(e.status, 409);
128
+ assert.strictEqual(e.retriable, true);
129
+ assert.ok(isBlocksError(e, 'ConditionalCheckFailedException'));
130
+ return true;
131
+ });
132
+ });
133
+ it('decodes reserved -32xxx codes as status 500', () => {
134
+ assert.throws(() => decodeRpcResponse({ jsonrpc: '2.0', error: { code: RpcErrorCode.InvalidRequest, message: 'Invalid Request' }, id: null }), (e) => e instanceof ApiError && e.status === 500);
135
+ });
136
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../src/scripts/console.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,wBAAsB,WAAW,CAAC,OAAO,EAAE,cAAc,iBAqBxD"}
1
+ {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../src/scripts/console.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA6BD,wBAAsB,WAAW,CAAC,OAAO,EAAE,cAAc,iBAqBxD"}
@@ -3,6 +3,34 @@
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { trackCommand } from '../telemetry/trackCommand.js';
6
+ function resolveRegion() {
7
+ const fromEnv = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION;
8
+ if (fromEnv)
9
+ return fromEnv;
10
+ try {
11
+ const fromConfig = execFileSync('aws', ['configure', 'get', 'region'], { encoding: 'utf-8' }).trim();
12
+ if (fromConfig)
13
+ return fromConfig;
14
+ }
15
+ catch {
16
+ // aws CLI not configured — fall through to default.
17
+ }
18
+ return 'us-east-1';
19
+ }
20
+ /** Launch the URL in the default browser. Best-effort: no opener (headless/CI) is not a failure. */
21
+ function openInBrowser(url) {
22
+ const opener = process.platform === 'darwin' ? 'open' :
23
+ process.platform === 'win32' ? 'cmd' :
24
+ 'xdg-open';
25
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
26
+ try {
27
+ execFileSync(opener, args, { stdio: 'ignore' });
28
+ }
29
+ catch {
30
+ // Headless environment (CI, remote shell) — the URL is already printed above.
31
+ console.log('(Could not launch a browser automatically — open the URL above manually.)');
32
+ }
33
+ }
6
34
  export async function openConsole(options) {
7
35
  return trackCommand('console', async () => {
8
36
  let stackName;
@@ -16,10 +44,10 @@ export async function openConsole(options) {
16
44
  else {
17
45
  throw new Error('Must provide either stackId or outputsFile');
18
46
  }
19
- const region = execFileSync('aws', ['configure', 'get', 'region'], { encoding: 'utf-8' }).trim() || 'us-east-1';
47
+ const region = resolveRegion();
20
48
  const stackUrl = `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks?filteringText=${encodeURIComponent(stackName)}`;
21
49
  console.log('Opening AWS Console...');
22
50
  console.log(stackUrl);
23
- execFileSync('open', [stackUrl], { stdio: 'inherit' });
51
+ openInBrowser(stackUrl);
24
52
  });
25
53
  }
@@ -0,0 +1,181 @@
1
+ /** What to do with a signal that arrives while a deploy is in flight. */
2
+ export type DeploySignalAction = 'defer' | 'abort';
3
+ export interface DeploySignalResponse {
4
+ action: DeploySignalAction;
5
+ /** Operator-facing line explaining what happened and how to force an abort. */
6
+ message: string;
7
+ /**
8
+ * True when this signal is a duplicate delivery of one the operator already
9
+ * sent (see {@link SIGNAL_COALESCE_MS}), so the caller can skip logging it
10
+ * twice.
11
+ */
12
+ coalesced?: boolean;
13
+ }
14
+ /** Signals whose delivery we take over while a deploy is in flight. */
15
+ export declare const DEPLOY_SIGNALS: readonly NodeJS.Signals[];
16
+ /** Default gap (ms) of silence after which the runner prints a progress heartbeat. */
17
+ export declare const DEFAULT_HEARTBEAT_MS = 30000;
18
+ /** Grace (ms) given to the child tree to exit after an operator-requested abort. */
19
+ export declare const ABORT_GRACE_MS = 10000;
20
+ /**
21
+ * Grace (ms) we wait after the child exits for its stdout/stderr pipes to end,
22
+ * so the last CloudFormation lines are relayed before we resolve. Bounded
23
+ * because a lingering grandchild could hold the pipe open forever; losing a
24
+ * trailing line is strictly better than hanging a finished deploy.
25
+ */
26
+ export declare const STREAM_FLUSH_GRACE_MS = 2000;
27
+ /**
28
+ * Window (ms) in which repeated SIGTERMs count as ONE operator request.
29
+ *
30
+ * A single external signal reaches this process more than once. `npm run deploy`
31
+ * runs `npm -> sh -> tsx -> node`, so a process-group SIGTERM is delivered to
32
+ * the node process directly AND relayed to it a second time by `tsx`, which
33
+ * forwards SIGTERM/SIGINT to its child. Measured on a real deploy: one
34
+ * `kill -TERM -<pgid>` produced two SIGTERMs about 50ms apart. Without this
35
+ * window the second delivery is read as "the operator insisted" and the deploy
36
+ * is abandoned, which is the exact failure this module exists to prevent.
37
+ *
38
+ * 2s is far longer than a delivery burst (tens of ms) and far shorter than a
39
+ * deliberate repeat (a human running `kill` twice, or a supervisor's
40
+ * SIGTERM-then-escalate cycle), so both intents stay distinguishable.
41
+ */
42
+ export declare const SIGNAL_COALESCE_MS = 2000;
43
+ /**
44
+ * Decide how to answer a signal that arrives while CloudFormation is still
45
+ * converging. This is the whole "decouple the CLI lifecycle from the in-flight
46
+ * deploy" policy, kept pure so it can be asserted directly.
47
+ *
48
+ * - `SIGHUP` → always `defer`. A hangup means the terminal or parent shell went
49
+ * away (a backgrounded `npm run deploy &`, a closed SSH session). The deploy
50
+ * is server-side work that is already paid for; killing the CLI here is what
51
+ * produced the phantom failures, so we keep streaming instead. Duplicate
52
+ * deliveries inside {@link SIGNAL_COALESCE_MS} are coalesced so one hangup
53
+ * logs one line, not one per delivery.
54
+ * - `SIGTERM` → `defer` the first time. A lone SIGTERM is almost always
55
+ * process-group collateral (a harness reaping the parent shell, a supervisor
56
+ * tidying up) rather than a deliberate "stop the deploy", so it only warns.
57
+ * Repeats inside {@link SIGNAL_COALESCE_MS} are duplicate *deliveries* of that
58
+ * same signal (the group delivers it, then `tsx` relays it) and are coalesced
59
+ * into the first. A SIGTERM after that window is a deliberate repeat and
60
+ * aborts. A SIGKILL follow-up (`docker stop`, most CI cancels) is uncatchable
61
+ * and still ends the process immediately, so this cannot wedge a shutdown.
62
+ * - `SIGINT` → always `abort`. Ctrl-C is unambiguous, interactive intent, so it
63
+ * stays responsive on the first press.
64
+ *
65
+ * @param signal - the signal received.
66
+ * @param msSinceFirstDeferral - ms since the first deferral of *this* signal, or
67
+ * `null` when this signal has not been deferred yet. Each signal is tracked
68
+ * separately, so a deferred SIGHUP never consumes the SIGTERM abort budget
69
+ * (and a repeated SIGHUP is deduped the same way a repeated SIGTERM is).
70
+ * @param coalesceWindowMs - see {@link SIGNAL_COALESCE_MS}.
71
+ */
72
+ export declare function decideSignalResponse(signal: NodeJS.Signals, msSinceFirstDeferral: number | null, coalesceWindowMs?: number): DeploySignalResponse;
73
+ /**
74
+ * Split a byte stream into whole lines across chunk boundaries.
75
+ *
76
+ * The child's output arrives in arbitrary chunks, so a naive
77
+ * `chunk.toString().split('\n')` emits torn lines (and drops the tail). This
78
+ * keeps the partial trailing line buffered until it completes; {@link flush}
79
+ * returns whatever is left when the stream ends (CDK's final line has no
80
+ * trailing newline).
81
+ */
82
+ export declare function createLineAssembler(): {
83
+ push(chunk: string): string[];
84
+ flush(): string[];
85
+ };
86
+ /** Human-readable elapsed time (`45s`, `4m 05s`) for progress lines. */
87
+ export declare function formatElapsed(ms: number): string;
88
+ export interface CdkDeployArgsOptions {
89
+ /** Project root passed to synth as `--context projectRoot=…`. */
90
+ projectRoot: string;
91
+ /** Path (relative to the project root) CDK writes stack outputs to. */
92
+ outputsFile: string;
93
+ }
94
+ /**
95
+ * Build the `cdk deploy` argv used by `npm run deploy`.
96
+ *
97
+ * Two of these flags exist purely so the deploy is observable — losing either
98
+ * one brings back the 0-byte stdout:
99
+ *
100
+ * - `--ci`: the CDK CLI picks its log stream as `isCI ? stdout : stderr`, so
101
+ * without it every CloudFormation event goes to **stderr** and a caller
102
+ * capturing stdout (`npm run deploy > deploy.log`) sees nothing for the whole
103
+ * multi-minute deploy. With it, progress goes to stdout and only error-level
104
+ * messages stay on stderr.
105
+ * - `--progress events`: print one line per resource transition instead of the
106
+ * redrawing progress bar. The bar needs a TTY, which a piped/backgrounded
107
+ * deploy does not have, and a half-rendered bar is not a usable progress
108
+ * signal in a log file.
109
+ */
110
+ export declare function buildCdkDeployArgs({ projectRoot, outputsFile }: CdkDeployArgsOptions): string[];
111
+ /** Minimal sink surface so tests can capture the relayed streams. */
112
+ export interface OutputSink {
113
+ write(chunk: string): unknown;
114
+ }
115
+ /** Minimal signal-registration surface ({@link process} satisfies it). */
116
+ export interface SignalRegistry {
117
+ on(signal: NodeJS.Signals, handler: () => void): unknown;
118
+ off(signal: NodeJS.Signals, handler: () => void): unknown;
119
+ }
120
+ export interface RunStreamingOptions {
121
+ cwd?: string;
122
+ env?: NodeJS.ProcessEnv;
123
+ /** Prefix used by the runner's own progress/status lines. Defaults to `deploy`. */
124
+ label?: string;
125
+ /** Idle gap before a heartbeat line is printed. `0` disables heartbeats. */
126
+ heartbeatMs?: number;
127
+ /** Where child stdout and runner progress lines go. Defaults to `process.stdout`. */
128
+ stdout?: OutputSink;
129
+ /** Where child stderr goes. Defaults to `process.stderr`. */
130
+ stderr?: OutputSink;
131
+ /** Injected for tests. */
132
+ now?: () => number;
133
+ /** Injected for tests: signal registration seam. */
134
+ signalTarget?: SignalRegistry;
135
+ }
136
+ /** Raised when the child exits non-zero, is killed, or the operator aborts. */
137
+ export declare class DeployProcessError extends Error {
138
+ readonly exitCode: number | null;
139
+ readonly signal: NodeJS.Signals | null;
140
+ readonly aborted: boolean;
141
+ constructor(message: string, details?: {
142
+ exitCode?: number | null;
143
+ signal?: NodeJS.Signals | null;
144
+ aborted?: boolean;
145
+ });
146
+ }
147
+ /**
148
+ * Run a long deployment command, relaying its output line by line as it happens
149
+ * and keeping it alive across a stray SIGTERM/SIGHUP.
150
+ *
151
+ * Behaviour that matters to callers:
152
+ * - **Streamed, non-empty stdout.** Child stdout is relayed to `stdout` the
153
+ * moment a line completes (never buffered until exit) and child stderr to
154
+ * `stderr`, so `npm run deploy | tee` shows CloudFormation progress live.
155
+ * - **Idle heartbeat.** While the child is silent for `heartbeatMs`, a
156
+ * `still deploying` line with elapsed time is written to `stdout`, so a
157
+ * ten-minute RDS resource never looks like a hung process.
158
+ * - **Own process group (POSIX only).** The child is spawned `detached` on
159
+ * POSIX, so a process-group signal aimed at the parent shell
160
+ * (`kill -TERM -pgid`, a harness reaping a backgrounded job) cannot kill the
161
+ * CDK CLI behind our back; this runner is the only thing that signals it.
162
+ * Windows has neither process groups nor OS-delivered SIGTERM/SIGHUP, so the
163
+ * signal resilience below does not apply there — a `taskkill` on the tree ends
164
+ * the deploy, and the abort path reaps by pid via `terminateProcessTree`.
165
+ * - **No stdin.** The child gets `ignore` for stdin so a backgrounded deploy
166
+ * can never be stopped by SIGTTIN trying to read a terminal it no longer
167
+ * owns; the caller must keep passing `--require-approval never`.
168
+ * - **Signal policy (POSIX).** See {@link decideSignalResponse}: a deferred
169
+ * signal only logs (which itself doubles as a progress signal on stdout),
170
+ * while an abort reaps the child tree and throws a {@link DeployProcessError}
171
+ * with `aborted: true`. Repeat deliveries of the same signal inside
172
+ * {@link SIGNAL_COALESCE_MS} log once, not once per delivery.
173
+ * - **Streams stay separated.** Child stdout and child stderr are relayed to
174
+ * their own sinks and never merged, so a deploy failure reason (which the CDK
175
+ * CLI keeps on stderr even under `--ci`) stays on stderr while progress is on
176
+ * stdout.
177
+ *
178
+ * Resolves when the child exits 0; otherwise throws {@link DeployProcessError}.
179
+ */
180
+ export declare function runStreaming(command: string, args: string[], options?: RunStreamingOptions): Promise<void>;
181
+ //# sourceMappingURL=deploy-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deploy-stream.d.ts","sourceRoot":"","sources":["../../src/scripts/deploy-stream.ts"],"names":[],"mappings":"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"}