@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 ADDED
@@ -0,0 +1,248 @@
1
+ # @everystack/server
2
+
3
+ Lambda runtime primitives for building serverless applications with SST. Provides event adapters, routing, database connection, SSR, image processing, migrations, and background job handling.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @everystack/server
9
+ ```
10
+
11
+ ## Entry Points
12
+
13
+ | Export | Purpose |
14
+ |--------|---------|
15
+ | `@everystack/server` | Lambda handler, router, event adapters, logging |
16
+ | `@everystack/server/db` | SST Resource-linked database connection |
17
+ | `@everystack/server/ssr` | Expo web SSR bundle serving |
18
+ | `@everystack/server/image` | On-demand image resizing via Sharp |
19
+ | `@everystack/server/migrate` | Drizzle migration runner |
20
+ | `@everystack/server/worker` | SQS job queue consumer |
21
+ | `@everystack/server/stubs` | esbuild bundling helpers for client packages |
22
+
23
+ ## Quick Start
24
+
25
+ ```typescript
26
+ import { createLambdaHandler } from '@everystack/server';
27
+ import { createDb, getJwtSecret } from '@everystack/server/db';
28
+ import { runMigrations } from '@everystack/server/migrate';
29
+ import { createWorkerHandler } from '@everystack/server/worker';
30
+ import * as schema from '../db/schema';
31
+
32
+ const { db } = createDb(schema);
33
+
34
+ // API Lambda
35
+ export const handler = createLambdaHandler({
36
+ init: async () => ({
37
+ api: createApiHandler(db),
38
+ }),
39
+ routes: (h) => [
40
+ { path: '/api', handler: h.api },
41
+ ],
42
+ onAction: async (action, payload) => {
43
+ // CLI invocations via IAM-authed Lambda invoke
44
+ if (action === 'migrate') return runMigrations(db, './drizzle');
45
+ if (action === 'seed') return runSeed(db);
46
+ },
47
+ });
48
+
49
+ // Worker Lambda
50
+ export const worker = createWorkerHandler(async () => ({
51
+ 'email:send': async (payload) => { /* ... */ },
52
+ 'image:process': async (payload) => { /* ... */ },
53
+ }));
54
+ ```
55
+
56
+ ## Main Export (`@everystack/server`)
57
+
58
+ ### `createLambdaHandler(options)`
59
+
60
+ The primary entry point. Wraps initialization, routing, and error handling into a Lambda handler.
61
+
62
+ ```typescript
63
+ interface LambdaHandlerOptions {
64
+ init: () => Promise<Record<string, Handler>>;
65
+ routes: (handlers: Record<string, Handler>) => Route[];
66
+ fallback?: (handlers: Record<string, Handler>) => Handler;
67
+ onAction?: (action: string, payload: unknown, handlers: Record<string, Handler>) => Promise<unknown>;
68
+ }
69
+ ```
70
+
71
+ **Behavior:**
72
+ - Lazy-initializes handlers once, caches across warm invocations
73
+ - Detects CLI invocations (events with `_action` field) and dispatches to `onAction`
74
+ - Sets Cache-Control: `private, no-store` (authenticated) or `public, s-maxage=300` (public GET)
75
+ - Guards response size (5MB max for Function URLs)
76
+ - Adds `x-request-id` from Lambda trace or UUID
77
+
78
+ ### `createRouter(routes, fallback?)`
79
+
80
+ Path/method dispatcher.
81
+
82
+ ```typescript
83
+ interface Route {
84
+ path: string; // URL prefix match
85
+ method?: string; // HTTP method filter (optional)
86
+ exact?: boolean; // Exact path match (default: prefix)
87
+ handler: Handler;
88
+ }
89
+ ```
90
+
91
+ Routes match by prefix unless `exact: true`. First match wins.
92
+
93
+ ### `eventToRequest(event)`
94
+
95
+ Converts AWS API Gateway V2 events to Web Standard `Request` objects.
96
+
97
+ ### `responseToResult(response)`
98
+
99
+ Converts Web Standard `Response` to API Gateway V2 results. Auto-detects and base64-encodes binary content.
100
+
101
+ ### `log(level, message, meta?)`
102
+
103
+ Structured JSON logging to stdout with ISO timestamp.
104
+
105
+ ## Database (`@everystack/server/db`)
106
+
107
+ SST Resource-linked database helpers. All credentials come from SST's Resource linking — no env vars needed.
108
+
109
+ ### `createDb(schema, options?)`
110
+
111
+ Lazy singleton Drizzle connection via postgres.js.
112
+
113
+ ```typescript
114
+ const { db } = createDb(schema, { maxConnections: 1 });
115
+ ```
116
+
117
+ Pool defaults: max 1, idle timeout 60s, connect timeout 5s, max lifetime 5min, SSL required.
118
+
119
+ ### `getDatabaseUrl()`
120
+
121
+ Returns PostgreSQL connection URL from SST Resources.
122
+
123
+ ### `getJwtSecret()`
124
+
125
+ Returns JWT secret as `Uint8Array` from `Resource.JwtSecret.value`.
126
+
127
+ ## Web SSR (`@everystack/server/ssr`)
128
+
129
+ Serves Expo web builds deployed via `@everystack/cli`.
130
+
131
+ ### `getWebHandler(db, storage)`
132
+
133
+ Returns a Request→Response handler for the latest web release.
134
+
135
+ - Queries database for latest `platform='web'` release
136
+ - Downloads and extracts brotli-compressed tar archive to `/tmp/web-build/`
137
+ - Caches handler with 60s TTL (re-queries for new releases)
138
+ - Returns `null` if no web release exists
139
+
140
+ ### `downloadAndExtract(storage, storagePrefix)`
141
+
142
+ Downloads `{prefix}/bundle.tar.br` from storage, decompresses with brotli, extracts to `/tmp/web-build/`.
143
+
144
+ ### `extractTar(data, destDir)`
145
+
146
+ Pure JavaScript POSIX tar extractor. Required because Lambda doesn't include the `tar` binary.
147
+
148
+ ## Image Processing (`@everystack/server/image`)
149
+
150
+ On-demand image resizing via Sharp (provided as Lambda layer).
151
+
152
+ ### `createImageHandler(config)`
153
+
154
+ ```typescript
155
+ interface ImageHandlerConfig {
156
+ bucket: string; // S3 bucket name
157
+ region?: string; // AWS region (default: AWS_REGION env)
158
+ pathPrefix?: string; // URL prefix (default: '/media/')
159
+ }
160
+ ```
161
+
162
+ URL format: `/media/{key}?w=400&h=300&fit=cover&fm=webp&q=80&dpr=2`
163
+
164
+ **Behavior:**
165
+ - No transforms → 302 redirect to presigned S3 URL (avoids 6MB Lambda limit)
166
+ - With transforms → fetch from S3, process with Sharp, return with immutable cache headers
167
+
168
+ ### `parseParams(query)`
169
+
170
+ Validates URL query params into transform options:
171
+
172
+ | Param | Range | Description |
173
+ |-------|-------|-------------|
174
+ | `w` | 1–4096 | Width |
175
+ | `h` | 1–4096 | Height |
176
+ | `fit` | cover, contain, fill, inside, outside | Resize fit |
177
+ | `fm` | webp, jpeg, png, avif | Output format |
178
+ | `q` | 1–100 | Quality |
179
+ | `dpr` | 1–3 | Device pixel ratio (multiplies w/h) |
180
+
181
+ ## Migrations (`@everystack/server/migrate`)
182
+
183
+ ### `runMigrations(db, migrationsFolder)`
184
+
185
+ Runs Drizzle migrations from the specified folder. Called via `onAction` handler (CLI → IAM → Lambda invoke).
186
+
187
+ - Returns `{ success: true, message: 'Migrations applied' }`
188
+ - Throws on error (caller handles response)
189
+
190
+ ## Worker (`@everystack/server/worker`)
191
+
192
+ ### `createWorkerHandler(init)`
193
+
194
+ SQS Lambda handler with type-based job dispatch.
195
+
196
+ ```typescript
197
+ type JobHandler = (payload: any, job: { id: string; type: string }) => Promise<void>;
198
+
199
+ const worker = createWorkerHandler(async () => ({
200
+ 'email:send': async (payload) => { /* ... */ },
201
+ 'image:process': async (payload) => { /* ... */ },
202
+ }));
203
+ ```
204
+
205
+ - Lazy-initializes handlers, caches across warm invocations
206
+ - Parses SQS message body as `{ jobId, type, payload }`
207
+ - Returns `batchItemFailures` for partial failure handling (failed messages retry)
208
+
209
+ ## Stubs (`@everystack/server/stubs`)
210
+
211
+ ### `clientStubs`
212
+
213
+ Array of client-side package names to exclude from Lambda bundles via esbuild `external`.
214
+
215
+ ### `stubsDir`
216
+
217
+ Path to no-op module stubs. Used with SST `copyFiles` to prevent "module not found" errors when server code transitively imports client packages.
218
+
219
+ ```typescript
220
+ // sst.config.ts
221
+ import { clientStubs, stubsDir } from '@everystack/server/stubs';
222
+
223
+ new sst.aws.Function('Api', {
224
+ handler: 'server/api.handler',
225
+ nodejs: {
226
+ esbuild: { external: clientStubs },
227
+ },
228
+ copyFiles: [{ from: stubsDir, to: 'stubs' }],
229
+ });
230
+ ```
231
+
232
+ ## Peer Dependencies
233
+
234
+ - `sst` — Resource linking for credentials
235
+ - `drizzle-orm` — Database ORM
236
+ - `postgres` — PostgreSQL driver
237
+ - `sharp` — Image processing (optional, Lambda layer)
238
+ - `@aws-sdk/client-s3` — S3 operations (image handler)
239
+ - `@aws-sdk/s3-request-presigner` — Presigned URLs (image handler)
240
+ - `@everystack/cli` — Storage adapter (SSR handler)
241
+
242
+ ---
243
+
244
+ Part of [everystack](https://github.com/scalable-technology/everystack) — a self-hosted application stack for Expo apps on AWS.
245
+
246
+ ## License
247
+
248
+ MIT
package/package.json ADDED
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "@everystack/server",
3
+ "version": "0.1.0",
4
+ "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "default": "./src/index.ts"
18
+ },
19
+ "./db": {
20
+ "types": "./src/db.ts",
21
+ "default": "./src/db.ts"
22
+ },
23
+ "./ssr": {
24
+ "types": "./src/ssr.ts",
25
+ "default": "./src/ssr.ts"
26
+ },
27
+ "./image": {
28
+ "types": "./src/image.ts",
29
+ "default": "./src/image.ts"
30
+ },
31
+ "./migrate": {
32
+ "types": "./src/migrate.ts",
33
+ "default": "./src/migrate.ts"
34
+ },
35
+ "./worker": {
36
+ "types": "./src/worker.ts",
37
+ "default": "./src/worker.ts"
38
+ },
39
+ "./stubs": {
40
+ "types": "./src/stubs.ts",
41
+ "default": "./src/stubs.ts"
42
+ },
43
+ "./cdn": {
44
+ "types": "./src/cdn/index.ts",
45
+ "default": "./src/cdn/index.ts"
46
+ }
47
+ },
48
+ "peerDependencies": {
49
+ "@aws-sdk/client-s3": ">=3.0.0",
50
+ "@aws-sdk/s3-request-presigner": ">=3.0.0",
51
+ "@everystack/auth": ">=0.1.0",
52
+ "@everystack/cli": ">=0.1.0",
53
+ "drizzle-orm": ">=0.30.0",
54
+ "postgres": ">=3.0.0",
55
+ "sharp": ">=0.33.0",
56
+ "sst": ">=3.0.0"
57
+ },
58
+ "peerDependenciesMeta": {
59
+ "sst": {
60
+ "optional": true
61
+ },
62
+ "@everystack/auth": {
63
+ "optional": true
64
+ },
65
+ "@everystack/cli": {
66
+ "optional": true
67
+ },
68
+ "sharp": {
69
+ "optional": true
70
+ },
71
+ "@aws-sdk/client-s3": {
72
+ "optional": true
73
+ },
74
+ "@aws-sdk/s3-request-presigner": {
75
+ "optional": true
76
+ }
77
+ },
78
+ "devDependencies": {
79
+ "@types/aws-lambda": "^8.10.145",
80
+ "@types/jest": "^29.5.14",
81
+ "@types/node": "^22.0.0",
82
+ "drizzle-orm": "^0.41.0",
83
+ "jest": "^29.7.0",
84
+ "postgres": "^3.4.0",
85
+ "sst": "^4.13.1",
86
+ "ts-jest": "^29.3.0",
87
+ "typescript": "^5.7.0",
88
+ "@everystack/auth": "0.1.0",
89
+ "@everystack/cli": "0.1.0"
90
+ },
91
+ "scripts": {
92
+ "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
93
+ "build": "tsc --build",
94
+ "lint": "tsc --noEmit"
95
+ }
96
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * composeViewerRequest — multi-feature CloudFront Functions composer.
3
+ *
4
+ * Turns a list of `Feature` records into a single CFF runtime 2.0 source
5
+ * string, deduping shared helpers, namespacing per-feature state, ordering
6
+ * per-request bodies, wrapping everything in a fail-closed try/catch, and
7
+ * (by default) running a safe minifier with a 10 KB size budget.
8
+ *
9
+ * Output is the BODY for an SST `injection` (i.e. it goes inside an existing
10
+ * `function handler(event) { ... return event.request; }`), so helpers and
11
+ * state are nested function/var declarations rather than top-level.
12
+ */
13
+
14
+ export interface Feature {
15
+ /** Unique feature name. Used to namespace state vars as `__<name>_<key>`. */
16
+ name: string;
17
+ /**
18
+ * Helper functions/vars shared with other features. Keys are dedupe IDs;
19
+ * values are full source ('function foo(){...}' or 'var T = ...;'). When
20
+ * two features register the same key, sources MUST be byte-identical or
21
+ * the composer throws.
22
+ */
23
+ helpers?: Record<string, string>;
24
+ /**
25
+ * JSON-serializable constants emitted as `var __<feature>_<key> = JSON;`.
26
+ */
27
+ state?: Record<string, unknown>;
28
+ /**
29
+ * Per-request code. Runs inside the wrapping try{}, in `order`. May
30
+ * `return <response>` to short-circuit downstream features.
31
+ */
32
+ body: string;
33
+ /** Smaller runs first. Default 100. Stable for ties. */
34
+ order?: number;
35
+ }
36
+
37
+ export interface ComposeOptions {
38
+ features: Feature[];
39
+ /** Default true. */
40
+ minify?: boolean;
41
+ /** CFF runtime 2.0 source-size hard limit. Default 10240. */
42
+ maxBytes?: number;
43
+ }
44
+
45
+ export interface ComposeResult {
46
+ code: string;
47
+ bytes: number;
48
+ }
49
+
50
+ const DEFAULT_MAX_BYTES = 10 * 1024;
51
+ const DEFAULT_ORDER = 100;
52
+
53
+ export function composeViewerRequest(opts: ComposeOptions): ComposeResult {
54
+ const features = opts.features;
55
+ const minify = opts.minify !== false;
56
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
57
+
58
+ // 1. Dedupe helpers across features. Conflict (same key, different source)
59
+ // is a hard error — silently picking one would mask a real bug.
60
+ const helpers = new Map<string, string>();
61
+ for (const f of features) {
62
+ if (!f.helpers) continue;
63
+ for (const [k, src] of Object.entries(f.helpers)) {
64
+ const existing = helpers.get(k);
65
+ if (existing !== undefined && existing !== src) {
66
+ throw new Error(
67
+ `composeViewerRequest: conflicting helper '${k}' between features (one of: ${f.name})`
68
+ );
69
+ }
70
+ helpers.set(k, src);
71
+ }
72
+ }
73
+
74
+ // 2. Emit state as namespaced vars.
75
+ const stateLines: string[] = [];
76
+ for (const f of features) {
77
+ if (!f.state) continue;
78
+ for (const [k, v] of Object.entries(f.state)) {
79
+ stateLines.push(`var __${f.name}_${k} = ${JSON.stringify(v)};`);
80
+ }
81
+ }
82
+
83
+ // 3. Stable-sort bodies by `order`.
84
+ const ordered = features
85
+ .map((f, i) => ({ f, i, order: f.order ?? DEFAULT_ORDER }))
86
+ .sort((a, b) => (a.order - b.order) || (a.i - b.i))
87
+ .map((x) => x.f);
88
+
89
+ const bodyParts = ordered.map((f) => `// --- ${f.name} ---\n${f.body}`).join('\n');
90
+
91
+ // 4. Wrap in fail-closed try/catch.
92
+ // Any uncaught throw becomes a 401 — fail closed at the edge.
93
+ const composed = [
94
+ ...Array.from(helpers.values()),
95
+ ...stateLines,
96
+ 'try {',
97
+ bodyParts,
98
+ '} catch (__e) {',
99
+ ' return { statusCode: 401, statusDescription: "Unauthorized", headers: { "content-type": { value: "application/json" }, "cache-control": { value: "no-store" } }, body: { encoding: "text", data: JSON.stringify({ message: "Edge auth error" }) } };',
100
+ '}',
101
+ ].join('\n');
102
+
103
+ const code = minify ? minifyCff(composed) : composed;
104
+ const bytes = Buffer.byteLength(code, 'utf8');
105
+
106
+ if (bytes > maxBytes) {
107
+ throw new Error(
108
+ `composeViewerRequest: output (${bytes} bytes) exceeds max (${maxBytes} bytes). ` +
109
+ `CloudFront Functions runtime 2.0 hard limit is 10 KB compiled.`
110
+ );
111
+ }
112
+
113
+ return { code, bytes };
114
+ }
115
+
116
+ /**
117
+ * Safe minifier for CFF source.
118
+ *
119
+ * Strips // and /* *\/ comments and collapses runs of whitespace, while
120
+ * preserving the contents of string literals (', ", `) and regex literals.
121
+ * Does NOT mangle identifiers (CFF runtime API surface like
122
+ * `event.request.headers.authorization.value` must survive intact).
123
+ *
124
+ * Hand-rolled tokenizer rather than terser so we ship zero new runtime deps
125
+ * and keep deploy-time evaluation fast.
126
+ */
127
+ export function minifyCff(src: string): string {
128
+ let out = '';
129
+ let i = 0;
130
+ const n = src.length;
131
+
132
+ // Track previous non-whitespace char to disambiguate `/` (regex vs divide).
133
+ let prevSig = '';
134
+
135
+ while (i < n) {
136
+ const c = src[i];
137
+ const c2 = src[i + 1];
138
+
139
+ // Line comment.
140
+ if (c === '/' && c2 === '/') {
141
+ while (i < n && src[i] !== '\n') i++;
142
+ continue;
143
+ }
144
+
145
+ // Block comment.
146
+ if (c === '/' && c2 === '*') {
147
+ i += 2;
148
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
149
+ i += 2;
150
+ continue;
151
+ }
152
+
153
+ // String literal — copy verbatim, handle escapes.
154
+ if (c === '"' || c === "'" || c === '`') {
155
+ const quote = c;
156
+ out += c;
157
+ i++;
158
+ while (i < n) {
159
+ const ch = src[i];
160
+ out += ch;
161
+ if (ch === '\\' && i + 1 < n) {
162
+ out += src[i + 1];
163
+ i += 2;
164
+ continue;
165
+ }
166
+ i++;
167
+ if (ch === quote) break;
168
+ }
169
+ prevSig = quote;
170
+ continue;
171
+ }
172
+
173
+ // Regex literal — only after operators / keywords where regex is valid.
174
+ if (c === '/' && isRegexContext(prevSig)) {
175
+ out += c;
176
+ i++;
177
+ while (i < n) {
178
+ const ch = src[i];
179
+ out += ch;
180
+ if (ch === '\\' && i + 1 < n) {
181
+ out += src[i + 1];
182
+ i += 2;
183
+ continue;
184
+ }
185
+ i++;
186
+ if (ch === '/') break;
187
+ }
188
+ // Flags.
189
+ while (i < n && /[a-z]/i.test(src[i])) {
190
+ out += src[i];
191
+ i++;
192
+ }
193
+ prevSig = '/';
194
+ continue;
195
+ }
196
+
197
+ // Whitespace collapse.
198
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
199
+ // Look ahead for the next non-whitespace char.
200
+ let j = i + 1;
201
+ while (j < n && (src[j] === ' ' || src[j] === '\t' || src[j] === '\n' || src[j] === '\r')) j++;
202
+ const nextCh = j < n ? src[j] : '';
203
+ // Keep a single space only if removing it would fuse two identifier/number chars.
204
+ if (nextCh && needsSeparator(prevSig, nextCh)) {
205
+ out += ' ';
206
+ }
207
+ i = j;
208
+ continue;
209
+ }
210
+
211
+ out += c;
212
+ prevSig = c;
213
+ i++;
214
+ }
215
+
216
+ return out;
217
+ }
218
+
219
+ // Identifier-like = [A-Za-z0-9_$]
220
+ function needsSeparator(a: string, b: string): boolean {
221
+ if (!a || !b) return false;
222
+ return isIdentChar(a) && isIdentChar(b);
223
+ }
224
+
225
+ function isIdentChar(c: string): boolean {
226
+ return /[A-Za-z0-9_$]/.test(c);
227
+ }
228
+
229
+ // Heuristic: a `/` is a regex literal start when the previous non-whitespace
230
+ // token is an operator, opening bracket/brace/paren, comma, semicolon, or
231
+ // the empty string (start of body). Otherwise it's division.
232
+ function isRegexContext(prev: string): boolean {
233
+ if (prev === '') return true;
234
+ return /[=([{,;!&|?:+\-*%^~<>]/.test(prev);
235
+ }