@aws-blocks/core 0.1.4 → 0.1.7

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 (58) hide show
  1. package/dist/cdk/index.d.ts +1 -1
  2. package/dist/cdk/index.d.ts.map +1 -1
  3. package/dist/cdk/index.js +1 -1
  4. package/dist/client/index.d.ts +1 -1
  5. package/dist/client/index.d.ts.map +1 -1
  6. package/dist/client/index.js +1 -1
  7. package/dist/errors.d.ts +28 -0
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/errors.js +27 -1
  10. package/dist/errors.test.d.ts +2 -0
  11. package/dist/errors.test.d.ts.map +1 -0
  12. package/dist/errors.test.js +47 -0
  13. package/dist/hosting.d.ts +22 -1
  14. package/dist/hosting.d.ts.map +1 -1
  15. package/dist/index.cdk.d.ts +1 -1
  16. package/dist/index.cdk.d.ts.map +1 -1
  17. package/dist/index.cdk.js +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/scripts/dev-server-supervisor.test.d.ts +2 -0
  22. package/dist/scripts/dev-server-supervisor.test.d.ts.map +1 -0
  23. package/dist/scripts/dev-server-supervisor.test.js +551 -0
  24. package/dist/scripts/dev-server.d.ts +73 -0
  25. package/dist/scripts/dev-server.d.ts.map +1 -1
  26. package/dist/scripts/dev-server.js +279 -29
  27. package/dist/scripts/index.d.ts +1 -0
  28. package/dist/scripts/index.d.ts.map +1 -1
  29. package/dist/scripts/index.js +1 -0
  30. package/dist/scripts/process-tree.d.ts +126 -0
  31. package/dist/scripts/process-tree.d.ts.map +1 -0
  32. package/dist/scripts/process-tree.js +198 -0
  33. package/dist/scripts/sandbox.d.ts.map +1 -1
  34. package/dist/scripts/sandbox.js +41 -3
  35. package/dist/scripts/stack-id.d.ts +12 -0
  36. package/dist/scripts/stack-id.d.ts.map +1 -0
  37. package/dist/scripts/stack-id.js +54 -0
  38. package/dist/scripts/stack-id.test.d.ts +2 -0
  39. package/dist/scripts/stack-id.test.d.ts.map +1 -0
  40. package/dist/scripts/stack-id.test.js +54 -0
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +1 -1
  44. package/src/cdk/index.ts +1 -1
  45. package/src/client/index.ts +1 -1
  46. package/src/errors.test.ts +55 -0
  47. package/src/errors.ts +32 -1
  48. package/src/hosting.ts +22 -1
  49. package/src/index.cdk.ts +1 -1
  50. package/src/index.ts +1 -1
  51. package/src/scripts/dev-server-supervisor.test.ts +621 -0
  52. package/src/scripts/dev-server.ts +316 -27
  53. package/src/scripts/index.ts +1 -0
  54. package/src/scripts/process-tree.ts +245 -0
  55. package/src/scripts/sandbox.ts +40 -3
  56. package/src/scripts/stack-id.test.ts +63 -0
  57. package/src/scripts/stack-id.ts +61 -0
  58. package/src/version.ts +1 -1
@@ -0,0 +1,198 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { spawnSync } from 'node:child_process';
4
+ /**
5
+ * Force-kill an entire process tree on Windows via `taskkill /T /F /PID <pid>`.
6
+ *
7
+ * Windows has no POSIX process groups, so a bare `child.kill()` only signals the
8
+ * spawned shell and orphans the real dev server (the Vite grandchild), which
9
+ * keeps holding `:3100` — the very wedge the POSIX process-group kill fixes.
10
+ * `taskkill /T` walks the live child tree by PID and terminates every
11
+ * descendant; `/F` is required because Windows cannot deliver a graceful
12
+ * shutdown to a non-console subtree anyway (Node maps SIGTERM/SIGKILL to
13
+ * `TerminateProcess`).
14
+ *
15
+ * Returns `true` only when `taskkill` ran AND reported the tree handled — exit
16
+ * `0` (reaped the tree) or `128` (`"process not found"`, i.e. already gone).
17
+ * Returns `false` when the command could not be spawned at all (e.g. not on
18
+ * `PATH`) OR when it ran but returned any other status (e.g. `1` = access
19
+ * denied): such a run did NOT reap the tree, so the caller must fall back to a
20
+ * direct `child.kill` rather than treat the leak as handled. (`child.kill`
21
+ * cannot reap the orphaned grandchild either, but the fallback is cheap and
22
+ * strictly correct — we never silently swallow a failed tree-kill.) Never
23
+ * throws.
24
+ */
25
+ export function windowsTreeKill(pid, runner = (command, args) => spawnSync(command, args, { stdio: 'ignore', windowsHide: true })) {
26
+ try {
27
+ const { status, error } = runner('taskkill', ['/T', '/F', '/PID', String(pid)]);
28
+ // Couldn't even spawn taskkill (e.g. not on PATH) → not handled; fall back.
29
+ if (error)
30
+ return false;
31
+ // taskkill ran: only exit 0 (reaped the tree) or 128 ("process not found",
32
+ // already gone) mean the tree is handled. Any other non-null status (e.g.
33
+ // 1 = access denied) means taskkill ran but did NOT reap the tree, so report
34
+ // not-handled and let the caller fall back to a direct child.kill.
35
+ return status === 0 || status === 128;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /**
42
+ * Terminate a process spawned with `shell: true`, including its descendants, on
43
+ * every platform.
44
+ *
45
+ * Under a shell the real dev server (e.g. Vite) is a **grandchild**: the direct
46
+ * child is the shell, so signalling only the shell (`child.kill`) orphans the
47
+ * grandchild, which keeps holding its port (`:3100`) and wedges the next
48
+ * restart.
49
+ *
50
+ * - **POSIX**: the process is spawned `detached` (its own process group,
51
+ * pgid === child.pid), so we signal the whole group with
52
+ * `process.kill(-pid, signal)` and every descendant dies, freeing the port.
53
+ * - **Windows**: there are no process groups, so we reap the tree with
54
+ * `taskkill /T /F /PID <pid>` (see {@link windowsTreeKill}), which walks the
55
+ * child tree by PID. A bare `child.kill` would leave the Vite grandchild
56
+ * bound to `:3100`, reproducing the POSIX wedge.
57
+ *
58
+ * Best-effort and never throws: a missing/invalid pid, an already-dead group
59
+ * (ESRCH), a failed group signal, or an unavailable `taskkill` all degrade to a
60
+ * direct `child.kill`.
61
+ */
62
+ export function killFrontendTree(child, signal = 'SIGTERM', platform = process.platform, killFn = (p, s) => process.kill(p, s), winTreeKill = windowsTreeKill) {
63
+ const { pid } = child;
64
+ // pid > 1 guards against signalling the whole current group (-0) or init (-1).
65
+ if (pid && pid > 1) {
66
+ if (platform !== 'win32') {
67
+ try {
68
+ killFn(-pid, signal);
69
+ return;
70
+ }
71
+ catch {
72
+ // Group already gone or signal failed — fall through to a direct kill.
73
+ }
74
+ }
75
+ else if (winTreeKill(pid)) {
76
+ // taskkill walked the PID tree and reaped the Vite grandchild.
77
+ return;
78
+ }
79
+ }
80
+ try {
81
+ child.kill(signal);
82
+ }
83
+ catch {
84
+ // Process already exited; nothing to do.
85
+ }
86
+ }
87
+ const defaultSleep = (ms) => new Promise((res) => {
88
+ setTimeout(res, ms).unref?.();
89
+ });
90
+ /**
91
+ * Grace (ms) we wait for the child's `exit` event *after* SIGKILL before giving
92
+ * up and reporting its last-known exit state. Deliberately shorter than — and
93
+ * intentionally decoupled from — the injectable SIGTERM `graceMs`: SIGKILL
94
+ * cannot be caught, blocked, or handled, so the child is already being
95
+ * force-terminated; we only need a brief beat to observe the `exit` event, not a
96
+ * full, tunable shutdown window. Fixed (not a parameter) because no caller needs
97
+ * to tune it — the injected `sleep` is the test seam.
98
+ */
99
+ export const KILL_GRACE_MS = 500;
100
+ /**
101
+ * Probe whether a detached process *group* still has at least one live member,
102
+ * **without signalling it**. Used to scope the post-exit group SIGKILL in
103
+ * {@link terminateProcessTree} to the only window where the `-pid` group signal
104
+ * is PID-reuse-safe.
105
+ *
106
+ * The hazard: {@link killFrontendTree}'s POSIX reap is `process.kill(-pid, …)`,
107
+ * which targets the process group whose gid is `pid`. That is safe only while a
108
+ * group member is still alive — a survivor keeps the kernel from recycling
109
+ * `pid` as a brand-new (unrelated) group leader. Once the whole group has
110
+ * drained, `pid` is eligible for reuse and a blind `-pid` kill could land on an
111
+ * unrelated group. So before a *post-exit* reap we probe here and skip when the
112
+ * group has already drained (there is then nothing of ours left to reap).
113
+ *
114
+ * - **POSIX**: `kill(-pid, 0)` sends no signal — it only checks the group
115
+ * exists and is signallable. Success or `EPERM` (exists but owned by another
116
+ * user) ⇒ alive. `ESRCH` (or anything else) ⇒ treat as drained.
117
+ * - **Windows**: there are no process groups and the reap path
118
+ * (`taskkill /T /F /PID`) walks the live PID tree, so there is no `-pid`
119
+ * recycle hazard — always allow the reap (`true`).
120
+ *
121
+ * Never throws. `platform`/`kill` are injected for tests.
122
+ */
123
+ export function isProcessGroupAlive(pid, platform = process.platform, kill = (p, s) => process.kill(p, s)) {
124
+ if (platform === 'win32')
125
+ return true;
126
+ try {
127
+ kill(-pid, 0);
128
+ return true;
129
+ }
130
+ catch (e) {
131
+ return e.code === 'EPERM';
132
+ }
133
+ }
134
+ /**
135
+ * Terminate a child process *tree* and wait — bounded — for the child to exit,
136
+ * escalating SIGTERM → SIGKILL. Reuses {@link killFrontendTree} so every
137
+ * entrypoint reaps the same way (POSIX process-group kill / Windows `taskkill`)
138
+ * instead of hand-rolling its own group kill.
139
+ *
140
+ * Post-exit policy: if the child has *already* exited, a detached grandchild may
141
+ * still be orphaned (still holding a port), so we issue one best-effort group
142
+ * SIGKILL to reap it — but ONLY when the group still has a live member
143
+ * ({@link isProcessGroupAlive}). When the whole group has already drained (the
144
+ * common healthy shutdown — Vite was already gone), `pid` is eligible for
145
+ * recycling and a blind `-pid` signal could hit an unrelated, newly created
146
+ * group; since there is also nothing of ours left to reap, we skip the kill.
147
+ * See the dev server's "POST-EXIT GROUP-KILL POLICY" for the full rationale and
148
+ * the accepted residual (the synchronous probe→kill window). Otherwise we
149
+ * SIGTERM the tree, wait up to `graceMs` for a clean exit, then SIGKILL the tree
150
+ * and wait a short grace.
151
+ *
152
+ * Return value — IMPORTANT: the boolean reflects only the **direct child's**
153
+ * exit state (its `exitCode`/`signalCode`), NOT whole-group teardown or port
154
+ * release. On POSIX the SIGKILL is delivered to the whole group (`-pid`), but a
155
+ * surviving *detached grandchild* can outlive the awaited child and keep holding
156
+ * a port even after this resolves `true`. So `true` means only "the child we
157
+ * awaited has exited (or was already gone)" and `false` means "it was still
158
+ * alive when the budget elapsed" — neither guarantees the port is free. Callers
159
+ * that need a freed port MUST follow this with a bounded port-free wait (see
160
+ * `waitForPortFree` in dev-server.ts, which the dev-server child's own SIGTERM
161
+ * handler runs). Dependencies are injected for tests.
162
+ */
163
+ export async function terminateProcessTree(child, graceMs = 2000, killTree = killFrontendTree, sleep = defaultSleep, isGroupAlive = isProcessGroupAlive) {
164
+ if (child.exitCode !== null || child.signalCode !== null) {
165
+ // ── POST-EXIT GROUP-KILL (scoped) ──────────────────────────────────────
166
+ // The direct child has already exited, but a detached *grandchild* (e.g. an
167
+ // orphaned Vite) may still be alive in its process group, still holding a
168
+ // port — reap it with one best-effort group SIGKILL.
169
+ //
170
+ // SCOPING: only reap when the group still has a live member. killFrontendTree's
171
+ // `-pid` group signal is PID-reuse-safe ONLY while a member keeps `pid`
172
+ // reserved as the group id; once the whole group has drained `pid` can be
173
+ // recycled and a blind `process.kill(-pid)` could hit an unrelated group. So
174
+ // we probe first (isProcessGroupAlive; POSIX signal 0) and skip when already
175
+ // drained — there is then nothing of ours to reap. The residual synchronous
176
+ // probe→kill window is the accepted trade-off documented in dev-server.ts
177
+ // "POST-EXIT GROUP-KILL POLICY", cross-referenced here so the risk is
178
+ // discoverable at this shared primitive.
179
+ const { pid } = child;
180
+ if (pid && pid > 1 && isGroupAlive(pid)) {
181
+ killTree(child, 'SIGKILL');
182
+ }
183
+ return true;
184
+ }
185
+ const exited = new Promise((res) => child.once('exit', () => res()));
186
+ killTree(child, 'SIGTERM');
187
+ const exitedCleanly = await Promise.race([
188
+ exited.then(() => true),
189
+ sleep(graceMs).then(() => false),
190
+ ]);
191
+ if (exitedCleanly)
192
+ return true;
193
+ killTree(child, 'SIGKILL');
194
+ // Shorter, fixed grace after SIGKILL (vs. the injectable SIGTERM graceMs):
195
+ // SIGKILL is uncatchable, so we only need a brief beat to observe `exit`.
196
+ await Promise.race([exited, sleep(KILL_GRACE_MS)]);
197
+ return child.exitCode !== null || child.signalCode !== null;
198
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../src/scripts/sandbox.ts"],"names":[],"mappings":"AA8BA,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,+BAqJzD;AAED,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,iBAwCvD"}
1
+ {"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../src/scripts/sandbox.ts"],"names":[],"mappings":"AA+BA,MAAM,WAAW,cAAc;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAsB,YAAY,CAAC,OAAO,EAAE,cAAc,+BAyLzD;AAED,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,iBAwCvD"}
@@ -10,6 +10,7 @@ import { trackCommand } from '../telemetry/trackCommand.js';
10
10
  import { buildAndSendEvent } from '../telemetry/client.js';
11
11
  import { getCdkTelemetryEnv } from './cdk-telemetry-env.js';
12
12
  import { runSync, spawnCommand } from './run-command.js';
13
+ import { terminateProcessTree } from './process-tree.js';
13
14
  /**
14
15
  * Import the backend definition to populate the Scope BB registry.
15
16
  *
@@ -121,6 +122,12 @@ export async function startSandbox(options) {
121
122
  `--app`, `npm exec tsx -- -C cdk ${backendPath}`
122
123
  ], {
123
124
  stdio: ["ignore", "pipe", "pipe"],
125
+ // Own process group on POSIX so cleanup can reap the whole `cdk watch` tree
126
+ // (npx → cdk → node) via terminateProcessTree, not just the npx shell — a
127
+ // bare kill() would orphan the real cdk-watch node process, the same
128
+ // shell-only-kill leak this PR eliminates for the dev server. Windows has no
129
+ // groups; terminateProcessTree reaps the tree via taskkill.
130
+ detached: process.platform !== 'win32',
124
131
  env: { ...process.env, NODE_OPTIONS: "--conditions=cdk", ...getCdkTelemetryEnv('sandbox') },
125
132
  });
126
133
  cdkWatch.stdout?.on("data", (data) => {
@@ -137,18 +144,49 @@ export async function startSandbox(options) {
137
144
  const devServer = spawnCommand(cmd, args, {
138
145
  stdio: "inherit",
139
146
  shell: true,
147
+ // Own process group on POSIX so cleanup can signal the whole dev-server
148
+ // tree (shell → tsx → node). The node dev server then runs its own SIGTERM
149
+ // handler — the ~2s terminateFrontend drain that reaps the *detached* Vite
150
+ // great-grandchild — which a bare `devServer.kill()` (the shell only) never
151
+ // triggers. Windows has no groups; terminateProcessTree reaps via taskkill.
152
+ detached: process.platform !== 'win32',
140
153
  env: {
141
154
  ...process.env,
142
155
  NODE_OPTIONS: '',
143
156
  BLOCKS_API_URL: apiUrl,
144
157
  },
145
158
  });
146
- const cleanup = () => {
159
+ let cleaningUp = false;
160
+ const cleanup = async () => {
161
+ if (cleaningUp)
162
+ return; // idempotent — a second signal must not re-enter
163
+ cleaningUp = true;
147
164
  console.log("\n\n🛑 Stopping local processes...");
148
165
  console.log(" (AWS resources are still running)");
149
166
  console.log("\n To destroy AWS resources, run: npm run sandbox:destroy\n");
150
- cdkWatch.kill();
151
- devServer.kill();
167
+ // Reap BOTH child trees the way the dev server reaps Vite — a process-group
168
+ // SIGTERM→SIGKILL via the shared terminateProcessTree — instead of a bare
169
+ // kill() that signals only the npx/shell parent and orphans the real
170
+ // grandchild (cdk-watch's node, or the dev server's detached Vite). Run them
171
+ // concurrently so the cdk-watch teardown doesn't serialize on top of the dev
172
+ // server's longer drain.
173
+ //
174
+ // Only the dev-server child owns the `:3100` port-free wait: its own SIGTERM
175
+ // handler runs terminateFrontend (a ~2s drain that reaps the detached Vite
176
+ // great-grandchild AND polls until the port frees), so we give it the longer
177
+ // 6s budget (> that ~2s drain) — a hung dev server still escalates to a tree
178
+ // SIGKILL and we exit regardless, so shutdown can never wedge. cdk watch
179
+ // holds no local port, so a bounded tree-kill is all it needs.
180
+ //
181
+ // That a group SIGTERM (terminateProcessTree → killFrontendTree's
182
+ // `process.kill(-pid, 'SIGTERM')`) actually reaches the *nested* node dev
183
+ // server and runs its own SIGTERM handler — the load-bearing assumption of
184
+ // the 6s budget above — is verified by the "group SIGTERM reaches a nested
185
+ // node child" integration test in dev-server-supervisor.test.ts.
186
+ await Promise.all([
187
+ terminateProcessTree(cdkWatch, 2000),
188
+ terminateProcessTree(devServer, 6000),
189
+ ]);
152
190
  process.exit(0);
153
191
  };
154
192
  process.on("SIGINT", cleanup);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Get the stackId from `.blocks/config.json` in the project root.
3
+ * This is the stable project identifier used as the base for CloudFormation stack names.
4
+ */
5
+ export declare function getStackId(projectRoot?: string): string;
6
+ /**
7
+ * Get or create a per-machine sandbox identifier.
8
+ * Stored in `.blocks-sandbox/sandbox-id.txt` (gitignored).
9
+ * Format: `<username(8)>-<random(6)>` — identifies the developer's sandbox.
10
+ */
11
+ export declare function getSandboxId(projectRoot?: string): string;
12
+ //# sourceMappingURL=stack-id.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stack-id.d.ts","sourceRoot":"","sources":["../../src/scripts/stack-id.ts"],"names":[],"mappings":"AAiBA;;;GAGG;AACH,wBAAgB,UAAU,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAavD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAWzD"}
@@ -0,0 +1,54 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
4
+ import { join, dirname } from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+ import { randomBytes } from 'node:crypto';
7
+ function randomSuffix(length) {
8
+ return randomBytes(length).toString('hex').slice(0, length);
9
+ }
10
+ /**
11
+ * Get the stackId from `.blocks/config.json` in the project root.
12
+ * This is the stable project identifier used as the base for CloudFormation stack names.
13
+ */
14
+ export function getStackId(projectRoot) {
15
+ const root = projectRoot || process.cwd();
16
+ const configPath = join(root, '.blocks', 'config.json');
17
+ try {
18
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
19
+ if (!config.stackId)
20
+ throw new Error('missing key');
21
+ return config.stackId;
22
+ }
23
+ catch {
24
+ throw new Error(`.blocks/config.json not found or missing stackId — it is created by create-blocks-app and should be committed. ` +
25
+ `To fix manually, create ${configPath} with: { "stackId": "<your-app-name>" }`);
26
+ }
27
+ }
28
+ /**
29
+ * Get or create a per-machine sandbox identifier.
30
+ * Stored in `.blocks-sandbox/sandbox-id.txt` (gitignored).
31
+ * Format: `<username(8)>-<random(6)>` — identifies the developer's sandbox.
32
+ */
33
+ export function getSandboxId(projectRoot) {
34
+ const root = projectRoot || process.cwd();
35
+ const filePath = join(root, '.blocks-sandbox', 'sandbox-id.txt');
36
+ if (existsSync(filePath))
37
+ return readFileSync(filePath, 'utf-8').trim();
38
+ const dir = dirname(filePath);
39
+ if (!existsSync(dir))
40
+ mkdirSync(dir, { recursive: true });
41
+ const username = getUsername().toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 8) || 'dev';
42
+ const random = randomSuffix(6);
43
+ const id = `${username}-${random}`;
44
+ writeFileSync(filePath, id);
45
+ return id;
46
+ }
47
+ function getUsername() {
48
+ try {
49
+ return execSync('git config user.name', { encoding: 'utf-8' }).trim();
50
+ }
51
+ catch {
52
+ return process.env.USER || process.env.USERNAME || 'user';
53
+ }
54
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=stack-id.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stack-id.test.d.ts","sourceRoot":"","sources":["../../src/scripts/stack-id.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,54 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { describe, it, afterEach } from 'node:test';
4
+ import assert from 'node:assert';
5
+ import { mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+ import { getStackId, getSandboxId } from './stack-id.js';
9
+ describe('getStackId', () => {
10
+ let tmpDir;
11
+ afterEach(() => {
12
+ if (tmpDir)
13
+ rmSync(tmpDir, { recursive: true, force: true });
14
+ });
15
+ it('reads stackId from .blocks/config.json', () => {
16
+ tmpDir = join(tmpdir(), `stack-id-test-${Date.now()}`);
17
+ mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
18
+ writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ stackId: 'test-abc123' }));
19
+ assert.strictEqual(getStackId(tmpDir), 'test-abc123');
20
+ });
21
+ it('throws actionable error when config is missing', () => {
22
+ tmpDir = join(tmpdir(), `stack-id-test-missing-${Date.now()}`);
23
+ mkdirSync(tmpDir, { recursive: true });
24
+ assert.throws(() => getStackId(tmpDir), /\.blocks\/config\.json not found/);
25
+ });
26
+ it('throws actionable error when stackId key is missing', () => {
27
+ tmpDir = join(tmpdir(), `stack-id-test-nokey-${Date.now()}`);
28
+ mkdirSync(join(tmpDir, '.blocks'), { recursive: true });
29
+ writeFileSync(join(tmpDir, '.blocks', 'config.json'), JSON.stringify({ other: 'value' }));
30
+ assert.throws(() => getStackId(tmpDir), /\.blocks\/config\.json not found/);
31
+ });
32
+ });
33
+ describe('getSandboxId', () => {
34
+ let tmpDir;
35
+ afterEach(() => {
36
+ if (tmpDir)
37
+ rmSync(tmpDir, { recursive: true, force: true });
38
+ });
39
+ it('generates and persists a sandbox id', () => {
40
+ tmpDir = join(tmpdir(), `sandbox-id-test-${Date.now()}`);
41
+ mkdirSync(tmpDir, { recursive: true });
42
+ const id = getSandboxId(tmpDir);
43
+ assert.match(id, /^[a-z0-9]+-[a-f0-9]{6}$/);
44
+ // Verify persisted
45
+ const stored = readFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'utf-8').trim();
46
+ assert.strictEqual(stored, id);
47
+ });
48
+ it('returns existing id on subsequent calls', () => {
49
+ tmpDir = join(tmpdir(), `sandbox-id-test-idem-${Date.now()}`);
50
+ mkdirSync(join(tmpDir, '.blocks-sandbox'), { recursive: true });
51
+ writeFileSync(join(tmpDir, '.blocks-sandbox', 'sandbox-id.txt'), 'alice-abc123');
52
+ assert.strictEqual(getSandboxId(tmpDir), 'alice-abc123');
53
+ });
54
+ });
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CORE_VERSION = "0.1.4";
1
+ export declare const CORE_VERSION = "0.1.7";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs — do not edit manually
2
- export const CORE_VERSION = '0.1.4';
2
+ export const CORE_VERSION = '0.1.7';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws-blocks/core",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "author": "Amazon Web Services",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cdk/index.ts CHANGED
@@ -22,7 +22,7 @@ export { SandboxDisableDeletionProtection } from './mixins.js';
22
22
  export { registerConfig, finalizeConfigRegistry } from './config-registry.js';
23
23
  export { synthGuard } from './synth-guard.js';
24
24
  export type { ScopeOptions } from '../index.js';
25
- export { ApiError, isBlocksError } from '../errors.js';
25
+ export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js';
26
26
 
27
27
  export class BlocksStack extends cdk.Stack implements BaseBlocksStack {
28
28
  public readonly id: string;
@@ -316,5 +316,5 @@ export function ApiNamespaceClient<T extends Record<string, (...args: any[]) =>
316
316
  });
317
317
  }
318
318
 
319
- export { ApiError, isBlocksError } from '../errors.js';
319
+ export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js';
320
320
  export { Scope, type ScopeOptions } from '../common/index.js';
@@ -0,0 +1,55 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, it } from 'node:test';
5
+ import assert from 'node:assert';
6
+ import { ApiError, isBlocksError, hasAuthError } from './errors.js';
7
+
8
+ describe('isBlocksError', () => {
9
+ it('matches a thrown ApiError by name', () => {
10
+ const e = new ApiError('nope', 401, { name: 'InvalidCredentialsException' });
11
+ assert.ok(isBlocksError(e, 'InvalidCredentialsException'));
12
+ });
13
+
14
+ it('does not match a different name', () => {
15
+ const e = new ApiError('nope', 401, { name: 'InvalidCredentialsException' });
16
+ assert.ok(!isBlocksError(e, 'SomeOtherException'));
17
+ });
18
+
19
+ it('does not match a plain object (not an Error)', () => {
20
+ assert.ok(!isBlocksError({ name: 'InvalidCredentialsException' }, 'InvalidCredentialsException'));
21
+ });
22
+ });
23
+
24
+ describe('hasAuthError', () => {
25
+ it('matches a state carrying the given errorName', () => {
26
+ const state = { state: 'signedOut', errorName: 'InvalidCredentialsException' } as const;
27
+ assert.ok(hasAuthError(state, 'InvalidCredentialsException'));
28
+ });
29
+
30
+ it('does not match a different errorName', () => {
31
+ const state = { errorName: 'InvalidCredentialsException' };
32
+ assert.ok(!hasAuthError(state, 'UserAlreadyExistsException'));
33
+ });
34
+
35
+ it('does not match a state with no errorName', () => {
36
+ const state: { errorName?: string } = {};
37
+ assert.ok(!hasAuthError(state, 'InvalidCredentialsException'));
38
+ });
39
+
40
+ it('is safe on null / undefined', () => {
41
+ assert.ok(!hasAuthError(null, 'InvalidCredentialsException'));
42
+ assert.ok(!hasAuthError(undefined, 'InvalidCredentialsException'));
43
+ });
44
+
45
+ it('narrows the errorName to the matched literal', () => {
46
+ const state: { errorName?: string } = { errorName: 'InvalidCredentialsException' };
47
+ if (hasAuthError(state, 'InvalidCredentialsException')) {
48
+ // Type-level: state.errorName is narrowed to the literal.
49
+ const name: 'InvalidCredentialsException' = state.errorName;
50
+ assert.strictEqual(name, 'InvalidCredentialsException');
51
+ } else {
52
+ assert.fail('expected match');
53
+ }
54
+ });
55
+ });
package/src/errors.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ /**
5
+ * The `name` an `ApiError` falls back to when no structured error name is
6
+ * given. A name equal to this carries no BB-level meaning, so consumers
7
+ * branching on the structured identity should treat it as "no name".
8
+ */
9
+ export const DEFAULT_API_ERROR_NAME = 'ApiError';
10
+
4
11
  /**
5
12
  * Error subclass for errors that cross the wire between server and client.
6
13
  *
@@ -46,7 +53,7 @@ export class ApiError extends Error {
46
53
 
47
54
  constructor(message: string, status: number, options?: { name?: string; cause?: unknown; retriable?: boolean }) {
48
55
  super(message, options?.cause ? { cause: options.cause } : undefined);
49
- this.name = options?.name ?? 'ApiError';
56
+ this.name = options?.name ?? DEFAULT_API_ERROR_NAME;
50
57
  this.status = status;
51
58
  this.retriable = options?.retriable ?? false;
52
59
  }
@@ -70,3 +77,27 @@ export class ApiError extends Error {
70
77
  export function isBlocksError<N extends string>(e: unknown, name: N): e is Error & { name: N } {
71
78
  return e instanceof Error && e.name === name;
72
79
  }
80
+
81
+ /**
82
+ * Type guard for branching on a failed `AuthState` (the recommended
83
+ * `setAuthState` client path) by its structured `errorName`.
84
+ *
85
+ * The returned state is a plain object, not a thrown `Error`, so
86
+ * `isBlocksError` does not apply — use this on the value returned by
87
+ * `setAuthState`/`getAuthState`. Match on the BB error constant, never on
88
+ * the human-facing `error` string.
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * const next = await authApi.setAuthState({ action: 'signIn', username, password });
93
+ * if (hasAuthError(next, AuthBasicErrors.InvalidCredentials)) {
94
+ * // unknown user → fall back to sign-up
95
+ * }
96
+ * ```
97
+ */
98
+ export function hasAuthError<T extends { errorName?: string }, N extends string>(
99
+ state: T | null | undefined,
100
+ name: N,
101
+ ): state is T & { errorName: N } {
102
+ return state?.errorName === name;
103
+ }
package/src/hosting.ts CHANGED
@@ -71,8 +71,29 @@ export type ComputeConfig = {
71
71
  * ```
72
72
  */
73
73
  timeout?: cdk.Duration | number;
74
- /** Reserved concurrent executions. Default: undefined (no reservation). */
74
+ /** Reserved concurrent executions for the SSR Lambda. Default: undefined (no reservation). */
75
75
  reservedConcurrency?: number;
76
+ /**
77
+ * Overrides for the image-optimization Lambda.
78
+ *
79
+ * `reservedConcurrency` defaults to undefined (no reservation). It is left
80
+ * unreserved so deploys succeed on fresh AWS accounts, whose default
81
+ * account-level unreserved-concurrency limit is 10 — reserving any
82
+ * concurrency there can drop the account below its required minimum and
83
+ * cause Lambda to reject the stack with a 400. Set this only if you have
84
+ * headroom and want to cap image-opt throughput.
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * compute: {
89
+ * imageOptimization: { reservedConcurrency: 5 },
90
+ * }
91
+ * ```
92
+ */
93
+ imageOptimization?: {
94
+ /** Reserved concurrent executions. Default: undefined (no reservation). */
95
+ reservedConcurrency?: number;
96
+ };
76
97
  /** CloudWatch log retention for the SSR Lambda. Default: TWO_WEEKS. */
77
98
  logRetention?: cdk.aws_logs.RetentionDays;
78
99
  };
package/src/index.cdk.ts CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  export { ApiNamespace, type BlocksContext, type ApiHandler } from './api.js';
5
5
  export { BLOCKS_RPC_PREFIX, BLOCKS_AUTH_PREFIX } from './constants.js';
6
- export { ApiError, isBlocksError } from './errors.js';
6
+ export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from './errors.js';
7
7
  export { EventSourceMapping } from './lambda-handler.js';
8
8
  export { BlocksStackProps } from './common/index.js';
9
9
  export { registerSdkIdentifiers, getSdkIdentifiers, getAllSdkIdentifiers, _resetSdkRegistry } from './common/sdk-registry.js';
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  export { ApiNamespace, type BlocksContext, type ApiHandler } from './api.js';
5
5
  export { BLOCKS_RPC_PREFIX, BLOCKS_AUTH_PREFIX } from './constants.js';
6
- export { ApiError, isBlocksError } from './errors.js';
6
+ export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from './errors.js';
7
7
  export { Scope, type ScopeOptions, type ScopeParent, type BuildingBlockMeta } from './common/index.js';
8
8
  export { registerSdkIdentifiers, getSdkIdentifiers, getAllSdkIdentifiers, _resetSdkRegistry } from './common/sdk-registry.js';
9
9
  export { getConfig, getConfigSync, preloadConfig, loadConfigToProcessEnv, _resetConfigCache } from './common/config.js';