@everystack/server 0.2.27 → 0.2.29

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 +10 -10
  2. package/src/db.ts +72 -0
  3. package/src/ssr.ts +74 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
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>",
@@ -74,11 +74,6 @@
74
74
  "default": "./src/media.ts"
75
75
  }
76
76
  },
77
- "scripts": {
78
- "test": "jest",
79
- "build": "tsc --build",
80
- "lint": "tsc --noEmit"
81
- },
82
77
  "peerDependencies": {
83
78
  "esbuild": ">=0.20.0",
84
79
  "@aws-sdk/client-cloudfront-keyvaluestore": ">=3.1053.0",
@@ -128,8 +123,6 @@
128
123
  "devDependencies": {
129
124
  "@aws-sdk/client-cloudfront-keyvaluestore": "3.1053.0",
130
125
  "@aws-sdk/signature-v4a": "3.1063.0",
131
- "@everystack/auth": "workspace:*",
132
- "@everystack/cli": "workspace:*",
133
126
  "@types/aws-lambda": "8.10.161",
134
127
  "@types/jest": "29.5.14",
135
128
  "@types/node": "22.19.18",
@@ -139,6 +132,13 @@
139
132
  "postgres": "3.4.9",
140
133
  "sst": "4.13.1",
141
134
  "ts-jest": "29.4.9",
142
- "typescript": "5.9.3"
135
+ "typescript": "5.9.3",
136
+ "@everystack/auth": "0.2.6",
137
+ "@everystack/cli": "0.2.40"
138
+ },
139
+ "scripts": {
140
+ "test": "jest",
141
+ "build": "tsc --build",
142
+ "lint": "tsc --noEmit"
143
143
  }
144
- }
144
+ }
package/src/db.ts CHANGED
@@ -262,3 +262,75 @@ export function getAdminSql(): ReturnType<typeof postgres> {
262
262
  if (adminSqlClient) return adminSqlClient;
263
263
  return getSql();
264
264
  }
265
+
266
+ /**
267
+ * Resolve the read-replica database URL, if configured.
268
+ *
269
+ * The third credential slot: DATABASE_URL (api), ADMIN_DATABASE_URL (operator),
270
+ * READ_DATABASE_URL (replica). Point it at a physical read replica's endpoint
271
+ * with the SAME least-privilege role as DATABASE_URL — same credential,
272
+ * different host. Returns null when no replica is configured.
273
+ */
274
+ export function getReadDatabaseUrl(): string | null {
275
+ // SST Secret — PascalCase (Resource.ReadDatabaseUrl.value)
276
+ const pascal = tryResource(() => (Resource as any).ReadDatabaseUrl?.value as string);
277
+ 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);
280
+ if (raw) return raw;
281
+ // process.env fallback
282
+ if (process.env.READ_DATABASE_URL) return process.env.READ_DATABASE_URL;
283
+ return null;
284
+ }
285
+
286
+ // Lazy singleton read-replica connection (separate from the API connection)
287
+ let readDbInstance: ReturnType<typeof drizzle> | null = null;
288
+ let readSqlClient: ReturnType<typeof postgres> | null = null;
289
+
290
+ /**
291
+ * Read-replica database connection for anonymous read traffic.
292
+ *
293
+ * Connects with READ_DATABASE_URL when configured. When it isn't, returns the
294
+ * primary connection (createDb) with NO warning — unlike the admin split this
295
+ * is a scaling knob, not a security boundary: absent is normal, not a
296
+ * misconfiguration. Apps without a replica keep working with zero config.
297
+ *
298
+ * `withRole` works unchanged against a hot standby: SET LOCAL ROLE and
299
+ * set_config(…, true) are permitted in read-only transactions, and physical
300
+ * replication carries roles, GRANTs, and RLS policies with the catalog. A
301
+ * write through this connection fails loudly on a real replica ("cannot
302
+ * execute … in a read-only transaction") — a routing bug can never silently
303
+ * diverge. See docs/plans/read-replica-routing.md.
304
+ */
305
+ export function createReadDb<T extends Record<string, unknown>>(
306
+ schema: T,
307
+ options?: { maxConnections?: number }
308
+ ): {
309
+ db: ReturnType<typeof drizzle>;
310
+ schema: T;
311
+ } {
312
+ if (readDbInstance) return { db: readDbInstance, schema };
313
+
314
+ const url = getReadDatabaseUrl();
315
+ if (!url) return createDb(schema, options);
316
+
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
+ });
324
+ readDbInstance = drizzle(readSqlClient, { schema });
325
+ return { db: readDbInstance, schema };
326
+ }
327
+
328
+ /**
329
+ * Raw postgres.js client for the read-replica connection.
330
+ * Must be called after createReadDb(). Falls back to the API client when no
331
+ * replica is configured (same fallback as createReadDb).
332
+ */
333
+ export function getReadSql(): ReturnType<typeof postgres> {
334
+ if (readSqlClient) return readSqlClient;
335
+ return getSql();
336
+ }
package/src/ssr.ts CHANGED
@@ -69,6 +69,15 @@ export interface WebHandlerOptions {
69
69
  /** Inject __EXPO_ROUTER_HYDRATE__ flag before </head>. Default: true. */
70
70
  injectHydrateFlag?: boolean;
71
71
 
72
+ /**
73
+ * Opaque the expo-router loader-data blob so search crawlers can't harvest
74
+ * URL-shaped strings (route keys like `/stories/index`, image/link values)
75
+ * out of the inline hydration script. Default: true. Runtime behaviour is
76
+ * unchanged — the decoded globalThis value is byte-identical. See
77
+ * deharvestLoaderData.
78
+ */
79
+ deharvestLoaderData?: boolean;
80
+
72
81
  /**
73
82
  * App-specific HTML post-processing. Called after built-in processing
74
83
  * (param stripping, hydrate flag). Receives the processed HTML and the
@@ -82,6 +91,59 @@ export interface WebHandlerOptions {
82
91
  const DEFAULT_STRIP_PARAMS = ['_v', '_a'];
83
92
  const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>\n';
84
93
 
94
+ /**
95
+ * Matches expo-router's `<PreloadedDataScript />` output. The renderer emits
96
+ * exactly:
97
+ *
98
+ * <script id="expo-router-data" type="module">
99
+ * globalThis.__EXPO_ROUTER_LOADER_DATA__ = JSON.parse("<escaped-json>");
100
+ * </script>
101
+ *
102
+ * Group 1 = the opening tag, group 2 = the double-quoted JS string literal
103
+ * argument. The literal is HTML-safe (expo escapes <, >, & to \uXXXX), so a
104
+ * `</script>` terminator never appears inside it.
105
+ */
106
+ const LOADER_DATA_SCRIPT_RE =
107
+ /(<script id="expo-router-data"[^>]*>globalThis\.__EXPO_ROUTER_LOADER_DATA__\s*=\s*JSON\.parse\()("(?:[^"\\]|\\.)*")(\);<\/script>)/;
108
+
109
+ /**
110
+ * Rewrite the expo-router loader-data script so its payload is opaque in the
111
+ * raw HTML while the executed value is byte-identical.
112
+ *
113
+ * expo-router keys the loader map by the route FILE path, so index routes get a
114
+ * `/index` suffix (`/stories/index`). Googlebot harvests URL-shaped strings
115
+ * from inline JS, resolves those same-origin, crawls `/stories/index`, and
116
+ * 404s. The key cannot be renamed: expo-router's client resolver
117
+ * (useLoaderData) looks the data up by the identical `/index` key, so any
118
+ * SSR-only rename mismatches on hydration (white flash).
119
+ *
120
+ * The one server-only fix that removes the harvestable text AND preserves the
121
+ * exact key: base64-encode the JSON payload. No URL-shaped substring survives
122
+ * in the source for the crawler, and at runtime `atob` + TextDecoder + JSON.parse
123
+ * reproduces the same object with the same keys — the client is untouched. This
124
+ * also opaques URL-shaped *values* (image/CDN links inside loader data), killing
125
+ * the whole harvest class, not just `/index` keys.
126
+ *
127
+ * Fail-safe: if the script is absent or doesn't match the expected shape (e.g.
128
+ * an expo-router format change), the HTML is returned unchanged. Idempotent —
129
+ * the rewritten script no longer matches the string-literal form.
130
+ */
131
+ export function deharvestLoaderData(html: string): string {
132
+ return html.replace(LOADER_DATA_SCRIPT_RE, (full, open: string, literal: string, close: string) => {
133
+ let jsonText: string;
134
+ try {
135
+ // The literal is valid JSON string syntax; parsing yields the JSON text
136
+ // the client would otherwise receive verbatim.
137
+ jsonText = JSON.parse(literal) as string;
138
+ } catch {
139
+ return full; // unexpected shape — leave untouched
140
+ }
141
+ const b64 = Buffer.from(jsonText, 'utf8').toString('base64');
142
+ const decode = `new TextDecoder().decode(Uint8Array.from(atob(${JSON.stringify(b64)}),c=>c.charCodeAt(0)))`;
143
+ return `${open}${decode}${close}`;
144
+ });
145
+ }
146
+
85
147
  /**
86
148
  * Wraps an SSR request handler with framework-level fixes:
87
149
  *
@@ -91,7 +153,10 @@ const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script
91
153
  * Native Web uses `hydrateRoot()` instead of `createRoot()`.
92
154
  * (React 19 suppresses <script> tags from SSR output, so this must
93
155
  * be done via string replacement after rendering.)
94
- * 3. Calls an optional `postProcessHtml` hook for app-specific transforms
156
+ * 3. Opaques the expo-router loader-data blob so crawlers can't harvest its
157
+ * URL-shaped route keys (`/{path}/index`) and 404 on them. See
158
+ * deharvestLoaderData.
159
+ * 4. Calls an optional `postProcessHtml` hook for app-specific transforms
95
160
  * (e.g., JSON-LD injection, status code override from meta tags).
96
161
  *
97
162
  * @param innerHandler - The expo-server request handler
@@ -100,11 +165,12 @@ const HYDRATE_SCRIPT = '<script>globalThis.__EXPO_ROUTER_HYDRATE__=true;</script
100
165
  */
101
166
  export function wrapSsrHandler(
102
167
  innerHandler: (request: Request) => Promise<Response>,
103
- options?: Pick<WebHandlerOptions, 'stripQueryParams' | 'injectHydrateFlag' | 'postProcessHtml'>,
168
+ options?: Pick<WebHandlerOptions, 'stripQueryParams' | 'injectHydrateFlag' | 'deharvestLoaderData' | 'postProcessHtml'>,
104
169
  metadata?: ReleaseMetadata,
105
170
  ): (request: Request) => Promise<Response> {
106
171
  const stripParams = options?.stripQueryParams ?? DEFAULT_STRIP_PARAMS;
107
172
  const injectHydrate = options?.injectHydrateFlag !== false;
173
+ const deharvest = options?.deharvestLoaderData !== false;
108
174
  const postProcess = options?.postProcessHtml;
109
175
 
110
176
  return async (request: Request): Promise<Response> => {
@@ -149,6 +215,12 @@ export function wrapSsrHandler(
149
215
  html = html.replace('</head>', `${HYDRATE_SCRIPT}</head>`);
150
216
  }
151
217
 
218
+ // Opaque the loader-data blob so crawlers can't harvest its URL-shaped
219
+ // route keys (index routes leak `/{path}/index`). No-op if absent.
220
+ if (deharvest) {
221
+ html = deharvestLoaderData(html);
222
+ }
223
+
152
224
  // 3. App-specific post-processing
153
225
  if (postProcess) {
154
226
  const result = await postProcess(html, request);