@nxara/olio-client 0.1.1 → 0.1.2
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 +3 -273
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,281 +1,11 @@
|
|
|
1
1
|
# @nxara/olio-client
|
|
2
2
|
|
|
3
|
-
A typed GraphQL client for
|
|
4
|
-
|
|
5
|
-
```ts
|
|
6
|
-
const { data } = await client.mutation.login({ email, password }, { headers });
|
|
7
|
-
```
|
|
3
|
+
A typed GraphQL client for olio apps: write operations in `.graphql` files, codegen types them, and call each one as a typed function. `testClient` runs your app in tests.
|
|
8
4
|
|
|
9
5
|
```sh
|
|
10
6
|
bun add @nxara/olio-client graphql@16
|
|
11
7
|
```
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
## Operations
|
|
16
|
-
|
|
17
|
-
```graphql
|
|
18
|
-
# src/operations/auth.graphql
|
|
19
|
-
mutation Login($email: String!, $password: String!) {
|
|
20
|
-
login(email: $email, password: $password) {
|
|
21
|
-
__typename
|
|
22
|
-
... on Session {
|
|
23
|
-
token
|
|
24
|
-
}
|
|
25
|
-
... on UserError {
|
|
26
|
-
message
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## Codegen
|
|
33
|
-
|
|
34
|
-
`codegenClient` reads the schema (a file, or the running server's URL by introspection) and the operations, checks the operations against the schema (errors point at `file:line:column`), and writes the types:
|
|
35
|
-
|
|
36
|
-
```ts
|
|
37
|
-
import { codegenClient } from "@nxara/olio-codegen";
|
|
38
|
-
|
|
39
|
-
await codegenClient({
|
|
40
|
-
schema: "../server/src/schema.gql", // or "http://localhost:4000/graphql"
|
|
41
|
-
documents: "src/operations/*.graphql",
|
|
42
|
-
output: "src/generated.ts",
|
|
43
|
-
headers: { authorization: "Bearer …" } // only for a URL that needs it
|
|
44
|
-
});
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Per operation, named the way graphql-codegen names them: `LoginMutation` (the result), `LoginMutationVariables` and `LoginDocument`. Plus `operations`, every document by root and name, which the client takes.
|
|
48
|
-
|
|
49
|
-
## Calls
|
|
50
|
-
|
|
51
|
-
```ts
|
|
52
|
-
import { createClient } from "@nxara/olio-client";
|
|
53
|
-
|
|
54
|
-
import { operations } from "./generated.js";
|
|
55
|
-
|
|
56
|
-
const client = createClient(operations, {
|
|
57
|
-
url: "https://erp.example/graphql",
|
|
58
|
-
// Nothing before sign-in: an `undefined` value is left out, not sent as text.
|
|
59
|
-
headers: () => ({ authorization: token && `Bearer ${token}` })
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const { data, errors } = await client.mutation.login({ email, password });
|
|
63
|
-
if (data?.login.__typename === "Session") data.login.token;
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
- `client.<query | mutation | subscription>.<operation>(variables?, options?)`. The operation's name with its first letter lowercased: `mutation Login` is `client.mutation.login`.
|
|
67
|
-
- Variables are typed from the operation; they may be left out when none are required.
|
|
68
|
-
- `data` is the operation's result, typed from its selection: fragments narrow on `__typename`, fields under `@include` / `@skip` are optional. `errors` are the GraphQL errors as the server sent them. A response that isn't GraphQL at all (a proxy's 502 page) rejects.
|
|
69
|
-
- `options` takes per-call `headers` and a `signal`.
|
|
70
|
-
- Headers, on the client or per call, are an object, a `Headers`, or entries; in an object, `undefined` values are left out (`token ? { authorization } : {}` works too). The call's win.
|
|
71
|
-
|
|
72
|
-
## Custom fetch
|
|
73
|
-
|
|
74
|
-
`fetch` takes anything fetch-shaped, such as a ky instance:
|
|
75
|
-
|
|
76
|
-
```ts
|
|
77
|
-
import ky from "ky";
|
|
78
|
-
|
|
79
|
-
createClient(operations, { url, fetch: ky.create({ throwHttpErrors: false }) });
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
GraphQL errors can come with 4xx/5xx statuses, so ky needs `throwHttpErrors: false`. A library that isn't fetch-shaped, such as axios, needs a few lines that return a `Response`:
|
|
83
|
-
|
|
84
|
-
```ts
|
|
85
|
-
import type { Fetch } from "@nxara/olio-client";
|
|
86
|
-
|
|
87
|
-
const axiosFetch: Fetch = async (url, init) => {
|
|
88
|
-
const res = await axios.request({
|
|
89
|
-
url,
|
|
90
|
-
method: init.method,
|
|
91
|
-
headers: Object.fromEntries(new Headers(init.headers)),
|
|
92
|
-
data: init.body,
|
|
93
|
-
signal: init.signal ?? undefined,
|
|
94
|
-
responseType: "text",
|
|
95
|
-
validateStatus: () => true // GraphQL errors come with 4xx/5xx too
|
|
96
|
-
});
|
|
97
|
-
return new Response(res.data, {
|
|
98
|
-
status: res.status,
|
|
99
|
-
headers: res.headers as Record<string, string>
|
|
100
|
-
});
|
|
101
|
-
};
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
`fetch` is for queries and mutations; subscriptions go through your own clients.
|
|
105
|
-
|
|
106
|
-
## Uploading files
|
|
107
|
-
|
|
108
|
-
The client sends JSON, and a `File` in JSON is `{}`. `fetch` also gets the request before JSON, with the variables as you passed them, so it can send them the [multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec)'s way instead:
|
|
109
|
-
|
|
110
|
-
```ts
|
|
111
|
-
import type { Fetch } from "@nxara/olio-client";
|
|
112
|
-
|
|
113
|
-
const multipart: Fetch = (url, init, { query, variables, operationName }) => {
|
|
114
|
-
const files = new Map<File, string[]>();
|
|
115
|
-
// Files leave the variables (null in their place); `map` says where each goes back.
|
|
116
|
-
const strip = (value: unknown, path: string): unknown => {
|
|
117
|
-
if (value instanceof File) {
|
|
118
|
-
files.set(value, [...(files.get(value) ?? []), path]);
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
if (Array.isArray(value)) {
|
|
122
|
-
return value.map((v, i) => strip(v, `${path}.${i}`));
|
|
123
|
-
}
|
|
124
|
-
if (value && typeof value === "object") {
|
|
125
|
-
return Object.fromEntries(
|
|
126
|
-
Object.entries(value).map(([k, v]) => [k, strip(v, `${path}.${k}`)])
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
return value;
|
|
130
|
-
};
|
|
131
|
-
const stripped = strip(variables, "variables");
|
|
132
|
-
if (files.size === 0) return fetch(url, init);
|
|
133
|
-
|
|
134
|
-
const form = new FormData();
|
|
135
|
-
form.set(
|
|
136
|
-
"operations",
|
|
137
|
-
JSON.stringify({ query, variables: stripped, operationName })
|
|
138
|
-
);
|
|
139
|
-
form.set(
|
|
140
|
-
"map",
|
|
141
|
-
JSON.stringify(Object.fromEntries([...files.values()].entries()))
|
|
142
|
-
);
|
|
143
|
-
for (const [i, file] of [...files.keys()].entries())
|
|
144
|
-
form.set(String(i), file);
|
|
145
|
-
|
|
146
|
-
const headers = new Headers(init.headers);
|
|
147
|
-
headers.delete("content-type"); // fetch sets it, with the boundary
|
|
148
|
-
headers.set("x-upload", "1"); // browsers can't add it cross-site, so the server can refuse CSRF
|
|
149
|
-
return fetch(url, { ...init, headers, body: form });
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const client = createClient(operations, { url, fetch: multipart });
|
|
153
|
-
await client.mutation.uploadAvatar({ file });
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
Register the scalar as `File` (`interface ClientScalars { Upload: File }`). On the server, `@nxara/olio-http` passes graphql-http's `parseRequestParams` through, which puts the files back:
|
|
157
|
-
|
|
158
|
-
```ts
|
|
159
|
-
fetchHandler(app, {
|
|
160
|
-
context,
|
|
161
|
-
parseRequestParams: async (req) => {
|
|
162
|
-
const request = req.raw as Request;
|
|
163
|
-
if (!request.headers.get("content-type")?.startsWith("multipart/form-data"))
|
|
164
|
-
return; // JSON, as usual
|
|
165
|
-
if (!request.headers.get("x-upload"))
|
|
166
|
-
return new Response(null, { status: 400 });
|
|
167
|
-
const form = await request.formData();
|
|
168
|
-
const params = JSON.parse(String(form.get("operations")));
|
|
169
|
-
const map: Record<string, string[]> = JSON.parse(String(form.get("map")));
|
|
170
|
-
for (const [key, paths] of Object.entries(map)) {
|
|
171
|
-
for (const path of paths) setPath(params, path, form.get(key)); // e.g. lodash's set
|
|
172
|
-
}
|
|
173
|
-
return params;
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
with `scalar Upload` in the SDL, typed `File` in `CustomScalars`. Size limits are the server's (or its proxy's). Many apps skip all this: a mutation returns a signed upload URL, and the browser sends the file straight to storage.
|
|
179
|
-
|
|
180
|
-
## Scalars
|
|
181
|
-
|
|
182
|
-
The way the server's `CustomScalars` works: one type, or `{ input; output }` when variables and results differ. Custom scalars are `unknown` until registered:
|
|
183
|
-
|
|
184
|
-
```ts
|
|
185
|
-
declare module "@nxara/olio-client" {
|
|
186
|
-
interface ClientScalars {
|
|
187
|
-
DateTime: { input: Date | string; output: string };
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Put it in a `.ts` file, or a `.d.ts` with an `import` or `export {}`.
|
|
193
|
-
|
|
194
|
-
Enums arrive by name (`"CONFIRMED"`), not as the server's internal values.
|
|
195
|
-
|
|
196
|
-
## Subscriptions
|
|
197
|
-
|
|
198
|
-
Over WebSocket by default ([graphql-ws](https://github.com/enisdenjo/graphql-ws)), or as server-sent events on the same URL ([graphql-sse](https://github.com/enisdenjo/graphql-sse)) for an operation marked `@sse`. The choice lives in the operation; the directive is the client's, and codegen removes it from the query the server receives:
|
|
199
|
-
|
|
200
|
-
```graphql
|
|
201
|
-
subscription StockChanged($sku: String) {
|
|
202
|
-
# WebSocket
|
|
203
|
-
stockChanged(sku: $sku) {
|
|
204
|
-
sku
|
|
205
|
-
onHand
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
subscription Notifications @sse {
|
|
210
|
-
# server-sent events
|
|
211
|
-
notifications {
|
|
212
|
-
message
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
```ts
|
|
218
|
-
for await (const { data } of client.subscription.stockChanged({ sku: "CHAIR-01" })) {
|
|
219
|
-
console.log(data?.stockChanged.onHand);
|
|
220
|
-
}
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
They go through your own [graphql-ws](https://github.com/enisdenjo/graphql-ws) and [graphql-sse](https://github.com/enisdenjo/graphql-sse) clients, which `createClient` requires as `ws` and `sse` once an operation uses that transport (and rejects when none does):
|
|
224
|
-
|
|
225
|
-
```ts
|
|
226
|
-
import { createClient as createSseClient } from "graphql-sse";
|
|
227
|
-
import { createClient as createWsClient } from "graphql-ws";
|
|
228
|
-
|
|
229
|
-
const headers = () => ({ authorization: token && `Bearer ${token}` });
|
|
230
|
-
const ws = createWsClient({
|
|
231
|
-
url: "wss://erp.example/graphql",
|
|
232
|
-
// Browsers can't set headers on a WebSocket: send them here.
|
|
233
|
-
connectionParams: () => ({ headers: headers() })
|
|
234
|
-
});
|
|
235
|
-
const client = createClient(operations, {
|
|
236
|
-
url: "https://erp.example/graphql",
|
|
237
|
-
headers,
|
|
238
|
-
ws,
|
|
239
|
-
sse: createSseClient({ url: "https://erp.example/graphql", headers })
|
|
240
|
-
});
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
The client calls their `iterate` and types the results; the rest is theirs and yours to configure: connecting, retries, keep-alive. Breaking out of the loop, or aborting `signal`, ends the subscription.
|
|
244
|
-
|
|
245
|
-
- **New credentials:** graphql-ws sends `connectionParams` each time the socket connects. After the token changes, `ws.terminate()` reconnects, and graphql-ws resubscribes live subscriptions on the new socket. It waits out its retry backoff first (`retryWait`, about 1.3 to 4 s by default), and events sent in between are lost. An open SSE stream keeps its headers until you subscribe again.
|
|
246
|
-
- **Refusals:** by default both libraries retry a refused connection for about 40 s before reporting it (graphql-ws on a 4403 close, graphql-sse on any response that isn't 2xx); `retryAttempts: 0` or graphql-ws's `shouldRetry` reports it at once. Then a WebSocket closed with a 4xxx code is an error result (`extensions: { code: "WEBSOCKET_CLOSED", closeCode }`), and graphql-sse throws its `NetworkError` (its `response` holds the status).
|
|
247
|
-
- **Rejected operations** (the server's `onSubscribe` returning errors) are an error result on both, as over HTTP.
|
|
248
|
-
|
|
249
|
-
## Bundle size
|
|
250
|
-
|
|
251
|
-
The client imports neither library: an app bundles only the ones it creates clients with. The client's own code is about 1 KB gzipped.
|
|
252
|
-
|
|
253
|
-
## Tests
|
|
254
|
-
|
|
255
|
-
`testClient` from `@nxara/olio-client/test` is the same client, answered in-process like Hono's `testClient`, through the app's own servers: its fetch handler for queries and mutations, and for subscriptions the graphql-ws options it passes to `useServer` (run over an in-memory socket) and its graphql-sse handler. So tests run production's context, `onConnect`, `onSubscribe`, error masking and cleanup. No server is started:
|
|
256
|
-
|
|
257
|
-
```ts
|
|
258
|
-
import { testClient } from "@nxara/olio-client/test";
|
|
259
|
-
|
|
260
|
-
// The same objects the server uses.
|
|
261
|
-
import { graphqlHandler } from "../src/http.js";
|
|
262
|
-
import { sseHandler, wsOptions } from "../src/subscriptions.js";
|
|
263
|
-
|
|
264
|
-
const client = testClient(
|
|
265
|
-
operations,
|
|
266
|
-
{ http: graphqlHandler, ws: wsOptions, sse: sseHandler },
|
|
267
|
-
{ headers: { authorization: `Bearer ${token}` } }
|
|
268
|
-
);
|
|
269
|
-
expect((await client.query.me()).data).toEqual({ me: { name: "Mia" } });
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
`ws` and `sse` are required exactly when an operation uses that transport; without subscriptions, the fetch handler alone is enough: `testClient(operations, graphqlHandler)`. Over WebSocket, the headers arrive as `connectionParams: { headers }`, and `ctx.extra` is empty (no socket or request in memory). Its clients never retry, since a failure in memory is final: a refusal over SSE throws at once. It's a separate import, so it never ends up in an app's bundle.
|
|
273
|
-
|
|
274
|
-
## API
|
|
9
|
+
**[Read the docs](https://olio.nxara.dev/reference/client/)**
|
|
275
10
|
|
|
276
|
-
|
|
277
|
-
| --- | --- |
|
|
278
|
-
| `createClient(operations, { url, headers?, fetch?, ws?, sse? })` | `headers`: an object, or a function for values that change. `fetch`: default the global one; called with `(url, init, params)`, `params` being the request before JSON (see Uploading files). `ws` / `sse`: your graphql-ws / graphql-sse client, required once an operation uses that transport |
|
|
279
|
-
| `testClient(operations, handler \| { http, ws?, sse? }, { headers?, url? })` | From `@nxara/olio-client/test`: the same client, answered in-process |
|
|
280
|
-
| `ClientScalars` | Augment it with how custom scalars arrive and are sent |
|
|
281
|
-
| `TypedDocument`, `Result`, `Client`, `ClientOptions`, `Operations`, `SubscriptionClient` | The types behind the calls |
|
|
11
|
+
Part of [olio](https://olio.nxara.dev): modular, type-safe GraphQL for TypeScript. MIT licensed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nxara/olio-client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "A typed GraphQL client for Nxara apps: select fields, get typed results.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"client",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
"check-types": "bun test/generate.ts && tsc -p tsconfig.test.json"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@nxara/olio": "0.1.
|
|
42
|
-
"@nxara/olio-codegen": "0.1.
|
|
43
|
-
"@nxara/olio-http": "0.1.
|
|
41
|
+
"@nxara/olio": "0.1.2",
|
|
42
|
+
"@nxara/olio-codegen": "0.1.2",
|
|
43
|
+
"@nxara/olio-http": "0.1.2",
|
|
44
44
|
"graphql": "^16.14.2",
|
|
45
45
|
"graphql-sse": "2.6.3",
|
|
46
46
|
"graphql-ws": "^6.4.1",
|