@ultimat3/http 20.2.0 → 21.0.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/CLAUDE.md +15 -0
- package/package.json +5 -5
- package/src/context.ts +7 -1
- package/src/error-map.ts +13 -0
- package/src/request-services.ts +65 -0
- package/src/request.ts +15 -2
- package/src/stages.ts +31 -10
package/CLAUDE.md
CHANGED
|
@@ -126,6 +126,21 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
126
126
|
`logger` loses to the request's own field and stays reachable as `ctx.services['actor']`; the
|
|
127
127
|
context's meaning never depends on what an app named a service. `context.test.ts` pins the
|
|
128
128
|
factory install, the spread and the collision order.
|
|
129
|
+
- **A member's saved locale and zone apply, re-resolved after `auth`** (21.0.0). The `locale`
|
|
130
|
+
stage runs before `auth`, so it resolved from the cookie and `Accept-Language` alone and the
|
|
131
|
+
`user` rung of `resolveLocale` / `resolveTimeZone` was filled by nothing since 2.0.0. Core's
|
|
132
|
+
`Actor` now carries `locale?` / `tz?` (the saved preferences, set by whoever authenticates), and
|
|
133
|
+
the `auth` stage re-runs `resolvePreferences` when either is present — the owners' order, so a
|
|
134
|
+
locale cookie still beats a saved locale and a saved zone beats the cookie. `member-preferences.test.ts`.
|
|
135
|
+
- **A request's registered services are LAZY and bound to the actor that authenticated**
|
|
136
|
+
(`request-services.ts`, 21.0.0). The context is built before the `auth` stage names anyone, and
|
|
137
|
+
core's constructor built every `defineService` factory right there — so every service acted as
|
|
138
|
+
the anonymous actor for the whole request while `ctx.actor` read the real one: the reference
|
|
139
|
+
app's like answered 500 `X_ORG_NOT_A_MEMBER` for a member. `createRequestContext` passes core
|
|
140
|
+
`installServices: false` and `bindRequestServices` turns `ctx.services` and each registered
|
|
141
|
+
`ctx.<name>` into getters: built on first read, rebuilt only if the actor, locale or tz it closed
|
|
142
|
+
over changed. One build for a handler; a hook that reads a service before auth gets its own.
|
|
143
|
+
`request-services.test.ts` drives the real pipeline. Pre-existing since 13.0.0.
|
|
129
144
|
- **The two inbound ids are read BEFORE the context and the span, in `correlation.ts`.** `startSpan`
|
|
130
145
|
resolves its parent from `currentSpanContext()`, which reads `ctx.traceId`, so a `traceparent`
|
|
131
146
|
parsed by a stage arrived one frame after the span's context was already frozen: the caller's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.0.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": "
|
|
35
|
-
"@ultimat3/i18n": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "21.0.0",
|
|
35
|
+
"@ultimat3/i18n": "21.0.0",
|
|
36
|
+
"@ultimat3/schema": "21.0.0",
|
|
37
|
+
"@ultimat3/time": "21.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/context.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { AuthzDecision } from './hooks';
|
|
|
24
24
|
import { readCookie } from './locale';
|
|
25
25
|
import type { PeerIdentity } from './peer-identity';
|
|
26
26
|
import type { RateLimitDecision } from './rate-limit';
|
|
27
|
+
import { bindRequestServices } from './request-services';
|
|
27
28
|
import type { CacheHint, RedirectIntent } from './response';
|
|
28
29
|
import type { Route, RouteParams } from './router';
|
|
29
30
|
|
|
@@ -195,8 +196,11 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
|
|
|
195
196
|
? {}
|
|
196
197
|
: { deadlineAt: init.deadlineAt }),
|
|
197
198
|
...(init.services === undefined ? {} : { services: init.services }),
|
|
199
|
+
// Registered services are bound lazily below, to the actor the `auth` stage authenticates —
|
|
200
|
+
// built here they would close over the anonymous actor for the whole request.
|
|
201
|
+
installServices: false,
|
|
198
202
|
});
|
|
199
|
-
|
|
203
|
+
const ctx: RequestContext = {
|
|
200
204
|
...base,
|
|
201
205
|
// Everything below is either this package's own or a core member the PIPELINE rewrites: the
|
|
202
206
|
// mutable slots are re-declared here so a stage can write them, and they must therefore be
|
|
@@ -227,6 +231,8 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
|
|
|
227
231
|
response: undefined,
|
|
228
232
|
error: undefined,
|
|
229
233
|
};
|
|
234
|
+
bindRequestServices(ctx, init.services ?? {});
|
|
235
|
+
return ctx;
|
|
230
236
|
};
|
|
231
237
|
|
|
232
238
|
/**
|
package/src/error-map.ts
CHANGED
|
@@ -401,6 +401,19 @@ export const ERROR_STATUS = {
|
|
|
401
401
|
// the answer. Deliberately not 409 — that spelling asks the client to reconcile and try again,
|
|
402
402
|
// and a fenced answer has nothing to reconcile against.
|
|
403
403
|
X_SUPERSEDED: 499,
|
|
404
|
+
// Plan 101's client seam. A typed call made from a server handler: an upstream that gave no usable
|
|
405
|
+
// answer is 502, the fence is `X_SUPERSEDED`'s twin. The rest are browser-side or declaration-time
|
|
406
|
+
// refusals no request carries; their rows keep the table closed.
|
|
407
|
+
X_CLIENT_TRANSPORT_FAILED: 502,
|
|
408
|
+
X_CLIENT_RECORD_ENVELOPE_INVALID: 502,
|
|
409
|
+
X_CLIENT_SCOPE_CHANGED: 499,
|
|
410
|
+
X_CHANNEL_DECLARATION_INVALID: 500,
|
|
411
|
+
X_LOCAL_STORE_UNAVAILABLE: 500,
|
|
412
|
+
X_MUTATOR_CLOCK_MISSING: 500,
|
|
413
|
+
X_REALTIME_UNINSTALLED: 500,
|
|
414
|
+
X_RECORD_KEY_MISSING: 500,
|
|
415
|
+
X_RECORD_REJECTED: 500,
|
|
416
|
+
X_SYNC_UNCONFIGURED: 500,
|
|
404
417
|
X_INTERNAL: 500,
|
|
405
418
|
// The keys are LITERAL — deliberately not `Readonly<Record<string, number>>`, which is what the
|
|
406
419
|
// annotation used to say. This table is the closed one, so `ERROR_STATUS.X_QUERY_NOT_PAGABLE`
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A request's registered services (`defineService`), bound to the actor that AUTHENTICATED. The
|
|
2
|
+
// context exists before the `auth` stage names anyone, so core's constructor is told to install
|
|
3
|
+
// nothing and every registered name becomes a lazy member here instead: built on first read, and
|
|
4
|
+
// built again only if the actor, locale or time zone it closed over has since changed.
|
|
5
|
+
|
|
6
|
+
import type { CtxFacts, ServiceBag } from '@ultimat3/core';
|
|
7
|
+
import { installedServices, registeredServiceNames } from '@ultimat3/core';
|
|
8
|
+
import type { RequestContext } from './context';
|
|
9
|
+
|
|
10
|
+
interface Built {
|
|
11
|
+
readonly actor: CtxFacts['actor'];
|
|
12
|
+
readonly locale: string;
|
|
13
|
+
readonly tz: string;
|
|
14
|
+
readonly bag: ServiceBag;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Makes `ctx.services` and each registered `ctx.<name>` lazy. Non-enumerable, so building the
|
|
19
|
+
* preview a factory reads — the context's facts, no sibling service — never reads one back.
|
|
20
|
+
* An explicit service (`init.services`) stays what it was: a caller's mock overrides the real one.
|
|
21
|
+
*/
|
|
22
|
+
export function bindRequestServices(ctx: RequestContext, explicit: ServiceBag): void {
|
|
23
|
+
let built: Built | undefined;
|
|
24
|
+
const bag = (): ServiceBag => {
|
|
25
|
+
if (
|
|
26
|
+
built !== undefined &&
|
|
27
|
+
built.actor === ctx.actor &&
|
|
28
|
+
built.locale === ctx.locale &&
|
|
29
|
+
built.tz === ctx.tz
|
|
30
|
+
) {
|
|
31
|
+
return built.bag;
|
|
32
|
+
}
|
|
33
|
+
const preview: CtxFacts = Object.freeze({
|
|
34
|
+
...explicit,
|
|
35
|
+
requestId: ctx.requestId,
|
|
36
|
+
traceId: ctx.traceId,
|
|
37
|
+
actor: ctx.actor,
|
|
38
|
+
locale: ctx.locale,
|
|
39
|
+
tz: ctx.tz,
|
|
40
|
+
buildId: ctx.buildId,
|
|
41
|
+
role: ctx.role,
|
|
42
|
+
clock: ctx.clock,
|
|
43
|
+
now: ctx.now,
|
|
44
|
+
logger: ctx.logger,
|
|
45
|
+
signal: ctx.signal,
|
|
46
|
+
deadlineAt: ctx.deadlineAt,
|
|
47
|
+
services: explicit,
|
|
48
|
+
});
|
|
49
|
+
const next = Object.freeze({ ...installedServices(preview), ...explicit });
|
|
50
|
+
built = { actor: ctx.actor, locale: ctx.locale, tz: ctx.tz, bag: next };
|
|
51
|
+
return next;
|
|
52
|
+
};
|
|
53
|
+
Object.defineProperty(ctx, 'services', { get: bag, enumerable: false, configurable: true });
|
|
54
|
+
for (const name of registeredServiceNames()) {
|
|
55
|
+
// Already an own property: a framework field (which keeps its meaning whatever an app named a
|
|
56
|
+
// service — core's rule) or an explicit `init.services` entry, which core's constructor spread
|
|
57
|
+
// onto the context and which overrides the registered factory of the same name.
|
|
58
|
+
if (Object.hasOwn(ctx, name)) continue;
|
|
59
|
+
Object.defineProperty(ctx, name, {
|
|
60
|
+
get: () => bag()[name],
|
|
61
|
+
enumerable: false,
|
|
62
|
+
configurable: true,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/request.ts
CHANGED
|
@@ -167,7 +167,20 @@ export class UltimateRequest {
|
|
|
167
167
|
throw bodyInvalid(this.pathname, [`body is ${declared} bytes, limit is ${limit}`]);
|
|
168
168
|
}
|
|
169
169
|
const type = contentTypeOf(this.raw);
|
|
170
|
-
|
|
170
|
+
// An EMPTY form is a form with no fields, never "no input": a button-only `<form>` posts
|
|
171
|
+
// `content-length: 0`, and reading that as `undefined` failed every schema with 400.
|
|
172
|
+
const form = type === 'application/x-www-form-urlencoded' || type === 'multipart/form-data';
|
|
173
|
+
if (type === '') return undefined;
|
|
174
|
+
// A multipart body is only a FORM with the boundary its header must announce. Checked before
|
|
175
|
+
// the empty-form shortcut below, so a zero-length body cannot launder a malformed header into
|
|
176
|
+
// `{}` — without it the parser would have refused, and an empty body must refuse the same.
|
|
177
|
+
if (
|
|
178
|
+
type === 'multipart/form-data' &&
|
|
179
|
+
!/;\s*boundary=[^;\s]/i.test(this.header('content-type') ?? '')
|
|
180
|
+
) {
|
|
181
|
+
throw bodyInvalid(this.pathname, ['multipart/form-data without a boundary parameter']);
|
|
182
|
+
}
|
|
183
|
+
if (declared === 0) return form ? {} : undefined;
|
|
171
184
|
|
|
172
185
|
// One capped read for every content type, multipart included: the parser runs on bytes this
|
|
173
186
|
// process already agreed to hold, never on a stream it hands to the runtime unbounded.
|
|
@@ -175,7 +188,7 @@ export class UltimateRequest {
|
|
|
175
188
|
if ('over' in read) {
|
|
176
189
|
throw bodyInvalid(this.pathname, [`body is at least ${read.over} bytes, limit is ${limit}`]);
|
|
177
190
|
}
|
|
178
|
-
if (read.bytes.byteLength === 0) return undefined;
|
|
191
|
+
if (read.bytes.byteLength === 0) return form ? {} : undefined;
|
|
179
192
|
|
|
180
193
|
if (type === 'multipart/form-data') {
|
|
181
194
|
try {
|
package/src/stages.ts
CHANGED
|
@@ -188,16 +188,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
188
188
|
* get one answer in the framework rather than one per package.
|
|
189
189
|
*/
|
|
190
190
|
locale: (request, ctx) => {
|
|
191
|
-
|
|
192
|
-
ctx.locale = resolveLocale({
|
|
193
|
-
header: request.header('accept-language'),
|
|
194
|
-
cookie: readCookie(cookies, config.locale.cookie),
|
|
195
|
-
}).locale;
|
|
196
|
-
ctx.tz = resolveTimeZone({
|
|
197
|
-
cookie: readCookie(cookies, config.tz.cookie),
|
|
198
|
-
header: request.header(config.tz.header),
|
|
199
|
-
}).zone;
|
|
200
|
-
ctx.headers.set('content-language', ctx.locale);
|
|
191
|
+
resolvePreferences(request, ctx, config);
|
|
201
192
|
return undefined;
|
|
202
193
|
},
|
|
203
194
|
|
|
@@ -206,6 +197,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
206
197
|
// The hook says "anonymous" with null; the context says it with core's anonymous actor,
|
|
207
198
|
// because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
|
|
208
199
|
ctx.actor = (await hooks.authenticate(request, ctx)) ?? anonymousActor();
|
|
200
|
+
// The `user` rung the `locale` stage could not know: re-resolved through the same owners,
|
|
201
|
+
// so a cookie the reader chose still beats a saved locale where i18n's order says it does.
|
|
202
|
+
if (ctx.actor.locale !== undefined || ctx.actor.tz !== undefined) {
|
|
203
|
+
resolvePreferences(request, ctx, config);
|
|
204
|
+
}
|
|
209
205
|
}
|
|
210
206
|
if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
|
|
211
207
|
throw unauthenticated(ctx.url.pathname);
|
|
@@ -465,3 +461,28 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
465
461
|
};
|
|
466
462
|
return table;
|
|
467
463
|
};
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* `ctx.locale`, `ctx.tz` and `content-language` from every source the request carries — the
|
|
467
|
+
* cookie, the header and, once `auth` has run, the actor's saved preference. One function for both
|
|
468
|
+
* stages, so the order is always the owners' (`resolveLocale`, `resolveTimeZone`) and never this
|
|
469
|
+
* file's.
|
|
470
|
+
*/
|
|
471
|
+
function resolvePreferences(
|
|
472
|
+
request: UltimateRequest,
|
|
473
|
+
ctx: RequestContext,
|
|
474
|
+
config: HttpConfig,
|
|
475
|
+
): void {
|
|
476
|
+
const cookies = request.header('cookie');
|
|
477
|
+
ctx.locale = resolveLocale({
|
|
478
|
+
header: request.header('accept-language'),
|
|
479
|
+
cookie: readCookie(cookies, config.locale.cookie),
|
|
480
|
+
user: ctx.actor.locale,
|
|
481
|
+
}).locale;
|
|
482
|
+
ctx.tz = resolveTimeZone({
|
|
483
|
+
cookie: readCookie(cookies, config.tz.cookie),
|
|
484
|
+
header: request.header(config.tz.header),
|
|
485
|
+
user: ctx.actor.tz ?? null,
|
|
486
|
+
}).zone;
|
|
487
|
+
ctx.headers.set('content-language', ctx.locale);
|
|
488
|
+
}
|