@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/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
+ }