You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

455 lines
15 KiB

Add arts/timr, arts/sess, arts/http; sess actor extension; aapp factory New artifacts: arts/timr — runtime timer scheduler (singleton-per-App, never global). Engine + Active split. Race-safe via (id, key, version) guard against stale native callbacks, async tasks resolving after cancel/replace, interval ticks scheduled after dispose, and ack-style timeouts. Per- entry AbortController; intervals reuse the signal across ticks. awaitTask:true (default, no overlap) vs awaitTask:false (fire-and- forget cadence; failures don't stop the interval — semantic frozen). Recursive setTimeout for intervals. scheduleAt(past) → delay 0 (no error). Fake-clock injectable for deterministic tests. Pure computeBackoffDelay helper. 50 server tests + 7 browser tests + full README + DESIGN_TIMR + interactive test page at /test/timr. arts/sess — session lifecycle with three generics (TUser/TCredential/TData) plus optional SessionActor metadata (kind/source/confidence — orthogonal axis to identity). Tagged AdoptResult/RefreshResult/RevokeResult; never void. Generation guard against stale refresh from local cancel, cross-tab storage events, or re-adopt. onRefresh contract (null=fatal, throw=transient). onRevoke replaces revokeUrl (consumer controls fetch; engine just gets boolean). Default scope: 'global' when onRevoke configured. INITIAL_SESSION sync dispatch on subscribe. BroadcastChannel payload: {type, event, generation} only — never tokens. SSR cookie reader validates invariants. withAutoRefresh helper, 401-retry hook with applyAuth + loop guard, JWT exp helper. 103 tests (60 server + 5 browser + 38 actor/error/etc) + README + DESIGN.md + test page at /test/sess. arts/http — HTTP client with Standard Schema body validation, retry + Retry-After, attempt + total timeout via AbortSignal composition, hooks (beforeRequest/beforeRetry/afterResponse/beforeError), tagged HttpResult. Test page at /test/http. arts/conn — DESIGN_CONN.md only (no implementation yet). aapp: - App.createActiveSession<TUser, TCredential, TData>(opts) factory auto-injects App.Logger; throws SessAlreadyCreatedError on second call. App.Sess getter exposes the active session (undefined until first call). Auto-disposed by App.dispose(). - App.Timers integration deferred to timr Fase 3. libs/days: - toEpochMs(value) — permissive coercion (number | Date | string) → epoch ms. Exposed alongside the existing day/calendar helpers. Other: - sium examples moved to _examples/ (excluded from public surface). - sium-provider.svelte test removed (port pending; tracked elsewhere). - libs/env.ts — DEV flag single source of truth. - Various README touch-ups across artifacts. All artifacts: 1021 server tests + 12 browser tests; svelte-check + ESLint clean. The 9 server "errors" are pre-existing jsdom missing — unrelated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6 months ago
# http
Professional HTTP client. **Zero external dependencies.** Tagged-result error
model. Per-call schema validation against any Standard Schema (Sium, Zod,
Valibot, ArkType). Idempotent-by-default retry with `Retry-After` parsing.
Per-attempt and total-budget timeouts. SvelteKit-native via injectable
`fetch`. Hooks for auth refresh, logging, transformation.
```ts
import { createEngineHttp } from '$http';
const http = createEngineHttp({ baseUrl: 'https://api.example.com' });
const r = await http.get('/users/me', { schema: UserSchema });
if (r.ok) {
console.log(r.value.email);
} else if (r.kind === 'http') {
console.error(`HTTP ${r.status}: ${r.statusText}`);
} else if (r.kind === 'validation') {
console.error('Server returned an unexpected shape:', r.issues);
} else {
console.error('Network failure:', r.error);
}
```
---
## Why another one
`fetch` exists. `axios`, `ky`, `ofetch`, `wretch` exist. None ship with the
combination this artifact targets:
- **Zero dependencies.** `axios` carries IE polyfills, `wretch` ships
middlewares as separate addons, `ofetch` depends on `unjs/ufo`.
- **Tagged-result error model.** Every other library throws on non-2xx.
`arts/http` returns a discriminated union — errors are data, not exceptions
(matches `$sium`'s `Result<O>` for uniform pattern matching).
- **Standard Schema first-class.** Per-call `schema` parameter validates the
response body. Returns `Out<S>` typed. Works with Sium / Zod / Valibot /
ArkType identically. No runtime dep on Sium — the artifact only imports
the `StandardSchemaV1` interface from `$libs/standard-schema`.
- **`bodySchema` for write methods.** Validate the request payload before
it leaves the engine. Catches "I sent the wrong shape" — a class of bugs
no other generic HTTP client catches.
- **SvelteKit-aware.** `engine.with({ fetch: event.fetch })` returns a scoped
client that inherits cookies, resolves relative URLs and skips network
roundtrips when the route is local.
- **Retryer extracted.** The retry primitive is a separable export — a
future `arts/cache` can reuse it without a duplicate implementation.
---
## Architecture
```
http/
├── index.ts Barrel exports
├── types.ts EngineHttp, HttpResult, HttpInit, HttpHooks, RetryConfig
├── consts.ts DEFAULT_RETRY, DEFAULT_TIMEOUT, NULL_BODY_STATUSES, ...
├── engine-http.ts Factory createEngineHttp()
├── errors.ts HttpNetworkError / HttpTimeoutError / HttpAbortError /
│ HttpBodyValidationError + type guards
├── retry.ts parseRetryAfter, shouldRetryRequest, computeRetryDelay,
│ delayWithSignal (reusable by arts/cache)
├── timeout.ts composeSignals, attemptTimeoutSignal, totalTimeoutSignal,
│ classifyAbort
├── body.ts isJSONSerializable, serializeBody, parseBody, mergeHeaders
├── search.ts normalizeSearch, appendSearch, resolveUrl
└── test/
├── engine-http.test.ts end-to-end with stub fetch
├── retry.test.ts Retry-After parsing, policy, delay
├── timeout.test.ts signal composition + classification
├── body.test.ts JSON serialization + content-type sniffing
├── search.test.ts search + URL helpers
└── errors.test.ts hierarchy + type guards
```
## Alias
Configured in `svelte.config.js`:
```js
alias: { $http: 'src/arts/http' }
```
---
## HttpResult — the discriminated union
Every method returns `Promise<HttpResult<T>>`:
```ts
type HttpResult<T> =
| { ok: true; value: T; response: Response }
| { ok: false; kind: 'http'; status; statusText; body; response: Response }
| { ok: false; kind: 'validation'; issues; response: Response }
| { ok: false; kind: 'network'; error };
```
The `ok` discriminator matches Sium's. Pattern-match identically:
```ts
if (r.ok) {
use(r.value);
} else switch (r.kind) {
case 'http': toast(`Server returned ${r.status}`); break;
case 'validation': console.error(r.issues); break;
case 'network': toast('Offline?'); break;
}
```
The `response: Response` is preserved on the three branches that have one —
auth flows, rate-limit headers and ETags all live there. The `network`
branch deliberately omits `response` because no HTTP exchange happened.
---
## Validation — opt-in via `schema`
No schema → `value` is `unknown`, body is not parsed:
```ts
const r = await http.get('/api/health'); // r.value: unknown (undefined)
```
With a schema → body is parsed (JSON or text) and validated:
```ts
const UserSchema = sium.object({ id: sium.string(), email: sium.email() });
const r = await http.get('/api/users/me', { schema: UserSchema });
if (r.ok) r.value.email; // string, validated
```
Works with any Standard Schema vendor:
```ts
import { z } from 'zod';
const r = await http.get('/api/posts', {
schema: z.array(z.object({ id: z.string() }))
});
```
For `POST` / `PUT` / `PATCH`, validate the request body too:
```ts
await http.post('/api/users', {
body: payload,
bodySchema: CreateUserSchema, // pre-flight check; throws if invalid
schema: UserSchema // post-flight check on response
});
```
When `bodySchema` rejects, the engine throws `HttpBodyValidationError`
synchronously. This is a programmer error (you sent the wrong shape), not
a runtime data condition — different from `kind: 'validation'` which is
about the *server's* response.
---
## API
### `createEngineHttp(options?)`
```ts
const http = createEngineHttp({
baseUrl: 'https://api.example.com',
headers: { Accept: 'application/json' },
fetch: globalThis.fetch, // override per-engine
timeout: 10_000, // per-attempt (ms)
totalTimeout: 30_000, // total budget covering all attempts (0 = off)
retry: {
limit: 2,
statusCodes: [408, 425, 429, 500, 502, 503, 504],
methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS'], // idempotent
delay: (n) => 1000 * 2 ** (n - 1), // exponential
backoffLimit: 30_000,
jitter: false
},
hooks: {
beforeRequest: [],
beforeRetry: [],
afterResponse: [],
beforeError: []
},
logger: App.Logger // wired automatically when used via App.Http
});
```
### `engine.with(overrides)`
Returns a new engine with `overrides` merged on top of the current defaults.
Hook arrays concatenate (parent first); scalar fields are replaced. **Never
mutates** the parent engine.
```ts
const scoped = http.with({ fetch: event.fetch }); // SvelteKit pattern
const apiV2 = http.with({ baseUrl: 'https://api.example.com/v2' });
```
### Method shortcuts
```ts
http.get <S>(url, init?) → Promise<HttpResult<Out<S>>>
http.head <S>(url, init?) → Promise<HttpResult<Out<S>>>
http.delete<S>(url, init?) → Promise<HttpResult<Out<S>>>
http.options<S>(url, init?) → Promise<HttpResult<Out<S>>>
http.post <S, B>(url, init?) → Promise<HttpResult<Out<S>>>
http.put <S, B>(url, init?) → Promise<HttpResult<Out<S>>>
http.patch <S, B>(url, init?) → Promise<HttpResult<Out<S>>>
```
`Out<S>` is `unknown` when `S` is undefined, otherwise `InferOutput<S>`.
### Per-call init
```ts
http.get('/users', {
schema: UserSchema, // validate response body
bodySchema: NewUserSchema, // (write methods only) validate body
body: { name: 'Ada' }, // any HttpBodyInit
headers: { 'X-Trace-Id': '...' }, // merged on top of engine defaults
search: { page: 2, q: 'ada' }, // appended to URL
fetch: event.fetch, // per-call fetch override
timeout: 5_000, // per-attempt (ms)
signal: abortCtrl.signal, // user signal — aborts cancel retry
retry: { limit: 0 }, // override retry policy (or `false`)
hooks: { beforeRequest: [trace] } // per-call hooks (engine first)
});
```
---
## Retry policy
Defaults — *idempotent-by-default*: `GET`, `HEAD`, `PUT`, `DELETE`,
`OPTIONS` retry; `POST` and `PATCH` do not (unless `methods` is overridden).
Status codes: `408 425 429 500 502 503 504`.
### Retry-After
Five header variants are parsed in priority order:
```
Retry-After, RateLimit-Reset, X-RateLimit-Reset,
X-Rate-Limit-Reset, X-RateLimit-Retry-After
```
Numeric values below `1.7e9` are treated as delta-seconds; above as
epoch-seconds (the threshold is "no realistic retry delay is that large; no
realistic past timestamp is that small"). HTTP-dates (`Sun, 06 Nov 1994
08:49:37 GMT`) are also accepted. When the server signals a delay, it
overrides the policy's `delay(attempt)`.
### Signal hierarchy (no surprises)
| Signal source | Reaction |
| ------------------------- | ---------------------------------------------- |
| User-provided `signal` | Aborts the current attempt; **no retry** |
| `totalTimeout` exceeded | Aborts the current attempt; **no retry** |
| Per-attempt `timeout` | Aborts the attempt; **retries** if budget left |
Internal classification uses `signal.reason` (an `HttpTimeoutError` with
`scope: 'attempt' | 'total'`, or an `HttpAbortError` for user signals) so
the retryer makes the right call without ambiguity.
---
## Hooks lifecycle
Per attempt, in order:
```
beforeRequest[0..k] → fetch() → afterResponse[0..k] (success)
↘ beforeRetry[0..k] → wait(delay) → next attempt
Final failure: → beforeError[0..k] (may rescue with a Response)
```
Mutate `ctx.request.headers` in place — the change persists into the
`fetch` call. To rewrite the URL, mutate `ctx.url`. To short-circuit with a
cached response, return it from `beforeRequest`. To rescue a failed attempt,
return a `Response` from `beforeError` and the engine resolves as if it had
been received cleanly.
```ts
const http = createEngineHttp({
hooks: {
beforeRequest: [
(ctx) => {
ctx.request.headers.set('X-Trace-Id', crypto.randomUUID());
}
],
beforeRetry: [
({ attempt, retryDelay, error }) => {
console.warn(`retry ${attempt}, waiting ${retryDelay}ms`, error);
}
],
afterResponse: [
({ response }) => {
metrics.observe('http.duration', response.headers.get('x-time'));
}
],
beforeError: [
({ response }) => {
if (response?.status === 401) {
return refreshAndRetry(); // returns a new Response or undefined
}
}
]
}
});
```
### Auth refresh pattern
Use a header **hook** so the token is read per attempt — `beforeRetry` can
update the underlying token between attempts and the next attempt's
headers reflect the new value:
```ts
let token = await getToken();
const http = createEngineHttp({
headers: () => ({ Authorization: `Bearer ${token}` }),
hooks: {
beforeRetry: [
async ({ error, response }) => {
if (response?.status === 401) {
token = await refreshToken();
}
}
]
}
});
```
---
## SvelteKit integration
In a `+page.ts` / `+page.server.ts` / `+layout.ts` `load`, scope the engine
to the request via `event.fetch`:
```ts
// +page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => {
const api = App.Http.with({ fetch }); // inherits cookies + relative URLs
const user = await api.get('/api/users/me', { schema: UserSchema });
if (!user.ok) throw error(401, 'Not authenticated');
return { user: user.value };
};
```
Or per-call:
```ts
const r = await App.Http.get('/api/users/me', {
fetch: event.fetch,
schema: UserSchema
});
```
Without `event.fetch`, server-side requests use `globalThis.fetch` and lose
SvelteKit's automatic cookie forwarding and relative-URL resolution.
---
## Errors
Five subclasses, all with stable literal `name` and exported type guards:
| Class | When | Surfaces as |
| --------------------------- | ------------------------------------------ | -------------------------- |
| `HttpNetworkError` | Wrapper for `fetch` rejection | `kind: 'network'` |
| `HttpTimeoutError` | Per-attempt or total timeout fired | `kind: 'network'` (final) |
| `HttpAbortError` | User signal aborted | `kind: 'network'` (final) |
| `HttpBodyValidationError` | `bodySchema` rejected the payload | **Thrown** (programmer err)|
Type guards: `isHttpNetworkError`, `isHttpTimeoutError`, `isHttpAbortError`,
`isHttpBodyValidationError`. Each carries stable `name` so they survive
serialization across worker boundaries.
---
## Composition with App
When used through `createActiveApp(...)`, `App.Http` is built with the
shared `Logger` injected automatically:
```ts
const App = createActiveApp({
http: {
baseUrl: 'https://api.example.com',
timeout: 10_000
}
});
await App.Http.get('/users/me', { schema: UserSchema });
```
Per-request `event.fetch` for SSR:
```ts
const api = App.Http.with({ fetch: event.fetch });
```
Validation messages with the App's locale:
```ts
const r = await App.Http.get('/api/users/me', { schema: UserSchema });
if (!r.ok && r.kind === 'validation') {
const sium = App.createSiumEngine();
console.error(sium.resolveIssues(r.issues));
}
```
---
## Testing
The engine takes any `fetch`-compatible function. Stub it:
```ts
import { createEngineHttp } from '$http';
const http = createEngineHttp({
retry: { limit: 0 },
timeout: 0,
fetch: ((_url, _init) =>
Promise.resolve(new Response('{"ok":true}', {
status: 200,
headers: { 'content-type': 'application/json' }
}))) as typeof fetch
});
```
Or queue several responses for retry scenarios — see
`src/arts/http/test/engine-http.test.ts` for the test helpers.
---
## Bundle profile
| Layer | Approx. size (min) |
| ------------------------------- | ------------------ |
| Engine + Retryer + helpers | ~5 KB |
| Errors + type guards | ~1 KB |
| Total (everything reached) | ~6 KB |
Zero runtime dependencies. The `StandardSchemaV1` import is type-only.

Powered by TurnKey Linux.