@everystack/server 0.1.1 → 0.2.1
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/package.json +33 -26
- package/src/cdn/compose.ts +3 -0
- package/src/cdn/features.ts +112 -68
- package/src/cdn/index.ts +11 -13
- package/src/cdn/transforms.ts +157 -0
- package/src/db.ts +1 -0
- package/src/image.ts +28 -1
- package/src/index.ts +2 -0
- package/src/kvs.ts +50 -0
- package/src/plugin.ts +517 -0
- package/src/ssr.ts +3 -3
- package/src/worker.ts +46 -2
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
|
-
"license": "
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
8
8
|
},
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
"stubs",
|
|
12
12
|
"README.md"
|
|
13
13
|
],
|
|
14
|
-
"type": "module",
|
|
15
14
|
"exports": {
|
|
16
15
|
".": {
|
|
17
16
|
"types": "./src/index.ts",
|
|
@@ -41,20 +40,33 @@
|
|
|
41
40
|
"types": "./src/stubs.ts",
|
|
42
41
|
"default": "./src/stubs.ts"
|
|
43
42
|
},
|
|
43
|
+
"./plugin": {
|
|
44
|
+
"types": "./src/plugin.ts",
|
|
45
|
+
"default": "./src/plugin.ts"
|
|
46
|
+
},
|
|
44
47
|
"./cdn": {
|
|
45
48
|
"types": "./src/cdn/index.ts",
|
|
46
49
|
"default": "./src/cdn/index.ts"
|
|
50
|
+
},
|
|
51
|
+
"./kvs": {
|
|
52
|
+
"types": "./src/kvs.ts",
|
|
53
|
+
"default": "./src/kvs.ts"
|
|
47
54
|
}
|
|
48
55
|
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"test": "jest",
|
|
58
|
+
"build": "tsc --build",
|
|
59
|
+
"lint": "tsc --noEmit"
|
|
60
|
+
},
|
|
49
61
|
"peerDependencies": {
|
|
50
|
-
"@aws-sdk/client-s3": "
|
|
51
|
-
"@aws-sdk/s3-request-presigner": "
|
|
62
|
+
"@aws-sdk/client-s3": "3.1045.0",
|
|
63
|
+
"@aws-sdk/s3-request-presigner": "3.1045.0",
|
|
52
64
|
"@everystack/auth": ">=0.1.0",
|
|
53
65
|
"@everystack/cli": ">=0.1.0",
|
|
54
|
-
"drizzle-orm": "
|
|
55
|
-
"postgres": "
|
|
56
|
-
"sharp": "
|
|
57
|
-
"sst": "
|
|
66
|
+
"drizzle-orm": "0.41.0",
|
|
67
|
+
"postgres": "3.4.9",
|
|
68
|
+
"sharp": "0.34.5",
|
|
69
|
+
"sst": "4.13.1"
|
|
58
70
|
},
|
|
59
71
|
"peerDependenciesMeta": {
|
|
60
72
|
"sst": {
|
|
@@ -77,21 +89,16 @@
|
|
|
77
89
|
}
|
|
78
90
|
},
|
|
79
91
|
"devDependencies": {
|
|
80
|
-
"@
|
|
81
|
-
"@
|
|
82
|
-
"@types/
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
"
|
|
91
|
-
},
|
|
92
|
-
"scripts": {
|
|
93
|
-
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
94
|
-
"build": "tsc --build",
|
|
95
|
-
"lint": "tsc --noEmit"
|
|
92
|
+
"@everystack/auth": "workspace:*",
|
|
93
|
+
"@everystack/cli": "workspace:*",
|
|
94
|
+
"@types/aws-lambda": "8.10.161",
|
|
95
|
+
"@types/jest": "29.5.14",
|
|
96
|
+
"@types/node": "22.19.18",
|
|
97
|
+
"drizzle-orm": "0.41.0",
|
|
98
|
+
"jest": "29.7.0",
|
|
99
|
+
"postgres": "3.4.9",
|
|
100
|
+
"sst": "4.13.1",
|
|
101
|
+
"ts-jest": "29.4.9",
|
|
102
|
+
"typescript": "5.9.3"
|
|
96
103
|
}
|
|
97
|
-
}
|
|
104
|
+
}
|
package/src/cdn/compose.ts
CHANGED
|
@@ -72,6 +72,9 @@ export function composeViewerRequest(opts: ComposeOptions): ComposeResult {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// 2. Emit state as namespaced vars.
|
|
75
|
+
// SECURITY: State values originate from developer configuration (e.g., HandlerOptions,
|
|
76
|
+
// SST config), not from end-user input. JSON.stringify safely serializes these values.
|
|
77
|
+
// If state sources ever include user-controlled data, this interpolation must be revisited.
|
|
75
78
|
const stateLines: string[] = [];
|
|
76
79
|
for (const f of features) {
|
|
77
80
|
if (!f.state) continue;
|
package/src/cdn/features.ts
CHANGED
|
@@ -13,34 +13,34 @@ import type { Feature } from './compose';
|
|
|
13
13
|
|
|
14
14
|
// ---------- auth ----------
|
|
15
15
|
|
|
16
|
-
export type Role = string;
|
|
17
|
-
|
|
18
|
-
export interface AuthRoleEntry {
|
|
19
|
-
/** Read role required (GET/HEAD). */
|
|
20
|
-
r: Role;
|
|
21
|
-
/** Write role required (POST/PUT/PATCH/DELETE). */
|
|
22
|
-
w: Role;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
16
|
export interface AuthFeatureOptions {
|
|
26
17
|
/** HS256 secret — same secret your Lambda mints with. */
|
|
27
18
|
secret: string;
|
|
28
19
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*
|
|
35
|
-
*
|
|
20
|
+
* Paths that require authentication. Prefix with `!` to negate (exempt).
|
|
21
|
+
* Longest matching prefix wins. Paths with no match are public.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* requireAuth: ['/api/', '!/api/auth/', '!/api/health']
|
|
26
|
+
* ```
|
|
27
|
+
* - `/api/*` — requires JWT
|
|
28
|
+
* - `/api/auth/*` — public (exempted, longer match wins)
|
|
29
|
+
* - `/api/health` — public (exempted, longer match wins)
|
|
30
|
+
* - `/`, `/about` — public (no matching rule)
|
|
36
31
|
*/
|
|
37
|
-
|
|
38
|
-
/** Role hierarchy, weakest first. Required when `roles` is set. */
|
|
39
|
-
roleHierarchy?: Role[];
|
|
32
|
+
requireAuth: string[];
|
|
40
33
|
/**
|
|
41
|
-
*
|
|
34
|
+
* HTTP methods that bypass auth on protected paths.
|
|
35
|
+
* Aligns with the handler's `publicRoutes` option so the edge and Lambda
|
|
36
|
+
* share the same "reads are public" semantics.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* publicMethods: ['GET', 'HEAD']
|
|
41
|
+
* ```
|
|
42
42
|
*/
|
|
43
|
-
|
|
43
|
+
publicMethods?: string[];
|
|
44
44
|
/** Allowed clock skew (seconds). Default 30. */
|
|
45
45
|
clockSkewSec?: number;
|
|
46
46
|
/**
|
|
@@ -58,28 +58,22 @@ export interface AuthFeatureOptions {
|
|
|
58
58
|
order?: number;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
const DEFAULT_AUTH_PUBLIC = [
|
|
62
|
-
'/api/auth/',
|
|
63
|
-
'/api/health',
|
|
64
|
-
'/api/updates',
|
|
65
|
-
'/api/storage/public',
|
|
66
|
-
];
|
|
67
|
-
|
|
68
61
|
export function authFeature(opts: AuthFeatureOptions): Feature {
|
|
69
|
-
const publicPaths = opts.publicPaths ?? DEFAULT_AUTH_PUBLIC;
|
|
70
|
-
const roles = opts.roles ?? null;
|
|
71
|
-
const hierarchy = opts.roleHierarchy ?? null;
|
|
72
|
-
const tableFromPath = opts.tableFromPath === true;
|
|
73
62
|
const cookieName = opts.cookieName !== false ? (opts.cookieName ?? '__es_token') : '';
|
|
74
63
|
|
|
75
|
-
if (roles && !hierarchy) {
|
|
76
|
-
throw new Error('authFeature: roleHierarchy is required when roles is set');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
64
|
const verifierSrc = opts.compact
|
|
80
65
|
? generateCompactCffVerifier({ clockSkewSec: opts.clockSkewSec })
|
|
81
66
|
: generateCffVerifier({ clockSkewSec: opts.clockSkewSec });
|
|
82
67
|
|
|
68
|
+
// Parse requireAuth entries: '!' prefix = negated (exempt from auth).
|
|
69
|
+
// Sort by path length descending so longest (most specific) prefix wins.
|
|
70
|
+
const rules = opts.requireAuth.map(entry => {
|
|
71
|
+
const negated = entry.startsWith('!');
|
|
72
|
+
const path = negated ? entry.slice(1) : entry;
|
|
73
|
+
return { path, auth: !negated };
|
|
74
|
+
});
|
|
75
|
+
rules.sort((a, b) => b.path.length - a.path.length);
|
|
76
|
+
|
|
83
77
|
// Cookie fallback block — only emitted when cookieName is set.
|
|
84
78
|
const cookieBlock = cookieName
|
|
85
79
|
? `
|
|
@@ -99,49 +93,35 @@ export function authFeature(opts: AuthFeatureOptions): Feature {
|
|
|
99
93
|
},
|
|
100
94
|
state: {
|
|
101
95
|
secret: opts.secret,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
tableFromPath,
|
|
96
|
+
rulePaths: rules.map(r => r.path),
|
|
97
|
+
ruleAuth: rules.map(r => r.auth),
|
|
98
|
+
publicMethods: opts.publicMethods ?? [],
|
|
106
99
|
cookieName,
|
|
107
100
|
},
|
|
108
101
|
body: `
|
|
109
|
-
function
|
|
110
|
-
for (var i = 0; i <
|
|
111
|
-
var p =
|
|
112
|
-
if (uri === p || uri.indexOf(p) === 0) return
|
|
102
|
+
function __auth_needsAuth(uri) {
|
|
103
|
+
for (var i = 0; i < __auth_rulePaths.length; i++) {
|
|
104
|
+
var p = __auth_rulePaths[i];
|
|
105
|
+
if (uri === p || uri.indexOf(p) === 0) return __auth_ruleAuth[i];
|
|
113
106
|
}
|
|
114
107
|
return false;
|
|
115
108
|
}
|
|
116
|
-
function __auth_roleLevel(role) {
|
|
117
|
-
for (var i = 0; i < __auth_hierarchy.length; i++) {
|
|
118
|
-
if (__auth_hierarchy[i] === role) return i;
|
|
119
|
-
}
|
|
120
|
-
return -1;
|
|
121
|
-
}
|
|
122
|
-
function __auth_required(uri, method) {
|
|
123
|
-
if (!__auth_tableFromPath) return null;
|
|
124
|
-
var m = uri.match(/^\\/api\\/([^\\/?]+)/);
|
|
125
|
-
if (!m) return 'public';
|
|
126
|
-
var entry = __auth_roles[m[1]] || __auth_roles['*'];
|
|
127
|
-
if (!entry) return null;
|
|
128
|
-
return (method === 'GET' || method === 'HEAD') ? entry.r : entry.w;
|
|
129
|
-
}
|
|
130
109
|
function __auth_reject(status, message) {
|
|
131
110
|
return { statusCode: status, statusDescription: status === 401 ? 'Unauthorized' : 'Forbidden', headers: { 'content-type': { value: 'application/json' }, 'cache-control': { value: 'no-store' } }, body: { encoding: 'text', data: JSON.stringify({ message: message }) } };
|
|
132
111
|
}
|
|
133
112
|
var __auth_req = event.request;
|
|
134
113
|
var __auth_uri = __auth_req.uri;
|
|
135
|
-
|
|
136
|
-
var
|
|
137
|
-
|
|
138
|
-
var
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (!
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
114
|
+
if (__auth_needsAuth(__auth_uri)) {
|
|
115
|
+
var __auth_m = __auth_req.method;
|
|
116
|
+
var __auth_pub = false;
|
|
117
|
+
for (var __auth_mi = 0; __auth_mi < __auth_publicMethods.length; __auth_mi++) {
|
|
118
|
+
if (__auth_m === __auth_publicMethods[__auth_mi]) { __auth_pub = true; break; }
|
|
119
|
+
}
|
|
120
|
+
if (!__auth_pub) {
|
|
121
|
+
var __auth_h = __auth_req.headers.authorization && __auth_req.headers.authorization.value;${cookieBlock}
|
|
122
|
+
if (!__auth_h || __auth_h.indexOf('Bearer ') !== 0) return __auth_reject(401, 'Authentication required');
|
|
123
|
+
var __auth_claims = verifyJwt(__auth_h.slice(7), __auth_secret);
|
|
124
|
+
if (!__auth_claims) return __auth_reject(401, 'Invalid or expired token');
|
|
145
125
|
}
|
|
146
126
|
}
|
|
147
127
|
`,
|
|
@@ -182,6 +162,70 @@ if (__geo_country && __geo_blocked.length) {
|
|
|
182
162
|
};
|
|
183
163
|
}
|
|
184
164
|
|
|
165
|
+
// ---------- cache-version ----------
|
|
166
|
+
|
|
167
|
+
export interface CacheVersionFeatureOptions {
|
|
168
|
+
/**
|
|
169
|
+
* URI prefix → scope name mapping. When a request URI matches a prefix,
|
|
170
|
+
* the corresponding scope epoch (`__epoch__:<scope>`) is checked in KVS.
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* ```ts
|
|
174
|
+
* scopes: { '/api/': 'api', '/media/': 'media' }
|
|
175
|
+
* ```
|
|
176
|
+
* Requests to `/api/posts` check `__epoch__:api`, `/media/img.jpg` checks
|
|
177
|
+
* `__epoch__:media`, all others check `__epoch__:<defaultScope>`.
|
|
178
|
+
*/
|
|
179
|
+
scopes?: Record<string, string>;
|
|
180
|
+
/** Scope for URIs that don't match any prefix. Default `'web'`. */
|
|
181
|
+
defaultScope?: string;
|
|
182
|
+
/** Override `order`. Default 10 (early — set cache key before auth). */
|
|
183
|
+
order?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* KVS-based cache versioning at the CloudFront edge.
|
|
188
|
+
*
|
|
189
|
+
* Reads three version layers from KVS and picks the highest:
|
|
190
|
+
* 1. Global epoch (`__epoch__`)
|
|
191
|
+
* 2. Scope epoch (`__epoch__:<scope>`) based on URI prefix
|
|
192
|
+
* 3. Per-path epoch (the URI itself)
|
|
193
|
+
*
|
|
194
|
+
* Sets `?_v=<version>` on the request querystring so CloudFront treats
|
|
195
|
+
* different versions as distinct cache keys.
|
|
196
|
+
*
|
|
197
|
+
* Requires `kvStore` to be set on the Router's `edge.viewerRequest`.
|
|
198
|
+
*/
|
|
199
|
+
export function cacheVersionFeature(opts: CacheVersionFeatureOptions = {}): Feature {
|
|
200
|
+
const scopes = opts.scopes ?? {};
|
|
201
|
+
const defaultScope = opts.defaultScope ?? 'web';
|
|
202
|
+
const prefixes = Object.keys(scopes);
|
|
203
|
+
const scopeNames = Object.values(scopes);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
name: 'cacheVersion',
|
|
207
|
+
order: opts.order ?? 10,
|
|
208
|
+
state: {
|
|
209
|
+
prefixes,
|
|
210
|
+
scopeNames,
|
|
211
|
+
defaultScope,
|
|
212
|
+
},
|
|
213
|
+
body: `
|
|
214
|
+
var __cv_u = event.request.uri, __cv_q = event.request.querystring;
|
|
215
|
+
var __cv_gv = "", __cv_nv = "", __cv_pv = "";
|
|
216
|
+
try { __cv_gv = await cf.kvs().get("__epoch__") || ""; } catch(__cv_e) {}
|
|
217
|
+
var __cv_scope = __cacheVersion_defaultScope;
|
|
218
|
+
for (var __cv_i = 0; __cv_i < __cacheVersion_prefixes.length; __cv_i++) {
|
|
219
|
+
if (__cv_u.indexOf(__cacheVersion_prefixes[__cv_i]) === 0) { __cv_scope = __cacheVersion_scopeNames[__cv_i]; break; }
|
|
220
|
+
}
|
|
221
|
+
try { __cv_nv = await cf.kvs().get("__epoch__:" + __cv_scope) || ""; } catch(__cv_e) {}
|
|
222
|
+
try { __cv_pv = await cf.kvs().get(__cv_u) || ""; } catch(__cv_e) {}
|
|
223
|
+
var __cv_v = [__cv_gv, __cv_nv, __cv_pv].filter(function(x){return x;}).sort().pop() || "";
|
|
224
|
+
if (__cv_v) __cv_q._v = { value: __cv_v };
|
|
225
|
+
`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
185
229
|
// ---------- cache-key ----------
|
|
186
230
|
|
|
187
231
|
export interface CacheKeyFeatureOptions {
|
package/src/cdn/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { composeViewerRequest } from './compose';
|
|
19
|
-
import { authFeature, type AuthFeatureOptions
|
|
19
|
+
import { authFeature, type AuthFeatureOptions } from './features';
|
|
20
20
|
|
|
21
21
|
export {
|
|
22
22
|
composeViewerRequest,
|
|
@@ -29,24 +29,25 @@ export {
|
|
|
29
29
|
authFeature,
|
|
30
30
|
geoFeature,
|
|
31
31
|
cacheKeyFeature,
|
|
32
|
+
cacheVersionFeature,
|
|
32
33
|
type AuthFeatureOptions,
|
|
33
|
-
type AuthRoleEntry,
|
|
34
34
|
type GeoFeatureOptions,
|
|
35
35
|
type CacheKeyFeatureOptions,
|
|
36
|
-
type
|
|
36
|
+
type CacheVersionFeatureOptions,
|
|
37
37
|
} from './features';
|
|
38
|
+
export {
|
|
39
|
+
stripSstDeadCode,
|
|
40
|
+
applyWafExclusions,
|
|
41
|
+
WAF_JSON_API_OVERRIDES,
|
|
42
|
+
} from './transforms';
|
|
38
43
|
|
|
39
44
|
// ---------- Back-compat: createAuthFunction ----------
|
|
40
45
|
|
|
41
|
-
export interface RoleEntry extends AuthRoleEntry {}
|
|
42
|
-
|
|
43
46
|
export interface AuthFunctionConfig {
|
|
44
47
|
/** HS256 secret — same secret your Lambda uses to mint tokens. */
|
|
45
48
|
secret: string;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
roleHierarchy?: Role[];
|
|
49
|
-
tableFromPath?: boolean;
|
|
49
|
+
/** Auth rules. Default: `['/']` (protect everything). See authFeature. */
|
|
50
|
+
requireAuth?: string[];
|
|
50
51
|
clockSkewSec?: number;
|
|
51
52
|
/** Cookie name for SSR auth fallback. Default: '__es_token'. Set false to disable. */
|
|
52
53
|
cookieName?: string | false;
|
|
@@ -65,10 +66,7 @@ export interface AuthFunctionResult {
|
|
|
65
66
|
export function createAuthFunction(config: AuthFunctionConfig): AuthFunctionResult {
|
|
66
67
|
const opts: AuthFeatureOptions = {
|
|
67
68
|
secret: config.secret,
|
|
68
|
-
|
|
69
|
-
roles: config.roles,
|
|
70
|
-
roleHierarchy: config.roleHierarchy,
|
|
71
|
-
tableFromPath: config.tableFromPath,
|
|
69
|
+
requireAuth: config.requireAuth ?? ['/'],
|
|
72
70
|
clockSkewSec: config.clockSkewSec,
|
|
73
71
|
cookieName: config.cookieName,
|
|
74
72
|
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SST infrastructure transforms for the CDN layer.
|
|
3
|
+
*
|
|
4
|
+
* Utilities for $transform() callbacks in sst.config.ts. The $transform()
|
|
5
|
+
* call itself must stay in the app (it's an SST global), but the
|
|
6
|
+
* transformation logic lives here so every everystack app doesn't
|
|
7
|
+
* duplicate it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ---------- Dead code stripping for CloudFront Functions ----------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Strip a named function declaration from source code. Handles both
|
|
14
|
+
* `function name(` and `async function name(`. Tracks brace depth and
|
|
15
|
+
* skips string literals to find the matching closing brace.
|
|
16
|
+
*
|
|
17
|
+
* Returns code unchanged if the function isn't found (fail-safe).
|
|
18
|
+
*/
|
|
19
|
+
function stripFunction(code: string, name: string): string {
|
|
20
|
+
let start = code.indexOf('function ' + name + '(');
|
|
21
|
+
if (start === -1) return code;
|
|
22
|
+
// Include "async " prefix if present
|
|
23
|
+
if (start >= 6 && code.slice(start - 6, start) === 'async ') {
|
|
24
|
+
start -= 6;
|
|
25
|
+
}
|
|
26
|
+
let i = code.indexOf('{', start);
|
|
27
|
+
if (i === -1) return code;
|
|
28
|
+
let depth = 1;
|
|
29
|
+
i++;
|
|
30
|
+
while (i < code.length && depth > 0) {
|
|
31
|
+
const c = code[i];
|
|
32
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
33
|
+
i++;
|
|
34
|
+
while (i < code.length && code[i] !== c) {
|
|
35
|
+
if (code[i] === '\\') i++;
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
} else if (c === '{') {
|
|
39
|
+
depth++;
|
|
40
|
+
} else if (c === '}') {
|
|
41
|
+
depth--;
|
|
42
|
+
}
|
|
43
|
+
i++;
|
|
44
|
+
}
|
|
45
|
+
return code.slice(0, start) + code.slice(i);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Strip dead SST-generated functions from CloudFront Function wrapper code.
|
|
50
|
+
*
|
|
51
|
+
* SST's Router includes `routeSite()` (~3KB) for Next.js/static-site routing
|
|
52
|
+
* with nested helpers (setNextjsGeoHeaders, setNextjsCacheKey, getHeader,
|
|
53
|
+
* findNearestServer, haversineDistance). Expo apps only use "url" and "bucket"
|
|
54
|
+
* route types, so routeSite is dead code. Stripping it reclaims ~3000 bytes
|
|
55
|
+
* toward the 10,240-byte CFF limit.
|
|
56
|
+
*
|
|
57
|
+
* Also removes the `if (route.type === "site")` call site that invokes
|
|
58
|
+
* the stripped function.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* // In sst.config.ts:
|
|
63
|
+
* import { stripSstDeadCode } from '@everystack/server/cdn';
|
|
64
|
+
*
|
|
65
|
+
* $transform(aws.cloudfront.Function, (args: any) => {
|
|
66
|
+
* if (typeof args.code === 'string') {
|
|
67
|
+
* args.code = stripSstDeadCode(args.code);
|
|
68
|
+
* } else if (args.code?.apply) {
|
|
69
|
+
* args.code = args.code.apply(stripSstDeadCode);
|
|
70
|
+
* }
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function stripSstDeadCode(code: string): string {
|
|
75
|
+
const deadFunctions = ['routeSite'];
|
|
76
|
+
for (const name of deadFunctions) {
|
|
77
|
+
code = stripFunction(code, name);
|
|
78
|
+
}
|
|
79
|
+
// Remove the call site: if (route.type === "site") { ... await routeSite(...); ... }
|
|
80
|
+
code = code.replace(
|
|
81
|
+
/if \(route\.type === "site"\) \{[^}]*await routeSite\([^)]*\);[^}]*\}/g,
|
|
82
|
+
'',
|
|
83
|
+
);
|
|
84
|
+
return code;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------- WAF rule exclusions ----------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Default WAF rule exclusions for JSON APIs behind AWS Managed Rules.
|
|
91
|
+
*
|
|
92
|
+
* These rules commonly cause false positives on JSON request bodies:
|
|
93
|
+
* - SizeRestrictions_BODY: large JSON payloads (admin bulk operations)
|
|
94
|
+
* - GenericRFI_BODY: URLs in JSON values (avatarUrl, cdnUrl)
|
|
95
|
+
* - SQLi_BODY: JSON keys resembling SQL keywords (limit, select, order)
|
|
96
|
+
* - Log4JRCE_HEADER: JWT Authorization headers with encoded payloads
|
|
97
|
+
*
|
|
98
|
+
* Keys are AWS managed rule group names. Values are rule names to set to
|
|
99
|
+
* COUNT mode (log but don't block).
|
|
100
|
+
*/
|
|
101
|
+
export const WAF_JSON_API_OVERRIDES: Record<string, string[]> = {
|
|
102
|
+
AWSManagedRulesCommonRuleSet: ['SizeRestrictions_BODY', 'GenericRFI_BODY'],
|
|
103
|
+
AWSManagedRulesSQLiRuleSet: ['SQLi_BODY'],
|
|
104
|
+
AWSManagedRulesKnownBadInputsRuleSet: ['Log4JRCE_HEADER'],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Apply WAF rule exclusions by setting specific rules to COUNT mode.
|
|
109
|
+
*
|
|
110
|
+
* Takes a resolved rules array (from `$resolve(args.rules).apply(...)`)
|
|
111
|
+
* and returns the transformed array with the specified rule overrides.
|
|
112
|
+
*
|
|
113
|
+
* @param rules Resolved WAF rules array from Pulumi.
|
|
114
|
+
* @param overrides Rule group → rule names to set to COUNT. Defaults to
|
|
115
|
+
* {@link WAF_JSON_API_OVERRIDES}.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* // In sst.config.ts:
|
|
120
|
+
* import { applyWafExclusions } from '@everystack/server/cdn';
|
|
121
|
+
*
|
|
122
|
+
* $transform(aws.wafv2.WebAcl, (args: any) => {
|
|
123
|
+
* if (!args.rules) return;
|
|
124
|
+
* args.rules = $resolve(args.rules).apply(applyWafExclusions);
|
|
125
|
+
* });
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
export function applyWafExclusions(
|
|
129
|
+
rules: any[],
|
|
130
|
+
overrides: Record<string, string[]> = WAF_JSON_API_OVERRIDES,
|
|
131
|
+
): any[] {
|
|
132
|
+
// Build the override map in the shape WAF expects.
|
|
133
|
+
const ruleOverrides: Record<string, Array<{ name: string; actionToUse: { count: Record<string, never> } }>> = {};
|
|
134
|
+
for (const [group, ruleNames] of Object.entries(overrides)) {
|
|
135
|
+
ruleOverrides[group] = ruleNames.map((name) => ({
|
|
136
|
+
name,
|
|
137
|
+
actionToUse: { count: {} },
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return rules.map((rule: any) => {
|
|
142
|
+
const groupName = rule.statement?.managedRuleGroupStatement?.name;
|
|
143
|
+
if (groupName && ruleOverrides[groupName]) {
|
|
144
|
+
return {
|
|
145
|
+
...rule,
|
|
146
|
+
statement: {
|
|
147
|
+
...rule.statement,
|
|
148
|
+
managedRuleGroupStatement: {
|
|
149
|
+
...rule.statement.managedRuleGroupStatement,
|
|
150
|
+
ruleActionOverrides: ruleOverrides[groupName],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return rule;
|
|
156
|
+
});
|
|
157
|
+
}
|
package/src/db.ts
CHANGED
package/src/image.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface ImageHandlerConfig {
|
|
|
26
26
|
/** Cache-Control for image responses.
|
|
27
27
|
* Default: 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5' */
|
|
28
28
|
cacheControl?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Optional key validator. Called before fetching from S3.
|
|
31
|
+
* Return true to allow the request, false to reject with 403.
|
|
32
|
+
* Use this to verify the key exists in your database or matches an allowed prefix.
|
|
33
|
+
*/
|
|
34
|
+
validateKey?: (key: string) => boolean | Promise<boolean>;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export interface TransformParams {
|
|
@@ -142,7 +148,7 @@ export async function processImage(
|
|
|
142
148
|
export function createImageHandler(
|
|
143
149
|
config: ImageHandlerConfig
|
|
144
150
|
): (event: APIGatewayProxyEventV2) => Promise<APIGatewayProxyStructuredResultV2> {
|
|
145
|
-
const { bucket, pathPrefix = '/media/' } = config;
|
|
151
|
+
const { bucket, pathPrefix = '/media/', validateKey } = config;
|
|
146
152
|
const region = config.region || process.env.AWS_REGION;
|
|
147
153
|
|
|
148
154
|
return async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyStructuredResultV2> => {
|
|
@@ -159,6 +165,27 @@ export function createImageHandler(
|
|
|
159
165
|
};
|
|
160
166
|
}
|
|
161
167
|
|
|
168
|
+
// Block path traversal attempts
|
|
169
|
+
if (key.includes('..') || key.startsWith('/')) {
|
|
170
|
+
return {
|
|
171
|
+
statusCode: 403,
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({ error: 'Forbidden' }),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Optional key validation (e.g. verify key exists in database)
|
|
178
|
+
if (validateKey) {
|
|
179
|
+
const allowed = await validateKey(key);
|
|
180
|
+
if (!allowed) {
|
|
181
|
+
return {
|
|
182
|
+
statusCode: 403,
|
|
183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
184
|
+
body: JSON.stringify({ error: 'Forbidden' }),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
162
189
|
const params = parseParams(event.queryStringParameters || {});
|
|
163
190
|
|
|
164
191
|
const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* - createRouter — path-based request dispatch
|
|
7
7
|
* - createLambdaHandler — full handler with lazy init, routing, error handling
|
|
8
8
|
* - log — structured JSON logging
|
|
9
|
+
*
|
|
10
|
+
* For the plugin-based composition API, import from '@everystack/server/plugin'.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import type {
|
package/src/kvs.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudFront KeyValueStore cache invalidation utility.
|
|
3
|
+
*
|
|
4
|
+
* Writes a timestamp to KVS keys, signaling CloudFront Functions
|
|
5
|
+
* to treat cached responses as stale. Used by both the events
|
|
6
|
+
* listener (wildcard callback) and worker (RPC dispatch).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { invalidateKvsPaths } from '@everystack/server/kvs';
|
|
10
|
+
* await invalidateKvsPaths(kvsArn, ['/api/posts', '/api/profiles']);
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write a fresh timestamp to each KVS path, invalidating CloudFront caches.
|
|
15
|
+
*
|
|
16
|
+
* Each put changes the ETag, so we re-describe after each write.
|
|
17
|
+
* Dynamically imports AWS SDK to avoid bundling when unused.
|
|
18
|
+
*/
|
|
19
|
+
export async function invalidateKvsPaths(kvsArn: string, paths: string[]): Promise<void> {
|
|
20
|
+
if (!kvsArn || paths.length === 0) return;
|
|
21
|
+
|
|
22
|
+
await import('@aws-sdk/signature-v4a');
|
|
23
|
+
const {
|
|
24
|
+
CloudFrontKeyValueStoreClient,
|
|
25
|
+
DescribeKeyValueStoreCommand,
|
|
26
|
+
PutKeyCommand,
|
|
27
|
+
} = await import('@aws-sdk/client-cloudfront-keyvaluestore');
|
|
28
|
+
|
|
29
|
+
const client = new CloudFrontKeyValueStoreClient({});
|
|
30
|
+
const desc = await client.send(
|
|
31
|
+
new DescribeKeyValueStoreCommand({ KvsARN: kvsArn })
|
|
32
|
+
);
|
|
33
|
+
let etag = desc.ETag!;
|
|
34
|
+
|
|
35
|
+
for (const path of paths) {
|
|
36
|
+
await client.send(
|
|
37
|
+
new PutKeyCommand({
|
|
38
|
+
KvsARN: kvsArn,
|
|
39
|
+
Key: path,
|
|
40
|
+
Value: String(Math.floor(Date.now() / 1000)),
|
|
41
|
+
IfMatch: etag,
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
// KVS etag changes after each put — re-describe for next iteration
|
|
45
|
+
const newDesc = await client.send(
|
|
46
|
+
new DescribeKeyValueStoreCommand({ KvsARN: kvsArn })
|
|
47
|
+
);
|
|
48
|
+
etag = newDesc.ETag!;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/server/plugin — Plugin system for composable Lambda handlers.
|
|
3
|
+
*
|
|
4
|
+
* Plugins are factory functions that receive a shared context and return
|
|
5
|
+
* route/action/handler contributions. The framework composes them into a
|
|
6
|
+
* single Lambda handler with lazy initialization.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Handler, Route, LogSink, ServerCacheConfig } from './index';
|
|
10
|
+
import type {
|
|
11
|
+
APIGatewayProxyEventV2,
|
|
12
|
+
APIGatewayProxyStructuredResultV2,
|
|
13
|
+
} from 'aws-lambda';
|
|
14
|
+
import {
|
|
15
|
+
createRouter,
|
|
16
|
+
eventToRequest,
|
|
17
|
+
responseToResult,
|
|
18
|
+
log,
|
|
19
|
+
} from './index';
|
|
20
|
+
|
|
21
|
+
// --- Plugin Types ---
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shared context provided to all plugins during initialization.
|
|
25
|
+
* Apps extend this with additional resources (auth handlers, job adapters, etc.).
|
|
26
|
+
*/
|
|
27
|
+
export interface PluginContext {
|
|
28
|
+
/** Drizzle database connection */
|
|
29
|
+
db: any;
|
|
30
|
+
/** App schema (all tables) */
|
|
31
|
+
schema: Record<string, any>;
|
|
32
|
+
/** Shared JWT verification — one function, used by all plugins */
|
|
33
|
+
verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
|
|
34
|
+
/** Runtime environment */
|
|
35
|
+
environment: string;
|
|
36
|
+
/** Publish a background job */
|
|
37
|
+
publishJob: (type: string, payload: unknown) => Promise<string>;
|
|
38
|
+
/** Extensible — apps add whatever their plugins need */
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a plugin contributes to the application.
|
|
44
|
+
*/
|
|
45
|
+
export interface PluginContribution {
|
|
46
|
+
/** Routes this plugin handles (matched in plugin array order) */
|
|
47
|
+
routes?: Route[];
|
|
48
|
+
/** Named handlers for cross-plugin reference (e.g., SSR as fallback) */
|
|
49
|
+
handlers?: Record<string, Handler>;
|
|
50
|
+
/** CLI actions this plugin handles (invoked via Lambda direct invoke) */
|
|
51
|
+
actions?: Record<string, ActionHandler>;
|
|
52
|
+
/** Log sink — at most one plugin should provide this */
|
|
53
|
+
logSink?: LogSink;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An action handler receives the payload and the shared context.
|
|
58
|
+
* No module-level globals needed — context is injected.
|
|
59
|
+
*/
|
|
60
|
+
export type ActionHandler = (
|
|
61
|
+
payload: unknown,
|
|
62
|
+
ctx: PluginContext
|
|
63
|
+
) => Promise<unknown>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A plugin is a factory function.
|
|
67
|
+
* It receives the shared context and returns its contributions.
|
|
68
|
+
* Called lazily during init — dynamic imports go inside the factory body.
|
|
69
|
+
*/
|
|
70
|
+
export type Plugin = (ctx: PluginContext) => Promise<PluginContribution>;
|
|
71
|
+
|
|
72
|
+
// --- Plugin Lambda Handler Options ---
|
|
73
|
+
|
|
74
|
+
export interface PluginLambdaHandlerOptions {
|
|
75
|
+
/**
|
|
76
|
+
* Creates the shared context that all plugins receive.
|
|
77
|
+
* Called once on first request (lazy init for cold starts).
|
|
78
|
+
*/
|
|
79
|
+
context: () => Promise<PluginContext>;
|
|
80
|
+
/**
|
|
81
|
+
* Plugins to compose. Order matters for route priority —
|
|
82
|
+
* earlier plugins' routes match first.
|
|
83
|
+
*/
|
|
84
|
+
plugins: Plugin[];
|
|
85
|
+
/**
|
|
86
|
+
* App-level routes that don't belong to any plugin.
|
|
87
|
+
* Evaluated after plugin routes.
|
|
88
|
+
*/
|
|
89
|
+
routes?: (ctx: PluginContext) => Route[];
|
|
90
|
+
/**
|
|
91
|
+
* Fallback handler when no route matches (e.g., SSR).
|
|
92
|
+
* Can be async if the handler requires initialization.
|
|
93
|
+
*/
|
|
94
|
+
fallback?: (ctx: PluginContext) => Handler | Promise<Handler>;
|
|
95
|
+
/**
|
|
96
|
+
* App-level actions merged with plugin-contributed actions.
|
|
97
|
+
* App actions take precedence over plugin actions on name collision.
|
|
98
|
+
*/
|
|
99
|
+
actions?: Record<string, ActionHandler>;
|
|
100
|
+
/** Override default Cache-Control headers */
|
|
101
|
+
cache?: ServerCacheConfig;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Plugin Lambda Handler Implementation ---
|
|
105
|
+
|
|
106
|
+
export function createPluginLambdaHandler(
|
|
107
|
+
options: PluginLambdaHandlerOptions
|
|
108
|
+
): (event: APIGatewayProxyEventV2 | Record<string, unknown>) => Promise<APIGatewayProxyStructuredResultV2 | unknown> {
|
|
109
|
+
let cached: {
|
|
110
|
+
ctx: PluginContext;
|
|
111
|
+
router: (path: string, method: string) => Handler;
|
|
112
|
+
actions: Record<string, ActionHandler>;
|
|
113
|
+
logSink?: LogSink;
|
|
114
|
+
} | null = null;
|
|
115
|
+
|
|
116
|
+
async function ensureInitialized() {
|
|
117
|
+
if (cached) return cached;
|
|
118
|
+
|
|
119
|
+
const ctx = await options.context();
|
|
120
|
+
|
|
121
|
+
// Initialize all plugins (sequential to allow import ordering)
|
|
122
|
+
const contributions: PluginContribution[] = [];
|
|
123
|
+
for (const plugin of options.plugins) {
|
|
124
|
+
contributions.push(await plugin(ctx));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Collect routes (plugin order = priority)
|
|
128
|
+
const pluginRoutes = contributions.flatMap(c => c.routes ?? []);
|
|
129
|
+
const appRoutes = options.routes?.(ctx) ?? [];
|
|
130
|
+
const allRoutes = [...pluginRoutes, ...appRoutes];
|
|
131
|
+
|
|
132
|
+
// Collect actions (app-level takes precedence on collision)
|
|
133
|
+
const pluginActions: Record<string, ActionHandler> = {};
|
|
134
|
+
for (const contribution of contributions) {
|
|
135
|
+
if (contribution.actions) {
|
|
136
|
+
Object.assign(pluginActions, contribution.actions);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const allActions = { ...pluginActions, ...(options.actions ?? {}) };
|
|
140
|
+
|
|
141
|
+
// Collect log sink (first one wins)
|
|
142
|
+
const logSink = contributions.find(c => c.logSink)?.logSink;
|
|
143
|
+
|
|
144
|
+
// Build router
|
|
145
|
+
const fallback = await options.fallback?.(ctx);
|
|
146
|
+
const router = createRouter(allRoutes, fallback);
|
|
147
|
+
|
|
148
|
+
cached = { ctx, router, actions: allActions, logSink };
|
|
149
|
+
log('info', 'Plugin handlers initialized', { plugins: options.plugins.length });
|
|
150
|
+
return cached;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return async (
|
|
154
|
+
event: APIGatewayProxyEventV2 | Record<string, unknown>
|
|
155
|
+
): Promise<APIGatewayProxyStructuredResultV2 | unknown> => {
|
|
156
|
+
// Direct CLI invoke via Lambda Invoke — IAM is the auth layer.
|
|
157
|
+
if ('_action' in event && typeof event._action === 'string') {
|
|
158
|
+
const { ctx, actions } = await ensureInitialized();
|
|
159
|
+
const actionName = event._action;
|
|
160
|
+
const actionHandler = actions[actionName];
|
|
161
|
+
if (!actionHandler) {
|
|
162
|
+
return { error: `Unknown action: ${actionName}` };
|
|
163
|
+
}
|
|
164
|
+
log('info', 'CLI action invoked', { action: actionName });
|
|
165
|
+
try {
|
|
166
|
+
return await actionHandler(event._payload, ctx);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
log('error', 'CLI action error', { action: actionName, error: String(error) });
|
|
169
|
+
return { error: String(error) };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// HTTP event — Function URL or API Gateway
|
|
174
|
+
const httpEvent = event as APIGatewayProxyEventV2;
|
|
175
|
+
const requestId =
|
|
176
|
+
httpEvent.headers?.['x-amzn-trace-id'] ||
|
|
177
|
+
httpEvent.headers?.['x-request-id'] ||
|
|
178
|
+
crypto.randomUUID();
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const { router, logSink } = await ensureInitialized();
|
|
182
|
+
const request = eventToRequest(httpEvent);
|
|
183
|
+
const path = httpEvent.rawPath;
|
|
184
|
+
const method = httpEvent.requestContext.http.method;
|
|
185
|
+
const handlerFn = router(path, method);
|
|
186
|
+
const response = await handlerFn(request);
|
|
187
|
+
const result = await responseToResult(response);
|
|
188
|
+
|
|
189
|
+
// Propagate request ID
|
|
190
|
+
result.headers = { ...result.headers, 'x-request-id': requestId };
|
|
191
|
+
|
|
192
|
+
// Set Cache-Control if not already set by the handler
|
|
193
|
+
if (!result.headers['cache-control']) {
|
|
194
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
195
|
+
const isAuthenticated = !!httpEvent.headers?.authorization;
|
|
196
|
+
result.headers['cache-control'] = isAuthenticated
|
|
197
|
+
? (options.cache?.private ?? 'private, no-store')
|
|
198
|
+
: (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
|
|
199
|
+
} else {
|
|
200
|
+
result.headers['cache-control'] = options.cache?.mutation ?? 'no-store';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Response size guard — Lambda Function URLs have a 6MB limit
|
|
205
|
+
if (result.body && result.body.length > 5 * 1024 * 1024) {
|
|
206
|
+
return {
|
|
207
|
+
statusCode: 413,
|
|
208
|
+
headers: {
|
|
209
|
+
'Content-Type': 'application/json',
|
|
210
|
+
'cache-control': 'no-store',
|
|
211
|
+
'x-request-id': requestId,
|
|
212
|
+
},
|
|
213
|
+
body: JSON.stringify({ error: 'Response too large' }),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
log('info', 'Request completed', {
|
|
218
|
+
requestId,
|
|
219
|
+
path,
|
|
220
|
+
method,
|
|
221
|
+
status: result.statusCode,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Write to log sink (non-blocking)
|
|
225
|
+
if (logSink) {
|
|
226
|
+
logSink.ingest([{
|
|
227
|
+
id: requestId,
|
|
228
|
+
timestamp: Date.now(),
|
|
229
|
+
level: (result.statusCode ?? 200) >= 500 ? 'error' : 'info',
|
|
230
|
+
message: `${method} ${path} ${result.statusCode ?? 200}`,
|
|
231
|
+
source: 'lambda',
|
|
232
|
+
traceId: requestId,
|
|
233
|
+
data: { path, method, status: result.statusCode ?? 200, platform: 'server' },
|
|
234
|
+
}]).catch(() => {});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
log('error', 'Lambda handler error', {
|
|
240
|
+
requestId,
|
|
241
|
+
error: String(error),
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
statusCode: 500,
|
|
245
|
+
headers: {
|
|
246
|
+
'Content-Type': 'application/json',
|
|
247
|
+
'cache-control': 'no-store',
|
|
248
|
+
'x-request-id': requestId,
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify({
|
|
251
|
+
error: 'Internal Server Error',
|
|
252
|
+
...(process.env.ENVIRONMENT === 'dev'
|
|
253
|
+
? { details: String(error) }
|
|
254
|
+
: {}),
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- SSR Fallback Plugin ---
|
|
262
|
+
|
|
263
|
+
import type { PostProcessResult, WebHandlerOptions } from './ssr';
|
|
264
|
+
|
|
265
|
+
export interface SsrPluginOptions {
|
|
266
|
+
/** App-specific HTML post-processing (e.g., JSON-LD injection, meta status codes) */
|
|
267
|
+
postProcessHtml?: (html: string, request: Request) => PostProcessResult | Promise<PostProcessResult>;
|
|
268
|
+
/** Channel name for bundle resolution (default: from ENVIRONMENT env var) */
|
|
269
|
+
channel?: string;
|
|
270
|
+
/** Query params to strip before SSR (default: ['_v']) */
|
|
271
|
+
stripQueryParams?: string[];
|
|
272
|
+
/** Inject __EXPO_ROUTER_HYDRATE__ flag (default: true) */
|
|
273
|
+
injectHydrateFlag?: boolean;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
interface SsrContext extends PluginContext {
|
|
277
|
+
updatesStorage: any;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Creates an SSR fallback handler from plugin context.
|
|
282
|
+
* Resolves the web handler from S3/storage, extracts auth from cookies,
|
|
283
|
+
* and runs each request with auth context for SSR loaders.
|
|
284
|
+
*
|
|
285
|
+
* Usage:
|
|
286
|
+
* fallback: ssrPlugin(),
|
|
287
|
+
* fallback: ssrPlugin({ postProcessHtml: injectJsonLd }),
|
|
288
|
+
*/
|
|
289
|
+
export function ssrPlugin(options: SsrPluginOptions = {}): (ctx: PluginContext) => Promise<Handler> {
|
|
290
|
+
return async (ctx: PluginContext): Promise<Handler> => {
|
|
291
|
+
const { getWebHandler } = await import('./ssr');
|
|
292
|
+
|
|
293
|
+
// Auth helpers are optional — SSR works without auth (just no user context)
|
|
294
|
+
let getTokenFromCookies: ((cookieHeader: string | null) => string | null) | null = null;
|
|
295
|
+
let runWithAuthContext: (<T>(user: any, fn: () => T | Promise<T>) => T | Promise<T>) | null = null;
|
|
296
|
+
try {
|
|
297
|
+
const cookies = await import('@everystack/auth/cookies');
|
|
298
|
+
const context = await import('@everystack/auth/context');
|
|
299
|
+
getTokenFromCookies = cookies.getTokenFromCookies;
|
|
300
|
+
runWithAuthContext = context.runWithAuthContext;
|
|
301
|
+
} catch {
|
|
302
|
+
// @everystack/auth not installed — SSR works without auth context
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const ssrOptions: Partial<WebHandlerOptions> = {};
|
|
306
|
+
if (options.postProcessHtml) ssrOptions.postProcessHtml = options.postProcessHtml;
|
|
307
|
+
if (options.channel) ssrOptions.channel = options.channel;
|
|
308
|
+
if (options.stripQueryParams) ssrOptions.stripQueryParams = options.stripQueryParams;
|
|
309
|
+
if (options.injectHydrateFlag !== undefined) ssrOptions.injectHydrateFlag = options.injectHydrateFlag;
|
|
310
|
+
|
|
311
|
+
return async (request: Request): Promise<Response> => {
|
|
312
|
+
const webHandler = await getWebHandler(
|
|
313
|
+
(ctx as SsrContext).updatesStorage,
|
|
314
|
+
{ db: ctx.db, ...ssrOptions },
|
|
315
|
+
);
|
|
316
|
+
if (!webHandler) return new Response('Not Found', { status: 404 });
|
|
317
|
+
|
|
318
|
+
// Extract auth context from cookie for SSR loaders
|
|
319
|
+
let user: Record<string, unknown> | null = null;
|
|
320
|
+
if (getTokenFromCookies && runWithAuthContext) {
|
|
321
|
+
const cookieHeader = request.headers.get('cookie');
|
|
322
|
+
if (cookieHeader) {
|
|
323
|
+
const token = getTokenFromCookies(cookieHeader);
|
|
324
|
+
if (token) {
|
|
325
|
+
const claims = await ctx.verifyToken(token);
|
|
326
|
+
if (claims && typeof claims.sub === 'string') {
|
|
327
|
+
user = claims;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return runWithAuthContext(user, () => webHandler(request));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return webHandler(request);
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- Database Actions Plugin ---
|
|
340
|
+
|
|
341
|
+
export interface DbPluginOptions {
|
|
342
|
+
/** Absolute path to drizzle migrations folder */
|
|
343
|
+
migrationsFolder: string;
|
|
344
|
+
/** App-provided seed function — action only registered if provided */
|
|
345
|
+
seed?: (db: any, schema: any) => Promise<any>;
|
|
346
|
+
/** PostgreSQL schemas to drop during db:reset (default: ['public', 'drizzle']) */
|
|
347
|
+
schemas?: string[];
|
|
348
|
+
/** Connection info callback for db:psql (falls back to SST Resource.Database) */
|
|
349
|
+
connectionInfo?: () => Promise<Record<string, string>>;
|
|
350
|
+
/** Enable console action (default: true) */
|
|
351
|
+
allowConsole?: boolean;
|
|
352
|
+
/** Read replica URL for db:query (default: process.env.READ_DATABASE_URL) */
|
|
353
|
+
readDatabaseUrl?: string;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Database administration actions as a plugin.
|
|
358
|
+
* Contributes: migrate, seed, db:reset, db:psql, db:query, console.
|
|
359
|
+
*
|
|
360
|
+
* Usage:
|
|
361
|
+
* dbPlugin({ migrationsFolder: join(__dirname, '..', 'drizzle'), seed: runSeed })
|
|
362
|
+
*/
|
|
363
|
+
export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
364
|
+
return async (ctx: PluginContext): Promise<PluginContribution> => {
|
|
365
|
+
const actions: Record<string, ActionHandler> = {};
|
|
366
|
+
|
|
367
|
+
// --- migrate ---
|
|
368
|
+
actions.migrate = async (_payload, ctx) => {
|
|
369
|
+
const { runMigrations } = await import('./migrate');
|
|
370
|
+
return await runMigrations(ctx.db, options.migrationsFolder);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// --- seed (only when app provides a seed function) ---
|
|
374
|
+
if (options.seed) {
|
|
375
|
+
const seedFn = options.seed;
|
|
376
|
+
actions.seed = async (_payload, ctx) => {
|
|
377
|
+
if (ctx.environment !== 'dev') {
|
|
378
|
+
return { error: 'Seed is only available in dev environment' };
|
|
379
|
+
}
|
|
380
|
+
return await seedFn(ctx.db, ctx.schema);
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// --- db:reset ---
|
|
385
|
+
actions['db:reset'] = async (_payload, ctx) => {
|
|
386
|
+
if (ctx.environment !== 'dev') {
|
|
387
|
+
return { error: 'db:reset is only available in dev environment' };
|
|
388
|
+
}
|
|
389
|
+
const { sql } = await import('drizzle-orm');
|
|
390
|
+
const schemas = options.schemas ?? ['public', 'drizzle'];
|
|
391
|
+
const dropStatements = schemas
|
|
392
|
+
.map(s => `DROP SCHEMA IF EXISTS ${s} CASCADE;`)
|
|
393
|
+
.join('\n ');
|
|
394
|
+
await ctx.db.execute(sql.raw(`
|
|
395
|
+
${dropStatements}
|
|
396
|
+
CREATE SCHEMA public;
|
|
397
|
+
GRANT ALL ON SCHEMA public TO postgres;
|
|
398
|
+
GRANT ALL ON SCHEMA public TO public;
|
|
399
|
+
`));
|
|
400
|
+
const { runMigrations } = await import('./migrate');
|
|
401
|
+
const result = await runMigrations(ctx.db, options.migrationsFolder);
|
|
402
|
+
return { reset: true, ...result };
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// --- db:psql ---
|
|
406
|
+
actions['db:psql'] = async () => {
|
|
407
|
+
if (options.connectionInfo) {
|
|
408
|
+
return await options.connectionInfo();
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
const { Resource } = await import('sst');
|
|
412
|
+
return {
|
|
413
|
+
host: (Resource as any).Database.host,
|
|
414
|
+
port: (Resource as any).Database.port,
|
|
415
|
+
database: (Resource as any).Database.database,
|
|
416
|
+
username: (Resource as any).Database.username,
|
|
417
|
+
password: (Resource as any).Database.password,
|
|
418
|
+
};
|
|
419
|
+
} catch {
|
|
420
|
+
return { error: 'No connectionInfo callback provided and SST Resource.Database not available' };
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// --- db:query ---
|
|
425
|
+
actions['db:query'] = async (payload, ctx) => {
|
|
426
|
+
const { sql: querySql } = payload as { sql: string };
|
|
427
|
+
if (!querySql) {
|
|
428
|
+
return { error: 'SQL query is required' };
|
|
429
|
+
}
|
|
430
|
+
const writePatterns = [
|
|
431
|
+
/^INSERT\s/i, /^UPDATE\s/i, /^DELETE\s/i, /^DROP\s/i,
|
|
432
|
+
/^CREATE\s/i, /^ALTER\s/i, /^TRUNCATE\s/i, /^GRANT\s/i, /^REVOKE\s/i,
|
|
433
|
+
];
|
|
434
|
+
if (writePatterns.some(p => p.test(querySql.trim()))) {
|
|
435
|
+
return {
|
|
436
|
+
error: 'Write operations are not allowed via db:query. Use db:migrate, seed, or console for write operations.',
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const { sql } = await import('drizzle-orm');
|
|
441
|
+
let queryDb = ctx.db;
|
|
442
|
+
const readUrl = options.readDatabaseUrl ?? process.env.READ_DATABASE_URL;
|
|
443
|
+
if (readUrl && readUrl !== process.env.DATABASE_URL) {
|
|
444
|
+
const { drizzle } = await import('drizzle-orm/postgres-js');
|
|
445
|
+
const postgres = (await import('postgres')).default;
|
|
446
|
+
queryDb = drizzle(postgres(readUrl, { max: 1, idle_timeout: 20, connect_timeout: 10 }));
|
|
447
|
+
}
|
|
448
|
+
const result: any = await queryDb.execute(sql.raw(querySql));
|
|
449
|
+
const rows = Array.isArray(result) ? result : result.rows || [];
|
|
450
|
+
return { rows };
|
|
451
|
+
} catch (err: any) {
|
|
452
|
+
return { error: err.message || String(err) };
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// --- console ---
|
|
457
|
+
if (options.allowConsole !== false) {
|
|
458
|
+
actions.console = async (payload, ctx) => {
|
|
459
|
+
const { expression } = payload as { expression: string };
|
|
460
|
+
if (!expression) return { error: 'No expression provided' };
|
|
461
|
+
try {
|
|
462
|
+
const { eq, and, or, gt, lt, gte, lte, ne, count, sum, avg, sql, desc, asc } =
|
|
463
|
+
await import('drizzle-orm');
|
|
464
|
+
const evalContext: Record<string, unknown> = {
|
|
465
|
+
db: ctx.db, schema: ctx.schema,
|
|
466
|
+
eq, and, or, gt, lt, gte, lte, ne, count, sum, avg, sql, desc, asc,
|
|
467
|
+
};
|
|
468
|
+
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
469
|
+
const fn = new AsyncFunction(
|
|
470
|
+
...Object.keys(evalContext),
|
|
471
|
+
`return (${expression})`,
|
|
472
|
+
);
|
|
473
|
+
const result = await fn(...Object.values(evalContext));
|
|
474
|
+
return { result };
|
|
475
|
+
} catch (err: any) {
|
|
476
|
+
return { error: err.message || String(err) };
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return { actions };
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// --- Plugin Handler for Non-Lambda Runtimes (Expo Router, Bun, Deno) ---
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Creates a Web Standard handler from plugins — no Lambda, no event adaptation.
|
|
489
|
+
* For use with Expo Router API routes, Bun.serve, Deno.serve, etc.
|
|
490
|
+
*/
|
|
491
|
+
export function createPluginHandler(options: {
|
|
492
|
+
context: () => Promise<PluginContext>;
|
|
493
|
+
plugins: Plugin[];
|
|
494
|
+
routes?: (ctx: PluginContext) => Route[];
|
|
495
|
+
fallback?: (ctx: PluginContext) => Handler | Promise<Handler>;
|
|
496
|
+
}): Handler {
|
|
497
|
+
let cached: { router: (path: string, method: string) => Handler } | null = null;
|
|
498
|
+
|
|
499
|
+
return async (request: Request): Promise<Response> => {
|
|
500
|
+
if (!cached) {
|
|
501
|
+
const ctx = await options.context();
|
|
502
|
+
const contributions: PluginContribution[] = [];
|
|
503
|
+
for (const plugin of options.plugins) {
|
|
504
|
+
contributions.push(await plugin(ctx));
|
|
505
|
+
}
|
|
506
|
+
const pluginRoutes = contributions.flatMap(c => c.routes ?? []);
|
|
507
|
+
const appRoutes = options.routes?.(ctx) ?? [];
|
|
508
|
+
const allRoutes = [...pluginRoutes, ...appRoutes];
|
|
509
|
+
const fallback = await options.fallback?.(ctx);
|
|
510
|
+
cached = { router: createRouter(allRoutes, fallback) };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const url = new URL(request.url);
|
|
514
|
+
const handler = cached.router(url.pathname, request.method);
|
|
515
|
+
return handler(request);
|
|
516
|
+
};
|
|
517
|
+
}
|
package/src/ssr.ts
CHANGED
|
@@ -79,13 +79,13 @@ export interface WebHandlerOptions {
|
|
|
79
79
|
PostProcessResult | Promise<PostProcessResult>;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const DEFAULT_STRIP_PARAMS = ['_v'];
|
|
82
|
+
const DEFAULT_STRIP_PARAMS = ['_v', '_a'];
|
|
83
83
|
const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>\n';
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
86
|
* Wraps an SSR request handler with framework-level fixes:
|
|
87
87
|
*
|
|
88
|
-
* 1. Strips CFF query params (_v) so expo-router loader data keys
|
|
88
|
+
* 1. Strips CFF query params (_v, _a) so expo-router loader data keys
|
|
89
89
|
* match the client-side route path (which has no CFF params).
|
|
90
90
|
* 2. Injects `__EXPO_ROUTER_HYDRATE__=true` before `</head>` so React
|
|
91
91
|
* Native Web uses `hydrateRoot()` instead of `createRoot()`.
|
|
@@ -278,7 +278,7 @@ export async function getWebHandler(
|
|
|
278
278
|
options?: WebHandlerOptions,
|
|
279
279
|
): Promise<((request: Request) => Promise<Response>) | null> {
|
|
280
280
|
const now = Date.now();
|
|
281
|
-
const channel = options?.channel || 'production';
|
|
281
|
+
const channel = options?.channel || process.env.ENVIRONMENT || 'production';
|
|
282
282
|
const buildDir = `${BUILD_DIR_BASE}/${channel}`;
|
|
283
283
|
|
|
284
284
|
const cached = cachedHandlers.get(channel);
|
package/src/worker.ts
CHANGED
|
@@ -7,6 +7,26 @@
|
|
|
7
7
|
|
|
8
8
|
import { log } from './index.js';
|
|
9
9
|
|
|
10
|
+
/** Job shape — mirrors @everystack/jobs Job interface (type-compatible, no circular dep). */
|
|
11
|
+
export interface WorkerJob<T = unknown> {
|
|
12
|
+
id: string;
|
|
13
|
+
type: string;
|
|
14
|
+
payload: T;
|
|
15
|
+
status: 'pending' | 'active' | 'completed' | 'failed' | 'dead';
|
|
16
|
+
attempts: number;
|
|
17
|
+
maxAttempts: number;
|
|
18
|
+
priority: number;
|
|
19
|
+
runAt: Date;
|
|
20
|
+
lockedAt: Date | null;
|
|
21
|
+
lockedBy: string | null;
|
|
22
|
+
completedAt: Date | null;
|
|
23
|
+
failedAt: Date | null;
|
|
24
|
+
error: string | null;
|
|
25
|
+
createdAt: Date;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type JobHandler<T = unknown> = (payload: T, job: WorkerJob<T>) => Promise<void>;
|
|
29
|
+
|
|
10
30
|
export interface SQSEvent {
|
|
11
31
|
Records: Array<{
|
|
12
32
|
messageId: string;
|
|
@@ -20,7 +40,30 @@ export interface SQSBatchResponse {
|
|
|
20
40
|
batchItemFailures: Array<{ itemIdentifier: string }>;
|
|
21
41
|
}
|
|
22
42
|
|
|
23
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Build a Job object from SQS message fields.
|
|
45
|
+
* Fills required Job properties with sensible defaults — the handler
|
|
46
|
+
* receives a complete Job even without a database.
|
|
47
|
+
*/
|
|
48
|
+
function jobFromSqsMessage(jobId: string, type: string, payload: unknown): WorkerJob {
|
|
49
|
+
const now = new Date();
|
|
50
|
+
return {
|
|
51
|
+
id: jobId,
|
|
52
|
+
type,
|
|
53
|
+
payload,
|
|
54
|
+
status: 'active',
|
|
55
|
+
attempts: 1,
|
|
56
|
+
maxAttempts: 3,
|
|
57
|
+
priority: 0,
|
|
58
|
+
runAt: now,
|
|
59
|
+
lockedAt: now,
|
|
60
|
+
lockedBy: null,
|
|
61
|
+
completedAt: null,
|
|
62
|
+
failedAt: null,
|
|
63
|
+
error: null,
|
|
64
|
+
createdAt: now,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
24
67
|
|
|
25
68
|
export function createWorkerHandler(
|
|
26
69
|
init: () => Promise<Record<string, JobHandler>>
|
|
@@ -50,7 +93,8 @@ export function createWorkerHandler(
|
|
|
50
93
|
continue;
|
|
51
94
|
}
|
|
52
95
|
|
|
53
|
-
|
|
96
|
+
const job = jobFromSqsMessage(jobId, type, payload);
|
|
97
|
+
await handlerFn(payload, job);
|
|
54
98
|
log('info', 'Job completed', { type, jobId });
|
|
55
99
|
} catch (error) {
|
|
56
100
|
log('error', 'Job failed', { messageId: record.messageId, error: String(error) });
|