@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 +5 -5
- package/src/context.ts +6 -0
- package/src/error-map.ts +4 -0
- package/src/locale-prefix.ts +70 -0
- package/src/preferences.ts +38 -0
- package/src/router.ts +8 -0
- package/src/stages.ts +11 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "22.
|
|
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.
|
|
35
|
-
"@ultimat3/i18n": "22.
|
|
36
|
-
"@ultimat3/schema": "22.
|
|
37
|
-
"@ultimat3/time": "22.
|
|
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 {
|
|
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
|
|
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
|
-
}
|