@ultimat3/http 22.2.1 → 22.3.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.2.1",
3
+ "version": "22.3.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.2.1",
35
- "@ultimat3/i18n": "22.2.1",
36
- "@ultimat3/schema": "22.2.1",
37
- "@ultimat3/time": "22.2.1"
34
+ "@ultimat3/core": "22.3.0",
35
+ "@ultimat3/i18n": "22.3.0",
36
+ "@ultimat3/schema": "22.3.0",
37
+ "@ultimat3/time": "22.3.0"
38
38
  }
39
39
  }
package/src/context.ts CHANGED
@@ -112,6 +112,11 @@ export interface RequestContext extends Ctx {
112
112
  */
113
113
  actor: Actor;
114
114
  locale: string;
115
+ /**
116
+ * The locale a leading `/<locale>/` segment named, set by the `context` stage before the route
117
+ * is matched. Authoritative: the `locale` stage answers it over every other source.
118
+ */
119
+ pathLocale: string | undefined;
115
120
  tz: string;
116
121
  /** What the CLIENT says it is running, from `config.buildIdHeader`. `assertBuild()` reads it. */
117
122
  clientBuildId: string | null;
@@ -228,6 +233,7 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
228
233
  route: undefined,
229
234
  actor: anonymousActor(),
230
235
  locale: localeConfig().fallback,
236
+ pathLocale: undefined,
231
237
  tz: timeConfig().defaultZone,
232
238
  clientBuildId: null,
233
239
  input: undefined,
package/src/error-map.ts CHANGED
@@ -376,6 +376,10 @@ export const ERROR_STATUS = {
376
376
  // request that ran the loader, so it needs a row for the same reason the line above does: an
377
377
  // unclassified 500 blanks the sentence naming the status and the `redirect()` to use instead.
378
378
  X_ROUTE_STATUS_INVALID: 500,
379
+ // `asset('assets/…')` named a file that is not on disk. The static build refuses it, but an `ssr`
380
+ // page calls `asset()` INSIDE the request it renders, so it reaches a caller there — a deploy
381
+ // defect, never the visitor's, hence 500, declared so the cause naming the missing file survives.
382
+ X_ASSET_MISSING: 500,
379
383
  // @ultimat3/mail
380
384
  // The deployment configured no transport. It reaches a caller only through an inline
381
385
  // `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
@@ -0,0 +1,70 @@
1
+ // The locale a URL names. A leading `/<locale>/` segment is split off BEFORE the route table is
2
+ // matched — so `/en/precios` is the `/precios` route in English and `/fr/x` is still a 404 — and
3
+ // it is authoritative over query, cookie, user and header. Which locales route, and how a path is
4
+ // spelled in one, are `@ultimat3/i18n`'s; this file only applies them to a request.
5
+
6
+ import { type LocaleSources, localeConfig, resolveLocale, splitLocalePrefix } from '@ultimat3/i18n';
7
+ import type { RequestContext } from './context';
8
+
9
+ /** Methods whose redirect may change nothing but the URL. Anything else is a 308, never a 301. */
10
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
11
+
12
+ /**
13
+ * The pathname the router should match, or the redirect that answers the request instead.
14
+ *
15
+ * A prefix naming the DEFAULT locale is a duplicate URL (`/es-co/precios` is `/precios`), so it is
16
+ * answered with a permanent redirect to the unprefixed path rather than served twice. A prefix
17
+ * naming another routed locale is stripped and recorded on `ctx.pathLocale` — and on `ctx.locale`
18
+ * at once, so a 404 for `/en/nothing` renders its error page in the language the URL asked for.
19
+ */
20
+ export function routeLocalePrefix(
21
+ pathname: string,
22
+ ctx: RequestContext,
23
+ basePath: string,
24
+ ): string | Response {
25
+ const prefix = splitLocalePrefix(pathname);
26
+ if (prefix === undefined) return pathname;
27
+ if (prefix.isDefault) {
28
+ const mount = basePath === '/' || basePath === '' ? '' : basePath.replace(/\/$/, '');
29
+ const location = `${mount}${prefix.path}${ctx.url.search}`;
30
+ // PERMANENT, and so not `redirect()`, whose statuses are an application's (it refuses 301 on
31
+ // purpose: a cached permanent redirect cannot be taken back). This one is the framework's URL
32
+ // scheme, and it IS permanent — the default locale is never prefixed, in any release.
33
+ return new Response(null, {
34
+ status: SAFE_METHODS.has(ctx.method) ? 301 : 308,
35
+ headers: { location },
36
+ });
37
+ }
38
+ ctx.pathLocale = prefix.locale;
39
+ ctx.locale = prefix.locale;
40
+ return prefix.path;
41
+ }
42
+
43
+ /**
44
+ * The request's locale. The path first, always; then a route that takes its locale from the path
45
+ * alone gets the DEFAULT for an unprefixed URL — no negotiation, because the document a CDN serves
46
+ * for `/` is one file and cannot vary by header (the bug this rule closes: a `site/` page answered
47
+ * in English to an English browser from the process, and in Spanish from the export). Every other
48
+ * route keeps `resolveLocale`'s own order.
49
+ */
50
+ export function requestLocale(ctx: RequestContext, sources: LocaleSources): string {
51
+ if (ctx.pathLocale !== undefined) return ctx.pathLocale;
52
+ if (ctx.route?.meta.localeSource === 'path') return localeConfig().fallback;
53
+ return resolveLocale(sources).locale;
54
+ }
55
+
56
+ /** Whether this response's locale is a function of its URL alone. */
57
+ export const localeFromPath = (ctx: RequestContext): boolean =>
58
+ ctx.pathLocale !== undefined || ctx.route?.meta.localeSource === 'path';
59
+
60
+ /** Removes one dimension from `Vary`, case-insensitively, deleting the header when none is left. */
61
+ export const dropVary = (response: Response, name: string): Response => {
62
+ const existing = response.headers.get('vary');
63
+ if (existing === null) return response;
64
+ const kept = existing
65
+ .split(/,\s*/)
66
+ .filter((value) => value !== '' && value.toLowerCase() !== name.toLowerCase());
67
+ if (kept.length === 0) response.headers.delete('vary');
68
+ else response.headers.set('vary', kept.join(', '));
69
+ return response;
70
+ };
@@ -0,0 +1,38 @@
1
+ // `ctx.locale`, `ctx.tz` and `content-language` for one request: the `locale` stage calls this,
2
+ // and `auth` calls it again once the actor's saved preference is known. WHERE each value is read
3
+ // from is this file's; what a locale or a zone IS stays `@ultimat3/i18n`'s and `@ultimat3/time`'s.
4
+
5
+ import { resolveTimeZone } from '@ultimat3/time';
6
+ import type { HttpConfig } from './config';
7
+ import type { RequestContext } from './context';
8
+ import { readCookie } from './locale';
9
+ import { requestLocale } from './locale-prefix';
10
+ import type { UltimateRequest } from './request';
11
+
12
+ /**
13
+ * `ctx.locale`, `ctx.tz` and `content-language` from every source the request carries — the
14
+ * cookie, the header and, once `auth` has run, the actor's saved preference. One function for both
15
+ * stages, so the order is always the owners' (`resolveLocale`, `resolveTimeZone`) and never this
16
+ * file's.
17
+ */
18
+ export function resolvePreferences(
19
+ request: UltimateRequest,
20
+ ctx: RequestContext,
21
+ config: HttpConfig,
22
+ ): void {
23
+ const cookies = request.header('cookie');
24
+ ctx.locale = requestLocale(ctx, {
25
+ // Documented as a source and never read until 2026-09: an email preview link's `?locale=es`
26
+ // rendered in the visitor's cookie locale. The ORDER stays `resolveLocale`'s.
27
+ query: ctx.url.searchParams.get('locale'),
28
+ header: request.header('accept-language'),
29
+ cookie: readCookie(cookies, config.locale.cookie),
30
+ user: ctx.actor.locale,
31
+ });
32
+ ctx.tz = resolveTimeZone({
33
+ cookie: readCookie(cookies, config.tz.cookie),
34
+ header: request.header(config.tz.header),
35
+ user: ctx.actor.tz ?? null,
36
+ }).zone;
37
+ ctx.headers.set('content-language', ctx.locale);
38
+ }
package/src/router.ts CHANGED
@@ -71,6 +71,14 @@ export interface RouteMeta {
71
71
  readonly rateLimitBucket?: Bucket;
72
72
  readonly tags?: readonly string[];
73
73
  readonly description?: string;
74
+ /**
75
+ * Where this route's locale comes from. `'request'` (the default) is `resolveLocale`'s order —
76
+ * query → cookie → user → header. `'path'` is the URL alone: a `/<locale>/` prefix names it and
77
+ * the unprefixed path is ALWAYS the default locale, so the response is a function of its URL and
78
+ * `Vary: accept-language` is dropped. A prerendered `site/` page is `'path'`: the file a CDN
79
+ * serves for `/` cannot negotiate, so the served process must not either.
80
+ */
81
+ readonly localeSource?: 'path' | 'request';
74
82
  }
75
83
 
76
84
  export type RouteHandler = (
package/src/stages.ts CHANGED
@@ -12,8 +12,6 @@ import {
12
12
  lifecycleState,
13
13
  reportError,
14
14
  } from '@ultimat3/core';
15
- import { resolveLocale } from '@ultimat3/i18n';
16
- import { resolveTimeZone } from '@ultimat3/time';
17
15
  import { signInRedirect } from './auth-redirect';
18
16
  import { defaultCache, offersSharedCache, PRIVATE_CACHE, reviewedHint } from './cache-policy';
19
17
  import { type HttpConfig, stripBasePath } from './config';
@@ -35,9 +33,10 @@ import {
35
33
  } from './errors';
36
34
  import type { ServerHooks } from './hooks';
37
35
  import { acceptsHtml } from './html-render';
38
- import { readCookie } from './locale';
36
+ import { dropVary, localeFromPath, routeLocalePrefix } from './locale-prefix';
39
37
  import { compose, type Middleware } from './middleware';
40
38
  import { overlayResponse } from './overlay';
39
+ import { resolvePreferences } from './preferences';
41
40
  import { type RateLimitDecision, type RateLimiter, rateLimitSpends } from './rate-limit';
42
41
  import { rateLimited } from './rate-limit-errors';
43
42
  import type { UltimateRequest } from './request';
@@ -173,8 +172,13 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
173
172
  if (config.buildId !== null) ctx.headers.set(config.buildIdHeader, config.buildId);
174
173
  request.assertBuild();
175
174
 
175
+ // A `/<locale>/` prefix is stripped BEFORE the match, never by middleware: middleware wraps
176
+ // matched routes only, so `/en/precios` could never reach the `/precios` route through it.
177
+ // A refusal still names the path as REQUESTED, so a 404 for `/en/x` does not say `/x`.
176
178
  const pathname = stripBasePath(ctx.url.pathname, config.basePath);
177
- const match = matchRoute(input.table, ctx.method, pathname);
179
+ const routed = routeLocalePrefix(pathname, ctx, config.basePath);
180
+ if (routed instanceof Response) return routed;
181
+ const match = matchRoute(input.table, ctx.method, routed);
178
182
  if (!match.ok) {
179
183
  if (match.reason === 'not-found') throw routeNotFound(ctx.method, pathname);
180
184
  if (match.reason === 'path-invalid') throw pathInvalid(pathname, match.segment);
@@ -457,6 +461,9 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
457
461
  if (name === 'vary') addVary(response, [value]);
458
462
  else response.headers.set(name, value);
459
463
  }
464
+ // After every contributor: a locale read off the URL makes the body a function of the URL,
465
+ // and a CDN keying it on `accept-language` too stores one copy per browser for nothing.
466
+ if (localeFromPath(ctx)) dropVary(response, 'accept-language');
460
467
  for (const [name, value] of Object.entries(
461
468
  responseSecurityHeaders(config.security, ctx.https),
462
469
  )) {
@@ -468,31 +475,3 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
468
475
  };
469
476
  return table;
470
477
  };
471
-
472
- /**
473
- * `ctx.locale`, `ctx.tz` and `content-language` from every source the request carries — the
474
- * cookie, the header and, once `auth` has run, the actor's saved preference. One function for both
475
- * stages, so the order is always the owners' (`resolveLocale`, `resolveTimeZone`) and never this
476
- * file's.
477
- */
478
- function resolvePreferences(
479
- request: UltimateRequest,
480
- ctx: RequestContext,
481
- config: HttpConfig,
482
- ): void {
483
- const cookies = request.header('cookie');
484
- ctx.locale = resolveLocale({
485
- // Documented as a source and never read until 2026-09: an email preview link's `?locale=es`
486
- // rendered in the visitor's cookie locale. The ORDER stays `resolveLocale`'s.
487
- query: ctx.url.searchParams.get('locale'),
488
- header: request.header('accept-language'),
489
- cookie: readCookie(cookies, config.locale.cookie),
490
- user: ctx.actor.locale,
491
- }).locale;
492
- ctx.tz = resolveTimeZone({
493
- cookie: readCookie(cookies, config.tz.cookie),
494
- header: request.header(config.tz.header),
495
- user: ctx.actor.tz ?? null,
496
- }).zone;
497
- ctx.headers.set('content-language', ctx.locale);
498
- }