@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/ssr.ts ADDED
@@ -0,0 +1,425 @@
1
+ /**
2
+ * @everystack/server/ssr — Web SSR handler for Lambda.
3
+ *
4
+ * Downloads a brotli-compressed tar archive from S3, extracts it to /tmp,
5
+ * and serves requests via expo-server. Caches the handler across warm
6
+ * Lambda invocations with a TTL-based re-check for new releases.
7
+ *
8
+ * Two modes:
9
+ * V1 (no DB): reads from convention path {channel}/web/bundle.tar.br
10
+ * V2 (with DB): resolves channel → branch → latest release → storagePrefix
11
+ *
12
+ * Per-channel caching: each channel maintains its own cached handler and
13
+ * build directory, allowing one Lambda to serve multiple channels.
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import fsp from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import zlib from 'node:zlib';
20
+ import { AsyncLocalStorage } from 'node:async_hooks';
21
+ import type { StorageAdapter } from '@everystack/cli';
22
+
23
+ // expo-server internals — accessed via package exports
24
+ import { createRequestHandler } from 'expo-server/adapter/abstract';
25
+ import { createNodeEnv, createNodeRequestScope } from 'expo-server/vendor/environment/node';
26
+
27
+ const BUILD_DIR_BASE = '/tmp/web-build';
28
+ const TTL_MS = 60_000; // Re-check storage every 60s
29
+
30
+ /** Metadata from the channel's web manifest, used for diagnostics headers. */
31
+ export interface ReleaseMetadata {
32
+ updateId?: string;
33
+ runtimeVersion?: string;
34
+ createdAt?: string;
35
+ storagePrefix?: string;
36
+ }
37
+
38
+ /** Resolved bundle key with optional manifest metadata. */
39
+ export interface WebRelease {
40
+ bundleKey: string;
41
+ metadata?: ReleaseMetadata;
42
+ }
43
+
44
+ interface CachedHandler {
45
+ bundleKey: string;
46
+ etag: string;
47
+ metadata?: ReleaseMetadata;
48
+ handler: (request: Request) => Promise<Response>;
49
+ run: (fn: (request: Request) => Promise<Response>, request: Request) => Promise<Response>;
50
+ checkedAt: number;
51
+ }
52
+
53
+ const cachedHandlers = new Map<string, CachedHandler>();
54
+
55
+ /** Return type for postProcessHtml — string replaces HTML, object also overrides status. */
56
+ export type PostProcessResult = string | { html: string; status?: number };
57
+
58
+ export interface WebHandlerOptions {
59
+ /** Channel name (default: 'production') */
60
+ channel?: string;
61
+ /** Hook called after bundle extraction, before handler creation. */
62
+ afterExtract?: (buildDir: string) => Promise<void> | void;
63
+ /** Drizzle database instance. When provided, resolves bundle via DB (V2). */
64
+ db?: any;
65
+
66
+ /** Query params to strip before SSR. Default: ['_v']. Set [] to disable. */
67
+ stripQueryParams?: string[];
68
+
69
+ /** Inject __EXPO_ROUTER_HYDRATE__ flag before </head>. Default: true. */
70
+ injectHydrateFlag?: boolean;
71
+
72
+ /**
73
+ * App-specific HTML post-processing. Called after built-in processing
74
+ * (param stripping, hydrate flag). Receives the processed HTML and the
75
+ * ORIGINAL request (pre-stripping). Return string to replace HTML, or
76
+ * { html, status } to also override the response status code.
77
+ */
78
+ postProcessHtml?: (html: string, request: Request) =>
79
+ PostProcessResult | Promise<PostProcessResult>;
80
+ }
81
+
82
+ const DEFAULT_STRIP_PARAMS = ['_v'];
83
+ const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>\n';
84
+
85
+ /**
86
+ * Wraps an SSR request handler with framework-level fixes:
87
+ *
88
+ * 1. Strips CFF query params (_v) so expo-router loader data keys
89
+ * match the client-side route path (which has no CFF params).
90
+ * 2. Injects `__EXPO_ROUTER_HYDRATE__=true` before `</head>` so React
91
+ * Native Web uses `hydrateRoot()` instead of `createRoot()`.
92
+ * (React 19 suppresses <script> tags from SSR output, so this must
93
+ * be done via string replacement after rendering.)
94
+ * 3. Calls an optional `postProcessHtml` hook for app-specific transforms
95
+ * (e.g., JSON-LD injection, status code override from meta tags).
96
+ *
97
+ * @param innerHandler - The expo-server request handler
98
+ * @param bundleKey - Bundle identifier, set as X-Bundle-Key header
99
+ * @param options - SSR processing options
100
+ */
101
+ export function wrapSsrHandler(
102
+ innerHandler: (request: Request) => Promise<Response>,
103
+ bundleKey: string,
104
+ options?: Pick<WebHandlerOptions, 'stripQueryParams' | 'injectHydrateFlag' | 'postProcessHtml'>,
105
+ metadata?: ReleaseMetadata,
106
+ ): (request: Request) => Promise<Response> {
107
+ const stripParams = options?.stripQueryParams ?? DEFAULT_STRIP_PARAMS;
108
+ const injectHydrate = options?.injectHydrateFlag !== false;
109
+ const postProcess = options?.postProcessHtml;
110
+
111
+ return async (request: Request): Promise<Response> => {
112
+ // 1. Strip CFF query params from request URL
113
+ let cleanRequest = request;
114
+ if (stripParams.length > 0) {
115
+ const url = new URL(request.url);
116
+ let modified = false;
117
+ for (const param of stripParams) {
118
+ if (url.searchParams.has(param)) {
119
+ url.searchParams.delete(param);
120
+ modified = true;
121
+ }
122
+ }
123
+ if (modified) {
124
+ cleanRequest = new Request(url.toString(), request);
125
+ }
126
+ }
127
+
128
+ // Delegate to inner handler
129
+ const response = await innerHandler(cleanRequest);
130
+
131
+ // Set bundle key and release metadata on all responses
132
+ const headers = new Headers(response.headers);
133
+ headers.set('X-Bundle-Key', bundleKey);
134
+ if (metadata?.updateId) headers.set('X-Update-Id', metadata.updateId);
135
+ if (metadata?.runtimeVersion) headers.set('X-Runtime-Version', metadata.runtimeVersion);
136
+ if (metadata?.createdAt) headers.set('X-Release-Created', metadata.createdAt);
137
+
138
+ // Non-HTML responses pass through untouched
139
+ const ct = headers.get('content-type') || '';
140
+ if (!ct.includes('text/html')) {
141
+ return new Response(response.body, {
142
+ status: response.status,
143
+ headers,
144
+ });
145
+ }
146
+
147
+ // 2. HTML post-processing
148
+ let html = await response.text();
149
+ let status = response.status;
150
+
151
+ // Inject hydration flag (idempotent — only if not already present)
152
+ if (injectHydrate && !html.includes('__EXPO_ROUTER_HYDRATE__')) {
153
+ html = html.replace('</head>', `${HYDRATE_SCRIPT}</head>`);
154
+ }
155
+
156
+ // 3. App-specific post-processing
157
+ if (postProcess) {
158
+ const result = await postProcess(html, request);
159
+ if (typeof result === 'string') {
160
+ html = result;
161
+ } else {
162
+ html = result.html;
163
+ if (result.status !== undefined) {
164
+ status = result.status;
165
+ }
166
+ }
167
+ }
168
+
169
+ // Update content-length to match modified HTML
170
+ headers.set('content-length', String(Buffer.byteLength(html)));
171
+
172
+ return new Response(html, { status, headers });
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Resolve the S3 bundle key and release metadata for a channel.
178
+ *
179
+ * Resolution order:
180
+ * 1. S3 channel pointer: {channel}/web/manifest.json → storagePrefix + metadata → bundle key
181
+ * 2. DB resolution: channel → branchRef → latest web release → storagePrefix
182
+ * 3. Convention fallback: {channel}/web/bundle.tar.br
183
+ */
184
+ export async function resolveWebRelease(channel: string, storage?: StorageAdapter, db?: any): Promise<WebRelease> {
185
+ const fallback = `${channel}/web/bundle.tar.br`;
186
+
187
+ // Priority 1: S3 channel pointer manifest
188
+ if (storage) {
189
+ try {
190
+ const pointerKey = `${channel}/web/manifest.json`;
191
+ const result = await storage.get(pointerKey);
192
+ if (result) {
193
+ const pointer = JSON.parse(result.data.toString('utf8'));
194
+ if (pointer.storagePrefix) {
195
+ return {
196
+ bundleKey: `${pointer.storagePrefix}/bundle.tar.br`,
197
+ metadata: {
198
+ updateId: pointer.updateId,
199
+ runtimeVersion: pointer.runtimeVersion,
200
+ createdAt: pointer.createdAt,
201
+ storagePrefix: pointer.storagePrefix,
202
+ },
203
+ };
204
+ }
205
+ }
206
+ } catch {
207
+ // Pointer doesn't exist or is malformed — fall through
208
+ }
209
+ }
210
+
211
+ // Priority 2: DB resolution
212
+ if (!db) return { bundleKey: fallback };
213
+
214
+ try {
215
+ // Dynamic import — only loaded when DB is used (keeps V1 zero-dep)
216
+ const { opsItems, buildArtifacts, runs } = await import('@everystack/cli/schema');
217
+ const { eq, and, desc, sql } = await import('drizzle-orm');
218
+
219
+ // Look up channel (opsItems type='release_channel')
220
+ const [ch] = await db
221
+ .select()
222
+ .from(opsItems)
223
+ .where(and(
224
+ eq(opsItems.type, 'release_channel'),
225
+ eq(opsItems.name, channel),
226
+ ))
227
+ .limit(1);
228
+
229
+ if (!ch) return { bundleKey: fallback };
230
+
231
+ const branchRef = (ch.data as Record<string, unknown>)?.branchRef as string;
232
+ if (!branchRef) return { bundleKey: fallback };
233
+
234
+ // Look up latest web release (buildArtifact type='ota') via run branchRef
235
+ const releaseResults = await db
236
+ .select({ release: buildArtifacts })
237
+ .from(buildArtifacts)
238
+ .innerJoin(runs, eq(buildArtifacts.runId, runs.id))
239
+ .where(and(
240
+ eq(buildArtifacts.type, 'ota'),
241
+ eq(buildArtifacts.platform, 'web'),
242
+ eq(runs.type, 'release'),
243
+ sql`${runs.data}->>'branchRef' = ${branchRef}`,
244
+ ))
245
+ .orderBy(desc(buildArtifacts.indexedAt))
246
+ .limit(1);
247
+
248
+ const rel = releaseResults[0]?.release;
249
+ if (!rel?.s3Key) return { bundleKey: fallback };
250
+
251
+ return { bundleKey: `${rel.s3Key}/bundle.tar.br` };
252
+ } catch {
253
+ // DB query failed — fall back to convention path
254
+ return { bundleKey: fallback };
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Resolve the S3 bundle key for a channel.
260
+ * Delegates to resolveWebRelease — preserves existing API contract.
261
+ */
262
+ export async function resolveBundleKey(channel: string, storage?: StorageAdapter, db?: any): Promise<string> {
263
+ return (await resolveWebRelease(channel, storage, db)).bundleKey;
264
+ }
265
+
266
+ /**
267
+ * Returns a Request→Response handler for the current web release,
268
+ * or null if no web release exists yet.
269
+ *
270
+ * V1: reads from convention path {channel}/web/bundle.tar.br (no DB required)
271
+ * V2: resolves channel → branch → latest release → storagePrefix (DB required)
272
+ *
273
+ * Per-channel caching: each channel has its own handler and build directory.
274
+ * Re-checks storage every TTL_MS to pick up new releases.
275
+ */
276
+ export async function getWebHandler(
277
+ storage: StorageAdapter,
278
+ options?: WebHandlerOptions,
279
+ ): Promise<((request: Request) => Promise<Response>) | null> {
280
+ const now = Date.now();
281
+ const channel = options?.channel || 'production';
282
+ const buildDir = `${BUILD_DIR_BASE}/${channel}`;
283
+
284
+ const cached = cachedHandlers.get(channel);
285
+
286
+ // If cached and within TTL, return existing handler (no DB/S3 check)
287
+ if (cached && (now - cached.checkedAt) < TTL_MS) {
288
+ const rawHandler = async (req: Request) => cached.run(cached.handler, req);
289
+ return wrapSsrHandler(rawHandler, cached.bundleKey, options, cached.metadata);
290
+ }
291
+
292
+ // TTL expired or no cache — resolve bundle key and metadata
293
+ const { bundleKey, metadata } = await resolveWebRelease(channel, storage, options?.db);
294
+
295
+ // Check if bundle exists and get its ETag
296
+ let meta: { etag: string } | null = null;
297
+ try {
298
+ meta = await storage.head(bundleKey);
299
+ } catch {
300
+ // Storage unavailable — graceful fallback
301
+ return null;
302
+ }
303
+
304
+ if (!meta) {
305
+ return null;
306
+ }
307
+
308
+ // If already cached with same key and ETag, just update TTL
309
+ if (cached && cached.bundleKey === bundleKey && cached.etag === meta.etag) {
310
+ cached.checkedAt = now;
311
+ const rawHandler = async (req: Request) => cached.run(cached.handler, req);
312
+ return wrapSsrHandler(rawHandler, bundleKey, options, cached.metadata);
313
+ }
314
+
315
+ // New or updated release — download and extract server bundle archive
316
+ try {
317
+ await downloadAndExtract(storage, bundleKey, buildDir);
318
+ } catch {
319
+ // Download/extraction failed — no release available
320
+ return null;
321
+ }
322
+
323
+ if (options?.afterExtract) {
324
+ await options.afterExtract(buildDir);
325
+ }
326
+
327
+ // Create expo-server handler from the extracted bundle
328
+ const store = new AsyncLocalStorage();
329
+ const params = { build: buildDir };
330
+ const run = createNodeRequestScope(store, params);
331
+ const env = createNodeEnv(params);
332
+ const handler = createRequestHandler(env);
333
+
334
+ const entry: CachedHandler = {
335
+ bundleKey,
336
+ metadata,
337
+ etag: meta.etag,
338
+ handler,
339
+ run,
340
+ checkedAt: now,
341
+ };
342
+ cachedHandlers.set(channel, entry);
343
+
344
+ const rawHandler = async (req: Request) => entry.run(entry.handler, req);
345
+ return wrapSsrHandler(rawHandler, bundleKey, options, metadata);
346
+ }
347
+
348
+ /**
349
+ * Downloads a bundle.tar.br from storage and extracts to a build directory.
350
+ * Pure JS — no external tar binary required (Lambda doesn't have it).
351
+ */
352
+ export async function downloadAndExtract(storage: StorageAdapter, bundleKey: string, buildDir: string = BUILD_DIR_BASE): Promise<void> {
353
+ // Clean previous build
354
+ if (fs.existsSync(buildDir)) {
355
+ await fsp.rm(buildDir, { recursive: true, force: true });
356
+ }
357
+ await fsp.mkdir(buildDir, { recursive: true });
358
+
359
+ // Download the archive as a single blob
360
+ const result = await storage.get(bundleKey);
361
+ if (!result) {
362
+ throw new Error(`Server bundle not found at ${bundleKey}`);
363
+ }
364
+
365
+ // Brotli decompress → tar extract (pure JS)
366
+ const decompressed = zlib.brotliDecompressSync(result.data);
367
+ await extractTar(decompressed, buildDir);
368
+ }
369
+
370
+ /**
371
+ * Minimal tar extractor — handles POSIX ustar format.
372
+ * Tar is a sequence of 512-byte header blocks followed by file data blocks.
373
+ */
374
+ export async function extractTar(data: Buffer, destDir: string): Promise<void> {
375
+ const BLOCK = 512;
376
+ let offset = 0;
377
+
378
+ while (offset + BLOCK <= data.length) {
379
+ const header = data.subarray(offset, offset + BLOCK);
380
+
381
+ // Two consecutive zero blocks = end of archive
382
+ if (header.every(b => b === 0)) break;
383
+
384
+ // Parse file name (bytes 0-99, may continue in prefix bytes 345-499)
385
+ const prefix = readString(header, 345, 155);
386
+ const name = readString(header, 0, 100);
387
+ const fullName = prefix ? `${prefix}/${name}` : name;
388
+
389
+ // Parse size (octal, bytes 124-135)
390
+ const sizeStr = readString(header, 124, 12);
391
+ const fileSize = parseInt(sizeStr, 8) || 0;
392
+
393
+ // Parse type flag (byte 156): '0' or '\0' = regular file, '5' = directory
394
+ const typeFlag = header[156];
395
+
396
+ offset += BLOCK;
397
+
398
+ // Strip leading './' from tar paths
399
+ const cleanName = fullName.replace(/^\.\//, '');
400
+ if (!cleanName) {
401
+ offset += Math.ceil(fileSize / BLOCK) * BLOCK;
402
+ continue;
403
+ }
404
+
405
+ const destPath = path.join(destDir, cleanName);
406
+
407
+ if (typeFlag === 53) { // '5' = directory
408
+ await fsp.mkdir(destPath, { recursive: true });
409
+ } else if (typeFlag === 48 || typeFlag === 0) { // '0' or '\0' = regular file
410
+ await fsp.mkdir(path.dirname(destPath), { recursive: true });
411
+ const fileData = data.subarray(offset, offset + fileSize);
412
+ await fsp.writeFile(destPath, fileData);
413
+ }
414
+ // Skip other types (symlinks, etc.)
415
+
416
+ // Advance past file data (padded to 512-byte boundary)
417
+ offset += Math.ceil(fileSize / BLOCK) * BLOCK;
418
+ }
419
+ }
420
+
421
+ function readString(buf: Buffer, offset: number, length: number): string {
422
+ const end = buf.indexOf(0, offset);
423
+ const slice = buf.subarray(offset, Math.min(end >= offset ? end : offset + length, offset + length));
424
+ return slice.toString('utf8').trim();
425
+ }
@@ -0,0 +1,27 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /**
5
+ * SST Resource type declarations for @everystack/server/db.
6
+ *
7
+ * Augments the `sst` module's Resource interface with the shapes
8
+ * expected by db.ts. Consumers must link matching SST resources
9
+ * (e.g., sst.aws.Postgres named "Database", sst.Secret named "JwtSecret").
10
+ */
11
+ declare module 'sst' {
12
+ export interface Resource {
13
+ Database: {
14
+ username: string;
15
+ password: string;
16
+ host: string;
17
+ port: number;
18
+ database: string;
19
+ };
20
+ JwtSecret: {
21
+ value: string;
22
+ };
23
+ }
24
+ }
25
+
26
+ import 'sst';
27
+ export {};
package/src/stubs.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @everystack/server/stubs — Client stub configuration for Lambda bundling.
3
+ *
4
+ * Lambda bundles use esbuild with `external` to skip client-side packages.
5
+ * These stubs provide no-op modules so runtime imports don't crash.
6
+ *
7
+ * Usage in sst.config.ts:
8
+ * import { clientStubs, stubsDir } from '@everystack/server/stubs';
9
+ * // esbuild: { external: clientStubs }
10
+ * // copyFiles: [{ from: stubsDir, to: 'node_modules' }]
11
+ */
12
+
13
+ import { fileURLToPath } from 'node:url';
14
+ import path from 'node:path';
15
+
16
+ /** Client-side packages that must be stubbed in Lambda bundles. */
17
+ export const clientStubs = [
18
+ 'react',
19
+ 'react-dom',
20
+ 'react-native',
21
+ 'react-native-web',
22
+ '@tanstack/react-query',
23
+ 'expo',
24
+ 'expo-router',
25
+ 'expo-status-bar',
26
+ 'react-native-safe-area-context',
27
+ 'react-native-screens',
28
+ 'd3-shape',
29
+ 'd3-scale',
30
+ 'd3-array',
31
+ 'd3-time',
32
+ 'd3-time-format',
33
+ ];
34
+
35
+ /** Absolute path to the stubs directory for use with SST copyFiles. */
36
+ export const stubsDir = path.resolve(
37
+ path.dirname(fileURLToPath(import.meta.url)),
38
+ '../stubs'
39
+ );
package/src/worker.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @everystack/server/worker — SQS consumer pattern for Lambda.
3
+ *
4
+ * Provides the dispatch/error-handling/batch-failure shell.
5
+ * The app provides the job handler registry via the init function.
6
+ */
7
+
8
+ import { log } from './index.js';
9
+
10
+ export interface SQSEvent {
11
+ Records: Array<{
12
+ messageId: string;
13
+ body: string;
14
+ attributes: Record<string, string>;
15
+ messageAttributes: Record<string, unknown>;
16
+ }>;
17
+ }
18
+
19
+ export interface SQSBatchResponse {
20
+ batchItemFailures: Array<{ itemIdentifier: string }>;
21
+ }
22
+
23
+ export type JobHandler = (payload: any, job: { id: string; type: string }) => Promise<void>;
24
+
25
+ export function createWorkerHandler(
26
+ init: () => Promise<Record<string, JobHandler>>
27
+ ): (event: SQSEvent) => Promise<SQSBatchResponse> {
28
+ let jobHandlers: Record<string, JobHandler> | null = null;
29
+
30
+ async function ensureInitialized(): Promise<Record<string, JobHandler>> {
31
+ if (jobHandlers) return jobHandlers;
32
+ jobHandlers = await init();
33
+ log('info', 'Worker initialized', { jobTypes: Object.keys(jobHandlers) });
34
+ return jobHandlers;
35
+ }
36
+
37
+ return async (event: SQSEvent): Promise<SQSBatchResponse> => {
38
+ const handlers = await ensureInitialized();
39
+ const batchItemFailures: Array<{ itemIdentifier: string }> = [];
40
+
41
+ for (const record of event.Records) {
42
+ try {
43
+ const body = JSON.parse(record.body);
44
+ const { jobId, type, payload } = body;
45
+
46
+ const handlerFn = handlers[type];
47
+ if (!handlerFn) {
48
+ log('warn', 'No handler for job type', { type, jobId });
49
+ batchItemFailures.push({ itemIdentifier: record.messageId });
50
+ continue;
51
+ }
52
+
53
+ await handlerFn(payload, { id: jobId, type });
54
+ log('info', 'Job completed', { type, jobId });
55
+ } catch (error) {
56
+ log('error', 'Job failed', { messageId: record.messageId, error: String(error) });
57
+ batchItemFailures.push({ itemIdentifier: record.messageId });
58
+ }
59
+ }
60
+
61
+ return { batchItemFailures };
62
+ };
63
+ }