@shware/http 3.5.0 → 3.6.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 +450 -35
- package/dist/cache/index.d.cts +1 -0
- package/dist/cache/index.d.mts +1 -0
- package/dist/google-one-tap/types.d.cts +1 -0
- package/dist/google-one-tap/types.d.mts +1 -0
- package/dist/hono/authorizer.d.cts +1 -0
- package/dist/hono/authorizer.d.mts +1 -0
- package/dist/hono/csrf.d.cts +1 -0
- package/dist/hono/csrf.d.mts +1 -0
- package/dist/hono/handler.d.cts +1 -0
- package/dist/hono/handler.d.mts +1 -0
- package/package.json +7 -11
package/README.md
CHANGED
|
@@ -1,72 +1,164 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shware/http
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
HTTP building blocks shared by Shware servers and clients: a Google-style error model (`Status`,
|
|
4
|
+
`StatusError`, `Details`) with client-side parsers, cursor pagination types and helpers, a
|
|
5
|
+
Snowflake ID generator, Hono middlewares (error handler, zod validation, CSRF, authorization, rate
|
|
6
|
+
limiting), Google One Tap sign-in, Standard Webhooks verification and a few small server utilities.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
## Install
|
|
6
9
|
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import { Status } from '@repo/error';
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add @shware/http zod
|
|
12
|
+
```
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
14
|
-
const res = new Response(JSON.stringify(response), { status, headers });
|
|
15
|
-
return new HTTPException(status, { res });
|
|
16
|
-
};
|
|
14
|
+
Optional peer dependencies, needed only for the parts that use them:
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add hono # @shware/http/hono
|
|
18
|
+
pnpm add i18next # types for getErrorMessage()
|
|
19
|
+
```
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
`react` is also declared as an optional peer; no entry point currently requires it.
|
|
22
|
+
|
|
23
|
+
## Entry points
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
| Import | Contents |
|
|
26
|
+
| ----------------------------- | -------------------------------------------------------------- |
|
|
27
|
+
| `@shware/http` | Error model and parsers, pagination, ids, constants, utilities |
|
|
28
|
+
| `@shware/http/hono` | Hono error handler, zod validators and security middlewares |
|
|
29
|
+
| `@shware/http/google-one-tap` | Browser Google One Tap (FedCM) with an OAuth code fallback |
|
|
30
|
+
| `@shware/http/webhook` | `verifyStandardWebhook()` for Standard Webhooks |
|
|
31
|
+
| `@shware/http/polyfills` | Side-effect import making `BigInt` JSON-serializable |
|
|
32
|
+
|
|
33
|
+
## Error model
|
|
34
|
+
|
|
35
|
+
Errors follow the [Google API error model](https://cloud.google.com/apis/design/errors): a
|
|
36
|
+
canonical status code mapped to an HTTP status, a message, and a list of typed details.
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"error": {
|
|
41
|
+
"code": 400,
|
|
42
|
+
"status": "INVALID_ARGUMENT",
|
|
43
|
+
"message": "The client specified an invalid argument",
|
|
44
|
+
"details": [{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "INVALID_CODE" }]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
25
47
|
```
|
|
26
48
|
|
|
27
|
-
###
|
|
49
|
+
### Status
|
|
50
|
+
|
|
51
|
+
Each `Status.*` factory takes an optional message (a default English message is used otherwise)
|
|
52
|
+
and returns a `StatusCode` with three ways to emit it:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { Details, Status } from '@shware/http';
|
|
56
|
+
|
|
57
|
+
// throw it: a StatusError (or whatever Status.adapter returns)
|
|
58
|
+
throw Status.notFound('Team not found').error();
|
|
59
|
+
|
|
60
|
+
// return it: a JSON Response with the matching HTTP status
|
|
61
|
+
return Status.invalidArgument("a batch is one visitor's events").response();
|
|
28
62
|
|
|
29
|
-
|
|
63
|
+
// just the body: ErrorBody
|
|
64
|
+
const body = Status.internal().body();
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| Factory | Status | HTTP |
|
|
68
|
+
| --------------------------- | --------------------- | ---- |
|
|
69
|
+
| `Status.invalidArgument` | `INVALID_ARGUMENT` | 400 |
|
|
70
|
+
| `Status.failedPrecondition` | `FAILED_PRECONDITION` | 400 |
|
|
71
|
+
| `Status.outOfRange` | `OUT_OF_RANGE` | 400 |
|
|
72
|
+
| `Status.unauthorized` | `UNAUTHENTICATED` | 401 |
|
|
73
|
+
| `Status.permissionDenied` | `PERMISSION_DENIED` | 403 |
|
|
74
|
+
| `Status.notFound` | `NOT_FOUND` | 404 |
|
|
75
|
+
| `Status.methodNotAllowed` | `METHOD_NOT_ALLOWED` | 405 |
|
|
76
|
+
| `Status.alreadyExists` | `ALREADY_EXISTS` | 409 |
|
|
77
|
+
| `Status.aborted` | `ABORTED` | 409 |
|
|
78
|
+
| `Status.resourceExhausted` | `RESOURCE_EXHAUSTED` | 429 |
|
|
79
|
+
| `Status.cancelled` | `CANCELLED` | 499 |
|
|
80
|
+
| `Status.unknown` | `UNKNOWN` | 500 |
|
|
81
|
+
| `Status.internal` | `INTERNAL` | 500 |
|
|
82
|
+
| `Status.dataLoss` | `DATA_LOSS` | 500 |
|
|
83
|
+
| `Status.unimplemented` | `UNIMPLEMENTED` | 501 |
|
|
84
|
+
| `Status.unavailable` | `UNAVAILABLE` | 503 |
|
|
85
|
+
| `Status.deadlineExceeded` | `DEADLINE_EXCEEDED` | 504 |
|
|
86
|
+
| `Status.ok` | `OK` | 200 |
|
|
87
|
+
|
|
88
|
+
### Details
|
|
89
|
+
|
|
90
|
+
`Details.new()` builds the `details` array with chainable methods mirroring
|
|
91
|
+
[`google.rpc` error details](https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto):
|
|
92
|
+
`errorInfo`, `retryInfo`, `debugInfo`, `quotaFailure`, `preconditionFailure`, `badRequest`,
|
|
93
|
+
`requestInfo`, `resourceInfo`, `help` and `localizedMessage`.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { Details, Status } from '@shware/http';
|
|
97
|
+
|
|
98
|
+
const details = Details.new()
|
|
99
|
+
.errorInfo({ reason: 'RATE_LIMIT_EXCEEDED' })
|
|
100
|
+
.retryInfo({ retryDelay: 60_000 });
|
|
101
|
+
|
|
102
|
+
throw Status.resourceExhausted('Too many requests, please try again later').error(details);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### StatusError and Status.adapter
|
|
106
|
+
|
|
107
|
+
By default `.error()` returns a `StatusError` carrying `status` (the HTTP status) and `body` (the
|
|
108
|
+
`ErrorBody`). With Hono, the `errorHandler` from `@shware/http/hono` turns it into a response (see
|
|
109
|
+
below). Without it, handle it yourself:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { Status, StatusError } from '@shware/http';
|
|
30
113
|
import { Hono } from 'hono';
|
|
31
|
-
import { HTTPException } from 'hono/http-exception';
|
|
32
|
-
import { Status, StatusError } from '@repo/error';
|
|
33
114
|
|
|
34
115
|
const app = new Hono();
|
|
35
116
|
|
|
36
117
|
app.onError((error, c) => {
|
|
37
|
-
if (error instanceof StatusError)
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
return c.json({ error: 'Internal Server Error' }, 500);
|
|
118
|
+
if (error instanceof StatusError) return c.json(error.body, error.status as 400);
|
|
119
|
+
return c.json(Status.internal().body(), 500);
|
|
41
120
|
});
|
|
42
121
|
|
|
43
122
|
app.get('/', () => {
|
|
44
123
|
throw Status.invalidArgument('Hello World!').error();
|
|
45
124
|
});
|
|
125
|
+
```
|
|
46
126
|
|
|
47
|
-
|
|
127
|
+
To have `.error()` produce a framework-native error instead, set `Status.adapter` once at startup:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { Status } from '@shware/http';
|
|
131
|
+
import { HTTPException } from 'hono/http-exception';
|
|
132
|
+
|
|
133
|
+
Status.adapter = (status, body) => {
|
|
134
|
+
const res = Response.json(body, { status });
|
|
135
|
+
return new HTTPException(status as 400, { res });
|
|
136
|
+
};
|
|
48
137
|
```
|
|
49
138
|
|
|
50
|
-
|
|
139
|
+
### Custom ErrorReason
|
|
51
140
|
|
|
52
|
-
By default, `ErrorInfo.reason` accepts any `string`. You can use TypeScript
|
|
141
|
+
By default, `ErrorInfo.reason` accepts any `string`. You can use TypeScript
|
|
142
|
+
[declaration merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) to
|
|
143
|
+
constrain it to a set of known keys:
|
|
53
144
|
|
|
54
|
-
```
|
|
55
|
-
// e.g.
|
|
56
|
-
import '@shware/http'; // Required: makes this file a module so
|
|
145
|
+
```ts
|
|
146
|
+
// e.g. src/http.d.ts
|
|
147
|
+
import '@shware/http'; // Required: makes this file a module so `declare module` augments it
|
|
57
148
|
|
|
58
149
|
declare module '@shware/http' {
|
|
59
150
|
interface ErrorReason {
|
|
60
151
|
ACCOUNT_BLOCKED: string;
|
|
61
|
-
|
|
152
|
+
RATE_LIMIT_EXCEEDED: string;
|
|
62
153
|
SUBSCRIPTION_EXPIRED: string;
|
|
63
154
|
}
|
|
64
155
|
}
|
|
65
156
|
```
|
|
66
157
|
|
|
67
|
-
Once declared, `
|
|
158
|
+
Once declared, `errorInfo({ reason })`, `isErrorReason()` and `getErrorMessage()` only accept or
|
|
159
|
+
return the keys you defined, with autocomplete:
|
|
68
160
|
|
|
69
|
-
```
|
|
161
|
+
```ts
|
|
70
162
|
import { Details } from '@shware/http';
|
|
71
163
|
|
|
72
164
|
// OK
|
|
@@ -76,4 +168,327 @@ Details.new().errorInfo({ reason: 'ACCOUNT_BLOCKED' });
|
|
|
76
168
|
Details.new().errorInfo({ reason: 'INVALID' });
|
|
77
169
|
```
|
|
78
170
|
|
|
79
|
-
|
|
171
|
+
A convenient pattern is to reuse the keys of your i18n error namespace, so every reason has a
|
|
172
|
+
translation:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
import '@shware/http';
|
|
176
|
+
import type { ErrorReason as I18nErrorReason } from '@repo/i18n';
|
|
177
|
+
|
|
178
|
+
declare module '@shware/http' {
|
|
179
|
+
interface ErrorReason extends I18nErrorReason {}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`rateLimit` from `@shware/http/hono` emits `RATE_LIMIT_EXCEEDED`; include it if you use it.
|
|
184
|
+
|
|
185
|
+
### Reading errors on the client
|
|
186
|
+
|
|
187
|
+
The parsers take the decoded response body (`unknown`) and never throw:
|
|
188
|
+
|
|
189
|
+
- `getErrorInfo(data)` returns the `ErrorInfo` detail or `null`.
|
|
190
|
+
- `isErrorReason(data, ...reasons)` checks whether the body carries one of the given reasons.
|
|
191
|
+
- `getFieldViolations(data)` returns the `BadRequest` field violations (`[]` if none).
|
|
192
|
+
- `getErrorMessage(data, t)` returns `{ reason, message }`, preferring a `LocalizedMessage`, then
|
|
193
|
+
`t(reason, metadata)`, then the English message, then `t(status)`, then `t('UNKNOWN')`.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { getErrorInfo, getFieldViolations, isErrorReason } from '@shware/http';
|
|
197
|
+
import { HTTPError } from 'ky';
|
|
198
|
+
|
|
199
|
+
const login = useMutation({
|
|
200
|
+
mutationFn: api.auth.loginEmail,
|
|
201
|
+
meta: { handledReasons: ['INVALID_VERIFICATION_CODE'] },
|
|
202
|
+
onError: (e) => {
|
|
203
|
+
if (!(e instanceof HTTPError)) return;
|
|
204
|
+
if (isErrorReason(e.data, 'INVALID_VERIFICATION_CODE')) {
|
|
205
|
+
setError('verificationCode', { message: t('INVALID_VERIFICATION_CODE', { ns: 'error' }) });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
getFieldViolations(e.data).forEach(({ field, description }) => {
|
|
209
|
+
setError(field as Path<LoginEmailDTO>, { message: description });
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// a global handler that skips the reasons a mutation handles itself
|
|
215
|
+
new MutationCache({
|
|
216
|
+
onError: (e, _variables, _context, mutation) => {
|
|
217
|
+
if (!(e instanceof HTTPError)) return;
|
|
218
|
+
if (isErrorReason(e.data, ...(mutation.meta?.handledReasons ?? []))) return;
|
|
219
|
+
const info = getErrorInfo(e.data);
|
|
220
|
+
toast.error(info ? i18n.t(info.reason, { ns: 'error', ...info.metadata }) : 'Unknown error');
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Client-side error classes
|
|
226
|
+
|
|
227
|
+
`LoginTimeoutError`, `LoginCanceledError` and `CheckoutCreateError` are plain `Error` subclasses
|
|
228
|
+
for sign-in and checkout flows; `PurchaseError` is a const enum of purchase failure codes
|
|
229
|
+
(`LOGIN_TIMEOUT`, `LOGIN_CANCELED`, `CHECKOUT_CANCELED`, `CHECKOUT_CREATE_FAILED`,
|
|
230
|
+
`CHECKOUT_SESSION_ID_NOT_FOUND`, `UNKNOWN`).
|
|
231
|
+
|
|
232
|
+
## Hono (`@shware/http/hono`)
|
|
233
|
+
|
|
234
|
+
| Export | Description |
|
|
235
|
+
| -------------- | -------------------------------------------------------------------- |
|
|
236
|
+
| `errorHandler` | `app.onError` handler turning any error into an `ErrorBody` response |
|
|
237
|
+
| `zValidator` | zod / zod mini validator; failures become `INVALID_ARGUMENT` |
|
|
238
|
+
| `zBeaconJson` | `json` validator that also accepts `text/plain` beacon bodies |
|
|
239
|
+
| `bigintId` | zod mini schema parsing a decimal string into a `bigint` |
|
|
240
|
+
| `csrf` | Double-submit cookie CSRF check (`XSRF-TOKEN` / `X-XSRF-TOKEN`) |
|
|
241
|
+
| `authorizer` | Requires authentication on the paths matching `rules` |
|
|
242
|
+
| `rateLimit` | Token-bucket rate limiter backed by any async key-value store |
|
|
243
|
+
| `geolocation` | Visitor geolocation from Cloudflare, Vercel or CloudFront headers |
|
|
244
|
+
|
|
245
|
+
### Wiring an app
|
|
246
|
+
|
|
247
|
+
`errorHandler` reads `c.get('requestId')`, so register Hono's `requestId()` middleware. Every
|
|
248
|
+
error response gets a `RequestInfo` detail; 5xx are logged with `console.error`, 4xx with
|
|
249
|
+
`console.warn`.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import '@shware/http/polyfills';
|
|
253
|
+
import { Status } from '@shware/http';
|
|
254
|
+
import { type CSRFConfig, authorizer, csrf, errorHandler } from '@shware/http/hono';
|
|
255
|
+
import { OAuth2Error } from '@shware/security';
|
|
256
|
+
import { Hono } from 'hono';
|
|
257
|
+
import { requestId } from 'hono/request-id';
|
|
258
|
+
|
|
259
|
+
const csrfConfig: CSRFConfig = {
|
|
260
|
+
origin: ['https://app.example.com'],
|
|
261
|
+
secFetchSite: ['same-origin', 'same-site', 'none'],
|
|
262
|
+
ignores: [
|
|
263
|
+
'/login/oauth2/code/*',
|
|
264
|
+
'/v1/webhooks/*',
|
|
265
|
+
{ path: '/v1/analytics/*', methods: ['POST'] },
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const app = new Hono();
|
|
270
|
+
|
|
271
|
+
app.onError((error, c) => {
|
|
272
|
+
// map third-party errors to a Status before delegating
|
|
273
|
+
if (error instanceof OAuth2Error) return errorHandler(Status.unauthorized().error(), c);
|
|
274
|
+
return errorHandler(error, c);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
app.use(requestId());
|
|
278
|
+
app.use(csrf(csrfConfig));
|
|
279
|
+
app.use(
|
|
280
|
+
authorizer({
|
|
281
|
+
auth, // { isAuthenticated: (request: Request) => Promise<boolean> }
|
|
282
|
+
rules: ['/v1/user/*', '/v1/credits/*', { path: '/v1/analytics/links', methods: ['POST'] }],
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`csrf` lets safe methods (`GET`, `HEAD`, `OPTIONS` by default), allowed `origin`s, allowed
|
|
288
|
+
`Sec-Fetch-Site` values and `ignores` through; otherwise it compares the cookie and header tokens
|
|
289
|
+
and throws `PERMISSION_DENIED`. `authorizer` skips `OPTIONS` and throws `UNAUTHENTICATED` for
|
|
290
|
+
matching paths when `auth.isAuthenticated()` is false.
|
|
291
|
+
|
|
292
|
+
### Validation
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { pageParamsSchema } from '@shware/http';
|
|
296
|
+
import { bigintId, geolocation, zBeaconJson, zValidator } from '@shware/http/hono';
|
|
297
|
+
import { object } from 'zod/mini';
|
|
298
|
+
|
|
299
|
+
app.get('/v1/credits/usages', zValidator('query', pageParamsSchema()), async (c) => {
|
|
300
|
+
const { limit, prev, next } = c.req.valid('query');
|
|
301
|
+
// ...
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
app.get('/v1/teams/:id', zValidator('param', object({ id: bigintId })), async (c) => {
|
|
305
|
+
const { id } = c.req.valid('param'); // bigint
|
|
306
|
+
// ...
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
app.post('/v1/analytics/events', zBeaconJson(createTrackEventSchema), async (c) => {
|
|
310
|
+
const events = c.req.valid('json');
|
|
311
|
+
const geo = geolocation(c); // { ip_address, country, city, region, time_zone, ... }
|
|
312
|
+
// ...
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Rate limiting
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { rateLimit } from '@shware/http/hono';
|
|
320
|
+
|
|
321
|
+
app.use(
|
|
322
|
+
'/v1/auth/*',
|
|
323
|
+
rateLimit({
|
|
324
|
+
kv, // { getItem, setItem(key, value, expiresIn?), removeItem }
|
|
325
|
+
rate: 1, // tokens refilled per second
|
|
326
|
+
capacity: 10,
|
|
327
|
+
// optional: getIdentifier (defaults to client IP), skip, onRateLimited, keyPrefix, expiresIn
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
It sets `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; when exhausted it
|
|
333
|
+
adds `Retry-After` and answers `RESOURCE_EXHAUSTED` with `RATE_LIMIT_EXCEEDED` and `RetryInfo`.
|
|
334
|
+
|
|
335
|
+
## Pagination
|
|
336
|
+
|
|
337
|
+
Cursor pagination types (`Page<T>`, `PageParams`, `Response<T>`, `Empty`, ...) and helpers shared
|
|
338
|
+
by the API and its clients.
|
|
339
|
+
|
|
340
|
+
Server side, `pageParamsSchema(max = 100, defaultLimit = 20)` validates `limit`, `prev` and
|
|
341
|
+
`next`, and `Cursor` encodes ids into opaque base64 tokens:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import { Cursor, type Page, pageParamsSchema } from '@shware/http';
|
|
345
|
+
import { zValidator } from '@shware/http/hono';
|
|
346
|
+
import { hasText } from '@shware/utils';
|
|
347
|
+
|
|
348
|
+
app.get('/v1/credits/usages', zValidator('query', pageParamsSchema()), async (c) => {
|
|
349
|
+
const { prev, next, limit } = c.req.valid('query');
|
|
350
|
+
const before = hasText(next) ? Cursor.decode(next, 'string') : undefined;
|
|
351
|
+
const list = await findUsages({ before, limit: limit + 1 });
|
|
352
|
+
|
|
353
|
+
const hasMore = list.length > limit;
|
|
354
|
+
const data = hasMore ? list.slice(0, limit) : list;
|
|
355
|
+
const paging = hasMore ? Cursor.of(data.at(0)?.id, data.at(-1)?.id) : Cursor.empty();
|
|
356
|
+
return c.json<Page<CreditUsage>>({ data, paging });
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Client side, the page param helpers plug straight into TanStack Query:
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
import { getNextPageParam, getPreviousPageParam, initialPageParam } from '@shware/http';
|
|
364
|
+
|
|
365
|
+
const query = useInfiniteQuery({
|
|
366
|
+
queryKey: ['charges'],
|
|
367
|
+
initialPageParam,
|
|
368
|
+
getNextPageParam: getNextPageParam<Charge>,
|
|
369
|
+
getPreviousPageParam: getPreviousPageParam<Charge>,
|
|
370
|
+
queryFn: ({ pageParam }) => api.purchase.listCharges({ limit: 20, ...pageParam }),
|
|
371
|
+
});
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
`Pages` (`flatten`, `dedupe`, `prepend`, `append`, `update`, `updateById`, `remove`,
|
|
375
|
+
`removeById`) and `Items` (`push`, `unshift`, `pushAndDedupe`, `unshiftAndDedupe`, `update`,
|
|
376
|
+
`updateById`, `remove`, `removeById`) return updater functions for `setQueryData` on infinite and
|
|
377
|
+
plain list queries:
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
import { Pages } from '@shware/http';
|
|
381
|
+
|
|
382
|
+
queryClient.setQueryData(['charges'], Pages.removeById<Charge>(id));
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Google One Tap (`@shware/http/google-one-tap`)
|
|
386
|
+
|
|
387
|
+
Browser-only. `prompt()` loads the Google Identity Services script and shows One Tap via FedCM. It
|
|
388
|
+
never rejects; when One Tap cannot run (`fedcm_unsupported`, `script_load_failed`,
|
|
389
|
+
`prompt_error`) fall back to `requestCode()`, an OAuth 2.0 authorization-code popup that must be
|
|
390
|
+
called from a user gesture.
|
|
391
|
+
|
|
392
|
+
```ts
|
|
393
|
+
import { prompt, requestCode } from '@shware/http/google-one-tap';
|
|
394
|
+
|
|
395
|
+
const result = await prompt({ client_id: GOOGLE_CLIENT_ID });
|
|
396
|
+
|
|
397
|
+
if (result.authorized) {
|
|
398
|
+
await api.auth.loginOAuth2Onetap(result.credential); // { credential: ID token, select_by }
|
|
399
|
+
} else if ('unsupported' in result) {
|
|
400
|
+
// later, on a button click:
|
|
401
|
+
const code = await requestCode({ client_id: GOOGLE_CLIENT_ID });
|
|
402
|
+
if (code.ok) await exchangeCodeOnBackend(code.response.code);
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
## Webhooks (`@shware/http/webhook`)
|
|
407
|
+
|
|
408
|
+
`verifyStandardWebhook<T>(headers, payload, secret)` verifies the `webhook-id`,
|
|
409
|
+
`webhook-timestamp` (5 minute tolerance) and `webhook-signature` headers against a base64 secret
|
|
410
|
+
and returns the parsed payload, or throws `INVALID_ARGUMENT`. Node.js `crypto` is required.
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
import { verifyStandardWebhook } from '@shware/http/webhook';
|
|
414
|
+
|
|
415
|
+
app.post('/v1/webhooks/acme', async (c) => {
|
|
416
|
+
const event = verifyStandardWebhook<AcmeEvent>(c.req.header(), await c.req.text(), secret);
|
|
417
|
+
// ...
|
|
418
|
+
return c.body(null, 204);
|
|
419
|
+
});
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
## Polyfills (`@shware/http/polyfills`)
|
|
423
|
+
|
|
424
|
+
Import once at the entry point so `bigint` values (e.g. Snowflake ids) serialize as strings in
|
|
425
|
+
`JSON.stringify` and `c.json()` instead of throwing:
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
import '@shware/http/polyfills';
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## Other utilities
|
|
432
|
+
|
|
433
|
+
### Snowflake ids
|
|
434
|
+
|
|
435
|
+
`uid` is a ready-made `UidGenerator` with a random machine id; create your own with
|
|
436
|
+
`new UidGenerator(machineId)` (10-bit machine id, 12-bit sequence, epoch 2020-01-01).
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import { uid } from '@shware/http';
|
|
440
|
+
|
|
441
|
+
const id: bigint = uid.next();
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Column lengths
|
|
445
|
+
|
|
446
|
+
`MAX_LENGTH` holds maximum lengths for common identifiers, handy for database columns and
|
|
447
|
+
validators: `UUID`, `DEVICE_ID`, `ADVERTISING_ID`, `IP`, `TIMEZONE`, `COUNTRY_CODE`,
|
|
448
|
+
`LANGUAGE_CODE`, `EMAIL`, `PHONE_NUMBER` and more.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
import { MAX_LENGTH } from '@shware/http';
|
|
452
|
+
|
|
453
|
+
const email = varchar('email', { length: MAX_LENGTH.EMAIL }); // 320
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### Countries
|
|
457
|
+
|
|
458
|
+
`ISO_3601_1` is the list of ISO 3166-1 countries (`{ numeric, name, alpha2, alpha3 }`, typed as
|
|
459
|
+
`ISO3166CountryCode`).
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
import { ISO_3601_1 } from '@shware/http';
|
|
463
|
+
|
|
464
|
+
app.get('/v1/iso/countries', (c) => c.json({ data: ISO_3601_1 }));
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### Geolocation and Google Tag Gateway
|
|
468
|
+
|
|
469
|
+
`getGeolocation(request)` reads visitor location headers from Vercel, Cloudflare or CloudFront.
|
|
470
|
+
`forwardToGoogleTagGateway(request, gaId)` proxies a first-party measurement path to Google Tag
|
|
471
|
+
Gateway, forwarding cookies, client hints and geolocation:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
import { forwardToGoogleTagGateway } from '@shware/http';
|
|
475
|
+
|
|
476
|
+
// e.g. a catch-all route at /metrics/*
|
|
477
|
+
const proxy = ({ request }: { request: Request }) => forwardToGoogleTagGateway(request, GA_ID);
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### Server-Timing
|
|
481
|
+
|
|
482
|
+
`timing()` collects metrics and writes `Server-Timing` (and `Timing-Allow-Origin`) headers:
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
import { timing } from '@shware/http';
|
|
486
|
+
|
|
487
|
+
const t = timing();
|
|
488
|
+
t.startTime('db', 'Database');
|
|
489
|
+
const rows = await query();
|
|
490
|
+
t.endTime('db');
|
|
491
|
+
|
|
492
|
+
const response = Response.json(rows);
|
|
493
|
+
t.setTiming(response);
|
|
494
|
+
```
|
package/dist/cache/index.d.cts
CHANGED
package/dist/cache/index.d.mts
CHANGED
package/dist/hono/csrf.d.cts
CHANGED
package/dist/hono/csrf.d.mts
CHANGED
package/dist/hono/handler.d.cts
CHANGED
|
@@ -35,4 +35,5 @@ export declare function isAxiosError(payload: unknown): payload is AxiosError;
|
|
|
35
35
|
export declare function isStatusError(payload: unknown): payload is StatusError;
|
|
36
36
|
export declare function errorHandler<E extends Env = never>(error: Error | HTTPResponseError, c: Context<E>): Response | Promise<Response>;
|
|
37
37
|
//#endregion
|
|
38
|
+
export {};
|
|
38
39
|
//# sourceMappingURL=handler.d.cts.map
|
package/dist/hono/handler.d.mts
CHANGED
|
@@ -35,4 +35,5 @@ export declare function isAxiosError(payload: unknown): payload is AxiosError;
|
|
|
35
35
|
export declare function isStatusError(payload: unknown): payload is StatusError;
|
|
36
36
|
export declare function errorHandler<E extends Env = never>(error: Error | HTTPResponseError, c: Context<E>): Response | Promise<Response>;
|
|
37
37
|
//#endregion
|
|
38
|
+
export {};
|
|
38
39
|
//# sourceMappingURL=handler.d.mts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shware/http",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -66,20 +66,19 @@
|
|
|
66
66
|
"access": "public"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@shware/utils": "^1.6.
|
|
70
|
-
"vitest": "^5.0.2",
|
|
71
|
-
"zod": "^4.6.5"
|
|
69
|
+
"@shware/utils": "^1.6.3"
|
|
72
70
|
},
|
|
73
71
|
"devDependencies": {
|
|
74
72
|
"@repo/typescript-config": "0.0.0",
|
|
75
|
-
"@types/node": "^26.6.
|
|
76
|
-
"
|
|
77
|
-
"
|
|
73
|
+
"@types/node": "^26.6.5",
|
|
74
|
+
"typescript": "^7.0.2",
|
|
75
|
+
"vitest": "^5.0.5",
|
|
76
|
+
"zod": "^4.6.7"
|
|
78
77
|
},
|
|
79
78
|
"peerDependencies": {
|
|
80
79
|
"hono": "^4.13.9",
|
|
81
80
|
"i18next": "^26.4.2",
|
|
82
|
-
"
|
|
81
|
+
"zod": "^4.6.5"
|
|
83
82
|
},
|
|
84
83
|
"peerDependenciesMeta": {
|
|
85
84
|
"hono": {
|
|
@@ -87,9 +86,6 @@
|
|
|
87
86
|
},
|
|
88
87
|
"i18next": {
|
|
89
88
|
"optional": true
|
|
90
|
-
},
|
|
91
|
-
"react": {
|
|
92
|
-
"optional": true
|
|
93
89
|
}
|
|
94
90
|
},
|
|
95
91
|
"scripts": {
|