@everystack/server 0.4.3 → 0.4.5

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 (3) hide show
  1. package/package.json +2 -2
  2. package/src/db.ts +79 -45
  3. package/src/plugin.ts +10 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -151,8 +151,8 @@
151
151
  "sst": "4.13.1",
152
152
  "ts-jest": "29.4.9",
153
153
  "typescript": "5.9.3",
154
- "@everystack/cli": "0.4.12",
155
154
  "@everystack/auth": "0.4.0",
155
+ "@everystack/cli": "0.4.19",
156
156
  "@everystack/pg-tools": "0.4.0"
157
157
  },
158
158
  "scripts": {
package/src/db.ts CHANGED
@@ -7,19 +7,26 @@
7
7
  * transparently. Works with createSecrets() or manual sst.Secret declarations.
8
8
  */
9
9
 
10
- import { Resource } from 'sst';
11
10
  import { drizzle } from 'drizzle-orm/postgres-js';
12
11
  import postgres from 'postgres';
13
12
 
14
13
  /**
15
- * Safely read a Resource property. Returns undefined if the Resource
16
- * or property doesn't exist (avoids runtime throw from SST's proxy).
14
+ * Read an SST-linked resource WITHOUT importing the `sst` package. SST injects
15
+ * every link as `process.env.SST_RESOURCE_<Name>` (a JSON blob) — SST's own
16
+ * Resource proxy just parses that env var (see sst/dist/resource/shared.js:
17
+ * `raw[name] = JSON.parse(process.env['SST_RESOURCE_' + name])`). Reading it
18
+ * directly means this module never imports `sst`, which is a deploy-time
19
+ * construct: Metro/esbuild cannot bundle it, and SST's own build-time
20
+ * version-check plugin mis-reads a node_module's `sst` import and fails the
21
+ * build. So the API/SSR helpers stayed unbundleable in the exact runtime half
22
+ * the framework targets. Returns undefined when the link is absent — every
23
+ * getter then falls through to process.env, unchanged.
17
24
  */
18
- function tryResource<T>(fn: () => T): T | undefined {
25
+ function sstLink<T = Record<string, any>>(name: string): T | undefined {
26
+ const raw = process.env[`SST_RESOURCE_${name}`];
27
+ if (!raw) return undefined;
19
28
  try {
20
- const val = fn();
21
- // SST's Resource proxy may return undefined for missing keys
22
- return val === undefined ? undefined : val;
29
+ return JSON.parse(raw) as T;
23
30
  } catch {
24
31
  return undefined;
25
32
  }
@@ -32,11 +39,11 @@ export function getDatabaseUrl(): string {
32
39
  // resolved first, setting DATABASE_URL to a least-privilege role would be silently
33
40
  // ignored. See docs/plans/credential-split-rls-everywhere.md.
34
41
 
35
- // SST Secret — PascalCase (Resource.DatabaseUrl.value)
36
- const pascal = tryResource(() => (Resource as any).DatabaseUrl?.value as string);
42
+ // SST Secret — PascalCase (SST_RESOURCE_DatabaseUrl → { value })
43
+ const pascal = sstLink('DatabaseUrl')?.value as string | undefined;
37
44
  if (pascal) return pascal;
38
- // SST Secret — raw env name (Resource.DATABASE_URL.value)
39
- const raw = tryResource(() => (Resource as any).DATABASE_URL?.value as string);
45
+ // SST Secret — raw env name (SST_RESOURCE_DATABASE_URL → { value })
46
+ const raw = sstLink('DATABASE_URL')?.value as string | undefined;
40
47
  if (raw) return raw;
41
48
  // process.env fallback
42
49
  if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
@@ -54,8 +61,25 @@ export function getDatabaseUrl(): string {
54
61
  * no dedicated ADMIN_DATABASE_URL is provisioned — kept separate from getDatabaseUrl() so the
55
62
  * operator path never accidentally resolves the least-privilege DATABASE_URL secret.
56
63
  */
64
+ export interface DatabaseComponentInfo {
65
+ username: string;
66
+ password: string;
67
+ host: string;
68
+ port: number;
69
+ database: string;
70
+ }
71
+
72
+ /**
73
+ * The linked SST Postgres component's connection fields, or null when none is linked.
74
+ * The single reader of the `Database` link — callers that only need host/port/database
75
+ * (db:psql, provision probe) use this instead of importing `sst`.
76
+ */
77
+ export function getDatabaseComponentInfo(): DatabaseComponentInfo | null {
78
+ return sstLink<DatabaseComponentInfo>('Database') ?? null;
79
+ }
80
+
57
81
  export function getMasterDatabaseUrl(): string | null {
58
- const db = tryResource(() => Resource.Database);
82
+ const db = getDatabaseComponentInfo();
59
83
  if (db?.host) {
60
84
  return `postgresql://${db.username}:${db.password}@${db.host}:${db.port}/${db.database}`;
61
85
  }
@@ -73,11 +97,11 @@ export function getMasterDatabaseUrl(): string | null {
73
97
  * Returns null when no admin URL is configured (single-credential setup).
74
98
  */
75
99
  export function getAdminDatabaseUrl(): string | null {
76
- // SST Secret — PascalCase (Resource.AdminDatabaseUrl.value)
77
- const pascal = tryResource(() => (Resource as any).AdminDatabaseUrl?.value as string);
100
+ // SST Secret — PascalCase (SST_RESOURCE_AdminDatabaseUrl → { value })
101
+ const pascal = sstLink('AdminDatabaseUrl')?.value as string | undefined;
78
102
  if (pascal) return pascal;
79
- // SST Secret — raw env name (Resource.ADMIN_DATABASE_URL.value)
80
- const raw = tryResource(() => (Resource as any).ADMIN_DATABASE_URL?.value as string);
103
+ // SST Secret — raw env name (SST_RESOURCE_ADMIN_DATABASE_URL → { value })
104
+ const raw = sstLink('ADMIN_DATABASE_URL')?.value as string | undefined;
81
105
  if (raw) return raw;
82
106
  // process.env fallback
83
107
  if (process.env.ADMIN_DATABASE_URL) return process.env.ADMIN_DATABASE_URL;
@@ -85,17 +109,45 @@ export function getAdminDatabaseUrl(): string | null {
85
109
  }
86
110
 
87
111
  export function getJwtSecret(): Uint8Array {
88
- // PascalCase Resource (Resource.JwtSecret.value)
89
- const pascal = tryResource(() => (Resource as any).JwtSecret?.value as string);
112
+ // PascalCase Secret (SST_RESOURCE_JwtSecret → { value })
113
+ const pascal = sstLink('JwtSecret')?.value as string | undefined;
90
114
  if (pascal) return new TextEncoder().encode(pascal);
91
- // Raw env name Resource (Resource.JWT_SECRET.value)
92
- const raw = tryResource(() => (Resource as any).JWT_SECRET?.value as string);
115
+ // Raw env name Secret (SST_RESOURCE_JWT_SECRET → { value })
116
+ const raw = sstLink('JWT_SECRET')?.value as string | undefined;
93
117
  if (raw) return new TextEncoder().encode(raw);
94
118
  // process.env fallback
95
119
  if (process.env.JWT_SECRET) return new TextEncoder().encode(process.env.JWT_SECRET);
96
120
  throw new Error('JWT_SECRET not set — link an sst.Secret or set process.env.JWT_SECRET');
97
121
  }
98
122
 
123
+ /**
124
+ * The ssl option for a connection URL: an explicit `sslmode` query param wins
125
+ * (return no override — postgres.js honors the URL natively), otherwise default
126
+ * to 'require' (RDS). This is what lets ONE handler serve both venues: local
127
+ * Postgres appends `?sslmode=disable` to its URL, deployed stages carry no
128
+ * param and keep TLS. An unparseable URL fails toward TLS and lets the connect
129
+ * report the real error.
130
+ */
131
+ export function sslOptionForUrl(url: string): 'require' | undefined {
132
+ try {
133
+ return new URL(url).searchParams.has('sslmode') ? undefined : 'require';
134
+ } catch {
135
+ return 'require';
136
+ }
137
+ }
138
+
139
+ /** postgres.js options shared by every connection — ssl derived from the URL. */
140
+ function connectionOptions(url: string, maxConnections?: number): Parameters<typeof postgres>[1] {
141
+ const ssl = sslOptionForUrl(url);
142
+ return {
143
+ max: maxConnections ?? 1,
144
+ idle_timeout: 60,
145
+ connect_timeout: 5,
146
+ max_lifetime: 60 * 5,
147
+ ...(ssl ? { ssl } : {}),
148
+ };
149
+ }
150
+
99
151
  // Lazy singleton DB connection
100
152
  let dbInstance: ReturnType<typeof drizzle> | null = null;
101
153
  let sqlClient: ReturnType<typeof postgres> | null = null;
@@ -110,13 +162,7 @@ export function createDb<T extends Record<string, unknown>>(
110
162
  if (dbInstance) return { db: dbInstance, schema };
111
163
 
112
164
  const url = getDatabaseUrl();
113
- sqlClient = postgres(url, {
114
- max: options?.maxConnections ?? 1,
115
- idle_timeout: 60,
116
- connect_timeout: 5,
117
- max_lifetime: 60 * 5,
118
- ssl: 'require',
119
- });
165
+ sqlClient = postgres(url, connectionOptions(url, options?.maxConnections));
120
166
  dbInstance = drizzle(sqlClient, { schema });
121
167
  return { db: dbInstance, schema };
122
168
  }
@@ -242,13 +288,7 @@ export function createAdminDb<T extends Record<string, unknown>>(
242
288
  return createDb(schema, options);
243
289
  }
244
290
 
245
- adminSqlClient = postgres(adminUrl, {
246
- max: options?.maxConnections ?? 1,
247
- idle_timeout: 60,
248
- connect_timeout: 5,
249
- max_lifetime: 60 * 5,
250
- ssl: 'require',
251
- });
291
+ adminSqlClient = postgres(adminUrl, connectionOptions(adminUrl, options?.maxConnections));
252
292
  adminDbInstance = drizzle(adminSqlClient, { schema });
253
293
  return { db: adminDbInstance, schema };
254
294
  }
@@ -272,11 +312,11 @@ export function getAdminSql(): ReturnType<typeof postgres> {
272
312
  * different host. Returns null when no replica is configured.
273
313
  */
274
314
  export function getReadDatabaseUrl(): string | null {
275
- // SST Secret — PascalCase (Resource.ReadDatabaseUrl.value)
276
- const pascal = tryResource(() => (Resource as any).ReadDatabaseUrl?.value as string);
315
+ // SST Secret — PascalCase (SST_RESOURCE_ReadDatabaseUrl → { value })
316
+ const pascal = sstLink('ReadDatabaseUrl')?.value as string | undefined;
277
317
  if (pascal) return pascal;
278
- // SST Secret — raw env name (Resource.READ_DATABASE_URL.value)
279
- const raw = tryResource(() => (Resource as any).READ_DATABASE_URL?.value as string);
318
+ // SST Secret — raw env name (SST_RESOURCE_READ_DATABASE_URL → { value })
319
+ const raw = sstLink('READ_DATABASE_URL')?.value as string | undefined;
280
320
  if (raw) return raw;
281
321
  // process.env fallback
282
322
  if (process.env.READ_DATABASE_URL) return process.env.READ_DATABASE_URL;
@@ -314,13 +354,7 @@ export function createReadDb<T extends Record<string, unknown>>(
314
354
  const url = getReadDatabaseUrl();
315
355
  if (!url) return createDb(schema, options);
316
356
 
317
- readSqlClient = postgres(url, {
318
- max: options?.maxConnections ?? 1,
319
- idle_timeout: 60,
320
- connect_timeout: 5,
321
- max_lifetime: 60 * 5,
322
- ssl: 'require',
323
- });
357
+ readSqlClient = postgres(url, connectionOptions(url, options?.maxConnections));
324
358
  readDbInstance = drizzle(readSqlClient, { schema });
325
359
  return { db: readDbInstance, schema };
326
360
  }
package/src/plugin.ts CHANGED
@@ -42,8 +42,8 @@ export interface PluginContext {
42
42
  verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
43
43
  /** Runtime environment */
44
44
  environment: string;
45
- /** Publish a background job */
46
- publishJob: (type: string, payload: unknown, options?: { schedulable?: boolean; runAt?: Date }) => Promise<string>;
45
+ /** Publish a background job. Optional — a lean app with no @everystack/jobs never sets it. */
46
+ publishJob?: (type: string, payload: unknown, options?: { schedulable?: boolean; runAt?: Date }) => Promise<string>;
47
47
  /** Cancel a scheduled job */
48
48
  cancelJob?: (jobId: string) => Promise<void>;
49
49
  /** Reschedule a pending job */
@@ -596,18 +596,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
596
596
  if (options.connectionInfo) {
597
597
  return await options.connectionInfo();
598
598
  }
599
- try {
600
- const { Resource } = await import('sst');
601
- return {
602
- host: (Resource as any).Database.host,
603
- port: (Resource as any).Database.port,
604
- database: (Resource as any).Database.database,
605
- username: (Resource as any).Database.username,
606
- password: (Resource as any).Database.password,
607
- };
608
- } catch {
599
+ const { getDatabaseComponentInfo } = await import('./db');
600
+ const db = getDatabaseComponentInfo();
601
+ if (!db) {
609
602
  return { error: 'No connectionInfo callback provided and SST Resource.Database not available' };
610
603
  }
604
+ return { host: db.host, port: db.port, database: db.database, username: db.username, password: db.password };
611
605
  };
612
606
 
613
607
  // --- db:query ---
@@ -778,13 +772,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
778
772
  connection = { host: info.host, port: info.port, database: info.database };
779
773
  }
780
774
  } else {
781
- try {
782
- const { Resource } = await import('sst');
783
- const db = (Resource as any).Database;
784
- if (db?.host) connection = { host: db.host, port: db.port, database: db.database };
785
- } catch {
786
- // No linked component and no callback — the admin login lands unverified.
787
- }
775
+ const { getDatabaseComponentInfo } = await import('./db');
776
+ const db = getDatabaseComponentInfo();
777
+ // No linked component and no callback — the admin login lands unverified.
778
+ if (db?.host) connection = { host: db.host, port: db.port, database: db.database };
788
779
  }
789
780
 
790
781
  return await runProvision(