@everystack/cli 0.3.6 → 0.3.8
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 +27 -0
- package/package.json +3 -3
- package/src/env.js +19 -5
- package/src/env.ts +19 -5
- package/src/storage/index.ts +16 -3
- package/src/storage/s3.ts +66 -1
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.
|
|
3
|
+
"version": "0.3.8",
|
|
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>",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
88
88
|
"drizzle-orm": "0.41.0",
|
|
89
89
|
"expo-updates": "55.0.21",
|
|
90
|
-
"react": "19.2.
|
|
90
|
+
"react": "19.2.0",
|
|
91
91
|
"react-native": "0.83.6"
|
|
92
92
|
},
|
|
93
93
|
"peerDependenciesMeta": {
|
|
@@ -139,7 +139,7 @@
|
|
|
139
139
|
"@types/react": "19.2.14",
|
|
140
140
|
"drizzle-orm": "0.41.0",
|
|
141
141
|
"jest": "29.7.0",
|
|
142
|
-
"react": "19.2.
|
|
142
|
+
"react": "19.2.0",
|
|
143
143
|
"ts-jest": "29.4.9"
|
|
144
144
|
},
|
|
145
145
|
"scripts": {
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
+
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
+
|
|
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
|
}
|
package/src/storage/index.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
-
|
|
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;
|