@aws-blocks/core 0.1.13 → 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 (53) 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/rpc.test.js +77 -1
  14. package/dist/scripts/console.d.ts.map +1 -1
  15. package/dist/scripts/console.js +30 -2
  16. package/dist/scripts/deploy-stream.d.ts +181 -0
  17. package/dist/scripts/deploy-stream.d.ts.map +1 -0
  18. package/dist/scripts/deploy-stream.js +332 -0
  19. package/dist/scripts/deploy-stream.test.d.ts +2 -0
  20. package/dist/scripts/deploy-stream.test.d.ts.map +1 -0
  21. package/dist/scripts/deploy-stream.test.js +845 -0
  22. package/dist/scripts/deploy.d.ts.map +1 -1
  23. package/dist/scripts/deploy.js +16 -9
  24. package/dist/scripts/dev-server-cors.test.js +19 -1
  25. package/dist/scripts/dev-server-rpc.test.js +50 -0
  26. package/dist/scripts/dev-server.d.ts +8 -0
  27. package/dist/scripts/dev-server.d.ts.map +1 -1
  28. package/dist/scripts/dev-server.js +35 -8
  29. package/dist/scripts/sandbox.js +1 -1
  30. package/dist/telemetry/client.js +4 -4
  31. package/dist/telemetry/telemetry-send-worker.js +4 -0
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/package.json +10 -1
  35. package/src/cors.test.ts +96 -2
  36. package/src/cors.ts +59 -2
  37. package/src/errors.test.ts +29 -1
  38. package/src/hosting.test.ts +107 -0
  39. package/src/hosting.ts +27 -1
  40. package/src/lambda-handler.test.ts +71 -2
  41. package/src/lambda-handler.ts +4 -20
  42. package/src/rpc.test.ts +96 -1
  43. package/src/scripts/console.ts +29 -2
  44. package/src/scripts/deploy-stream.test.ts +1035 -0
  45. package/src/scripts/deploy-stream.ts +475 -0
  46. package/src/scripts/deploy.ts +18 -11
  47. package/src/scripts/dev-server-cors.test.ts +26 -1
  48. package/src/scripts/dev-server-rpc.test.ts +54 -0
  49. package/src/scripts/dev-server.ts +38 -8
  50. package/src/scripts/sandbox.ts +1 -1
  51. package/src/telemetry/client.ts +4 -4
  52. package/src/telemetry/telemetry-send-worker.ts +5 -0
  53. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -63,7 +63,7 @@ The typed `import { api } from 'aws-blocks'` client is the normal path. The HTTP
63
63
 
64
64
  `POST` to the RPC path `/aws-blocks/api`:
65
65
 
66
- - Local dev: `http://localhost:3001/aws-blocks/api`
66
+ - Local dev: `http://localhost:3000/aws-blocks/api` (the default template serves the backend and frontend from a single front door on `:3000`). Only the `backend` and `amplify` templates serve the API on `:3001`.
67
67
  - Deployed: the API Gateway stage URL + `/aws-blocks/api`
68
68
 
69
69
  The body is JSON-RPC 2.0:
@@ -79,15 +79,70 @@ The body is JSON-RPC 2.0:
79
79
  Working example:
80
80
 
81
81
  ```bash
82
- curl -X POST http://localhost:3001/aws-blocks/api \
82
+ curl -X POST http://localhost:3000/aws-blocks/api \
83
83
  -H 'Content-Type: application/json' \
84
84
  -d '{"jsonrpc":"2.0","method":"api.greet","params":["World"],"id":1}'
85
85
  # → {"jsonrpc":"2.0","result":{"message":"Hello, World!"},"id":1}
86
86
  ```
87
87
 
88
- ### ApiError / isBlocksError
88
+ #### How the server reads `params` (and what it rejects)
89
89
 
90
- Typed error handling across the wire.
90
+ The request body is parsed by `parseRpcRequest`, which turns `params` into the positional argument list your method is called with:
91
+
92
+ | Body `params` | Arguments the method receives |
93
+ |---|---|
94
+ | `["World", 42]` (array) | `('World', 42)`, used as-is |
95
+ | `{"name":"World","times":42}` (object) | `('World', 42)` via `Object.values()`, so **key insertion order decides argument order** |
96
+ | omitted or `null` | `()`, no arguments |
97
+
98
+ A named object is convenient for `curl`, but it is only safe when the keys are written in the same order as the method signature. Prefer the array form in anything automated.
99
+
100
+ One shape that looks reasonable and is **not** supported: a top-level JSON **array** as the whole body (a JSON-RPC batch). The parser reads `jsonrpc` and `method` off the body object, so an array body fails validation instead of running anything:
101
+
102
+ ```bash
103
+ curl -X POST http://localhost:3000/aws-blocks/api \
104
+ -H 'Content-Type: application/json' \
105
+ -d '[{"jsonrpc":"2.0","method":"api.greet","params":["a"],"id":1}]'
106
+ # → HTTP 200
107
+ # {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: expected JSON-RPC 2.0 — ...",
108
+ # "data":{"name":"InvalidRequest"}},"id":null}
109
+ ```
110
+
111
+ `id` is `null` in that response because an array body has no `id` to echo. Send one call per request. Body that isn't valid JSON at all returns `-32700 Parse error`, also with HTTP `200`.
112
+
113
+ #### Runtime config (`/.blocks-sandbox/config.json`)
114
+
115
+ The generated client does not hardcode the API URL; it resolves one at first call, in this order:
116
+
117
+ 1. `BLOCKS_API_URL` env var (set by the Hosting construct on SSR compute).
118
+ 2. `BLOCKS_CONFIG` env var (the whole config as JSON).
119
+ 3. Node only: the file `.blocks-sandbox/config.json`, read from the **process working directory**.
120
+ 4. Browser only: `fetch('/.blocks-sandbox/config.json')`.
121
+
122
+ Both env vars are normally written for you: `Hosting` injects them into every SSR compute function at synth, and `npm run sandbox` sets `BLOCKS_API_URL` on the dev server it spawns. You set `BLOCKS_API_URL` yourself only when you run the SSR host outside that tooling, like a framework dev server on its own port (`BLOCKS_API_URL=http://localhost:3001/aws-blocks/api next dev`) or your own container. `BLOCKS_CONFIG` is Hosting's serialized `backendConfig` rather than a knob to hand-write, so prefer `BLOCKS_API_URL` for a custom host.
123
+
124
+ Two things about that path burn time when debugging:
125
+
126
+ - The directory is **dotted**: `/.blocks-sandbox/config.json`. `/config.json` is not a route and never was: locally the dev server only answers `GET /.blocks-sandbox/config.json`, and in production the Hosting construct only adds a `/.blocks-sandbox/*` static behaviour. A `404` from `curl http://localhost:3000/config.json` says nothing about your config.
127
+ - `{"_placeholder":true}` is a **valid, expected** body in the frontend build output. The Hosting construct writes that stub into the static assets directory during CDK synth so the file exists as a static route while the real `apiUrl` is still an unresolved CloudFormation token; the deploy then uploads the resolved config over it. Finding the stub in `dist/.blocks-sandbox/config.json` (or on the origin between synth and deploy) is the design working, not a broken config.
128
+
129
+ What you should see instead, per environment:
130
+
131
+ ```bash
132
+ # Local dev / sandbox: served by the dev server itself, Cache-Control: no-store
133
+ curl http://localhost:3000/.blocks-sandbox/config.json
134
+ # → {"apiUrl":"http://localhost:3000/aws-blocks/api","environment":"local"}
135
+
136
+ # After a deploy: written by the deploy script and uploaded to the origin
137
+ cat .blocks-sandbox/config.json
138
+ # → { "apiUrl": "https://<id>.execute-api.<region>.amazonaws.com/prod/aws-blocks/api", "environment": "production" }
139
+ ```
140
+
141
+ So a real config problem looks like the client throwing `Blocks API URL not configured` (or `... is not configured (source: ...)`), not like a `404` on `/config.json`. If a **deployed** origin keeps serving `{"_placeholder":true}` after a successful deploy, that is a genuine bug: the config upload or the CloudFront invalidation did not land.
142
+
143
+ ### ApiError / isBlocksError / hasAuthError
144
+
145
+ Typed error handling across the wire. All three are exported from `@aws-blocks/core`, and re-exported from `@aws-blocks/blocks`.
91
146
 
92
147
  ```typescript
93
148
  import { ApiError, isBlocksError } from '@aws-blocks/core';
@@ -101,32 +156,140 @@ catch (e) {
101
156
  }
102
157
  ```
103
158
 
159
+ #### `new ApiError(message, status, options?)`
160
+
161
+ ```typescript
162
+ new ApiError(
163
+ message: string,
164
+ status: number,
165
+ options?: { name?: string; cause?: unknown; retriable?: boolean },
166
+ )
167
+ ```
168
+
169
+ | Argument | Required | What it does |
170
+ |---|---|---|
171
+ | `message` | yes | Human-facing text. Crosses the wire, so don't put internals in it. |
172
+ | `status` | yes | HTTP status code. Any number; nothing validates it against a known status. |
173
+ | `options.name` | no | The structured error name `isBlocksError` / `hasAuthError` match on (e.g. `'ItemNotFoundException'`). Defaults to `'ApiError'`, which carries no meaning, so treat it as "unnamed". |
174
+ | `options.cause` | no | Underlying error. **Stays server-side**, never serialized. |
175
+ | `options.retriable` | no | `true` when the caller can retry the same action without restarting the flow (wrong MFA code, wrong password on re-prompt). Defaults to `false`. |
176
+
177
+ The instance exposes `message`, `name`, `status` and `retriable` as readable properties, and it is a real `Error`, so `instanceof Error` and stack traces behave normally.
178
+
179
+ How `status` reaches the client over JSON-RPC: an uncaught error inside an RPC method is encoded as an error response whose **`code` is the `ApiError`'s `status`** (positive numbers can't collide with the reserved `-32xxx` range). `name` and `retriable` ride along in `error.data`. A non-`ApiError` throw becomes code `500` with no `data.name`.
180
+
181
+ ```jsonc
182
+ // throw new ApiError('Username already taken', 409,
183
+ // { name: 'ConditionalCheckFailedException', retriable: true })
184
+ { "jsonrpc": "2.0", "id": 1, "error": {
185
+ "code": 409,
186
+ "message": "Username already taken",
187
+ "data": { "name": "ConditionalCheckFailedException", "retriable": true }
188
+ }}
189
+ ```
190
+
191
+ The client decodes that back into an `ApiError` with the same `status`, `name` and `retriable`, which is why the same `isBlocksError(e, ...)` check works on both sides. Reserved JSON-RPC codes (`-32600`, `-32700`, …) decode to `status: 500`.
192
+
193
+ #### `hasAuthError(state, name)`
194
+
195
+ ```typescript
196
+ import { hasAuthError } from '@aws-blocks/core';
197
+
198
+ function hasAuthError<T extends { errorName?: string }, N extends string>(
199
+ state: T | null | undefined,
200
+ name: N,
201
+ ): state is T & { errorName: N }
202
+ ```
203
+
204
+ The auth blocks' recommended client path (`setAuthState()` / `getAuthState()`) **returns** a failed `AuthState` instead of throwing, so there is no `Error` for `isBlocksError` to inspect. `hasAuthError` is the equivalent guard for that returned object: it compares `state.errorName` to `name` and narrows the type. It's a plain equality check, so a `null` / `undefined` state and a state with no `errorName` both return `false` and no defensive wrapping is needed.
205
+
206
+ ```typescript
207
+ const next = await authApi.setAuthState({ action: 'signIn', username, password });
208
+ if (hasAuthError(next, AuthBasicErrors.InvalidCredentials)) {
209
+ // unknown user or wrong password → offer sign-up
210
+ }
211
+ ```
212
+
213
+ Rule of thumb: **thrown error → `isBlocksError`; returned `AuthState` → `hasAuthError`.** Match on the block's error constant, never on the human-facing `error` string.
214
+
104
215
  ### RawRoute
105
216
 
106
- Path-based HTTP routing Building Block for endpoints that need full request/response control webhooks, REST APIs, health checks, file downloads. Use `ApiNamespace` (RPC) for typed function calls; use `RawRoute` when you need raw HTTP semantics.
217
+ Path-based HTTP routing Building Block for endpoints that need full request/response control: webhooks, health checks, redirects, file downloads, anything a browser or third party has to hit with a plain `GET`. Use `ApiNamespace` (RPC) for typed function calls; use `RawRoute` when you need raw HTTP semantics.
218
+
219
+ ```typescript
220
+ new RawRoute(scope: ScopeParent, id: string, options: {
221
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
222
+ path?: string;
223
+ handler: (context: BlocksContext) => Promise<void>;
224
+ })
225
+ ```
226
+
227
+ A full `GET` that sets its own status, content type and body:
107
228
 
108
229
  ```typescript
109
230
  import { RawRoute } from '@aws-blocks/blocks';
110
231
 
111
- // Explicit path
112
- new RawRoute(scope, 'GetUser', {
232
+ new RawRoute(scope, 'status', {
113
233
  method: 'GET',
114
- path: '/users/{id}',
115
- handler: async (context) => {
116
- const userId = context.request.params.id;
117
- context.response.send({ id: userId });
234
+ path: '/status',
235
+ handler: async (ctx) => {
236
+ ctx.response.status = 200;
237
+ ctx.response.headers.set('Content-Type', 'text/html; charset=utf-8');
238
+ ctx.response.send('<h1>ok</h1>');
118
239
  },
119
240
  });
241
+ ```
120
242
 
121
- // Derived path — path omitted, becomes /health from scope chain
122
- new RawRoute(scope, 'health', { method: 'GET', handler: async (ctx) => {
123
- ctx.response.send({ status: 'ok' });
124
- }});
243
+ ```bash
244
+ curl -i http://localhost:3000/status
245
+ # HTTP/1.1 200 OK
246
+ # content-type: text/html; charset=utf-8
247
+ #
248
+ # <h1>ok</h1>
125
249
  ```
126
250
 
127
- Supports exact paths (`/health`), named parameters (`/users/{id}`), and wildcards (`/files/*`). Path can be omitted — it's derived from scope-chain IDs.
251
+ The handler returns nothing; you write the response through `ctx.response`:
252
+
253
+ | On `ctx.response` | Notes |
254
+ |---|---|
255
+ | `status: number` | Assignable. Defaults to `200`. |
256
+ | `headers: Headers` | Standard `Headers`. Set `Content-Type` yourself; a string body is sent as-is, an object is serialized as JSON. |
257
+ | `send(body)` | Call once with the body. `send('')` for an empty body (redirects, `204`). |
258
+
259
+ And what you read from `ctx.request`:
260
+
261
+ | On `ctx.request` | Notes |
262
+ |---|---|
263
+ | `params` | Path parameters, e.g. route `/users/{id}` + request `/users/42` → `{ id: '42' }`. Always `{}` for RPC methods. |
264
+ | `url` | Absolute `URL` of the request. Use `url.searchParams` for the query string. |
265
+ | `headers` | Request `Headers`, including `cookie`. |
266
+ | `json()` / `text()` / `body` | Body as parsed JSON, raw text, or a `ReadableStream`. |
267
+ | `signal` | `AbortSignal` that fires just before the platform's timeout response. Pass it to `fetch`/SDK calls. `undefined` in local dev. |
268
+
269
+ Reading a path parameter and a query parameter:
270
+
271
+ ```typescript
272
+ new RawRoute(scope, 'user', {
273
+ method: 'GET',
274
+ path: '/users/{id}',
275
+ handler: async (ctx) => {
276
+ ctx.response.headers.set('Content-Type', 'application/json');
277
+ ctx.response.send({ id: ctx.request.params.id, q: ctx.request.url.searchParams.get('q') });
278
+ },
279
+ });
280
+ // GET /users/42?q=hello → {"id":"42","q":"hello"}
281
+ ```
282
+
283
+ Path syntax: exact (`/health`), named parameter capturing one segment (`/users/{id}`), or a trailing wildcard capturing the rest (`/files/*`, available as `params['*']`). One wildcard per route, last segment only. Named parameters are URL-decoded; wildcard captures are not, so validate them before touching a filesystem or an S3 key.
284
+
285
+ `path` can be omitted, in which case it is derived from the scope-chain IDs, so `Scope('app') → Scope('v1') → RawRoute('health')` gives `/v1/health`. That means restructuring your construct tree silently changes URLs, so pass an explicit `path` for anything a client depends on.
286
+
287
+ Registration rules worth knowing before you hit them at runtime:
128
288
 
129
- 📖 **Full RawRoute documentation (see source repo)**
289
+ - Routes must be constructed while the backend module is loading (top level of `aws-blocks/index.ts`, or from a block's constructor). Registering after the handler is created throws.
290
+ - `/aws-blocks` itself and `/aws-blocks/api` (plus anything under it) are reserved for RPC dispatch, and `/` is not routable, so use a sub-path.
291
+ - The same `method` + `path` twice throws `RawRouteErrors.DuplicateRoute`; catch it with `isBlocksError(e, RawRouteErrors.DuplicateRoute)`.
292
+ - No extra AWS resources are created. The existing API Gateway proxy already forwards every path to the same Lambda, which checks the route registry before falling through to RPC.
130
293
 
131
294
  ### Pipeline
132
295
 
package/dist/cors.d.ts CHANGED
@@ -1,3 +1,12 @@
1
+ /**
2
+ * `Access-Control-Max-Age` for preflight responses, in seconds.
3
+ *
4
+ * Chromium caps the preflight cache at 7200s and silently clamps anything
5
+ * higher, so a larger value buys nothing while widening the window in which a
6
+ * stale per-origin grant can be served. Shared by the Lambda handler and the
7
+ * local dev server so the two can't drift.
8
+ */
9
+ export declare const CORS_MAX_AGE = "7200";
1
10
  /**
2
11
  * Parse a comma-separated CORS origin string into anchored RegExp patterns.
3
12
  *
@@ -24,6 +33,23 @@ export declare function getCorsPatterns(): RegExp[] | null;
24
33
  * @returns `true` if the origin matches at least one pattern, `false` otherwise
25
34
  */
26
35
  export declare function isOriginAllowed(origin: string): boolean;
36
+ /**
37
+ * Build the CORS response headers for a request origin.
38
+ *
39
+ * Only reflects the origin when it matches the configured allowlist. When no
40
+ * allowlist is configured, or the origin is configured-but-not-allowed, no
41
+ * `Access-Control-Allow-Origin` / `Access-Control-Allow-Credentials` headers
42
+ * are emitted, so a disallowed origin is never reflected back.
43
+ *
44
+ * `Vary: Origin` is always emitted, including on the not-allowed path: the
45
+ * response headers depend on the request `Origin`, so any shared cache (CDN,
46
+ * forward proxy) must key on it or it can serve one origin's grant — or one
47
+ * origin's *absence* of a grant — to a different origin.
48
+ *
49
+ * @param origin - The `Origin` header value from the request (may be empty)
50
+ * @returns The CORS headers to merge into the response
51
+ */
52
+ export declare function buildCorsHeaders(origin: string): Record<string, string>;
27
53
  /**
28
54
  * Build a 403 Forbidden response for cross-origin requests from disallowed origins.
29
55
  */
@@ -33,7 +59,7 @@ export declare function corsRejection(): {
33
59
  body: string;
34
60
  };
35
61
  /**
36
- * Reset the lazy CORS pattern cache. **For testing only.**
62
+ * Reset the lazy CORS pattern cache and the warned-origin set. **For testing only.**
37
63
  */
38
64
  export declare function _resetCorsPatterns(): void;
39
65
  //# sourceMappingURL=cors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cors.d.ts","sourceRoot":"","sources":["../src/cors.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAYvD;AAcD;;;;;GAKG;AACH,wBAAgB,eAAe,IAAI,MAAM,EAAE,GAAG,IAAI,CAcjD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAIvD;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAMrG;AAED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC"}
1
+ {"version":3,"file":"cors.d.ts","sourceRoot":"","sources":["../src/cors.ts"],"names":[],"mappings":"AAGA;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,SAAS,CAAC;AAEnC;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAYvD;AAcD;;;;;GAKG;AACH,wBAAgB,eAAe,IAAI,MAAM,EAAE,GAAG,IAAI,CAcjD;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAIvD;AAqBD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASvE;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAMrG;AAED;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAGzC"}
package/dist/cors.js CHANGED
@@ -1,5 +1,14 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * `Access-Control-Max-Age` for preflight responses, in seconds.
5
+ *
6
+ * Chromium caps the preflight cache at 7200s and silently clamps anything
7
+ * higher, so a larger value buys nothing while widening the window in which a
8
+ * stale per-origin grant can be served. Shared by the Lambda handler and the
9
+ * local dev server so the two can't drift.
10
+ */
11
+ export const CORS_MAX_AGE = '7200';
3
12
  /**
4
13
  * Parse a comma-separated CORS origin string into anchored RegExp patterns.
5
14
  *
@@ -67,19 +76,63 @@ export function isOriginAllowed(origin) {
67
76
  return false;
68
77
  return patterns.some(re => re.test(origin));
69
78
  }
79
+ /**
80
+ * Distinct origins already warned about, so a caller retrying — or a bot
81
+ * spraying bogus `Origin` values — can't amplify one log line per request now
82
+ * that this helper runs on every response path.
83
+ */
84
+ const warnedOrigins = new Set();
85
+ /** Cap on {@link warnedOrigins} so an untrusted input can't grow it unbounded. */
86
+ const WARNED_ORIGINS_LIMIT = 100;
87
+ function warnDisallowedOriginOnce(origin) {
88
+ if (warnedOrigins.has(origin))
89
+ return;
90
+ if (warnedOrigins.size < WARNED_ORIGINS_LIMIT)
91
+ warnedOrigins.add(origin);
92
+ const example = 'CORS_ALLOWED_ORIGINS=https://myapp\\.com,^https?://(localhost|127\\.0\\.0\\.1)(:\\d+)?$';
93
+ console.warn(`[CORS] Origin "${origin}" is not allowed. Set the CORS_ALLOWED_ORIGINS environment variable to allow this origin. Example: ${example}`);
94
+ }
95
+ /**
96
+ * Build the CORS response headers for a request origin.
97
+ *
98
+ * Only reflects the origin when it matches the configured allowlist. When no
99
+ * allowlist is configured, or the origin is configured-but-not-allowed, no
100
+ * `Access-Control-Allow-Origin` / `Access-Control-Allow-Credentials` headers
101
+ * are emitted, so a disallowed origin is never reflected back.
102
+ *
103
+ * `Vary: Origin` is always emitted, including on the not-allowed path: the
104
+ * response headers depend on the request `Origin`, so any shared cache (CDN,
105
+ * forward proxy) must key on it or it can serve one origin's grant — or one
106
+ * origin's *absence* of a grant — to a different origin.
107
+ *
108
+ * @param origin - The `Origin` header value from the request (may be empty)
109
+ * @returns The CORS headers to merge into the response
110
+ */
111
+ export function buildCorsHeaders(origin) {
112
+ const headers = { Vary: 'Origin' };
113
+ if (isOriginAllowed(origin)) {
114
+ headers['Access-Control-Allow-Origin'] = origin;
115
+ headers['Access-Control-Allow-Credentials'] = 'true';
116
+ }
117
+ else if (origin) {
118
+ warnDisallowedOriginOnce(origin);
119
+ }
120
+ return headers;
121
+ }
70
122
  /**
71
123
  * Build a 403 Forbidden response for cross-origin requests from disallowed origins.
72
124
  */
73
125
  export function corsRejection() {
74
126
  return {
75
127
  statusCode: 403,
76
- headers: { 'Content-Type': 'application/json' },
128
+ headers: { 'Content-Type': 'application/json', Vary: 'Origin' },
77
129
  body: JSON.stringify({ error: 'Forbidden: cross-origin request rejected' }),
78
130
  };
79
131
  }
80
132
  /**
81
- * Reset the lazy CORS pattern cache. **For testing only.**
133
+ * Reset the lazy CORS pattern cache and the warned-origin set. **For testing only.**
82
134
  */
83
135
  export function _resetCorsPatterns() {
84
136
  _corsPatterns = undefined;
137
+ warnedOrigins.clear();
85
138
  }
package/dist/cors.test.js CHANGED
@@ -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 { parseCorsPatterns, _resetCorsPatterns } from './cors.js';
5
+ import { parseCorsPatterns, _resetCorsPatterns, buildCorsHeaders, CORS_MAX_AGE } from './cors.js';
6
6
  import { createLambdaHandler } from './lambda-handler.js';
7
7
  import { clearRouteRegistry } from './raw-route.js';
8
8
  // ── parseCorsPatterns unit tests ────────────────────────────────────────────
@@ -49,6 +49,81 @@ describe('parseCorsPatterns', () => {
49
49
  assert.ok(patterns[0].test('http://localhost:9999'));
50
50
  });
51
51
  });
52
+ // ── buildCorsHeaders unit tests ─────────────────────────────────────────────
53
+ describe('buildCorsHeaders', () => {
54
+ beforeEach(() => {
55
+ delete process.env.CORS_ALLOWED_ORIGINS;
56
+ delete process.env.CORS_HOSTING_ORIGINS;
57
+ _resetCorsPatterns();
58
+ });
59
+ it('reflects an origin that matches the configured allowlist', () => {
60
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
61
+ _resetCorsPatterns();
62
+ const headers = buildCorsHeaders('https://myapp.example.com');
63
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], 'https://myapp.example.com');
64
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], 'true');
65
+ });
66
+ it('never reflects an origin that is not on a configured allowlist', () => {
67
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
68
+ _resetCorsPatterns();
69
+ const headers = buildCorsHeaders('https://evil.example.com');
70
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], undefined);
71
+ assert.strictEqual(headers['Access-Control-Allow-Credentials'], undefined);
72
+ assert.deepStrictEqual(headers, { Vary: 'Origin' });
73
+ });
74
+ it('never reflects an origin when no allowlist is configured', () => {
75
+ const headers = buildCorsHeaders('https://evil.example.com');
76
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], undefined);
77
+ assert.deepStrictEqual(headers, { Vary: 'Origin' });
78
+ });
79
+ it('returns no reflection headers when there is no origin', () => {
80
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
81
+ _resetCorsPatterns();
82
+ assert.deepStrictEqual(buildCorsHeaders(''), { Vary: 'Origin' });
83
+ });
84
+ it('always sets Vary: Origin so shared caches key on the request origin', () => {
85
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
86
+ _resetCorsPatterns();
87
+ assert.strictEqual(buildCorsHeaders('https://myapp.example.com').Vary, 'Origin');
88
+ assert.strictEqual(buildCorsHeaders('https://evil.example.com').Vary, 'Origin');
89
+ assert.strictEqual(buildCorsHeaders('').Vary, 'Origin');
90
+ });
91
+ it('warns only once per distinct disallowed origin', () => {
92
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
93
+ _resetCorsPatterns();
94
+ const original = console.warn;
95
+ const lines = [];
96
+ console.warn = (msg) => { lines.push(msg); };
97
+ try {
98
+ buildCorsHeaders('https://evil.example.com');
99
+ buildCorsHeaders('https://evil.example.com');
100
+ buildCorsHeaders('https://evil.example.com');
101
+ assert.strictEqual(lines.length, 1, 'repeat requests from one origin should warn once');
102
+ buildCorsHeaders('https://other.example.com');
103
+ assert.strictEqual(lines.length, 2, 'a distinct origin should still warn');
104
+ assert.ok(lines[0].includes('https://evil.example.com'));
105
+ assert.ok(lines[1].includes('https://other.example.com'));
106
+ }
107
+ finally {
108
+ console.warn = original;
109
+ }
110
+ });
111
+ it('does not warn for an allowed origin or an absent origin', () => {
112
+ process.env.CORS_ALLOWED_ORIGINS = 'https://myapp\\.example\\.com';
113
+ _resetCorsPatterns();
114
+ const original = console.warn;
115
+ const lines = [];
116
+ console.warn = (msg) => { lines.push(msg); };
117
+ try {
118
+ buildCorsHeaders('https://myapp.example.com');
119
+ buildCorsHeaders('');
120
+ assert.strictEqual(lines.length, 0);
121
+ }
122
+ finally {
123
+ console.warn = original;
124
+ }
125
+ });
126
+ });
52
127
  // ── isOriginAllowed + getCorsPatterns integration via handler ────────────────
53
128
  // These tests exercise the full CORS flow through createLambdaHandler to
54
129
  // verify origin validation, header injection, and rejection work end-to-end.
@@ -137,7 +212,11 @@ describe('createLambdaHandler — CORS origin validation', () => {
137
212
  assert.strictEqual(result.headers['Access-Control-Allow-Credentials'], 'true');
138
213
  assert.ok(result.headers['Access-Control-Allow-Methods']);
139
214
  assert.ok(result.headers['Access-Control-Allow-Headers']);
140
- assert.strictEqual(result.headers['Access-Control-Max-Age'], '86400');
215
+ assert.strictEqual(result.headers['Access-Control-Max-Age'], CORS_MAX_AGE);
216
+ assert.strictEqual(result.headers['Vary'], 'Origin');
217
+ });
218
+ it('pins Access-Control-Max-Age at the browser preflight cap', () => {
219
+ assert.strictEqual(CORS_MAX_AGE, '7200');
141
220
  });
142
221
  it('OPTIONS preflight with rejected origin returns 403', async () => {
143
222
  const result = await invoke(echoBackend, makeEvent({
@@ -2,7 +2,32 @@
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 { ApiError, isBlocksError, hasAuthError } from './errors.js';
5
+ import { ApiError, DEFAULT_API_ERROR_NAME, isBlocksError, hasAuthError } from './errors.js';
6
+ describe('ApiError constructor', () => {
7
+ it('exposes message and status, and stays a real Error', () => {
8
+ const e = new ApiError('Not found', 404);
9
+ assert.ok(e instanceof Error);
10
+ assert.strictEqual(e.message, 'Not found');
11
+ assert.strictEqual(e.status, 404);
12
+ });
13
+ it('defaults name to ApiError and retriable to false', () => {
14
+ const e = new ApiError('boom', 500);
15
+ assert.strictEqual(e.name, DEFAULT_API_ERROR_NAME);
16
+ assert.strictEqual(e.retriable, false);
17
+ });
18
+ it('takes name, cause and retriable from the options argument', () => {
19
+ const cause = new Error('root');
20
+ const e = new ApiError('Username already taken', 409, {
21
+ name: 'ConditionalCheckFailedException',
22
+ cause,
23
+ retriable: true,
24
+ });
25
+ assert.strictEqual(e.name, 'ConditionalCheckFailedException');
26
+ assert.strictEqual(e.status, 409);
27
+ assert.strictEqual(e.retriable, true);
28
+ assert.strictEqual(e.cause, cause);
29
+ });
30
+ });
6
31
  describe('isBlocksError', () => {
7
32
  it('matches a thrown ApiError by name', () => {
8
33
  const e = new ApiError('nope', 401, { name: 'InvalidCredentialsException' });
@@ -1 +1 @@
1
- {"version":3,"file":"hosting.d.ts","sourceRoot":"","sources":["../src/hosting.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hosting.d.ts","sourceRoot":"","sources":["../src/hosting.ts"],"names":[],"mappings":"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"}
package/dist/hosting.js CHANGED
@@ -11,11 +11,13 @@ import { join, resolve } from 'node:path';
11
11
  import { HostingConstruct, generateBuildId, } from '@aws-blocks/hosting/constructs';
12
12
  import { detectFramework, getAdapter, normalizeBasePath, } from '@aws-blocks/hosting/adapters';
13
13
  import { BLOCKS_RPC_PREFIX, BLOCKS_AUTH_PREFIX } from './constants.js';
14
+ import { BLOCKS_SANDBOX_DIR } from './common/constants.js';
14
15
  import { registerConfig } from './cdk/config-registry.js';
15
16
  import { getRegisteredRoutes } from './raw-route.js';
16
17
  // ─── Default build output directories per framework ──────────────
17
18
  const DEFAULT_BUILD_DIRS = {
18
19
  nextjs: '.next',
20
+ sveltekit: 'build',
19
21
  spa: 'dist',
20
22
  static: 'dist',
21
23
  };
@@ -164,6 +166,18 @@ export class Hosting extends Construct {
164
166
  const blocksSandboxDir = join(staticDir, '.blocks-sandbox');
165
167
  mkdirSync(blocksSandboxDir, { recursive: true });
166
168
  writeFileSync(join(blocksSandboxDir, 'config.json'), JSON.stringify({ _placeholder: true }));
169
+ // ── 5a. Mark config.json as a no-cache path ─────────────────────
170
+ // config.json is a fixed-name, mutable runtime-config file: it must
171
+ // NOT inherit the content-hashed mutable-asset cache-control
172
+ // (`s-maxage=31536000`) applied to `/assets/<hash>.js`. Registering it
173
+ // as a no-cache path uploads the build-time placeholder with
174
+ // `no-cache, no-store, must-revalidate` so an edge never caches it
175
+ // long-term; the real config is deployed in step 8 with `max-age=60`.
176
+ const configNoCachePath = `${BLOCKS_SANDBOX_DIR}/config.json`;
177
+ const existingNoCachePaths = manifest.staticAssets.noCachePaths ?? [];
178
+ manifest.staticAssets.noCachePaths = existingNoCachePaths.includes(configNoCachePath)
179
+ ? existingNoCachePaths
180
+ : [...existingNoCachePaths, configNoCachePath];
167
181
  // ── 5b. Inject static route for .blocks-sandbox config ──────────
168
182
  // Insert a static route for /.blocks-sandbox/* so CloudFront
169
183
  // serves config.json from S3 instead of routing to compute.
@@ -249,7 +263,18 @@ export class Hosting extends Construct {
249
263
  destinationKeyPrefix: `builds/${buildId}/.blocks-sandbox`,
250
264
  prune: false,
251
265
  distribution: hosting.distribution,
252
- distributionPaths: ['/.blocks-sandbox/*'],
266
+ // The skew-protection viewer-request CloudFront function rewrites the
267
+ // URI to `/builds/<buildId>/.blocks-sandbox/config.json` BEFORE the
268
+ // cache lookup, so the real edge cache key lives under `/builds/<id>/`.
269
+ // Invalidating only `/.blocks-sandbox/*` never matches that key and is
270
+ // a no-op for config.json. Invalidate the post-rewrite key too. (The
271
+ // primary guard against staleness is step 5a's no-cache placeholder;
272
+ // this is defense-in-depth so a post-deploy invalidation actually
273
+ // clears any edge entry at its real key.)
274
+ distributionPaths: [
275
+ `/builds/${buildId}/.blocks-sandbox/*`,
276
+ '/.blocks-sandbox/*',
277
+ ],
253
278
  cacheControl: [s3deploy.CacheControl.fromString('public, max-age=60, must-revalidate')],
254
279
  });
255
280
  // Ensure the config deployment runs AFTER the hosting construct's