@everystack/server 0.2.19 → 0.2.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/index.ts CHANGED
@@ -55,9 +55,22 @@ export interface LogSink {
55
55
 
56
56
  /** Cache-Control configuration for the Lambda handler's fallback headers. */
57
57
  export interface ServerCacheConfig {
58
- /** Unauthenticated GET/HEAD responses.
58
+ /** Unauthenticated GET/HEAD responses with a non-HTML body (e.g. JSON API).
59
59
  * Default: 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5' */
60
60
  public?: string;
61
+ /** Unauthenticated GET/HEAD HTML responses (SSR documents).
62
+ * INVARIANT: SSR HTML is a PUBLIC shell — it must NOT be personalized.
63
+ * Personalization happens on client rehydrate, never in the server-rendered
64
+ * document. This is what makes public edge-caching safe; rendering per-user
65
+ * data into the SSR HTML would let the edge serve one user's page to another.
66
+ *
67
+ * HTML pins content-hashed chunk names, so it is the mutable entry point and
68
+ * must turn over fast — unlike the immutable chunks it references. A short
69
+ * s-maxage bounds edge staleness (incl. the version-key empty-`_v` fallback)
70
+ * so `cache:purge --origin web` / any deploy refreshes HTML within the TTL,
71
+ * with no CloudFront invalidation. No stale-while-revalidate: the cap is hard.
72
+ * Default: 'public, max-age=0, s-maxage=60' */
73
+ html?: string;
61
74
  /** Authenticated GET/HEAD responses.
62
75
  * Default: 'private, no-store' */
63
76
  private?: string;
@@ -260,9 +273,18 @@ export function createLambdaHandler(
260
273
  if (!result.headers['cache-control']) {
261
274
  if (method === 'GET' || method === 'HEAD') {
262
275
  const isAuthenticated = !!httpEvent.headers?.authorization;
263
- result.headers['cache-control'] = isAuthenticated
264
- ? (options.cache?.private ?? 'private, no-store')
265
- : (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
276
+ if (isAuthenticated) {
277
+ result.headers['cache-control'] = options.cache?.private ?? 'private, no-store';
278
+ } else {
279
+ // SSR HTML is a PUBLIC shell (never personalized — personalization
280
+ // happens on client rehydrate), so it caches public but must turn
281
+ // over fast: short s-maxage, no SWR → hard ≤60s edge cap. JSON/API
282
+ // GETs are version-keyed and can cache long.
283
+ const isHtml = String(result.headers['content-type'] || '').includes('text/html');
284
+ result.headers['cache-control'] = isHtml
285
+ ? (options.cache?.html ?? 'public, max-age=0, s-maxage=60')
286
+ : (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
287
+ }
266
288
  } else {
267
289
  result.headers['cache-control'] = options.cache?.mutation ?? 'no-store';
268
290
  }
package/src/plugin.ts CHANGED
@@ -240,9 +240,17 @@ export function createPluginLambdaHandler(
240
240
  if (!result.headers['cache-control']) {
241
241
  if (method === 'GET' || method === 'HEAD') {
242
242
  const isAuthenticated = !!httpEvent.headers?.authorization;
243
- result.headers['cache-control'] = isAuthenticated
244
- ? (options.cache?.private ?? 'private, no-store')
245
- : (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
243
+ if (isAuthenticated) {
244
+ result.headers['cache-control'] = options.cache?.private ?? 'private, no-store';
245
+ } else {
246
+ // SSR HTML is the mutable, version-pinning document — it must turn
247
+ // over fast (short s-maxage, no SWR → hard ≤60s edge cap). JSON/API
248
+ // GETs are version-keyed and can cache long at the edge.
249
+ const isHtml = String(result.headers['content-type'] || '').includes('text/html');
250
+ result.headers['cache-control'] = isHtml
251
+ ? (options.cache?.html ?? 'public, max-age=0, s-maxage=60')
252
+ : (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
253
+ }
246
254
  } else {
247
255
  result.headers['cache-control'] = options.cache?.mutation ?? 'no-store';
248
256
  }
@@ -363,6 +371,20 @@ interface SsrContext extends PluginContext {
363
371
  * fallback: ssrPlugin(),
364
372
  * fallback: ssrPlugin({ postProcessHtml: injectJsonLd }),
365
373
  */
374
+ /**
375
+ * Re-stamp an SSR response as private/uncacheable, preserving status + body.
376
+ * Used when the SSR ran with a resolved cookie-user (potentially personalized).
377
+ */
378
+ function withPrivateCache(response: Response): Response {
379
+ const headers = new Headers(response.headers);
380
+ headers.set('cache-control', 'private, no-store');
381
+ return new Response(response.body, {
382
+ status: response.status,
383
+ statusText: response.statusText,
384
+ headers,
385
+ });
386
+ }
387
+
366
388
  export function ssrPlugin(options: SsrPluginOptions = {}): (ctx: PluginContext) => Promise<Handler> {
367
389
  return async (ctx: PluginContext): Promise<Handler> => {
368
390
  const { getWebHandler } = await import('./ssr');
@@ -405,7 +427,16 @@ export function ssrPlugin(options: SsrPluginOptions = {}): (ctx: PluginContext)
405
427
  }
406
428
  }
407
429
  }
408
- return runWithAuthContext(user, () => webHandler(request));
430
+ const response = await runWithAuthContext(user, () => webHandler(request));
431
+ // SECURITY: when the SSR ran with a cookie-resolved user, the response
432
+ // may be personalized. The edge cache-control default keys on the
433
+ // Authorization header (absent for cookie auth), so without this such a
434
+ // response would be cached `public` and could be served to other users
435
+ // (HTML 60s, but loader-data JSON 30 days). Force it private so a
436
+ // personalized SSR response can never be public-cached. Anonymous SSR
437
+ // (no resolved user) stays publicly cacheable — the public shell / SEO
438
+ // path, which by invariant is never personalized.
439
+ return user ? withPrivateCache(response) : response;
409
440
  }
410
441
 
411
442
  return webHandler(request);
package/src/ssr.ts CHANGED
@@ -259,6 +259,68 @@ export async function resolveBundleKey(channel: string, storage?: StorageAdapter
259
259
  return (await resolveWebRelease(channel, storage, db)).bundleKey;
260
260
  }
261
261
 
262
+ /**
263
+ * Filesystem-safe, release-unique directory segment derived from the bundle ETag.
264
+ *
265
+ * expo-server loads the server bundle (render.js and every route module) via
266
+ * require()/import(), and Node caches modules by absolute path. If two releases
267
+ * are extracted to the same path, the first release's modules stay resident in
268
+ * Node's module cache and a warm Lambda keeps serving the old HTML forever —
269
+ * even though the new bundle was downloaded and the new release metadata is
270
+ * reported. The ETag changes whenever bundle content changes, so keying the
271
+ * build directory on it gives each distinct release its own path and forces a
272
+ * fresh module load. See __tests__/ssr-build-dir.test.ts.
273
+ */
274
+ export function releaseDirSegment(etag: string): string {
275
+ return etag.replace(/[^a-zA-Z0-9]+/g, '') || 'default';
276
+ }
277
+
278
+ /**
279
+ * Evict CJS module-cache entries whose file lives under `dir`.
280
+ *
281
+ * expo-server loads the server bundle via require(), so a pruned release's
282
+ * modules would otherwise stay resident in Node's module cache (and uncollected)
283
+ * even after its files are deleted — leaking memory across releases on a warm
284
+ * container. Only modules under an already-pruned (inactive) release dir are
285
+ * evicted, so this never touches the live handler. No-op outside CJS (require
286
+ * unavailable) and harmless if expo-server used import() instead — the ESM
287
+ * registry has no eviction API, so those rely on container recycling.
288
+ */
289
+ export function evictModuleCache(
290
+ dir: string,
291
+ cache: Record<string, unknown> | null =
292
+ (typeof require !== 'undefined' && (require.cache as unknown as Record<string, unknown>)) || null,
293
+ ): void {
294
+ if (!cache) return;
295
+ const prefix = dir.endsWith(path.sep) ? dir : dir + path.sep;
296
+ for (const key of Object.keys(cache)) {
297
+ if (key.startsWith(prefix)) delete cache[key];
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Remove sibling release directories under a channel base, keeping only `keep`.
303
+ * Bounds /tmp usage (512MB ephemeral) AND evicts each removed release's modules
304
+ * from Node's module cache so they can be garbage-collected — without that, the
305
+ * fix to serve fresh releases would leak module memory across deploys on a
306
+ * long-lived warm container.
307
+ */
308
+ async function pruneOldReleaseDirs(channelBase: string, keep: string): Promise<void> {
309
+ let entries: string[];
310
+ try {
311
+ entries = await fsp.readdir(channelBase);
312
+ } catch {
313
+ return; // base doesn't exist yet — nothing to prune
314
+ }
315
+ const keepName = path.basename(keep);
316
+ await Promise.all(entries.map(async (name) => {
317
+ if (name === keepName) return;
318
+ const dir = path.join(channelBase, name);
319
+ await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
320
+ evictModuleCache(dir);
321
+ }));
322
+ }
323
+
262
324
  /**
263
325
  * Returns a Request→Response handler for the current web release,
264
326
  * or null if no web release exists yet.
@@ -275,7 +337,7 @@ export async function getWebHandler(
275
337
  ): Promise<((request: Request) => Promise<Response>) | null> {
276
338
  const now = Date.now();
277
339
  const channel = options?.channel || process.env.ENVIRONMENT || 'production';
278
- const buildDir = `${BUILD_DIR_BASE}/${channel}`;
340
+ const channelBase = `${BUILD_DIR_BASE}/${channel}`;
279
341
 
280
342
  const cached = cachedHandlers.get(channel);
281
343
 
@@ -308,7 +370,13 @@ export async function getWebHandler(
308
370
  return wrapSsrHandler(rawHandler, options, cached.metadata);
309
371
  }
310
372
 
311
- // New or updated release — download and extract server bundle archive
373
+ // New or updated release — extract to a RELEASE-UNIQUE directory keyed on the
374
+ // bundle ETag. expo-server require()s the server bundle and Node caches
375
+ // modules by absolute path, so re-extracting over a stable path would leave
376
+ // the previous release's render.js resident in a warm Lambda. A per-release
377
+ // path forces a fresh module load. See releaseDirSegment.
378
+ const buildDir = `${channelBase}/${releaseDirSegment(meta.etag)}`;
379
+
312
380
  try {
313
381
  await downloadAndExtract(storage, bundleKey, buildDir);
314
382
  } catch {
@@ -316,6 +384,10 @@ export async function getWebHandler(
316
384
  return null;
317
385
  }
318
386
 
387
+ // Drop sibling release dirs from earlier deploys so /tmp doesn't fill up
388
+ // across many releases on a long-lived warm container.
389
+ await pruneOldReleaseDirs(channelBase, buildDir);
390
+
319
391
  if (options?.afterExtract) {
320
392
  await options.afterExtract(buildDir);
321
393
  }