@ultimat3/http 22.6.2 → 22.7.1

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": "@ultimat3/http",
3
- "version": "22.6.2",
3
+ "version": "22.7.1",
4
4
  "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "22.6.2",
35
- "@ultimat3/i18n": "22.6.2",
36
- "@ultimat3/schema": "22.6.2",
37
- "@ultimat3/time": "22.6.2"
34
+ "@ultimat3/core": "22.7.1",
35
+ "@ultimat3/i18n": "22.7.1",
36
+ "@ultimat3/schema": "22.7.1",
37
+ "@ultimat3/time": "22.7.1"
38
38
  }
39
39
  }
@@ -49,3 +49,16 @@ export const defaultCache = (route: Route | undefined, actor: Actor): CacheHint
49
49
  */
50
50
  export const reviewedHint = (hint: CacheHint, actor: Actor): CacheHint =>
51
51
  hint.mode === 'public' && !isAnonymous(actor) ? PRIVATE_CACHE : hint;
52
+
53
+ const REPLAYABLE_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD']);
54
+
55
+ /**
56
+ * Whether a shared cache could ever replay this EXCHANGE to someone else. Only a safe method's
57
+ * answer can be (a CDN keys on the URL, and a POST's answer is a function of its body), and never
58
+ * a 4xx/5xx: a `401` + `WWW-Authenticate` is a challenge to THIS caller, an RFC 6749 §5.1 error
59
+ * must be `no-store`, and the next request may well succeed. The route default and a declared
60
+ * shared offer were both decided without the method or the status, so an anonymous `POST /mcp`
61
+ * refused `401` went out `public, s-maxage=60, stale-while-revalidate=600`.
62
+ */
63
+ export const replayableExchange = (method: string, status: number): boolean =>
64
+ REPLAYABLE_METHODS.has(method.toUpperCase()) && status < 400;
@@ -0,0 +1,47 @@
1
+ // Single responsibility: the `cache-headers` stage's final answer — the one owner of what a
2
+ // response may be cached as. Split out of `stages.ts` when the exchange rule (method + status)
3
+ // joined the route default, the declared hint and the review of a handler's own header.
4
+
5
+ import { isAnonymous } from '@ultimat3/core';
6
+ import {
7
+ defaultCache,
8
+ offersSharedCache,
9
+ PRIVATE_CACHE,
10
+ replayableExchange,
11
+ reviewedHint,
12
+ } from './cache-policy';
13
+ import type { RequestContext } from './context';
14
+ import { addVary, applyCacheHeaders, NO_STORE, SHARED_CACHE_VARY } from './response';
15
+
16
+ export const finalizeCacheHeaders = (response: Response, ctx: RequestContext): void => {
17
+ const declared = response.headers.get('cache-control');
18
+ // The EXCHANGE first, before any hint or declaration: a POST's answer or a refusal is never
19
+ // a shared cache's to replay, whatever the route or the handler offered (`replayableExchange`).
20
+ // `pragma: no-cache` beside it is RFC 6749 §5.1's pair for a token endpoint's answer, and
21
+ // costs nothing anywhere else. Only an OFFER is overruled — a handler's own `private` or
22
+ // `no-store` already says less than this would.
23
+ if (!replayableExchange(ctx.method, response.status)) {
24
+ if (declared === null || offersSharedCache(declared)) {
25
+ applyCacheHeaders(response, NO_STORE);
26
+ if (!response.headers.has('pragma')) response.headers.set('pragma', 'no-cache');
27
+ }
28
+ return;
29
+ }
30
+ if (declared === null) {
31
+ const hint = ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route, ctx.actor);
32
+ applyCacheHeaders(response, reviewedHint(hint, ctx.actor));
33
+ return;
34
+ }
35
+ // A declaration is the MODE's intent, never the last word: `@ultimat3/render`'s `ssrHeaders`
36
+ // offers any route without a `policy` to a CDN for 30 seconds, and `meta.auth` is
37
+ // `'public' | 'required'` — so the page that greets a signed-in visitor by name is a
38
+ // `'public'` route whose own header says `s-maxage`. This stage is the one owner of the
39
+ // final answer, which is why it REVIEWS what the handler wrote instead of standing down;
40
+ // the rule beside it was otherwise unreachable for every page route in every app.
41
+ if (!offersSharedCache(declared)) return;
42
+ if (!isAnonymous(ctx.actor)) {
43
+ applyCacheHeaders(response, PRIVATE_CACHE);
44
+ return;
45
+ }
46
+ addVary(response, SHARED_CACHE_VARY);
47
+ };
package/src/error-map.ts CHANGED
@@ -392,6 +392,10 @@ export const ERROR_STATUS = {
392
392
  // page calls `asset()` INSIDE the request it renders, so it reaches a caller there — a deploy
393
393
  // defect, never the visitor's, hence 500, declared so the cause naming the missing file survives.
394
394
  X_ASSET_MISSING: 500,
395
+ // @ultimat3/seo — a `meta.links` entry an `ssr` page's `meta()` built from data: an unsafe href,
396
+ // a preload with no `as`. Thrown while that request renders its head, so it reaches the caller —
397
+ // an authoring defect, never the visitor's, hence 500 with the cause naming the link intact.
398
+ X_SEO_LINK_INVALID: 500,
395
399
  // @ultimat3/mail
396
400
  // The deployment configured no transport. It reaches a caller only through an inline
397
401
  // `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
package/src/stages.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  reportError,
14
14
  } from '@ultimat3/core';
15
15
  import { signInRedirect } from './auth-redirect';
16
- import { defaultCache, offersSharedCache, PRIVATE_CACHE, reviewedHint } from './cache-policy';
16
+ import { finalizeCacheHeaders } from './cache-stage';
17
17
  import { type HttpConfig, stripBasePath } from './config';
18
18
  import { actorView, elapsedMs, type RequestContext } from './context';
19
19
  import { corsHeaders, preflight } from './cors';
@@ -40,7 +40,7 @@ import { resolvePreferences } from './preferences';
40
40
  import { type RateLimitDecision, type RateLimiter, rateLimitSpends } from './rate-limit';
41
41
  import { rateLimited } from './rate-limit-errors';
42
42
  import type { UltimateRequest } from './request';
43
- import { addVary, applyCacheHeaders, problem, redirect, SHARED_CACHE_VARY } from './response';
43
+ import { addVary, problem, redirect } from './response';
44
44
  import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
45
45
  import { responseSecurityHeaders } from './security-headers';
46
46
  import { validate } from './validate';
@@ -324,26 +324,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
324
324
  },
325
325
 
326
326
  'cache-headers': (_request, ctx) => {
327
- const response = ctx.response;
328
- if (response === undefined) return undefined;
329
- const declared = response.headers.get('cache-control');
330
- if (declared === null) {
331
- const hint = ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route, ctx.actor);
332
- applyCacheHeaders(response, reviewedHint(hint, ctx.actor));
333
- return undefined;
334
- }
335
- // A declaration is the MODE's intent, never the last word: `@ultimat3/render`'s `ssrHeaders`
336
- // offers any route without a `policy` to a CDN for 30 seconds, and `meta.auth` is
337
- // `'public' | 'required'` — so the page that greets a signed-in visitor by name is a
338
- // `'public'` route whose own header says `s-maxage`. This stage is the one owner of the
339
- // final answer, which is why it REVIEWS what the handler wrote instead of standing down;
340
- // the rule beside it was otherwise unreachable for every page route in every app.
341
- if (!offersSharedCache(declared)) return undefined;
342
- if (!isAnonymous(ctx.actor)) {
343
- applyCacheHeaders(response, PRIVATE_CACHE);
344
- return undefined;
345
- }
346
- addVary(response, SHARED_CACHE_VARY);
327
+ if (ctx.response !== undefined) finalizeCacheHeaders(ctx.response, ctx);
347
328
  return undefined;
348
329
  },
349
330