@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.
- package/package.json +10 -10
- package/src/db.ts +72 -0
- 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.
|
|
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.
|
|
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);
|