@croutonian/with-openapi 0.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/LICENSE +21 -0
- package/README.md +499 -0
- package/dist/index.d.ts +451 -0
- package/dist/index.js +1419 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Johnston
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
# `@croutonian/with-openapi`
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@croutonian/with-openapi)
|
|
4
|
+
[](https://jsr.io/@croutonian/with-openapi)
|
|
5
|
+
[](https://pkg.pr.new/~/croutonian/with-openapi)
|
|
6
|
+
[](https://github.com/croutonian/with-openapi/actions/workflows/ci.yml)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
OpenAPI middleware for [`@supabase/middleware`](https://github.com/supabase/middleware).
|
|
10
|
+
|
|
11
|
+
An OpenAPI document already says what your API accepts. This makes it say it at
|
|
12
|
+
runtime: every request is matched to an Operation Object, optionally refused if
|
|
13
|
+
it does not fit, and optionally published as a
|
|
14
|
+
[Scalar](https://scalar.com/products/api-references) reference from the same
|
|
15
|
+
document — so what you enforce and what you document cannot drift.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { pipeline } from '@supabase/middleware'
|
|
19
|
+
import { withOpenApi } from '@croutonian/with-openapi'
|
|
20
|
+
import document from './openapi.json' with { type: 'json' }
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
fetch: pipeline(
|
|
24
|
+
[withOpenApi({ document, reference: true })],
|
|
25
|
+
async (_req, ctx) => {
|
|
26
|
+
if (!ctx.openapi.matched) return new Response(null, { status: 404 })
|
|
27
|
+
// Already validated, already coerced: `limit` is a number.
|
|
28
|
+
const { limit } = ctx.openapi.params.query
|
|
29
|
+
return Response.json({ operation: ctx.openapi.operationId, limit })
|
|
30
|
+
},
|
|
31
|
+
),
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`GET /users?limit=abc` is answered `400` before the handler runs. `GET /nope` is
|
|
36
|
+
answered `404`. `DELETE /users` is answered `405` with an `Allow` header.
|
|
37
|
+
`GET /reference` serves the docs. Everything else reaches the handler with the
|
|
38
|
+
matched operation on `ctx`.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm install @croutonian/with-openapi
|
|
44
|
+
pnpm add @croutonian/with-openapi
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Also on [JSR](https://jsr.io/@croutonian/with-openapi), which serves the
|
|
48
|
+
TypeScript source rather than a build:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
deno add jsr:@croutonian/with-openapi
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// Supabase Edge Functions — no install
|
|
56
|
+
import { withOpenApi } from 'npm:@croutonian/with-openapi'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Requires TypeScript 5.4 or newer to typecheck against the shipped `.d.ts`, and
|
|
60
|
+
Node 22 or newer on Node. Deno, Bun and Cloudflare Workers add no floor of their
|
|
61
|
+
own — there are no `node:` imports and no code generation anywhere in the
|
|
62
|
+
runtime path.
|
|
63
|
+
|
|
64
|
+
## What lands on `ctx.openapi`
|
|
65
|
+
|
|
66
|
+
A discriminated union on `matched`:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
if (ctx.openapi.matched) {
|
|
70
|
+
ctx.openapi.route // '/users/{id}' — the path template, not the pathname
|
|
71
|
+
ctx.openapi.method // 'get'
|
|
72
|
+
ctx.openapi.operation // the Operation Object, `$ref` already followed
|
|
73
|
+
ctx.openapi.operationId // 'getUser'
|
|
74
|
+
ctx.openapi.security // the operation's, falling back to the document's
|
|
75
|
+
ctx.openapi.params // { path, query, header, cookie }, deserialized + coerced
|
|
76
|
+
ctx.openapi.body // the parsed request body
|
|
77
|
+
ctx.openapi.mediaType // the `content` key that matched
|
|
78
|
+
ctx.openapi.validated // false when `validate: false`
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
With the defaults, the handler only ever sees `matched: true` — anything else
|
|
83
|
+
was already answered with a `404` or a `405`. The narrowing matters once you set
|
|
84
|
+
`onUnknownRoute` or `onUnknownMethod` to `'pass'`, or pass a `skip`; then the
|
|
85
|
+
other branch carries a `reason` of `'no_route'`, `'no_operation'` or
|
|
86
|
+
`'skipped'`.
|
|
87
|
+
|
|
88
|
+
`security` is **contributed, not enforced.** Authentication is a different
|
|
89
|
+
middleware's job; this one just tells it what the document asks for.
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
| Option | Default | What it does |
|
|
94
|
+
| ----------------- | ------------ | --------------------------------------------------------------------------------------------- |
|
|
95
|
+
| `document` | _(required)_ | The OpenAPI 3.1 document. Read once, at construction. |
|
|
96
|
+
| `validate` | `true` | `false` to match without refusing anything, or an object to check some halves and not others. |
|
|
97
|
+
| `coerce` | `true` | Turn `'10'` into `10` where the schema says `integer`. |
|
|
98
|
+
| `basePath` | — | Prefix stripped before matching, for an API mounted under a sub-path. |
|
|
99
|
+
| `onUnknownRoute` | `'reject'` | `'pass'` falls through with `matched: false` instead of answering `404`. |
|
|
100
|
+
| `onUnknownMethod` | `'reject'` | `'pass'` falls through instead of answering `405`. |
|
|
101
|
+
| `reference` | off | `true` for the defaults, or an object to place and theme it. |
|
|
102
|
+
| `schemaDraft` | inferred | JSON Schema draft. `2020-12` for a 3.1 document, `4` for a 3.0 one. |
|
|
103
|
+
| `skip` | — | Leave a request alone entirely. |
|
|
104
|
+
| `reject` | — | Answer a refusal yourself. Return `undefined` for the default response. |
|
|
105
|
+
|
|
106
|
+
`validate` as an object takes `path`, `query`, `header`, `cookie`, `body` (all
|
|
107
|
+
`true`), `additionalQuery` (`'allow'` or `'reject'`), `status` (`400`) and
|
|
108
|
+
`maxViolations` (`20`).
|
|
109
|
+
|
|
110
|
+
### Describing without enforcing
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
withOpenApi({ document, validate: false, onUnknownRoute: 'pass' })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Routes are still matched and parameters still deserialized onto `ctx`; nothing
|
|
117
|
+
is refused. Useful for putting the middleware in front of an existing API and
|
|
118
|
+
watching what _would_ have been rejected before turning it on.
|
|
119
|
+
|
|
120
|
+
## Rejections
|
|
121
|
+
|
|
122
|
+
Four kinds, each with a default response and each available to a `reject`
|
|
123
|
+
callback before that response is built:
|
|
124
|
+
|
|
125
|
+
| Kind | Status | When |
|
|
126
|
+
| ------------------------ | ------ | ----------------------------------------------------------------------- |
|
|
127
|
+
| `route_not_found` | 404 | No path template matches the pathname. |
|
|
128
|
+
| `method_not_allowed` | 405 | The path matches; the operation is not declared. `Allow` names what is. |
|
|
129
|
+
| `unsupported_media_type` | 415 | The body's content type is not in the operation's `content`. |
|
|
130
|
+
| `validation_failed` | 400 | A parameter or body failed its schema. |
|
|
131
|
+
|
|
132
|
+
The default body:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"error": "validation_failed",
|
|
137
|
+
"message": "the request does not match the API description",
|
|
138
|
+
"violations": [
|
|
139
|
+
{
|
|
140
|
+
"in": "query",
|
|
141
|
+
"name": "limit",
|
|
142
|
+
"location": "#",
|
|
143
|
+
"keyword": "maximum",
|
|
144
|
+
"message": "999 is greater than 100.",
|
|
145
|
+
"description": "How many users to return. Between 1 and 100."
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Every violation is reported, not just the first, capped at `maxViolations`.
|
|
152
|
+
`location` is a JSON pointer into the offending value, which for a body is the
|
|
153
|
+
path to the property that failed.
|
|
154
|
+
|
|
155
|
+
### Descriptions
|
|
156
|
+
|
|
157
|
+
`message` is the validator's, and says what is mechanically wrong.
|
|
158
|
+
`description` is the **document's own prose** for whatever failed, and is
|
|
159
|
+
usually the half a caller can act on. You wrote it once; there is no reason for
|
|
160
|
+
an error response to throw it away.
|
|
161
|
+
|
|
162
|
+
It is resolved from the most specific place that has it:
|
|
163
|
+
|
|
164
|
+
| Violation | Described by |
|
|
165
|
+
| --------------------------- | ------------------------------------------------------------ |
|
|
166
|
+
| a parameter | its Parameter Object's `description`, else its schema's |
|
|
167
|
+
| a body property | the `description` on the schema that failed, `$ref` followed |
|
|
168
|
+
| a missing required property | that **property's** `description`, not its container's |
|
|
169
|
+
| a body that was never sent | the Request Body Object's `description` |
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
missing required param 999 is greater than 100.
|
|
173
|
+
→ How many users to return. Between 1 and 100.
|
|
174
|
+
|
|
175
|
+
body #/manager/name String is too short (0 < 1).
|
|
176
|
+
→ Display name. Shown to teammates.
|
|
177
|
+
|
|
178
|
+
body Instance does not have required property "name".
|
|
179
|
+
→ Display name. Shown to teammates.
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
That third row is the one worth pointing at: `required` fails against the
|
|
183
|
+
_object_, so the obvious implementation describes the object — "A person with
|
|
184
|
+
access to the workspace" — which says nothing about what is missing. The
|
|
185
|
+
property is named only inside the validator's message, so it is read from
|
|
186
|
+
there, and falls back to the container's prose if that wording ever changes.
|
|
187
|
+
|
|
188
|
+
A field with nothing written about it simply has no `description`. Set
|
|
189
|
+
`validate: { describe: false }` to leave them all off — descriptions are
|
|
190
|
+
written for a document's consumers, who are the same people reading these
|
|
191
|
+
errors, but turn it off if yours carries notes you would rather not return in
|
|
192
|
+
a response body.
|
|
193
|
+
|
|
194
|
+
To answer in your own error envelope:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
withOpenApi({
|
|
198
|
+
document,
|
|
199
|
+
reject: (rejection) =>
|
|
200
|
+
Response.json(
|
|
201
|
+
{ code: rejection.kind, detail: rejection.violations },
|
|
202
|
+
{ status: rejection.status },
|
|
203
|
+
),
|
|
204
|
+
})
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Return `undefined` from `reject` to fall back to the default for that kind —
|
|
208
|
+
handy for customizing one kind and leaving the rest alone.
|
|
209
|
+
|
|
210
|
+
## The Scalar reference
|
|
211
|
+
|
|
212
|
+
`reference: true` serves two routes, both **before** matching, so they need no
|
|
213
|
+
entry in the document:
|
|
214
|
+
|
|
215
|
+
- `GET /reference` — the HTML page
|
|
216
|
+
- `GET /reference/openapi.json` — the document, for the page to load
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
withOpenApi({
|
|
220
|
+
document,
|
|
221
|
+
reference: {
|
|
222
|
+
path: '/docs',
|
|
223
|
+
documentPath: '/docs/openapi.json',
|
|
224
|
+
title: 'Acme API',
|
|
225
|
+
configuration: { darkMode: true, theme: 'purple' },
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`configuration` is passed through to
|
|
231
|
+
[`Scalar.createApiReference`](https://scalar.com/products/api-references/configuration).
|
|
232
|
+
|
|
233
|
+
The page is a twenty-line shell that loads Scalar's standalone build from
|
|
234
|
+
jsDelivr. That is deliberate: `@scalar/api-reference` is a Vue application, and
|
|
235
|
+
bundling it into an edge middleware would add megabytes to every deploy to serve
|
|
236
|
+
one HTML page. Point `cdnUrl` at your own copy to self-host, or replace the page
|
|
237
|
+
entirely:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
reference: {
|
|
241
|
+
html: ({ documentPath }) => myOwnPage(documentPath)
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The reference paths are absolute — they are **not** relative to `basePath`.
|
|
246
|
+
|
|
247
|
+
## Parameters
|
|
248
|
+
|
|
249
|
+
`style` and `explode` are honored, so the document decides how a value is
|
|
250
|
+
spelled:
|
|
251
|
+
|
|
252
|
+
| `in` | Styles supported |
|
|
253
|
+
| -------- | ----------------------------------------------------------------- |
|
|
254
|
+
| `query` | `form` (default), `spaceDelimited`, `pipeDelimited`, `deepObject` |
|
|
255
|
+
| `path` | `simple` (default), `label`, `matrix` |
|
|
256
|
+
| `header` | `simple` |
|
|
257
|
+
| `cookie` | `form` |
|
|
258
|
+
|
|
259
|
+
Coercion then reads the schema and converts the text. It is deliberately
|
|
260
|
+
conservative — it only ever converts a string, it never converts when `string`
|
|
261
|
+
is among the schema's allowed types, and where a conversion would not round-trip
|
|
262
|
+
it leaves the text alone so the validator reports a real type error rather than
|
|
263
|
+
a silent `NaN`:
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
?limit=10 type: integer → 10
|
|
267
|
+
?limit=ten type: integer → 'ten', then a 400 naming the type
|
|
268
|
+
?limit=10 type: [string, integer] → '10' (string is allowed; leave it)
|
|
269
|
+
?flag=true type: boolean → true
|
|
270
|
+
?flag=1 type: boolean → '1', then a 400
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Bodies
|
|
274
|
+
|
|
275
|
+
The media type is matched against the operation's `content` — exact key first,
|
|
276
|
+
then a `type` wildcard range, then the catch-all range — and parsed from what
|
|
277
|
+
the request says it is:
|
|
278
|
+
|
|
279
|
+
| Content type | Parsed as | Validated |
|
|
280
|
+
| ------------------------------------ | ---------------------------------- | --------- |
|
|
281
|
+
| `application/json`, anything `+json` | JSON | yes |
|
|
282
|
+
| `application/x-www-form-urlencoded` | object, coerced against the schema | yes |
|
|
283
|
+
| `text/*`, anything `+xml` | string | yes |
|
|
284
|
+
| `multipart/form-data` | object, with parts left as `File` | no |
|
|
285
|
+
| anything else | not read at all | no |
|
|
286
|
+
|
|
287
|
+
Multipart parts are `File` objects, which no JSON Schema describes, so the body
|
|
288
|
+
is parsed onto `ctx` but not schema-checked. Binary media types are never
|
|
289
|
+
buffered — there is no shape to check, and reading a large upload to ignore it
|
|
290
|
+
is pure cost. `required` is enforced for both.
|
|
291
|
+
|
|
292
|
+
Reading the body here does not consume it. The framework hands every layer a
|
|
293
|
+
buffered request, so the handler can still call `req.json()`.
|
|
294
|
+
|
|
295
|
+
## CORS
|
|
296
|
+
|
|
297
|
+
An OpenAPI document already knows most of a CORS policy. `cors` derives it,
|
|
298
|
+
per route:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
withOpenApi({
|
|
302
|
+
document,
|
|
303
|
+
cors: { origin: ['https://app.example.com'], credentials: true },
|
|
304
|
+
})
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
| Header | Derived from |
|
|
308
|
+
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
309
|
+
| `Access-Control-Allow-Methods` | the operations the matched path declares |
|
|
310
|
+
| `Access-Control-Allow-Headers` | its `in: header` parameters, `Content-Type` where it takes a body, and the header its security schemes carry credentials in |
|
|
311
|
+
| `Access-Control-Expose-Headers` | its Response Objects' `headers`, minus the browser safelist |
|
|
312
|
+
|
|
313
|
+
So for a path declaring only `get` and `delete`:
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
preflight PUT /users/{id} → 204, Allow-Methods: GET, DELETE
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
A hand-maintained list would advertise `PUT` and let the request through to a
|
|
320
|
+
`405`. This one does not, because it is reading the same document the `405`
|
|
321
|
+
comes from.
|
|
322
|
+
|
|
323
|
+
Three things worth knowing:
|
|
324
|
+
|
|
325
|
+
- **`origin` is required and never derived.** A document says where an API
|
|
326
|
+
lives, not who may call it — `servers` is not an allowlist, and treating it
|
|
327
|
+
as one would be a security decision made from the wrong data. Same for
|
|
328
|
+
`credentials`.
|
|
329
|
+
- **Rejections are stamped too.** An unstamped `400` reaches a browser as an
|
|
330
|
+
opaque CORS error rather than the violations it is carrying.
|
|
331
|
+
- **The document is the source of truth for headers.** A request header the API
|
|
332
|
+
reads but the document does not declare will be refused by the browser. That
|
|
333
|
+
is usually the document being wrong; `allowedHeaders` is the escape hatch
|
|
334
|
+
when it genuinely is not.
|
|
335
|
+
|
|
336
|
+
Preflights are answered after the route match but before the method lookup —
|
|
337
|
+
otherwise the `OPTIONS` no document declares an operation for would come back
|
|
338
|
+
`405`. A plain `OPTIONS` with no `Access-Control-Request-Method` is not a
|
|
339
|
+
preflight and is still handled normally.
|
|
340
|
+
|
|
341
|
+
With `cors` unset the middleware is purely request-side and touches no response
|
|
342
|
+
headers at all.
|
|
343
|
+
|
|
344
|
+
## Composing
|
|
345
|
+
|
|
346
|
+
`withOpenApi` contributes one key and declares no prerequisites, so it goes
|
|
347
|
+
anywhere in a `pipeline` array. Put authentication after it, so it can read the
|
|
348
|
+
security requirements the document declares for the matched operation:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
pipeline(
|
|
352
|
+
[
|
|
353
|
+
withOpenApi({ document, cors: { origin: ['https://app.example.com'] } }),
|
|
354
|
+
// `ctx.openapi.security` is the operation's requirement, falling back to
|
|
355
|
+
// the document's. This middleware never enforces it — that is auth's job.
|
|
356
|
+
withAuth(),
|
|
357
|
+
],
|
|
358
|
+
handler,
|
|
359
|
+
)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
## Limits
|
|
363
|
+
|
|
364
|
+
Worth knowing before you wire this into something:
|
|
365
|
+
|
|
366
|
+
- **OpenAPI 3.1.** 3.1 schemas _are_ JSON Schema 2020-12, which is what makes
|
|
367
|
+
validation a matter of handing the schema to a validator rather than
|
|
368
|
+
translating it. A 3.0 document falls back to draft 4, which gets
|
|
369
|
+
`exclusiveMinimum` and `required` right but does **not** translate `nullable`.
|
|
370
|
+
Convert to 3.1 for full fidelity.
|
|
371
|
+
- **Local `$ref`s only.** External and remote references are not fetched.
|
|
372
|
+
Bundle the document first.
|
|
373
|
+
- **Requests only.** Responses are not validated. That is the framework's model,
|
|
374
|
+
not an omission: a middleware runs before the handler, and response shape stays
|
|
375
|
+
under the handler's ownership.
|
|
376
|
+
- **`deepObject` is one level deep**, matching what the specification defines.
|
|
377
|
+
- **Trailing slashes are normalized**, so `/users` and `/users/` are one route.
|
|
378
|
+
- Indexing the document for validation stamps each node with its own absolute
|
|
379
|
+
URI, as **non-enumerable** properties. The document object you pass in is
|
|
380
|
+
mutated in that one respect; nothing observable changes — not `Object.keys`,
|
|
381
|
+
not a spread, not `JSON.stringify` — and the reference endpoint serves the
|
|
382
|
+
document byte-for-byte as it came in.
|
|
383
|
+
|
|
384
|
+
## Dependencies
|
|
385
|
+
|
|
386
|
+
Three, and each is load-bearing:
|
|
387
|
+
|
|
388
|
+
- **`openapi3-ts`** — the OpenAPI 3.1 types. Imported `type`-only, so it is
|
|
389
|
+
erased from the runtime bundle entirely; it stays a real dependency because
|
|
390
|
+
the published `.d.ts` refers to `OpenAPIObject`.
|
|
391
|
+
- **`@cfworker/json-schema`** — the validator. Zero dependencies, and it
|
|
392
|
+
_interprets_ schemas rather than compiling them to JavaScript, which is what
|
|
393
|
+
lets it run on Cloudflare Workers and anywhere else `new Function` is
|
|
394
|
+
unavailable. The document is walked once at construction and every subschema in
|
|
395
|
+
it validated against that one index, so `$ref` — recursive ones included —
|
|
396
|
+
resolves without inlining anything.
|
|
397
|
+
- **`@supabase/middleware`** — the composition engine.
|
|
398
|
+
|
|
399
|
+
Scalar is **not** a dependency; the reference page loads it from a CDN.
|
|
400
|
+
|
|
401
|
+
## Development
|
|
402
|
+
|
|
403
|
+
```sh
|
|
404
|
+
pnpm install
|
|
405
|
+
pnpm test # vitest
|
|
406
|
+
pnpm typecheck # source + the must-compile type tests
|
|
407
|
+
pnpm typecheck:negative # asserts the must-NOT-compile cases still fail
|
|
408
|
+
pnpm build
|
|
409
|
+
pnpm smoke # exercises the built bundle end to end
|
|
410
|
+
pnpm check-exports # attw, against the ESM-only profile
|
|
411
|
+
pnpm check-jsr # jsr publish --dry-run, including the slow-type check
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Two artifacts ship from one source tree, and the TypeScript 5.4 floor applies to
|
|
415
|
+
both — so there are two floor checks, because neither covers the other:
|
|
416
|
+
|
|
417
|
+
```sh
|
|
418
|
+
pnpm typecheck:min # compiles src/ at 5.4 — what JSR consumers get
|
|
419
|
+
pnpm typecheck:consumer # compiles against dist/index.d.ts at 5.4 — npm consumers
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
`scripts/smoke.mjs` takes no arguments and imports nothing but `dist/`, so it
|
|
423
|
+
runs under any of the targets:
|
|
424
|
+
|
|
425
|
+
```sh
|
|
426
|
+
node scripts/smoke.mjs
|
|
427
|
+
deno run --allow-read --allow-env --node-modules-dir=auto scripts/smoke.mjs
|
|
428
|
+
bun scripts/smoke.mjs
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## Releasing
|
|
432
|
+
|
|
433
|
+
Conventional commits on `main` keep a
|
|
434
|
+
[release-please](https://github.com/googleapis/release-please) PR open. Merging
|
|
435
|
+
it tags a release, which publishes to npm and JSR.
|
|
436
|
+
|
|
437
|
+
### One-time setup
|
|
438
|
+
|
|
439
|
+
```sh
|
|
440
|
+
npm run setup-releases
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
A wizard that opens each page, says what to click, captures what you copy back,
|
|
444
|
+
and writes it to the right repository secret. It reads the current state before
|
|
445
|
+
each stage, so it is safe to re-run and safe to abandon halfway — and you re-run
|
|
446
|
+
it after the first release to swap npm from a token to trusted publishing.
|
|
447
|
+
|
|
448
|
+
What it configures, and why each is needed:
|
|
449
|
+
|
|
450
|
+
| | Why |
|
|
451
|
+
| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
452
|
+
| A **GitHub App** with Contents and Pull requests write, installed on the repo, as `GH_APP_ID` + `GH_APP_PRIVATE_KEY` | release-please has to open a PR, and this org does not let GitHub Actions do that. An App is not GitHub Actions, so the policy does not cover it — and unlike `GITHUB_TOKEN`, its pushes trigger workflows, so the release PR gets CI. |
|
|
453
|
+
| The **pkg.pr.new App** installed on the repo | Branch previews. Without it the preview job warns and skips rather than failing. |
|
|
454
|
+
| An **npm trusted publisher** for `@croutonian/with-openapi` | Publishing without a stored credential. |
|
|
455
|
+
| The **JSR package** linked to this repository | Same, on the JSR side. |
|
|
456
|
+
|
|
457
|
+
To do it by hand instead, the same steps are in the comments at the top of
|
|
458
|
+
[`release.yml`](./.github/workflows/release.yml).
|
|
459
|
+
|
|
460
|
+
### The first publish
|
|
461
|
+
|
|
462
|
+
Trusted publishing is configured against a package that already exists, so the
|
|
463
|
+
very first release of a new name has nothing to configure it on. The wizard
|
|
464
|
+
resolves that by publishing from your machine — your npm login, your 2FA, no
|
|
465
|
+
token created and none stored. CI takes over from the next release.
|
|
466
|
+
|
|
467
|
+
`release.yml` also accepts an `NPM_TOKEN` secret, but it is not a general
|
|
468
|
+
answer: a token cannot answer a one-time password, and npm asks for one on
|
|
469
|
+
every write unless the account's two-factor setting is _Authorization only_.
|
|
470
|
+
Where 2FA covers writes, a CI publish fails with `EOTP` and the first version
|
|
471
|
+
has to come from a human.
|
|
472
|
+
|
|
473
|
+
Between releases, every branch push and pull request publishes an installable
|
|
474
|
+
preview to [pkg.pr.new](https://pkg.pr.new):
|
|
475
|
+
|
|
476
|
+
```sh
|
|
477
|
+
npm i https://pkg.pr.new/@croutonian/with-openapi@<sha-or-pr-number>
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
That compact form needs the package to already be on npm with a `repository`
|
|
481
|
+
field. Before the first release, use the long form, which always resolves:
|
|
482
|
+
|
|
483
|
+
```sh
|
|
484
|
+
npm i https://pkg.pr.new/croutonian/with-openapi/@croutonian/with-openapi@<sha>
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Previews are npm-side only. pkg.pr.new serves npm-compatible tarballs, and
|
|
488
|
+
Deno's resolver rejects a bare tarball URL (`Not implemented scheme 'https'`),
|
|
489
|
+
so Deno and JSR consumers cannot install one. JSR has no pre-release channel at
|
|
490
|
+
all. To try a branch under Deno, check the repository out and point an import
|
|
491
|
+
map at `src/index.ts`.
|
|
492
|
+
|
|
493
|
+
CI runs the built bundle on Node, Deno and Bun on every push. The claim that
|
|
494
|
+
this package is Web Fetch only — no `node:` imports, no code generation — is
|
|
495
|
+
only worth making if something checks it.
|
|
496
|
+
|
|
497
|
+
## License
|
|
498
|
+
|
|
499
|
+
MIT
|