@znight/http 0.1.2 → 0.1.3

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/plugin.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { Container, Plugin, PluginContext, PluginOptions, ProviderKey, Token, ZnightMetadata } from '@znight/core'
2
2
  import type {
3
- AdapterReply,
4
3
  HttpAdapter,
5
4
  HttpServerAddress,
6
5
  InjectRequest,
@@ -519,7 +518,7 @@ export class HttpPlugin implements Plugin {
519
518
  adapter.registerRoute({
520
519
  method: registration.method,
521
520
  path: registration.path,
522
- handler: (request): Promise<AdapterReply> => pipeline.handle(plan, request),
521
+ handler: pipeline.handler(plan),
523
522
  })
524
523
  routes.push(
525
524
  Object.freeze({
package/src/response.ts CHANGED
@@ -229,12 +229,10 @@ function withCookie(cookies: readonly ResponseCookie[], cookie: ResponseCookie):
229
229
 
230
230
  /** Whether `value` is an {@link HttpResponse} (also across duplicated package copies). */
231
231
  export function isHttpResponse(value: unknown): value is HttpResponse {
232
- return (
233
- value instanceof HttpResponse ||
234
- (value !== null &&
235
- typeof value === 'object' &&
236
- (value as { constructor?: { name?: unknown } }).constructor?.name === 'HttpResponse' &&
237
- 'body' in value &&
238
- 'headers' in value)
239
- )
232
+ if (value instanceof HttpResponse) return true
233
+ if (value === null || typeof value !== 'object') return false
234
+ const type = (value as { constructor?: { name?: unknown } }).constructor
235
+ // object literals and arrays (most handler results): reading a function's name is slow
236
+ if (type === Object || type === Array) return false
237
+ return type?.name === 'HttpResponse' && 'body' in value && 'headers' in value
240
238
  }
package/src/serialize.ts CHANGED
@@ -13,7 +13,7 @@ import { isHttpResponse } from './response.ts'
13
13
  import { isSseBody } from './sse.ts'
14
14
  import { isBodylessStatus } from './status.ts'
15
15
  import { discardStream, isByteStream, toByteStream } from './stream.ts'
16
- import { emptyRecord, isBun } from './utils.ts'
16
+ import { fastRecord, isBun } from './utils.ts'
17
17
 
18
18
  const ENCODER = new TextEncoder()
19
19
 
@@ -96,8 +96,16 @@ export function serializeResult(result: unknown, options: SerializeOptions): Ser
96
96
  cookies = cookiesOf(result)
97
97
  }
98
98
  const finalStatus = options.status ?? status ?? defaultStatus(options.routeMethod, payload)
99
- const headers = emptyRecord<HeaderValue>()
100
- const body = encode(payload, headers)
99
+ const headers = fastRecord<HeaderValue>()
100
+ const encoded = encode(payload, headers)
101
+ let body: ReplyBody
102
+ let text: string | undefined
103
+ if (typeof encoded === 'string') {
104
+ text = encoded
105
+ body = encodeText(encoded, headers, sendsBody(finalStatus, options.method))
106
+ } else {
107
+ body = encoded
108
+ }
101
109
  let own = true
102
110
  if (options.headers !== undefined) {
103
111
  assignHeaders(headers, options.headers)
@@ -111,7 +119,7 @@ export function serializeResult(result: unknown, options: SerializeOptions): Ser
111
119
  assignHeaders(headers, extra)
112
120
  own = false
113
121
  }
114
- return withCookies(finish(finalStatus, headers, body, options.method, own), cookies)
122
+ return withCookies(finish(finalStatus, headers, body, options.method, text, own), cookies)
115
123
  }
116
124
 
117
125
  /**
@@ -127,7 +135,7 @@ export function serializeSseReply(
127
135
  options: SerializeOptions,
128
136
  ): SerializedReply {
129
137
  const status = options.status ?? response?.status ?? 200
130
- const headers = emptyRecord<HeaderValue>()
138
+ const headers = fastRecord<HeaderValue>()
131
139
  headers['cache-control'] = 'no-cache, no-transform'
132
140
  headers['x-accel-buffering'] = 'no'
133
141
  if (options.headers !== undefined) assignHeaders(headers, options.headers)
@@ -161,7 +169,7 @@ export function serializeFileReply(
161
169
  options: SerializeOptions,
162
170
  ): SerializedReply {
163
171
  const status = file.status === 200 ? (options.status ?? response.status ?? 200) : file.status
164
- const headers = emptyRecord<HeaderValue>()
172
+ const headers = fastRecord<HeaderValue>()
165
173
  assignHeaders(headers, file.headers)
166
174
  if (options.headers !== undefined) assignHeaders(headers, options.headers)
167
175
  if (options.context !== undefined) assignHeaders(headers, options.context)
@@ -177,9 +185,10 @@ function defaultStatus(routeMethod: string, payload: unknown): number {
177
185
 
178
186
  /** Serialises an error body as JSON. */
179
187
  export function serializeError(status: number, body: HttpErrorBody | unknown, method: string): SerializedReply {
180
- const headers = emptyRecord<HeaderValue>()
188
+ const headers = fastRecord<HeaderValue>()
181
189
  const encoded = encode(body, headers)
182
- return finish(status, headers, encoded, method)
190
+ if (typeof encoded !== 'string') return finish(status, headers, encoded, method)
191
+ return finish(status, headers, encodeText(encoded, headers, sendsBody(status, method)), method, encoded)
183
192
  }
184
193
 
185
194
  /** Copies headers with lower-case names (later values win). */
@@ -206,14 +215,15 @@ export function addMissingHeaders(
206
215
  }
207
216
 
208
217
  /**
209
- * The reply of a body: a string (see {@link encode}) is sent as its UTF-8 bytes, with its text.
210
- * `own`: only {@link encode} set `headers`, which are then valid for a text body.
218
+ * The reply of a body: `text` is the string `body` encodes (see {@link encodeText}). `own`: only
219
+ * {@link encode} set `headers`, which are then valid for a text body.
211
220
  */
212
221
  function finish(
213
222
  status: number,
214
223
  headers: Record<string, HeaderValue>,
215
- body: ReplyBody | string,
224
+ body: ReplyBody,
216
225
  method: string,
226
+ text?: string,
217
227
  own = false,
218
228
  ): SerializedReply {
219
229
  if (isBodylessStatus(status)) {
@@ -226,19 +236,38 @@ function finish(
226
236
  discard(body)
227
237
  return { status, headers, body: null }
228
238
  }
229
- if (typeof body !== 'string') return { status, headers, body }
230
- return own
231
- ? { status, headers, body: utf8(body), text: body, ownHeaders: true }
232
- : { status, headers, body: utf8(body), text: body }
239
+ if (text === undefined) return { status, headers, body }
240
+ return own ? { status, headers, body, text, ownHeaders: true } : { status, headers, body, text }
233
241
  }
234
242
 
235
- function discard(body: ReplyBody | string): void {
243
+ function discard(body: ReplyBody): void {
236
244
  if (body instanceof ReadableStream) discardStream(body)
237
245
  }
238
246
 
247
+ /** Whether a reply with this status, to this method, carries its body (see {@link finish}). */
248
+ function sendsBody(status: number, method: string): boolean {
249
+ return method !== 'HEAD' && !isBodylessStatus(status)
250
+ }
251
+
252
+ /**
253
+ * Sets the `content-length` of a text body (right after its `content-type`, before the route
254
+ * headers) and returns its UTF-8 bytes: their length is the header, a lone surrogate counting as
255
+ * U+FFFD. `null` when the reply sends no body (`HEAD`, bodyless status): nothing is encoded then,
256
+ * `Buffer.byteLength` counts the same bytes.
257
+ */
258
+ function encodeText(text: string, headers: Record<string, HeaderValue>, sent: boolean): Uint8Array | null {
259
+ if (!sent) {
260
+ headers['content-length'] = String(Buffer.byteLength(text, 'utf8'))
261
+ return null
262
+ }
263
+ const bytes = utf8(text)
264
+ headers['content-length'] = String(bytes.byteLength)
265
+ return bytes
266
+ }
267
+
239
268
  /**
240
- * The body of a payload, its `content-type` and, for bytes and text, its `content-length` set in
241
- * `headers`. Text (a string, JSON) is returned as a string, encoded as UTF-8 by {@link finish}.
269
+ * The body of a payload, its `content-type` and, for bytes, its `content-length` set in `headers`.
270
+ * Text (a string, JSON) is returned as a string: {@link encodeText} sets its length and encodes it.
242
271
  */
243
272
  function encode(payload: unknown, headers: Record<string, HeaderValue>): ReplyBody | string {
244
273
  if (payload === undefined) return null
@@ -277,8 +306,6 @@ function bytes(data: Uint8Array, headers: Record<string, HeaderValue>, type: str
277
306
 
278
307
  function text(data: string, headers: Record<string, HeaderValue>, type: string): string {
279
308
  headers['content-type'] = type
280
- // the length of its UTF-8 encoding (a lone surrogate counts as U+FFFD, like the encoders)
281
- headers['content-length'] = String(Buffer.byteLength(data, 'utf8'))
282
309
  return data
283
310
  }
284
311
 
@@ -294,7 +321,7 @@ function isNodeReadable(value: unknown): value is Readable {
294
321
  }
295
322
 
296
323
  function fromWebResponse(response: Response, method: string): SerializedReply {
297
- const headers = emptyRecord<HeaderValue>()
324
+ const headers = fastRecord<HeaderValue>()
298
325
  for (const [name, value] of response.headers) {
299
326
  if (name !== 'set-cookie') headers[name] = value
300
327
  }
package/src/utils.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  /** Internal helpers (not part of the public API). */
2
2
 
3
- /** Whether `value` is a promise-like object (has a callable `then`). */
4
- export function isThenable(value: unknown): value is PromiseLike<unknown> {
3
+ /**
4
+ * Whether `value` is a promise-like object (has a callable `then`). The guard keeps the thenable
5
+ * part of the value's type (`Promise<R>` of `R | Promise<R>`, even when `R` is `object` or `any`), so
6
+ * that generated code awaiting it gets `R`.
7
+ */
8
+ export function isThenable<T>(
9
+ value: T,
10
+ ): value is unknown extends T ? T & PromiseLike<unknown> : Extract<T, PromiseLike<unknown>> {
5
11
  return (
6
12
  value !== null &&
7
13
  (typeof value === 'object' || typeof value === 'function') &&
@@ -25,6 +31,23 @@ export function emptyRecord<V>(): Record<string, V> {
25
31
  return Object.create(null) as Record<string, V>
26
32
  }
27
33
 
34
+ /**
35
+ * Constructor of {@link fastRecord}: its prototype is an empty, frozen object without prototype, so
36
+ * nothing is inherited (`__proto__`, `constructor`, `toString`... are plain own keys).
37
+ */
38
+ const FastRecord = function FastRecord() {} as unknown as new () => object
39
+ FastRecord.prototype = Object.freeze(Object.create(null))
40
+
41
+ /**
42
+ * A `Record` that inherits nothing, like {@link emptyRecord}, but that V8 keeps in fast mode: an
43
+ * `Object.create(null)` object is a dictionary, about twice as slow to fill and enumerate (response
44
+ * headers: built, completed and enumerated on every request). Its prototype is not `null` (an empty
45
+ * frozen object): use {@link emptyRecord} for records handed to user code (query, fields...).
46
+ */
47
+ export function fastRecord<V>(): Record<string, V> {
48
+ return new FastRecord() as Record<string, V>
49
+ }
50
+
28
51
  /** Whether the code runs on Bun. */
29
52
  export function isBun(): boolean {
30
53
  return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'
package/src/validate.ts CHANGED
@@ -1,20 +1,28 @@
1
- import { ValidationException } from './exceptions.ts'
2
- import {
3
- type InferSchemaOutput,
4
- isStandardSchema,
5
- type StandardSchemaV1,
6
- type StandardSchemaV1Issue,
7
- type StandardSchemaV1Result,
1
+ import type {
2
+ InferSchemaOutput,
3
+ StandardSchemaV1,
4
+ StandardSchemaV1Issue,
5
+ StandardSchemaV1Props,
6
+ StandardSchemaV1Result,
8
7
  } from './standard-schema.ts'
8
+
9
+ import { ValidationException } from './exceptions.ts'
9
10
  import { isThenable } from './utils.ts'
10
11
 
12
+ /**
13
+ * Whether `value` is a promise-like object (has a callable `then`). Called by the generated
14
+ * `invoke` functions on the result of {@link validate} (README, "Compiler contract").
15
+ */
16
+ export { isThenable } from './utils.ts'
17
+
11
18
  /**
12
19
  * Validates `value` with a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType...)
13
20
  * and returns the schema **output** (defaults applied, transforms run, unknown keys stripped when
14
21
  * the schema strips them).
15
22
  *
16
23
  * Returns synchronously when the schema validates synchronously, a promise otherwise (async
17
- * refinements): generated code always `await`s it.
24
+ * refinements): generated code calls the handler directly with the value, and `await`s the value
25
+ * when it is a thenable (`isThenable`).
18
26
  *
19
27
  * @param location what is validated, used in the error message: `body`, `query`, `query "page"`,
20
28
  * `param "id"`, `headers`, `cookie "sid"`, `cookies`...
@@ -28,12 +36,20 @@ export function validate<S extends StandardSchemaV1>(
28
36
  value: unknown,
29
37
  location: string,
30
38
  ): InferSchemaOutput<S> | Promise<InferSchemaOutput<S>> {
31
- if (!isStandardSchema(schema)) {
39
+ // `~standard` is read once (the same checks as `isStandardSchema`)
40
+ const props =
41
+ schema !== null && (typeof schema === 'object' || typeof schema === 'function')
42
+ ? (schema as { readonly '~standard'?: unknown })['~standard']
43
+ : undefined
44
+ if (
45
+ props === null ||
46
+ typeof props !== 'object' ||
47
+ (props as { readonly version?: unknown }).version !== 1 ||
48
+ typeof (props as { readonly validate?: unknown }).validate !== 'function'
49
+ ) {
32
50
  throw new TypeError(`The schema of ${location} is not a Standard Schema v1 (missing "~standard.validate")`)
33
51
  }
34
- const result = schema['~standard'].validate(value) as
35
- | StandardSchemaV1Result<InferSchemaOutput<S>>
36
- | Promise<StandardSchemaV1Result<InferSchemaOutput<S>>>
52
+ const result = (props as StandardSchemaV1Props<unknown, InferSchemaOutput<S>>).validate(value)
37
53
  if (isThenable(result)) return Promise.resolve(result).then(settled => unwrap(settled, location))
38
54
  return unwrap(result, location)
39
55
  }
@@ -1,27 +0,0 @@
1
- // src/utils.ts
2
- /** Internal helpers (not part of the public API). */
3
- /** Whether `value` is a promise-like object (has a callable `then`). */
4
- function isThenable(value) {
5
- return value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
6
- }
7
- /** Message of any thrown value. */
8
- function errorMessage(error) {
9
- if (error instanceof Error) return error.message;
10
- if (typeof error === "string") return error;
11
- try {
12
- return String(error);
13
- } catch {
14
- return Object.prototype.toString.call(error);
15
- }
16
- }
17
- /** A `Record` without prototype (safe for user-controlled keys such as `__proto__`). */
18
- function emptyRecord() {
19
- return Object.create(null);
20
- }
21
- /** Whether the code runs on Bun. */
22
- function isBun() {
23
- return typeof globalThis.Bun !== "undefined";
24
- }
25
- export { isThenable as i, errorMessage as n, isBun as r, emptyRecord as t };
26
-
27
- //# sourceMappingURL=utils-DCbu3a4w.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"utils-DCbu3a4w.mjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["/** Internal helpers (not part of the public API). */\n\n/** Whether `value` is a promise-like object (has a callable `then`). */\nexport function isThenable(value: unknown): value is PromiseLike<unknown> {\n return (\n value !== null &&\n (typeof value === 'object' || typeof value === 'function') &&\n typeof (value as { then?: unknown }).then === 'function'\n )\n}\n\n/** Message of any thrown value. */\nexport function errorMessage(error: unknown): string {\n if (error instanceof Error) return error.message\n if (typeof error === 'string') return error\n try {\n return String(error)\n } catch {\n return Object.prototype.toString.call(error)\n }\n}\n\n/** A `Record` without prototype (safe for user-controlled keys such as `__proto__`). */\nexport function emptyRecord<V>(): Record<string, V> {\n return Object.create(null) as Record<string, V>\n}\n\n/** Whether the code runs on Bun. */\nexport function isBun(): boolean {\n return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'\n}\n"],"mappings":";;;AAGA,SAAgB,WAAW,OAA+C;CACxE,OACE,UAAU,SACT,OAAO,UAAU,YAAY,OAAO,UAAU,eAC/C,OAAQ,MAA6B,SAAS;AAElD;;AAGA,SAAgB,aAAa,OAAwB;CACnD,IAAI,iBAAiB,OAAO,OAAO,MAAM;CACzC,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,IAAI;EACF,OAAO,OAAO,KAAK;CACrB,QAAQ;EACN,OAAO,OAAO,UAAU,SAAS,KAAK,KAAK;CAC7C;AACF;;AAGA,SAAgB,cAAoC;CAClD,OAAO,OAAO,OAAO,IAAI;AAC3B;;AAGA,SAAgB,QAAiB;CAC/B,OAAO,OAAQ,WAAiC,QAAQ;AAC1D"}