@everystack/cli 0.3.6 → 0.3.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.
package/README.md CHANGED
@@ -103,6 +103,33 @@ const storage = createStorage({
103
103
  });
104
104
  ```
105
105
 
106
+ The S3 client is **bounded by default** so a single call can never hang: a
107
+ connect timeout (3s), a per-request timeout (10s), bounded retries (3 attempts),
108
+ and a capped `list()` (paged with a page backstop). This matters most for the
109
+ observability rollup — an unbounded client under an S3 503 storm blocks until
110
+ the Lambda's own timeout, and each hung invocation pins a concurrency slot, so
111
+ it can drain the account pool and starve the app it observes.
112
+
113
+ Tune per-field via `options`, or override the timeouts/retries by env var
114
+ without a code change (env is handy for turning a knob on a live Lambda):
115
+
116
+ ```typescript
117
+ const storage = createStorage({
118
+ type: 's3',
119
+ bucket: 'my-updates-bucket',
120
+ region: 'us-east-1',
121
+ options: {
122
+ connectionTimeoutMs: 3000, // env: EVERYSTACK_S3_CONNECT_TIMEOUT_MS
123
+ requestTimeoutMs: 10000, // env: EVERYSTACK_S3_REQUEST_TIMEOUT_MS
124
+ maxAttempts: 3, // env: EVERYSTACK_S3_MAX_ATTEMPTS
125
+ listMaxKeys: 1000, // keys per ListObjectsV2 page
126
+ listMaxPages: 1000, // hard backstop before list() truncates (warns)
127
+ },
128
+ });
129
+ ```
130
+
131
+ Precedence per field: explicit `options` > env var > default.
132
+
106
133
  ### Custom Adapter
107
134
 
108
135
  Implement the `StorageAdapter` interface:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -65,16 +65,22 @@
65
65
  "bin": {
66
66
  "everystack": "./src/cli/index.ts"
67
67
  },
68
+ "scripts": {
69
+ "test": "jest",
70
+ "build": "tsc --build",
71
+ "prepublishOnly": "tsc --module commonjs --moduleResolution node --target ES2022 --esModuleInterop --skipLibCheck --declaration false --outDir src src/env.ts",
72
+ "lint": "tsc --noEmit"
73
+ },
68
74
  "dependencies": {
69
75
  "@aws-sdk/client-cloudfront": "3.1053.0",
70
76
  "@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
71
77
  "@aws-sdk/signature-v4a": "3.1048.0",
78
+ "@everystack/model": "workspace:*",
72
79
  "glob": "13.0.6",
73
80
  "node-forge": "1.4.0",
74
81
  "structured-headers": "1.0.1",
75
82
  "tsx": "4.21.0",
76
- "typescript": "5.9.3",
77
- "@everystack/model": "0.3.3"
83
+ "typescript": "5.9.3"
78
84
  },
79
85
  "peerDependencies": {
80
86
  "@everystack/server": ">=0.1.0",
@@ -87,7 +93,7 @@
87
93
  "@aws-sdk/s3-request-presigner": "3.1053.0",
88
94
  "drizzle-orm": "0.41.0",
89
95
  "expo-updates": "55.0.21",
90
- "react": "19.2.6",
96
+ "react": "19.2.0",
91
97
  "react-native": "0.83.6"
92
98
  },
93
99
  "peerDependenciesMeta": {
@@ -139,12 +145,7 @@
139
145
  "@types/react": "19.2.14",
140
146
  "drizzle-orm": "0.41.0",
141
147
  "jest": "29.7.0",
142
- "react": "19.2.6",
148
+ "react": "19.2.0",
143
149
  "ts-jest": "29.4.9"
144
- },
145
- "scripts": {
146
- "test": "jest",
147
- "build": "tsc --build",
148
- "lint": "tsc --noEmit"
149
150
  }
150
- }
151
+ }
package/src/env.js CHANGED
@@ -30,12 +30,25 @@ const fs_1 = __importDefault(require("fs"));
30
30
  /**
31
31
  * Detect the current build surface from environment signals.
32
32
  *
33
- * EAS_BUILD / EAS_BUILD_PLATFORM -> native (EAS native build)
34
- * EAS_UPDATE -> native (EAS OTA update)
35
- * EVERYSTACK_UPDATE -> web (everystack update)
36
- * none of the above -> local (pnpm dev)
33
+ * EVERYSTACK_SURFACE=native|web|local -> explicit override (declared surface)
34
+ * EAS_BUILD / EAS_BUILD_PLATFORM -> native (EAS native build)
35
+ * EAS_UPDATE -> native (EAS OTA update)
36
+ * EVERYSTACK_UPDATE -> web (everystack update)
37
+ * none of the above -> local (pnpm dev)
38
+ *
39
+ * `EVERYSTACK_SURFACE` is the honest way to pin the surface for an `eas build`.
40
+ * EAS's own `EAS_BUILD` marker exists only on the build worker, not during the
41
+ * local config-resolution phase where `eas build` first evaluates app.config.js
42
+ * (to compute the fingerprint before upload). But EAS injects a build profile's
43
+ * `env` block into process.env during BOTH phases, so declaring
44
+ * `EVERYSTACK_SURFACE: "native"` in eas.json pins the surface everywhere without
45
+ * impersonating an EAS-internal variable.
37
46
  */
38
47
  function detectSurface() {
48
+ const explicit = process.env.EVERYSTACK_SURFACE;
49
+ if (explicit === 'native' || explicit === 'web' || explicit === 'local') {
50
+ return explicit;
51
+ }
39
52
  if (process.env.EAS_BUILD || process.env.EAS_BUILD_PLATFORM || process.env.EAS_UPDATE) {
40
53
  return 'native';
41
54
  }
@@ -171,7 +184,8 @@ function load(options) {
171
184
  }
172
185
  else {
173
186
  throw new Error(`[everystack/env] Surface resolved to 'local' but ENVIRONMENT=${process.env.ENVIRONMENT}. `
174
- + "Deployable builds must run under 'everystack update' (EVERYSTACK_UPDATE) or EAS (EAS_BUILD). "
187
+ + 'Declare the build surface: set EVERYSTACK_SURFACE=native (EAS build) or =web in the '
188
+ + "eas.json build profile env, or run under 'everystack update'. "
175
189
  + 'Refusing to bundle with the local tier set. Set EVERYSTACK_SURFACE=local to override for debugging.');
176
190
  }
177
191
  }
package/src/env.ts CHANGED
@@ -47,12 +47,25 @@ export type Surface = 'local' | 'native' | 'web';
47
47
  /**
48
48
  * Detect the current build surface from environment signals.
49
49
  *
50
- * EAS_BUILD / EAS_BUILD_PLATFORM -> native (EAS native build)
51
- * EAS_UPDATE -> native (EAS OTA update)
52
- * EVERYSTACK_UPDATE -> web (everystack update)
53
- * none of the above -> local (pnpm dev)
50
+ * EVERYSTACK_SURFACE=native|web|local -> explicit override (declared surface)
51
+ * EAS_BUILD / EAS_BUILD_PLATFORM -> native (EAS native build)
52
+ * EAS_UPDATE -> native (EAS OTA update)
53
+ * EVERYSTACK_UPDATE -> web (everystack update)
54
+ * none of the above -> local (pnpm dev)
55
+ *
56
+ * `EVERYSTACK_SURFACE` is the honest way to pin the surface for an `eas build`.
57
+ * EAS's own `EAS_BUILD` marker exists only on the build worker, not during the
58
+ * local config-resolution phase where `eas build` first evaluates app.config.js
59
+ * (to compute the fingerprint before upload). But EAS injects a build profile's
60
+ * `env` block into process.env during BOTH phases, so declaring
61
+ * `EVERYSTACK_SURFACE: "native"` in eas.json pins the surface everywhere without
62
+ * impersonating an EAS-internal variable.
54
63
  */
55
64
  export function detectSurface(): Surface {
65
+ const explicit = process.env.EVERYSTACK_SURFACE;
66
+ if (explicit === 'native' || explicit === 'web' || explicit === 'local') {
67
+ return explicit;
68
+ }
56
69
  if (process.env.EAS_BUILD || process.env.EAS_BUILD_PLATFORM || process.env.EAS_UPDATE) {
57
70
  return 'native';
58
71
  }
@@ -207,7 +220,8 @@ export function load(options?: { path?: string }): { extra: Record<string, strin
207
220
  } else {
208
221
  throw new Error(
209
222
  `[everystack/env] Surface resolved to 'local' but ENVIRONMENT=${process.env.ENVIRONMENT}. `
210
- + "Deployable builds must run under 'everystack update' (EVERYSTACK_UPDATE) or EAS (EAS_BUILD). "
223
+ + 'Declare the build surface: set EVERYSTACK_SURFACE=native (EAS build) or =web in the '
224
+ + "eas.json build profile env, or run under 'everystack update'. "
211
225
  + 'Refusing to bundle with the local tier set. Set EVERYSTACK_SURFACE=local to override for debugging.'
212
226
  );
213
227
  }
@@ -1,3 +1,5 @@
1
+ import type { S3StorageOptions } from './s3';
2
+
1
3
  export interface StorageAdapter {
2
4
  put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
3
5
  get(key: string): Promise<{ data: Buffer; contentType: string } | null>;
@@ -32,7 +34,18 @@ export interface StorageAdapter {
32
34
 
33
35
  export type StorageOptions =
34
36
  | { type: 'filesystem'; directory: string }
35
- | { type: 's3'; bucket: string; region?: string; endpoint?: string };
37
+ | {
38
+ type: 's3';
39
+ bucket: string;
40
+ region?: string;
41
+ endpoint?: string;
42
+ /**
43
+ * Bound the S3 client's failure modes (connect/request timeouts, retries,
44
+ * list caps). Every field defaults to a safe bound; env vars override too.
45
+ * See {@link S3StorageOptions}.
46
+ */
47
+ options?: S3StorageOptions;
48
+ };
36
49
 
37
50
  export async function createStorage(options: StorageOptions): Promise<StorageAdapter> {
38
51
  if (options.type === 'filesystem') {
@@ -40,8 +53,8 @@ export async function createStorage(options: StorageOptions): Promise<StorageAda
40
53
  return new FilesystemStorageAdapter(options.directory);
41
54
  }
42
55
  const { S3StorageAdapter } = await import('./s3');
43
- return new S3StorageAdapter(options.bucket, options.region, options.endpoint);
56
+ return new S3StorageAdapter(options.bucket, options.region, options.endpoint, options.options);
44
57
  }
45
58
 
46
59
  export { FilesystemStorageAdapter } from './filesystem';
47
- export { S3StorageAdapter } from './s3';
60
+ export { S3StorageAdapter, type S3StorageOptions } from './s3';
package/src/storage/s3.ts CHANGED
@@ -1,15 +1,58 @@
1
1
  import type { StorageAdapter } from './index';
2
2
 
3
+ /**
4
+ * Tuning for the S3 client's failure modes. Every value is bounded by default so
5
+ * a single call can never hang: under an S3 503 SlowDown storm an unbounded
6
+ * client blocks until the Lambda's own timeout (60s), and because each hung
7
+ * invocation pins a concurrency slot, the observability rollup can drain the
8
+ * whole account pool and starve the app it observes. Bounded here, a stuck call
9
+ * fails fast instead. Overridable per-field, then by env (so ops can tighten a
10
+ * live Lambda without a code redeploy), then these defaults.
11
+ */
12
+ export interface S3StorageOptions {
13
+ /** TCP connect timeout, ms. Env: EVERYSTACK_S3_CONNECT_TIMEOUT_MS. */
14
+ connectionTimeoutMs?: number;
15
+ /** Socket-idle timeout for a single request, ms. Env: EVERYSTACK_S3_REQUEST_TIMEOUT_MS. */
16
+ requestTimeoutMs?: number;
17
+ /** Total attempts per call (1 + retries). Env: EVERYSTACK_S3_MAX_ATTEMPTS. */
18
+ maxAttempts?: number;
19
+ /** Keys per ListObjectsV2 page (S3 caps this at 1000). */
20
+ listMaxKeys?: number;
21
+ /** Hard backstop on pages a single list() will walk before truncating (warns). */
22
+ listMaxPages?: number;
23
+ }
24
+
25
+ function envInt(name: string): number | undefined {
26
+ const raw = process.env[name];
27
+ if (raw === undefined) return undefined;
28
+ const n = Number(raw);
29
+ return Number.isFinite(n) && n > 0 ? n : undefined;
30
+ }
31
+
3
32
  export class S3StorageAdapter implements StorageAdapter {
4
33
  private bucket: string;
5
34
  private region: string;
6
35
  private endpoint?: string;
7
36
  private client: any;
8
37
 
9
- constructor(bucket: string, region?: string, endpoint?: string) {
38
+ private readonly connectionTimeoutMs: number;
39
+ private readonly requestTimeoutMs: number;
40
+ private readonly maxAttempts: number;
41
+ private readonly listMaxKeys: number;
42
+ private readonly listMaxPages: number;
43
+
44
+ constructor(bucket: string, region?: string, endpoint?: string, options: S3StorageOptions = {}) {
10
45
  this.bucket = bucket;
11
46
  this.region = region || 'us-east-1';
12
47
  this.endpoint = endpoint;
48
+
49
+ this.connectionTimeoutMs =
50
+ options.connectionTimeoutMs ?? envInt('EVERYSTACK_S3_CONNECT_TIMEOUT_MS') ?? 3000;
51
+ this.requestTimeoutMs =
52
+ options.requestTimeoutMs ?? envInt('EVERYSTACK_S3_REQUEST_TIMEOUT_MS') ?? 10000;
53
+ this.maxAttempts = options.maxAttempts ?? envInt('EVERYSTACK_S3_MAX_ATTEMPTS') ?? 3;
54
+ this.listMaxKeys = options.listMaxKeys ?? 1000;
55
+ this.listMaxPages = options.listMaxPages ?? 1000;
13
56
  }
14
57
 
15
58
  private async getClient(): Promise<any> {
@@ -17,6 +60,15 @@ export class S3StorageAdapter implements StorageAdapter {
17
60
  const { S3Client } = await import('@aws-sdk/client-s3');
18
61
  this.client = new S3Client({
19
62
  region: this.region,
63
+ // Bounded retries — the SDK default (3) is fine, but pin it so a config
64
+ // drift can't reintroduce unbounded blocking under throttling.
65
+ maxAttempts: this.maxAttempts,
66
+ // Connect + per-request timeouts. Without these a call can block forever
67
+ // on a stalled socket (the 60s-hang class the rollup outage rode in on).
68
+ requestHandler: {
69
+ connectionTimeout: this.connectionTimeoutMs,
70
+ requestTimeout: this.requestTimeoutMs,
71
+ },
20
72
  ...(this.endpoint ? { endpoint: this.endpoint, forcePathStyle: true } : {}),
21
73
  });
22
74
  }
@@ -136,11 +188,13 @@ export class S3StorageAdapter implements StorageAdapter {
136
188
  const client = await this.getClient();
137
189
  const keys: string[] = [];
138
190
  let continuationToken: string | undefined;
191
+ let pages = 0;
139
192
 
140
193
  do {
141
194
  const response = await client.send(new ListObjectsV2Command({
142
195
  Bucket: this.bucket,
143
196
  Prefix: prefix,
197
+ MaxKeys: this.listMaxKeys,
144
198
  ContinuationToken: continuationToken,
145
199
  }));
146
200
  if (response.Contents) {
@@ -149,6 +203,17 @@ export class S3StorageAdapter implements StorageAdapter {
149
203
  }
150
204
  }
151
205
  continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
206
+ pages += 1;
207
+ // Backstop: a prefix that walks past the page cap is almost always a sign
208
+ // the rollup's per-hour debounce isn't engaged (one object → one invoke).
209
+ // Truncate loudly rather than let a single list() run unbounded.
210
+ if (continuationToken && pages >= this.listMaxPages) {
211
+ console.warn(
212
+ `[s3] list('${prefix}') hit the ${this.listMaxPages}-page cap at ${keys.length} keys; truncating. ` +
213
+ `A prefix this large usually means the observability rollup is being invoked per-object instead of per-hour.`,
214
+ );
215
+ break;
216
+ }
152
217
  } while (continuationToken);
153
218
 
154
219
  return keys;