@everystack/server 0.1.0
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 +248 -0
- package/package.json +96 -0
- package/src/cdn/compose.ts +235 -0
- package/src/cdn/features.ts +220 -0
- package/src/cdn/index.ts +77 -0
- package/src/db.ts +59 -0
- package/src/image.ts +215 -0
- package/src/index.ts +324 -0
- package/src/migrate.ts +16 -0
- package/src/ssr.ts +425 -0
- package/src/sst-env.d.ts +27 -0
- package/src/stubs.ts +39 -0
- package/src/worker.ts +63 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/server — Lambda runtime primitives.
|
|
3
|
+
*
|
|
4
|
+
* Composable building blocks for Lambda handlers:
|
|
5
|
+
* - eventToRequest / responseToResult — Lambda ↔ Web Standard adapters
|
|
6
|
+
* - createRouter — path-based request dispatch
|
|
7
|
+
* - createLambdaHandler — full handler with lazy init, routing, error handling
|
|
8
|
+
* - log — structured JSON logging
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
APIGatewayProxyEventV2,
|
|
13
|
+
APIGatewayProxyStructuredResultV2,
|
|
14
|
+
} from 'aws-lambda';
|
|
15
|
+
|
|
16
|
+
// --- Types ---
|
|
17
|
+
|
|
18
|
+
export type Handler = (request: Request) => Promise<Response>;
|
|
19
|
+
|
|
20
|
+
export interface Route {
|
|
21
|
+
/** URL path prefix (or exact path if `exact` is true) */
|
|
22
|
+
path: string;
|
|
23
|
+
/** HTTP method filter — if omitted, matches all methods */
|
|
24
|
+
method?: string;
|
|
25
|
+
/** If true, path must match exactly instead of prefix match */
|
|
26
|
+
exact?: boolean;
|
|
27
|
+
/** The handler function for this route */
|
|
28
|
+
handler: Handler;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Optional log sink for writing structured request logs to persistent storage.
|
|
33
|
+
* Structurally compatible with S3LogStorage from @everystack/logging — pass it
|
|
34
|
+
* directly without importing the logging package.
|
|
35
|
+
*/
|
|
36
|
+
export interface LogSink {
|
|
37
|
+
ingest(entries: Array<{
|
|
38
|
+
id: string;
|
|
39
|
+
timestamp: number;
|
|
40
|
+
level: 'info' | 'error' | 'warn' | 'debug' | 'fatal';
|
|
41
|
+
message: string;
|
|
42
|
+
data?: Record<string, unknown>;
|
|
43
|
+
source?: string;
|
|
44
|
+
traceId?: string;
|
|
45
|
+
}>): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Cache-Control configuration for the Lambda handler's fallback headers. */
|
|
49
|
+
export interface ServerCacheConfig {
|
|
50
|
+
/** Unauthenticated GET/HEAD responses.
|
|
51
|
+
* Default: 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5' */
|
|
52
|
+
public?: string;
|
|
53
|
+
/** Authenticated GET/HEAD responses.
|
|
54
|
+
* Default: 'private, no-store' */
|
|
55
|
+
private?: string;
|
|
56
|
+
/** Non-GET/HEAD (mutations).
|
|
57
|
+
* Default: 'no-store' */
|
|
58
|
+
mutation?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LambdaHandlerOptions {
|
|
62
|
+
/** Async factory that creates and returns named handlers. Called once, cached. */
|
|
63
|
+
init: () => Promise<Record<string, Handler>>;
|
|
64
|
+
/** Given initialized handlers, returns route definitions (evaluated once). */
|
|
65
|
+
routes: (handlers: Record<string, Handler>) => Route[];
|
|
66
|
+
/** Fallback handler when no route matches. Given initialized handlers. */
|
|
67
|
+
fallback?: (handlers: Record<string, Handler>) => Handler;
|
|
68
|
+
/**
|
|
69
|
+
* Handle direct CLI invocations via Lambda Invoke.
|
|
70
|
+
* IAM is the auth — if you can invoke the Lambda, you're authenticated.
|
|
71
|
+
* Called when the event has an `_action` field (not an HTTP event).
|
|
72
|
+
*/
|
|
73
|
+
onAction?: (action: string, payload: unknown, handlers: Record<string, Handler>) => Promise<unknown>;
|
|
74
|
+
/** Override default fallback Cache-Control headers. */
|
|
75
|
+
cache?: ServerCacheConfig;
|
|
76
|
+
/** Optional log sink — writes structured request logs to S3 or other storage.
|
|
77
|
+
* Non-blocking: if the sink fails, logs still go to CloudWatch via stdout. */
|
|
78
|
+
logSink?: LogSink;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Event Adapter ---
|
|
82
|
+
|
|
83
|
+
export function eventToRequest(event: APIGatewayProxyEventV2): Request {
|
|
84
|
+
const url = new URL(
|
|
85
|
+
event.rawPath + (event.rawQueryString ? '?' + event.rawQueryString : ''),
|
|
86
|
+
`https://${event.requestContext.domainName}`
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const headers = new Headers();
|
|
90
|
+
for (const [key, value] of Object.entries(event.headers || {})) {
|
|
91
|
+
if (value) headers.set(key, value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const method = event.requestContext.http.method;
|
|
95
|
+
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
96
|
+
let body: BodyInit | undefined;
|
|
97
|
+
|
|
98
|
+
if (hasBody && event.body) {
|
|
99
|
+
body = event.isBase64Encoded
|
|
100
|
+
? new Uint8Array(Buffer.from(event.body, 'base64'))
|
|
101
|
+
: event.body;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return new Request(url.toString(), {
|
|
105
|
+
method,
|
|
106
|
+
headers,
|
|
107
|
+
body: hasBody ? body : undefined,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isBinaryContentType(ct: string): boolean {
|
|
112
|
+
if (ct.startsWith('image/')) return true;
|
|
113
|
+
if (ct.startsWith('audio/')) return true;
|
|
114
|
+
if (ct.startsWith('video/')) return true;
|
|
115
|
+
if (ct.includes('octet-stream')) return true;
|
|
116
|
+
if (ct.includes('protobuf')) return true;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function responseToResult(
|
|
121
|
+
response: Response
|
|
122
|
+
): Promise<APIGatewayProxyStructuredResultV2> {
|
|
123
|
+
const headers: Record<string, string> = {};
|
|
124
|
+
response.headers.forEach((value, key) => {
|
|
125
|
+
// Skip set-cookie — handled separately below for multi-value support
|
|
126
|
+
if (key.toLowerCase() !== 'set-cookie') {
|
|
127
|
+
headers[key] = value;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Lambda Function URLs require Set-Cookie values as a separate `cookies` array
|
|
132
|
+
// because the plain headers object can only hold one value per key.
|
|
133
|
+
const cookies: string[] =
|
|
134
|
+
typeof response.headers.getSetCookie === 'function'
|
|
135
|
+
? response.headers.getSetCookie()
|
|
136
|
+
: [];
|
|
137
|
+
|
|
138
|
+
const contentType = response.headers.get('content-type') || '';
|
|
139
|
+
const isBinary = isBinaryContentType(contentType);
|
|
140
|
+
|
|
141
|
+
let body: string;
|
|
142
|
+
let isBase64Encoded = false;
|
|
143
|
+
|
|
144
|
+
if (isBinary) {
|
|
145
|
+
const buffer = await response.arrayBuffer();
|
|
146
|
+
body = Buffer.from(buffer).toString('base64');
|
|
147
|
+
isBase64Encoded = true;
|
|
148
|
+
} else {
|
|
149
|
+
body = await response.text();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
statusCode: response.status,
|
|
154
|
+
headers,
|
|
155
|
+
...(cookies.length > 0 ? { cookies } : {}),
|
|
156
|
+
body,
|
|
157
|
+
isBase64Encoded,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Router ---
|
|
162
|
+
|
|
163
|
+
export function createRouter(
|
|
164
|
+
routes: Route[],
|
|
165
|
+
fallback?: Handler
|
|
166
|
+
): (path: string, method: string) => Handler {
|
|
167
|
+
const notFound: Handler = async () =>
|
|
168
|
+
new Response('Not Found', { status: 404 });
|
|
169
|
+
|
|
170
|
+
return (path: string, method: string): Handler => {
|
|
171
|
+
for (const route of routes) {
|
|
172
|
+
if (route.method && route.method !== method) continue;
|
|
173
|
+
if (route.exact) {
|
|
174
|
+
if (path === route.path) return route.handler;
|
|
175
|
+
} else {
|
|
176
|
+
if (path.startsWith(route.path)) return route.handler;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return fallback || notFound;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Main Handler ---
|
|
184
|
+
|
|
185
|
+
export function createLambdaHandler(
|
|
186
|
+
options: LambdaHandlerOptions
|
|
187
|
+
): (event: APIGatewayProxyEventV2 | Record<string, unknown>) => Promise<APIGatewayProxyStructuredResultV2 | unknown> {
|
|
188
|
+
let cached: {
|
|
189
|
+
router: (path: string, method: string) => Handler;
|
|
190
|
+
handlers: Record<string, Handler>;
|
|
191
|
+
} | null = null;
|
|
192
|
+
|
|
193
|
+
async function ensureInitialized() {
|
|
194
|
+
if (cached) return cached;
|
|
195
|
+
|
|
196
|
+
const handlers = await options.init();
|
|
197
|
+
const routeDefs = options.routes(handlers);
|
|
198
|
+
const fallback = options.fallback?.(handlers);
|
|
199
|
+
const router = createRouter(routeDefs, fallback);
|
|
200
|
+
|
|
201
|
+
cached = { router, handlers };
|
|
202
|
+
log('info', 'Handlers initialized');
|
|
203
|
+
return cached;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return async (
|
|
207
|
+
event: APIGatewayProxyEventV2 | Record<string, unknown>
|
|
208
|
+
): Promise<APIGatewayProxyStructuredResultV2 | unknown> => {
|
|
209
|
+
// Direct CLI invoke via Lambda Invoke — IAM is the auth layer.
|
|
210
|
+
// Events with _action are NOT HTTP events (no requestContext.http).
|
|
211
|
+
if ('_action' in event && typeof event._action === 'string' && options.onAction) {
|
|
212
|
+
const { handlers } = await ensureInitialized();
|
|
213
|
+
log('info', 'CLI action invoked', { action: event._action });
|
|
214
|
+
try {
|
|
215
|
+
return await options.onAction(event._action, event._payload, handlers);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
log('error', 'CLI action error', { action: event._action, error: String(error) });
|
|
218
|
+
return { error: String(error) };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// HTTP event — Function URL or API Gateway
|
|
222
|
+
const httpEvent = event as APIGatewayProxyEventV2;
|
|
223
|
+
const requestId =
|
|
224
|
+
httpEvent.headers?.['x-amzn-trace-id'] ||
|
|
225
|
+
httpEvent.headers?.['x-request-id'] ||
|
|
226
|
+
crypto.randomUUID();
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const { router } = await ensureInitialized();
|
|
230
|
+
const request = eventToRequest(httpEvent);
|
|
231
|
+
const path = httpEvent.rawPath;
|
|
232
|
+
const method = httpEvent.requestContext.http.method;
|
|
233
|
+
const handlerFn = router(path, method);
|
|
234
|
+
const response = await handlerFn(request);
|
|
235
|
+
const result = await responseToResult(response);
|
|
236
|
+
|
|
237
|
+
// Propagate request ID
|
|
238
|
+
result.headers = { ...result.headers, 'x-request-id': requestId };
|
|
239
|
+
|
|
240
|
+
// Set Cache-Control if not already set by the handler
|
|
241
|
+
if (!result.headers['cache-control']) {
|
|
242
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
243
|
+
const isAuthenticated = !!httpEvent.headers?.authorization;
|
|
244
|
+
result.headers['cache-control'] = isAuthenticated
|
|
245
|
+
? (options.cache?.private ?? 'private, no-store')
|
|
246
|
+
: (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
|
|
247
|
+
} else {
|
|
248
|
+
result.headers['cache-control'] = options.cache?.mutation ?? 'no-store';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Response size guard — Lambda Function URLs have a 6MB limit
|
|
253
|
+
if (result.body && result.body.length > 5 * 1024 * 1024) {
|
|
254
|
+
return {
|
|
255
|
+
statusCode: 413,
|
|
256
|
+
headers: {
|
|
257
|
+
'Content-Type': 'application/json',
|
|
258
|
+
'cache-control': 'no-store',
|
|
259
|
+
'x-request-id': requestId,
|
|
260
|
+
},
|
|
261
|
+
body: JSON.stringify({ error: 'Response too large' }),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
log('info', 'Request completed', {
|
|
266
|
+
requestId,
|
|
267
|
+
path,
|
|
268
|
+
method,
|
|
269
|
+
status: result.statusCode,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Write to log sink (non-blocking — CloudWatch is the primary log)
|
|
273
|
+
if (options.logSink) {
|
|
274
|
+
options.logSink.ingest([{
|
|
275
|
+
id: requestId,
|
|
276
|
+
timestamp: Date.now(),
|
|
277
|
+
level: (result.statusCode ?? 200) >= 500 ? 'error' : 'info',
|
|
278
|
+
message: `${method} ${path} ${result.statusCode ?? 200}`,
|
|
279
|
+
source: 'lambda',
|
|
280
|
+
traceId: requestId,
|
|
281
|
+
data: { path, method, status: result.statusCode ?? 200, platform: 'server' },
|
|
282
|
+
}]).catch(() => {});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return result;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
log('error', 'Lambda handler error', {
|
|
288
|
+
requestId,
|
|
289
|
+
error: String(error),
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
statusCode: 500,
|
|
293
|
+
headers: {
|
|
294
|
+
'Content-Type': 'application/json',
|
|
295
|
+
'cache-control': 'no-store',
|
|
296
|
+
'x-request-id': requestId,
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify({
|
|
299
|
+
error: 'Internal Server Error',
|
|
300
|
+
...(process.env.ENVIRONMENT === 'dev'
|
|
301
|
+
? { details: String(error) }
|
|
302
|
+
: {}),
|
|
303
|
+
}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- Structured Logging ---
|
|
310
|
+
|
|
311
|
+
export function log(
|
|
312
|
+
level: string,
|
|
313
|
+
message: string,
|
|
314
|
+
meta?: Record<string, unknown>
|
|
315
|
+
): void {
|
|
316
|
+
console.log(
|
|
317
|
+
JSON.stringify({
|
|
318
|
+
timestamp: new Date().toISOString(),
|
|
319
|
+
level,
|
|
320
|
+
message,
|
|
321
|
+
...meta,
|
|
322
|
+
})
|
|
323
|
+
);
|
|
324
|
+
}
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/server/migrate — Drizzle migration runner.
|
|
3
|
+
*
|
|
4
|
+
* Called directly from onAction handler (CLI → IAM → Lambda invoke).
|
|
5
|
+
* No HTTP, no auth — IAM is the authorization layer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
|
9
|
+
|
|
10
|
+
export async function runMigrations(
|
|
11
|
+
db: any,
|
|
12
|
+
migrationsFolder: string
|
|
13
|
+
): Promise<{ success: boolean; message: string }> {
|
|
14
|
+
await migrate(db, { migrationsFolder });
|
|
15
|
+
return { success: true, message: 'Migrations applied' };
|
|
16
|
+
}
|