@nifrajs/client 3.5.0 → 4.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/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  type InProcessClient,
13
13
  type InProcessClientOptions,
14
14
  inProcessClient,
15
+ type TestClientOptions,
15
16
  testClient,
16
17
  } from "./client.ts"
17
18
  export type { Jsonify } from "./jsonify.ts"
@@ -22,7 +23,20 @@ export {
22
23
  reservedKeyFor,
23
24
  } from "./reserved.ts"
24
25
  export type { ApiError, Result } from "./result.ts"
25
- export type { ActionArgs, ActionData, ApiProxy, LoaderArgs, LoaderData } from "./routes.ts"
26
+ export type {
27
+ ActionArgs,
28
+ ActionData,
29
+ ApiProxy,
30
+ LoaderArgs,
31
+ LoaderData,
32
+ LoaderResponseControls,
33
+ OutputOf,
34
+ Register,
35
+ RegisteredBackend,
36
+ RegisteredEnv,
37
+ RouteLoaderArgs,
38
+ SearchSchemaOf,
39
+ } from "./routes.ts"
26
40
  export type {
27
41
  RegistryOf,
28
42
  SubscribeOptions,
package/src/routes.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ContractShape, RegistryFor } from "@nifrajs/core/contract"
2
- import type { InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
2
+ import type { CookieOptions, InferOutput, StandardSchemaV1 } from "@nifrajs/core/server"
3
3
  import type { Treaty, TreatyFromRegistry } from "./treaty.ts"
4
4
 
5
5
  /**
@@ -11,6 +11,31 @@ export type ApiProxy<Api> = Api extends ContractShape
11
11
  ? TreatyFromRegistry<RegistryFor<Api>>
12
12
  : Treaty<Api>
13
13
 
14
+ /**
15
+ * Response controls a loader or action reaches as `ctx.set` - the page counterpart of a route
16
+ * handler's `c.set`. Write before the loader or action returns; a write from a deferred promise that
17
+ * settles later throws.
18
+ */
19
+ export interface LoaderResponseControls {
20
+ /**
21
+ * Headers for the rendered document (`cache-control`, `x-robots-tag`, `link`, ...). A layout's
22
+ * headers are applied first, then the page's, then the action's, so the most specific writer wins a
23
+ * name. They are not applied to a redirect, a status page, an error page, or a navigation data
24
+ * response. `content-type`, `set-cookie`, `location`, transport headers, and the `x-nifra-` prefix
25
+ * are refused.
26
+ */
27
+ readonly headers: Record<string, string>
28
+ /**
29
+ * Queue a `Set-Cookie`, with the same secure defaults as `c.set.cookie`
30
+ * (`HttpOnly; Secure; SameSite=Lax; Path=/`). The cookie rides every outcome - the document, a
31
+ * navigation data response, a redirect, a status or error page - and makes the response
32
+ * `cache-control: private, no-store`.
33
+ */
34
+ cookie(name: string, value: string, options?: CookieOptions): void
35
+ /** Queue a cookie deletion. Match the `path`/`domain` the cookie was set with. */
36
+ deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void
37
+ }
38
+
14
39
  /**
15
40
  * Context a route `loader` receives: the route params, the request, a typed in-process `api` (an
16
41
  * {@link ApiProxy} for the app contract `Api`), and the platform `env`. Pair with `inProcessClient`.
@@ -41,8 +66,53 @@ export interface LoaderArgs<Api, Env = unknown, Search = undefined> {
41
66
  ? InferOutput<Search>
42
67
  : never
43
68
  : Record<string, unknown>
69
+ /** Response headers and cookies for this page request: `ctx.set.headers["cache-control"] = ...`,
70
+ * `ctx.set.cookie("theme", "dark")`. */
71
+ readonly set: LoaderResponseControls
44
72
  }
45
73
 
74
+ /**
75
+ * The app's registered types. `nifra types` writes `.nifra/types/register.d.ts`, which fills it in
76
+ * from `backend/app.ts`, so a route's generated `Route.LoaderArgs` types `api` without the route
77
+ * importing the backend:
78
+ *
79
+ * declare module "@nifrajs/client" { interface Register { backend: typeof backend } }
80
+ *
81
+ * `env` may be registered the same way, for the platform bindings `ctx.env` carries.
82
+ */
83
+ // biome-ignore lint/suspicious/noEmptyInterface: filled in by declaration merging
84
+ export interface Register {}
85
+
86
+ /** The registered backend (`Register["backend"]`), or `unknown` before one is registered. */
87
+ export type RegisteredBackend = Register extends { readonly backend: infer Backend }
88
+ ? Backend
89
+ : unknown
90
+
91
+ /** The registered platform bindings (`Register["env"]`), or `unknown`. */
92
+ export type RegisteredEnv = Register extends { readonly env: infer Env } ? Env : unknown
93
+
94
+ /**
95
+ * What a route module's output schema lets through to the browser: the output type of its `Name`
96
+ * export (`loaderOutput`, `actionOutput`), or `null` when the module declares none - a loader without
97
+ * one sends no data.
98
+ */
99
+ export type OutputOf<Module, Name extends string> = Module extends { readonly [K in Name]: infer S }
100
+ ? S extends StandardSchemaV1
101
+ ? InferOutput<S>
102
+ : never
103
+ : null
104
+
105
+ /** The `searchSchema` a route's frontend half declares, or `undefined`. */
106
+ export type SearchSchemaOf<Module> = Module extends { readonly searchSchema: infer S }
107
+ ? S
108
+ : undefined
109
+
110
+ /** A route's loader or action context: its own `params`, and `api` typed by the registered backend. */
111
+ export type RouteLoaderArgs<Params, Search = undefined> = Omit<
112
+ LoaderArgs<RegisteredBackend, RegisteredEnv, Search>,
113
+ "params"
114
+ > & { readonly params: Params }
115
+
46
116
  /** The (awaited) return of a `loader`, for typing a page component's `data` prop. */
47
117
  export type LoaderData<L> = L extends (...args: never[]) => infer R ? Awaited<R> : never
48
118
 
@@ -61,9 +131,13 @@ export type ActionArgs<Api, Env = unknown, Search = undefined> = LoaderArgs<Api,
61
131
  * stays decoupled from `@nifrajs/web`) and unwrapped to its inner `data` - what the component receives.
62
132
  */
63
133
  export type ActionData<A> = A extends (...args: never[]) => infer R
64
- ? Awaited<R> extends { readonly __nifraRevalidate: readonly string[]; readonly data: infer D }
65
- ? Exclude<D, Response>
66
- : Exclude<Awaited<R>, Response>
134
+ ? // `infer D` makes the check distribute, so an action mixing `revalidate(...)` and plain returns
135
+ // unwraps each branch.
136
+ Awaited<R> extends infer D
137
+ ? D extends { readonly __nifraRevalidate: readonly string[]; readonly data: infer W }
138
+ ? Exclude<W, Response>
139
+ : Exclude<D, Response>
140
+ : never
67
141
  : never
68
142
 
69
143
  // Why a type annotation, not a `createRoutes()` factory: a module-level factory call defeats
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Whether a request path has a `.` or `..` segment.
3
+ *
4
+ * Such a path cannot be sent as written. URL parsing removes those segments before the request
5
+ * leaves - the percent-encoded spellings (`%2e`, `%2E`) included - so the server would be asked for
6
+ * a different path than the call names: `/users/../posts` arrives as `/posts`. No encoding survives
7
+ * that step, so the client refuses the call instead of sending it somewhere else. A backslash counts
8
+ * as a separator because an `http(s)` URL reads it as one.
9
+ */
10
+ const DOT_SEGMENT = /(?:^|[/\\])(?:\.|%2e){1,2}(?:[/\\]|$)/i
11
+
12
+ export function hasDotSegment(path: string): boolean {
13
+ return DOT_SEGMENT.test(path)
14
+ }
package/src/treaty.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { RouteInfo, Server } from "@nifrajs/core/server"
1
+ import type { RequestPath, RouteInfo, Server } from "@nifrajs/core/server"
2
2
  import type { Jsonify } from "./jsonify.ts"
3
3
  import type { Result } from "./result.ts"
4
4
 
@@ -199,12 +199,34 @@ type NextSegs<R, Prefix extends string> = {
199
199
  [P in Sub<R, Prefix> & string]: NextSeg<Prefix, P>
200
200
  }[Sub<R, Prefix> & string]
201
201
 
202
- // Static segments exclude params (`:`), wildcards (`*`), and the root's empty (``).
203
- type StaticSegs<R, Prefix extends string> = Exclude<
204
- NextSegs<R, Prefix>,
205
- `:${string}` | `*${string}` | ""
206
- >
207
- type ParamSeg<R, Prefix extends string> = Extract<NextSegs<R, Prefix>, `:${string}` | `*${string}`>
202
+ /**
203
+ * What a request carries in place of one route segment: a static segment is itself, `:id` and
204
+ * `*path` are any `string`, and a segment that is part literal, part parameter keeps its literal
205
+ * text - `:name.json` is `` `${string}.json` ``. Read with the router's own rules, so a name ends
206
+ * where the router ends it and a constraint is not part of the text.
207
+ */
208
+ type SegText<Seg extends string> = RequestPath<`/${Seg}`> extends `/${infer Text}` ? Text : never
209
+
210
+ // A segment is static when its text is the segment, a param when its text is any string (a whole
211
+ // `:name`, with or without a constraint, or a wildcard), and mixed otherwise.
212
+ type SegKind<Seg extends string> = string extends Seg
213
+ ? "static"
214
+ : SegText<Seg> extends Seg
215
+ ? "static"
216
+ : string extends SegText<Seg>
217
+ ? "param"
218
+ : "mixed"
219
+
220
+ // The root's empty segment (``) is never a child.
221
+ type SegsOf<Seg extends string, Kind extends string> = Seg extends ""
222
+ ? never
223
+ : SegKind<Seg> extends Kind
224
+ ? Seg
225
+ : never
226
+
227
+ type StaticSegs<R, Prefix extends string> = SegsOf<NextSegs<R, Prefix>, "static">
228
+ type ParamSeg<R, Prefix extends string> = SegsOf<NextSegs<R, Prefix>, "param">
229
+ type MixedSegs<R, Prefix extends string> = SegsOf<NextSegs<R, Prefix>, "mixed">
208
230
 
209
231
  // `unknown` (not `{}`/`never`) is the intersection identity, so empty branches
210
232
  // don't poison the node - and we never `Prettify` the node (it would strip the
@@ -217,15 +239,35 @@ type StaticChildren<R, Prefix extends string> = {
217
239
  : TreatyNode<R, `${Prefix}/${Seg}`>
218
240
  }
219
241
 
242
+ /**
243
+ * The call for one param segment. A constraint is not part of the name: `:id{[0-9]+}` is called
244
+ * with `{ id }`, and the node below is the route as it was registered.
245
+ */
246
+ type ParamCall<
247
+ R,
248
+ Prefix extends string,
249
+ Seg extends string,
250
+ > = Seg extends `:${infer Name}{${string}}`
251
+ ? (params: Record<Name, string>) => TreatyNode<R, `${Prefix}/${Seg}`>
252
+ : Seg extends `:${infer Name}`
253
+ ? (params: Record<Name, string>) => TreatyNode<R, `${Prefix}/${Seg}`>
254
+ : Seg extends `*${infer Name}`
255
+ ? (params: Record<Name extends "" ? "*" : Name, string>) => TreatyNode<R, `${Prefix}/${Seg}`>
256
+ : never
257
+
258
+ /** One callable carrying every signature in a union of functions. */
259
+ type EveryCall<Calls> = (Calls extends unknown ? (call: Calls) => void : never) extends (
260
+ call: infer All,
261
+ ) => void
262
+ ? All
263
+ : never
264
+
265
+ // One position can hold several param routes (`:id{[0-9]+}` beside `:slug`, or a param beside a
266
+ // wildcard). Each gets its own signature, picked by the name in the argument - so routes that share
267
+ // a position should not share a name, or the first one's types answer for both.
220
268
  type ParamChild<R, Prefix extends string> = [ParamSeg<R, Prefix>] extends [never]
221
269
  ? unknown
222
- : ParamSeg<R, Prefix> extends `:${infer Name}`
223
- ? (params: Record<Name, string>) => TreatyNode<R, `${Prefix}/:${Name}`>
224
- : ParamSeg<R, Prefix> extends `*${infer Name}`
225
- ? (
226
- params: Record<Name extends "" ? "*" : Name, string>,
227
- ) => TreatyNode<R, `${Prefix}/*${Name}`>
228
- : unknown
270
+ : EveryCall<ParamCall<R, Prefix, ParamSeg<R, Prefix>>>
229
271
 
230
272
  /**
231
273
  * The call spelling for a static child segment: `api("users").get()` is `api.users.get()`.
@@ -247,10 +289,30 @@ type SegmentCall<R, Prefix extends string> = [StaticSegs<R, Prefix>] extends [ne
247
289
  ? unknown
248
290
  : <S extends StaticSegs<R, Prefix> & string>(segment: S) => TreatyNode<R, `${Prefix}/${S}`>
249
291
 
292
+ type MixedCall<R, Prefix extends string, Seg extends string> = Seg extends unknown
293
+ ? (segment: SegText<Seg>) => TreatyNode<R, `${Prefix}/${Seg}`>
294
+ : never
295
+
296
+ /**
297
+ * The call for a segment that is part literal, part parameter. It has no single param to name, so
298
+ * it takes the segment as it appears in the request: `/files/:name.json` is
299
+ * `api.files("report.json")`, `/v:major.:minor` is `api("v1.2")`. The argument's type is the
300
+ * segment's literal text around any string, so `api.files("report.txt")` does not compile.
301
+ *
302
+ * Placed last in the node intersection. A segment the registry declares as static resolves to
303
+ * `SegmentCall` first - `api.files("index.json")` is the static `/files/index.json` when there is
304
+ * one, which is also the route the server picks. Two mixed segments at one position that accept the
305
+ * same text resolve to whichever was registered first.
306
+ */
307
+ type MixedChild<R, Prefix extends string> = [MixedSegs<R, Prefix>] extends [never]
308
+ ? unknown
309
+ : EveryCall<MixedCall<R, Prefix, MixedSegs<R, Prefix>>>
310
+
250
311
  type TreatyNode<R, Prefix extends string> = MethodsAt<R, Prefix> &
251
312
  StaticChildren<R, Prefix> &
252
313
  ParamChild<R, Prefix> &
253
- SegmentCall<R, Prefix>
314
+ SegmentCall<R, Prefix> &
315
+ MixedChild<R, Prefix>
254
316
 
255
317
  // The root path "/" is reached as `api.index.get()` (Eden convention).
256
318
  type RootIndex<R> = "/" extends keyof R ? { readonly index: Methods<R["/"]> } : unknown
@@ -15,7 +15,14 @@
15
15
 
16
16
  import { type Method, Router } from "@nifrajs/core/router"
17
17
  import { formatStandardIssues } from "@nifrajs/core/schema"
18
- import type { FetchFn } from "./client.ts"
18
+ import type { Platform } from "@nifrajs/core/server"
19
+
20
+ /** The in-process bridge: the client's `fetch(url, init)` shape, plus the calling request's platform. */
21
+ export type PlatformFetchFn = (
22
+ input: string,
23
+ init?: RequestInit,
24
+ platform?: Platform,
25
+ ) => Promise<Response>
19
26
 
20
27
  /** The slice of a Standard Schema this wrapper runs. */
21
28
  interface SchemaLike {
@@ -66,7 +73,7 @@ export class ResponseContractViolation extends Error {
66
73
  * Wrap the in-process bridge so every response is checked against its route's declared contract.
67
74
  * Throws at wrap time when the app cannot enumerate routes - a misconfiguration, not a soft skip.
68
75
  */
69
- export function withResponseValidation(app: unknown, bridge: FetchFn): FetchFn {
76
+ export function withResponseValidation(app: unknown, bridge: PlatformFetchFn): PlatformFetchFn {
70
77
  const routesOf = (app as Partial<RouteIntrospectable>).routes
71
78
  if (typeof routesOf !== "function") {
72
79
  throw new Error(
@@ -79,8 +86,8 @@ export function withResponseValidation(app: unknown, bridge: FetchFn): FetchFn {
79
86
  router.add(route.method as Method, route.path, route.schema)
80
87
  }
81
88
 
82
- return async (url, init) => {
83
- const response = await bridge(url, init)
89
+ return async (url, init, platform) => {
90
+ const response = await bridge(url, init, platform)
84
91
  const method = (init?.method ?? "GET").toUpperCase()
85
92
  const status = response.status
86
93
  if (method === "HEAD" || status === 204 || status === 205) return response
package/src/ws.ts CHANGED
@@ -14,13 +14,17 @@ import {
14
14
  type TransportCodec,
15
15
  type TransportCodecRegistry,
16
16
  } from "@nifrajs/core/transport-codec"
17
+ import { hasDotSegment } from "./sendable-path.ts"
17
18
  import type { WsCallOptions, WsHandle } from "./treaty.ts"
18
19
 
19
20
  /** Internal handshake between `inProcessClient` and the proxy: in-process apps have no socket. */
20
21
  export const NO_SOCKET = Symbol.for("@nifrajs/client/no-socket")
21
22
 
23
+ // `readyState` values, fixed by the WebSocket standard (an implementation's statics may be absent).
24
+ const CONNECTING = 0
25
+ const CLOSED = 3
26
+
22
27
  interface WsRuntimeOptions extends WsCallOptions {
23
- headers?: Record<string, string> | undefined
24
28
  transport?: {
25
29
  readonly codec: TransportCodec
26
30
  readonly registry?: TransportCodecRegistry
@@ -55,6 +59,12 @@ export function openWebSocket(
55
59
  )
56
60
  }
57
61
 
62
+ if (hasDotSegment(path)) {
63
+ throw new Error(
64
+ 'cannot open a WebSocket at a path with a "." or ".." segment - URL parsing removes that segment, so the socket would reach a different route',
65
+ )
66
+ }
67
+
58
68
  const url = toWsUrl(base, path, callOptions?.query)
59
69
  const socket =
60
70
  callOptions?.protocols !== undefined
@@ -89,6 +99,10 @@ export function openWebSocket(
89
99
  for (const frame of sendQueue) socket.send(frame)
90
100
  sendQueue.length = 0
91
101
  })
102
+ // A socket that closed before it opened will never send what was queued for it.
103
+ socket.addEventListener("close", () => {
104
+ sendQueue.length = 0
105
+ })
92
106
  socket.addEventListener("message", (event: MessageEvent) => {
93
107
  if (typeof event.data !== "string") return // binary frames live off the typed contract
94
108
  let parsed: unknown
@@ -105,9 +119,16 @@ export function openWebSocket(
105
119
  for (const listener of listeners) listener(parsed)
106
120
  })
107
121
 
108
- if (callOptions?.signal !== undefined) {
109
- if (callOptions.signal.aborted) socket.close()
110
- else callOptions.signal.addEventListener("abort", () => socket.close(), { once: true })
122
+ const signal = callOptions?.signal
123
+ if (signal !== undefined) {
124
+ if (signal.aborted) socket.close()
125
+ else {
126
+ const abort = (): void => socket.close()
127
+ signal.addEventListener("abort", abort, { once: true })
128
+ socket.addEventListener("close", () => signal.removeEventListener("abort", abort), {
129
+ once: true,
130
+ })
131
+ }
111
132
  }
112
133
 
113
134
  const onMessage = (callback: (message: unknown) => void): (() => void) => {
@@ -124,7 +145,9 @@ export function openWebSocket(
124
145
  ? JSON.stringify(message)
125
146
  : encodeTransportFrame(message, transport.codec)
126
147
  if (socket.readyState === WebSocketImpl.OPEN) socket.send(frame)
127
- else sendQueue.push(frame)
148
+ // Queued only while connecting: a closing or closed socket drops the frame, as
149
+ // `WebSocket.send` itself does, rather than holding it in a queue nothing will flush.
150
+ else if (socket.readyState === CONNECTING) sendQueue.push(frame)
128
151
  },
129
152
  close(code?: number, reason?: string): void {
130
153
  socket.close(code, reason)
@@ -134,6 +157,7 @@ export function openWebSocket(
134
157
  const buffered: unknown[] = []
135
158
  let pending: ((result: IteratorResult<unknown>) => void) | undefined
136
159
  let done = false
160
+ const signal = options?.signal
137
161
 
138
162
  const push = (message: unknown): void => {
139
163
  if (pending !== undefined) {
@@ -148,6 +172,9 @@ export function openWebSocket(
148
172
  if (done) return
149
173
  done = true
150
174
  unsubscribe()
175
+ socket.removeEventListener("close", end)
176
+ socket.removeEventListener("error", end)
177
+ signal?.removeEventListener("abort", end)
151
178
  pending?.({ value: undefined, done: true })
152
179
  pending = undefined
153
180
  }
@@ -155,7 +182,9 @@ export function openWebSocket(
155
182
  const unsubscribe = onMessage(push)
156
183
  socket.addEventListener("close", end, { once: true })
157
184
  socket.addEventListener("error", end, { once: true })
158
- options?.signal?.addEventListener("abort", end, { once: true })
185
+ signal?.addEventListener("abort", end, { once: true })
186
+ // Already over: no close or abort event is coming to end the iteration.
187
+ if (socket.readyState === CLOSED || signal?.aborted === true) end()
159
188
 
160
189
  const iterator: AsyncIterableIterator<unknown> = {
161
190
  [Symbol.asyncIterator]() {