@everystack/server 0.1.0 → 0.2.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.
Files changed (64) hide show
  1. package/LICENSE +681 -0
  2. package/package.json +29 -21
  3. package/src/cdn/compose.ts +3 -0
  4. package/src/cdn/features.ts +112 -68
  5. package/src/cdn/index.ts +11 -13
  6. package/src/cdn/transforms.ts +157 -0
  7. package/src/db.ts +1 -0
  8. package/src/image.ts +28 -1
  9. package/src/index.ts +2 -0
  10. package/src/kvs.ts +50 -0
  11. package/src/plugin.ts +517 -0
  12. package/src/ssr.ts +1 -1
  13. package/src/worker.ts +46 -2
  14. package/stubs/@tanstack/react-query/index.mjs +13 -0
  15. package/stubs/@tanstack/react-query/package.json +10 -0
  16. package/stubs/@tanstack/react-query/sst-env.d.ts +10 -0
  17. package/stubs/d3-array/index.mjs +15 -0
  18. package/stubs/d3-array/package.json +10 -0
  19. package/stubs/d3-array/sst-env.d.ts +10 -0
  20. package/stubs/d3-scale/index.mjs +11 -0
  21. package/stubs/d3-scale/package.json +10 -0
  22. package/stubs/d3-scale/sst-env.d.ts +10 -0
  23. package/stubs/d3-shape/index.mjs +16 -0
  24. package/stubs/d3-shape/package.json +10 -0
  25. package/stubs/d3-shape/sst-env.d.ts +10 -0
  26. package/stubs/d3-time/index.mjs +13 -0
  27. package/stubs/d3-time/package.json +10 -0
  28. package/stubs/d3-time/sst-env.d.ts +10 -0
  29. package/stubs/d3-time-format/index.mjs +11 -0
  30. package/stubs/d3-time-format/package.json +10 -0
  31. package/stubs/d3-time-format/sst-env.d.ts +10 -0
  32. package/stubs/expo/index.mjs +12 -0
  33. package/stubs/expo/package.json +10 -0
  34. package/stubs/expo/sst-env.d.ts +10 -0
  35. package/stubs/expo-router/index.mjs +18 -0
  36. package/stubs/expo-router/package.json +10 -0
  37. package/stubs/expo-router/sst-env.d.ts +10 -0
  38. package/stubs/expo-status-bar/index.mjs +8 -0
  39. package/stubs/expo-status-bar/package.json +10 -0
  40. package/stubs/expo-status-bar/sst-env.d.ts +10 -0
  41. package/stubs/react/index.mjs +34 -0
  42. package/stubs/react/jsx-dev-runtime/index.mjs +11 -0
  43. package/stubs/react/jsx-dev-runtime/package.json +6 -0
  44. package/stubs/react/jsx-dev-runtime/sst-env.d.ts +10 -0
  45. package/stubs/react/jsx-runtime/index.mjs +10 -0
  46. package/stubs/react/jsx-runtime/package.json +6 -0
  47. package/stubs/react/jsx-runtime/sst-env.d.ts +10 -0
  48. package/stubs/react/package.json +12 -0
  49. package/stubs/react/sst-env.d.ts +10 -0
  50. package/stubs/react-dom/index.mjs +11 -0
  51. package/stubs/react-dom/package.json +10 -0
  52. package/stubs/react-dom/sst-env.d.ts +10 -0
  53. package/stubs/react-native/index.mjs +78 -0
  54. package/stubs/react-native/package.json +11 -0
  55. package/stubs/react-native/sst-env.d.ts +10 -0
  56. package/stubs/react-native-safe-area-context/index.mjs +11 -0
  57. package/stubs/react-native-safe-area-context/package.json +10 -0
  58. package/stubs/react-native-safe-area-context/sst-env.d.ts +10 -0
  59. package/stubs/react-native-screens/index.mjs +13 -0
  60. package/stubs/react-native-screens/package.json +10 -0
  61. package/stubs/react-native-screens/sst-env.d.ts +10 -0
  62. package/stubs/react-native-web/index.mjs +7 -0
  63. package/stubs/react-native-web/package.json +10 -0
  64. package/stubs/react-native-web/sst-env.d.ts +10 -0
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
@@ -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
- export type JobHandler = (payload: any, job: { id: string; type: string }) => Promise<void>;
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
- await handlerFn(payload, { id: jobId, type });
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) });
@@ -0,0 +1,13 @@
1
+ // Auto-generated stub — these exports are never called at runtime.
2
+ const _ = new Proxy(function(){}, {
3
+ get: () => _,
4
+ apply: () => _,
5
+ construct: () => _,
6
+ });
7
+ export default _;
8
+ export const useQuery = _;
9
+ export const useMutation = _;
10
+ export const useQueryClient = _;
11
+ export const QueryClient = _;
12
+ export const QueryClientProvider = _;
13
+ export const useInfiniteQuery = _;
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@tanstack/react-query",
3
+ "version": "0.0.0-stub",
4
+ "type": "module",
5
+ "main": "index.mjs",
6
+ "exports": {
7
+ ".": "./index.mjs",
8
+ "./*": "./index.mjs"
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ /* This file is auto-generated by SST. Do not edit. */
2
+ /* tslint:disable */
3
+ /* eslint-disable */
4
+ /* deno-fmt-ignore-file */
5
+ /* biome-ignore-all lint: auto-generated */
6
+
7
+ /// <reference path="../../../../sst-env.d.ts" />
8
+
9
+ import "sst"
10
+ export {}
@@ -0,0 +1,15 @@
1
+ // Auto-generated stub — these exports are never called at runtime.
2
+ const _ = new Proxy(function(){}, {
3
+ get: () => _,
4
+ apply: () => _,
5
+ construct: () => _,
6
+ });
7
+ export default _;
8
+ export const extent = _;
9
+ export const max = _;
10
+ export const min = _;
11
+ export const bisector = _;
12
+ export const group = _;
13
+ export const rollup = _;
14
+ export const range = _;
15
+ export const bin = _;
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "d3-array",
3
+ "version": "0.0.0-stub",
4
+ "type": "module",
5
+ "main": "index.mjs",
6
+ "exports": {
7
+ ".": "./index.mjs",
8
+ "./*": "./index.mjs"
9
+ }
10
+ }
@@ -0,0 +1,10 @@
1
+ /* This file is auto-generated by SST. Do not edit. */
2
+ /* tslint:disable */
3
+ /* eslint-disable */
4
+ /* deno-fmt-ignore-file */
5
+ /* biome-ignore-all lint: auto-generated */
6
+
7
+ /// <reference path="../../../sst-env.d.ts" />
8
+
9
+ import "sst"
10
+ export {}
@@ -0,0 +1,11 @@
1
+ // Auto-generated stub — these exports are never called at runtime.
2
+ const _ = new Proxy(function(){}, {
3
+ get: () => _,
4
+ apply: () => _,
5
+ construct: () => _,
6
+ });
7
+ export default _;
8
+ export const scaleLinear = _;
9
+ export const scaleTime = _;
10
+ export const scaleBand = _;
11
+ export const scaleOrdinal = _;
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "d3-scale",
3
+ "version": "0.0.0-stub",
4
+ "type": "module",
5
+ "main": "index.mjs",
6
+ "exports": {
7
+ ".": "./index.mjs",
8
+ "./*": "./index.mjs"
9
+ }
10
+ }