@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
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:
|
|
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:
|
|
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
|
-
|
|
88
|
+
#### How the server reads `params` (and what it rejects)
|
|
89
89
|
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
new RawRoute(scope, 'GetUser', {
|
|
232
|
+
new RawRoute(scope, 'status', {
|
|
113
233
|
method: 'GET',
|
|
114
|
-
path: '/
|
|
115
|
-
handler: async (
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/cors.d.ts.map
CHANGED
|
@@ -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,
|
|
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'],
|
|
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({
|
package/dist/errors.test.js
CHANGED
|
@@ -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' });
|
package/dist/hosting.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hosting.d.ts","sourceRoot":"","sources":["../src/hosting.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,GAAG,MAAM,aAAa,CAAC;AASnC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAKvC,OAAO,EAIL,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EAC1B,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAIL,KAAK,kBAAkB,EACxB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAGV,aAAa,EACd,MAAM,qBAAqB,CAAC;
|
|
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
|
-
|
|
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
|