@ultimat3/core 27.1.0 → 27.2.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/README.md CHANGED
@@ -753,7 +753,7 @@ read; `@ultimat3/jobs` re-exports both rather than keeping a second pair.
753
753
 
754
754
  | Export | The one answer | The question it settles |
755
755
  |---|---|---|
756
- | `clientTransport({ method, url, body?, rawBody?, headers?, signal?, idempotencyKey?, flight?, fresh?, retry?, onResponse?, decodeError?, onEnvelope?, fetchImpl? })` | every browser request | `credentials: 'same-origin'`, JSON in and out, the `idempotency-key` header, a non-2xx `problem+json` back into the server's code (`meta.origin: 'remote'`) unless `decodeError` answers first, a network `TypeError` into `X_CLIENT_TRANSPORT_FAILED`. A GET is abortable on `rescope()` and deduped only when a `flight` is passed (`fresh` refuses to join); any other method is never deduped and never aborted by the fence. `rawBody` goes out verbatim with no default header and resolves `undefined`. `onResponse` sees headers before the body is read. `onEnvelope` sees the decoded records envelope after adoption — the one way to learn the order of `records[type]`. `fetchImpl` defaults to `globalThis.fetch`, read at call time |
756
+ | `clientTransport({ method, url, body?, rawBody?, headers?, signal?, idempotencyKey?, flight?, fresh?, retry?, responseType?, onResponse?, decodeError?, onEnvelope?, fetchImpl? })` | every browser request | `credentials: 'same-origin'`, JSON in and out, the `idempotency-key` header, a non-2xx `problem+json` back into the server's code (`meta.origin: 'remote'`) unless `decodeError` answers first, a network `TypeError` into `X_CLIENT_TRANSPORT_FAILED`. A GET is abortable on `rescope()` and deduped only when a `flight` is passed (`fresh` refuses to join); any other method is never deduped and never aborted by the fence. `rawBody` goes out verbatim with no default header and resolves `undefined` (the body as a string with `responseType: 'text'`). `responseType: 'text'` resolves a 2xx body as a string (typed `Promise<string>`), sends `accept: */*` unless `headers` names one, and adopts no envelope — for an HTML fragment or a CSV; a non-2xx is still decoded as a refusal. About 10.7 kB minified (4.6 kB gzipped) through `@ultimat3/core/page`, no titles table — pinned in `page-bundle.test.ts`. `onResponse` sees headers before the body is read. `onEnvelope` sees the decoded records envelope after adoption — the one way to learn the order of `records[type]`. `fetchImpl` defaults to `globalThis.fetch`, read at call time |
757
757
  | `actionPath(name)`, `actionRoute(name)`, `queryPath(name)`, `QUERY_PATH_PREFIX`, `splitWords`, `pluralize` | the one URL rule | `publishPost` → `/api/posts/publish`, `liveFeed` → `/_x/query/live-feed`. Tier 0 so `action`, `query` and `realtime` derive one URL with no sideways import |
758
758
  | `actionPath(name)` with no `style`, `renderedActionPathStyle()`, `CLIENT_PATH_STYLE_META` | the style nobody restates | `actionPath(name)` reads the document's `<meta name="ultimate-path-style">` stamp, so a browser caller derives under the style the server serves (`defineApi({ http: { pathStyle } })`). No document, no stamp or an unknown value is `'resource'` — a `'resource'` server writes no stamp. A named `style` is never overridden |
759
759
  | `RECORDS_HEADER`, `encodeRecordEnvelope`, `decodeRecordEnvelope`, `RecordRows` | `{ data, records?: { [type]: { [key]: Row } }, removed?: { [type]: key[] } }`, only behind `x-ultimate-records: 1` | how an answer carries entity rows without changing the wire of an answer that has none. A malformed envelope is `X_CLIENT_RECORD_ENVELOPE_INVALID` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "27.1.0",
3
+ "version": "27.2.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,6 +37,6 @@
37
37
  "test": "bun test"
38
38
  },
39
39
  "dependencies": {
40
- "@ultimat3/schema": "27.1.0"
40
+ "@ultimat3/schema": "27.2.0"
41
41
  }
42
42
  }
@@ -54,6 +54,13 @@ export interface TransportRequest {
54
54
  * ORDER of `records[type]`, which the store (keyed, unordered) cannot give it back.
55
55
  */
56
56
  readonly onEnvelope?: ((envelope: RecordEnvelope) => void) | undefined;
57
+ /**
58
+ * How a 2xx body is read. `'json'` (the default) decodes it and adopts a records envelope;
59
+ * `'text'` answers the body as a string, untouched — an HTML fragment, a CSV — sends
60
+ * the wildcard `accept` unless `headers` says otherwise, and adopts nothing. A non-2xx is decoded as a
61
+ * refusal either way.
62
+ */
63
+ readonly responseType?: 'json' | 'text' | undefined;
57
64
  /** Sees every response before its body is read — a header check may throw its own refusal. */
58
65
  readonly onResponse?: ((response: Response) => void | Promise<void>) | undefined;
59
66
  /**
@@ -142,7 +149,9 @@ async function onTheWire<T>(
142
149
 
143
150
  function initOf(req: TransportRequest, signal: AbortSignal | undefined): RequestInit {
144
151
  const raw = req.rawBody !== undefined;
145
- const headers: Record<string, string> = raw ? {} : { accept: 'application/json' };
152
+ const headers: Record<string, string> = raw
153
+ ? {}
154
+ : { accept: req.responseType === 'text' ? '*/*' : 'application/json' };
146
155
  if (req.body !== undefined && !raw) headers['content-type'] = 'application/json';
147
156
  if (req.idempotencyKey !== undefined) headers[IDEMPOTENCY_HEADER] = req.idempotencyKey;
148
157
  const body = raw ? req.rawBody : req.body === undefined ? undefined : JSON.stringify(req.body);
@@ -28,6 +28,11 @@ import { pageClient, recordSink } from './record-sink';
28
28
 
29
29
  const ONCE = { attempts: 1 } as const;
30
30
 
31
+ /** `responseType: 'text'`: the 2xx body as a string, never decoded. */
32
+ export function clientTransport(
33
+ req: TransportRequest & { readonly responseType: 'text' },
34
+ ): Promise<string>;
35
+ export function clientTransport<T = unknown>(req: TransportRequest): Promise<T>;
31
36
  export async function clientTransport<T = unknown>(req: TransportRequest): Promise<T> {
32
37
  const read = req.method === 'GET';
33
38
  const issued = pageClient().scope.epoch;
@@ -62,6 +67,9 @@ export async function clientTransport<T = unknown>(req: TransportRequest): Promi
62
67
  const current = pageClient().scope.epoch;
63
68
  // A read that raced the abort still belongs to the previous principal.
64
69
  if (read && current !== issued) throw scopeChanged(req.url, issued, current);
70
+ // Read as text: nothing to decode, and no records envelope — that is a JSON answer's. Before the
71
+ // `rawBody` return, so the overload's `Promise<string>` holds for a raw upload read as text too.
72
+ if (req.responseType === 'text') return answer.text as T;
65
73
  if (req.rawBody !== undefined) return undefined as T;
66
74
  const envelope = unwrap(answer, req.url, read);
67
75
  // A write that crossed a rescope HAS landed, so its caller is told — but its rows are the