@ultimat3/http 22.9.1 → 22.10.0

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.9.1",
3
+ "version": "22.10.0",
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.9.1",
35
- "@ultimat3/i18n": "22.9.1",
36
- "@ultimat3/schema": "22.9.1",
37
- "@ultimat3/time": "22.9.1"
34
+ "@ultimat3/core": "22.10.0",
35
+ "@ultimat3/i18n": "22.10.0",
36
+ "@ultimat3/schema": "22.10.0",
37
+ "@ultimat3/time": "22.10.0"
38
38
  }
39
39
  }
@@ -21,6 +21,11 @@ export interface ErrorFacts {
21
21
  readonly title: string;
22
22
  readonly cause: string;
23
23
  readonly fix: string;
24
+ /**
25
+ * The remote caller's fix (`UltimateErrorInit.callerFix`), branded errors only — `fix`'s trust
26
+ * rule. A production problem document carries it in place of `fix`; the terminal never does.
27
+ */
28
+ readonly callerFix: string | undefined;
24
29
  readonly docs: string;
25
30
  readonly status: number;
26
31
  /** Present only when the process is in dev mode; never sent to a client in prod. */
@@ -88,6 +93,7 @@ export const factsOf = (error: unknown): ErrorFacts => {
88
93
  (FRAMEWORK_CODE.test(code)
89
94
  ? `x errors explain ${code} --json # then fix the throwing call site`
90
95
  : 'x errors list --json # then fix the throwing call site'),
96
+ callerFix: isUltimateError(error) ? str(error, 'callerFix') : undefined,
91
97
  // Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
92
98
  // and a code lives there in a table row, which has no anchor. An `UltimateError` already
93
99
  // resolved this at construction, so the fallback only fires for a throwable the framework did
@@ -322,7 +328,10 @@ export const toProblem = (
322
328
  instance: meta.instance,
323
329
  code: facts.code,
324
330
  cause: hidden ? INTERNAL_CAUSE : facts.cause,
325
- fix: facts.fix,
331
+ // The CALLER's fix outside dev: a production client cannot run `x policy explain`, and the
332
+ // developer's line stays in the log this request wrote. Dev keeps the developer's, because the
333
+ // reader of a dev response is the developer.
334
+ fix: meta.dev === true ? facts.fix : (facts.callerFix ?? facts.fix),
326
335
  docs: facts.docs,
327
336
  requestId: meta.requestId,
328
337
  ...(issues === undefined ? {} : { issues }),
package/src/errors.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // factory here so a code, a cause and an exact fix always travel together — the
3
3
  // terminal, the dev overlay and `--json` all render the same three strings.
4
4
  import {
5
+ deniedCallerFix,
5
6
  registerErrorCodes,
6
7
  registerErrorRetry,
7
8
  renderFixShellArg,
@@ -139,6 +140,8 @@ export class HttpError extends UltimateError {
139
140
  code: HttpErrorCode;
140
141
  cause: string;
141
142
  fix: string;
143
+ /** A remote caller's fix, when `fix` needs the app's CLI — `UltimateErrorInit.callerFix`. */
144
+ callerFix?: string;
142
145
  /**
143
146
  * Facts an operator needs and a CALLER must not be handed. `toProblem` renders code, cause,
144
147
  * fix and docs — never this — so the rate limiter's internal key rides here instead of in a
@@ -146,12 +149,7 @@ export class HttpError extends UltimateError {
146
149
  */
147
150
  meta?: Readonly<Record<string, unknown>>;
148
151
  }) {
149
- super({
150
- code: init.code,
151
- cause: init.cause,
152
- fix: init.fix,
153
- ...(init.meta === undefined ? {} : { meta: init.meta }),
154
- });
152
+ super({ ...init }); // an `UltimateErrorInit`: optional fields attached only when present
155
153
  }
156
154
  }
157
155
 
@@ -256,6 +254,9 @@ export const forbidden = (pathname: string, reason: string, policy?: string): Ht
256
254
  policy !== undefined && POLICY_SUBJECT.test(policy)
257
255
  ? `x policy explain ${policy} --json # shows which clause denied`
258
256
  : `x routes --json # find ${pathname}, then read the policy it declares`,
257
+ callerFix: deniedCallerFix(
258
+ policy !== undefined && POLICY_SUBJECT.test(policy) ? policy : undefined,
259
+ ),
259
260
  });
260
261
 
261
262
  export const buildSkew = (clientBuildId: string, serverBuildId: string): HttpError =>
@@ -57,6 +57,16 @@ export function requestLocale(ctx: RequestContext, sources: LocaleSources): stri
57
57
  export const localeFromPath = (ctx: RequestContext): boolean =>
58
58
  ctx.pathLocale !== undefined || ctx.route?.meta.localeSource === 'path';
59
59
 
60
+ /**
61
+ * Whether `Accept-Language` can decide this response's locale — the one condition under which
62
+ * `Vary: accept-language` is true. Not when the URL names the locale (above), and not when the app's
63
+ * locale order leaves the header out (`configureLocales({ order: ['query', 'cookie', 'user'] })`):
64
+ * every renderer adds the dimension by default, and a CDN keying on a header nothing reads stores
65
+ * one copy of the page per browser language for nothing.
66
+ */
67
+ export const acceptLanguageConsulted = (ctx: RequestContext): boolean =>
68
+ !localeFromPath(ctx) && localeConfig().order.includes('header');
69
+
60
70
  /** Removes one dimension from `Vary`, case-insensitively, deleting the header when none is left. */
61
71
  export const dropVary = (response: Response, name: string): Response => {
62
72
  const existing = response.headers.get('vary');
package/src/stages.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  } from './errors';
34
34
  import type { ServerHooks } from './hooks';
35
35
  import { acceptsHtml } from './html-render';
36
- import { dropVary, localeFromPath, routeLocalePrefix } from './locale-prefix';
36
+ import { acceptLanguageConsulted, dropVary, routeLocalePrefix } from './locale-prefix';
37
37
  import { compose, type Middleware } from './middleware';
38
38
  import { navigationGate, redirectForRouter } from './navigation';
39
39
  import { overlayResponse } from './overlay';
@@ -453,9 +453,10 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
453
453
  if (name === 'vary') addVary(response, [value]);
454
454
  else response.headers.set(name, value);
455
455
  }
456
- // After every contributor: a locale read off the URL makes the body a function of the URL,
457
- // and a CDN keying it on `accept-language` too stores one copy per browser for nothing.
458
- if (localeFromPath(ctx)) dropVary(response, 'accept-language');
456
+ // After every contributor: a locale read off the URL — or an app whose locale order never
457
+ // reads the header — makes `accept-language` a key nothing varies on, and a CDN keying on it
458
+ // stores one copy per browser for nothing.
459
+ if (!acceptLanguageConsulted(ctx)) dropVary(response, 'accept-language');
459
460
  for (const [name, value] of Object.entries(
460
461
  responseSecurityHeaders(config.security, ctx.https),
461
462
  )) {