@znight/http 0.0.0-stage → 0.1.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 +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- package/src/validate.ts +65 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, structural access to the oxc AST nodes exposed by `ClassInfo` / `ArgInfo` (escape
|
|
3
|
+
* hatches of the extension API). The extension does not depend on `oxc-parser`: nodes are read
|
|
4
|
+
* through this loose shape and checked at runtime.
|
|
5
|
+
*/
|
|
6
|
+
import type { ClassInfo, MethodInfo, ParameterInfo } from '@znight/compiler'
|
|
7
|
+
|
|
8
|
+
/** An ESTree node, read structurally. */
|
|
9
|
+
export interface AstNode {
|
|
10
|
+
readonly type: string
|
|
11
|
+
readonly start: number
|
|
12
|
+
readonly end: number
|
|
13
|
+
readonly [key: string]: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `true` for an object shaped like an AST node. */
|
|
17
|
+
export function isNode(value: unknown): value is AstNode {
|
|
18
|
+
return (
|
|
19
|
+
typeof value === 'object' &&
|
|
20
|
+
value !== null &&
|
|
21
|
+
typeof (value as { type?: unknown }).type === 'string' &&
|
|
22
|
+
typeof (value as { start?: unknown }).start === 'number'
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The child node stored under `key`, when it is a node. */
|
|
27
|
+
export function child(node: AstNode, key: string): AstNode | undefined {
|
|
28
|
+
const value = node[key]
|
|
29
|
+
return isNode(value) ? value : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The child nodes stored under `key`, when it is an array. */
|
|
33
|
+
export function children(node: AstNode, key: string): AstNode[] {
|
|
34
|
+
const value = node[key]
|
|
35
|
+
return Array.isArray(value) ? value.filter(isNode) : []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const WRAPPERS = new Set([
|
|
39
|
+
'ParenthesizedExpression',
|
|
40
|
+
'TSAsExpression',
|
|
41
|
+
'TSSatisfiesExpression',
|
|
42
|
+
'TSNonNullExpression',
|
|
43
|
+
'TSTypeAssertion',
|
|
44
|
+
'TSInstantiationExpression',
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
/** Removes parentheses and type-only wrappers (`x as T`, `x satisfies T`, `x!`, `<T>x`). */
|
|
48
|
+
export function unwrap(node: AstNode): AstNode {
|
|
49
|
+
let current = node
|
|
50
|
+
while (WRAPPERS.has(current.type)) {
|
|
51
|
+
const inner = child(current, 'expression')
|
|
52
|
+
if (!inner) {
|
|
53
|
+
return current
|
|
54
|
+
}
|
|
55
|
+
current = inner
|
|
56
|
+
}
|
|
57
|
+
return current
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The default value expression of a method parameter (`page = 1` → `1`), found in the class
|
|
62
|
+
* declaration by the location of the method key.
|
|
63
|
+
*/
|
|
64
|
+
export function defaultValueOf(cls: ClassInfo, method: MethodInfo, param: ParameterInfo): AstNode | undefined {
|
|
65
|
+
const classNode = cls.declaration.node as unknown
|
|
66
|
+
if (!isNode(classNode)) {
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
const body = child(classNode, 'body')
|
|
70
|
+
if (!body) {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
for (const member of children(body, 'body')) {
|
|
74
|
+
const key = child(member, 'key')
|
|
75
|
+
if (!key || key.start !== method.location.start) {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
const fn = child(member, 'value')
|
|
79
|
+
if (!fn) {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
const params = children(fn, 'params').filter(node => !(node.type === 'Identifier' && node['name'] === 'this'))
|
|
83
|
+
let pattern = params[param.index]
|
|
84
|
+
if (pattern?.type === 'TSParameterProperty') {
|
|
85
|
+
pattern = child(pattern, 'parameter')
|
|
86
|
+
}
|
|
87
|
+
return pattern?.type === 'AssignmentPattern' ? child(pattern, 'right') : undefined
|
|
88
|
+
}
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-based coercion of raw request values (route parameters, query values, headers) to the
|
|
3
|
+
* declared type of a handler parameter (README, "Compiler contract" → `coerce.X` by declared type).
|
|
4
|
+
*/
|
|
5
|
+
import type { AnalyzeContext, ClassInfo, MethodInfo, ParameterInfo, TypeInfo } from '@znight/compiler'
|
|
6
|
+
import type { CoercionPlan, HttpControllerData, ParamCoercion, ScalarCoercion } from './model.ts'
|
|
7
|
+
|
|
8
|
+
import { child, defaultValueOf, isNode, unwrap } from './ast.ts'
|
|
9
|
+
|
|
10
|
+
type Context = AnalyzeContext<HttpControllerData>
|
|
11
|
+
type Project = Context['project']
|
|
12
|
+
type ExpressionInput = Parameters<Project['evaluate']>[1]
|
|
13
|
+
type ScopeInput = Parameters<Project['evaluate']>[0]
|
|
14
|
+
type EnumValue = string | number | boolean
|
|
15
|
+
|
|
16
|
+
const DATE_IDENTITY = 'globalThis#Date'
|
|
17
|
+
const STRING: TypeInfo = { kind: 'primitive', name: 'string' }
|
|
18
|
+
const NUMBER: TypeInfo = { kind: 'primitive', name: 'number' }
|
|
19
|
+
const BOOLEAN: TypeInfo = { kind: 'primitive', name: 'boolean' }
|
|
20
|
+
const BIGINT: TypeInfo = { kind: 'primitive', name: 'bigint' }
|
|
21
|
+
const DATE: TypeInfo = { kind: 'reference', identity: DATE_IDENTITY, name: 'Date', typeArgs: [] }
|
|
22
|
+
|
|
23
|
+
/** The type of a handler parameter: its annotation, or the type of its default value. */
|
|
24
|
+
export interface ParameterType {
|
|
25
|
+
/** `undefined` when the parameter has no annotation and its default value tells nothing. */
|
|
26
|
+
readonly type: TypeInfo | undefined
|
|
27
|
+
/** `?`, default value, `| undefined` or `| null`. */
|
|
28
|
+
readonly optional: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Result of {@link planCoercion}. */
|
|
32
|
+
export type CoercionResult =
|
|
33
|
+
| { readonly ok: true; readonly coercion: ParamCoercion }
|
|
34
|
+
| { readonly ok: false; readonly reason: string; readonly hints: readonly string[] }
|
|
35
|
+
|
|
36
|
+
/** Annotated or inferred type of a parameter. */
|
|
37
|
+
export function parameterType(ctx: Context, cls: ClassInfo, method: MethodInfo, param: ParameterInfo): ParameterType {
|
|
38
|
+
let type: TypeInfo | undefined = param.type
|
|
39
|
+
if (param.type.kind === 'opaque' && param.type.reason === 'missing-annotation') {
|
|
40
|
+
type = param.hasDefault ? inferFromDefault(ctx, cls, method, param) : undefined
|
|
41
|
+
}
|
|
42
|
+
const nullish = type !== undefined && hasNullish(type)
|
|
43
|
+
return { type, optional: param.optional || param.hasDefault || nullish }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Plans the coercion of a raw value to `type`. `required` is set for route parameters, which are
|
|
48
|
+
* always present (no `coerce.optional`).
|
|
49
|
+
*/
|
|
50
|
+
export function planCoercion(
|
|
51
|
+
ctx: Context,
|
|
52
|
+
declared: ParameterType,
|
|
53
|
+
param: ParameterInfo,
|
|
54
|
+
required: boolean,
|
|
55
|
+
): CoercionResult {
|
|
56
|
+
const type = declared.type
|
|
57
|
+
if (type === undefined) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
reason: `the type of parameter '${param.name}' is unknown`,
|
|
61
|
+
hints: [`Annotate the parameter: ${param.name}: string`],
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const { type: base } = ctx.types.stripNullish(type)
|
|
65
|
+
const raw = isRaw(base)
|
|
66
|
+
if (raw) {
|
|
67
|
+
return { ok: true, coercion: { plan: { kind: 'raw' }, cast: false } }
|
|
68
|
+
}
|
|
69
|
+
const scalar = scalarPlan(ctx, base)
|
|
70
|
+
let plan: CoercionPlan
|
|
71
|
+
let cast: boolean
|
|
72
|
+
if ('reason' in scalar) {
|
|
73
|
+
if (base.kind !== 'array') {
|
|
74
|
+
return failure(ctx, type, scalar.reason)
|
|
75
|
+
}
|
|
76
|
+
const item = scalarPlan(ctx, base.element)
|
|
77
|
+
if ('reason' in item) {
|
|
78
|
+
return failure(ctx, type, item.reason)
|
|
79
|
+
}
|
|
80
|
+
plan = { kind: 'array', item: item.plan }
|
|
81
|
+
cast = item.cast
|
|
82
|
+
} else {
|
|
83
|
+
plan = scalar.plan
|
|
84
|
+
cast = scalar.cast
|
|
85
|
+
}
|
|
86
|
+
if (!required && declared.optional) {
|
|
87
|
+
plan = { kind: 'optional', item: plan }
|
|
88
|
+
// `coerce.optional` returns `T | undefined`: `x: T | null` (without `?` / default) needs a cast
|
|
89
|
+
cast ||= !(param.optional || param.hasDefault || acceptsUndefined(type))
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, coercion: { plan, cast } }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `unknown` and `any`: the raw value is passed as is. */
|
|
95
|
+
export function isRaw(type: TypeInfo): boolean {
|
|
96
|
+
return type.kind === 'primitive' && (type.name === 'unknown' || type.name === 'any')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type ScalarResult =
|
|
100
|
+
| { readonly plan: CoercionPlan; readonly cast: boolean }
|
|
101
|
+
| { readonly reason: string; readonly cast?: undefined }
|
|
102
|
+
|
|
103
|
+
function scalarPlan(ctx: Context, type: TypeInfo): ScalarResult {
|
|
104
|
+
switch (type.kind) {
|
|
105
|
+
case 'primitive': {
|
|
106
|
+
const kind = primitiveCoercion(type.name)
|
|
107
|
+
return kind ? { plan: { kind }, cast: false } : { reason: `'${type.name}' values cannot be read from a request` }
|
|
108
|
+
}
|
|
109
|
+
case 'literal':
|
|
110
|
+
return typeof type.value === 'bigint'
|
|
111
|
+
? { reason: 'bigint literal types are not supported' }
|
|
112
|
+
: { plan: { kind: 'enum', values: [type.value] }, cast: false }
|
|
113
|
+
case 'union': {
|
|
114
|
+
const values: EnumValue[] = []
|
|
115
|
+
for (const member of type.types) {
|
|
116
|
+
if (member.kind === 'literal' && typeof member.value !== 'bigint') {
|
|
117
|
+
values.push(member.value)
|
|
118
|
+
} else if (member.kind === 'primitive' && member.name === 'boolean') {
|
|
119
|
+
values.push(false, true)
|
|
120
|
+
} else {
|
|
121
|
+
return { reason: 'only unions of literal types can be coerced' }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { plan: { kind: 'enum', values }, cast: false }
|
|
125
|
+
}
|
|
126
|
+
case 'reference': {
|
|
127
|
+
if (type.identity === DATE_IDENTITY) {
|
|
128
|
+
return { plan: { kind: 'date' }, cast: false }
|
|
129
|
+
}
|
|
130
|
+
const values = enumValues(ctx, type.identity)
|
|
131
|
+
if (values) {
|
|
132
|
+
// enum members are nominal: the literal values are cast to the enum type
|
|
133
|
+
return values.length === 0
|
|
134
|
+
? { reason: `the enum ${type.name} has no member` }
|
|
135
|
+
: { plan: { kind: 'enum', values }, cast: true }
|
|
136
|
+
}
|
|
137
|
+
return { reason: 'objects cannot be read from a single request value' }
|
|
138
|
+
}
|
|
139
|
+
case 'intersection': {
|
|
140
|
+
// branded primitives: `string & { readonly __brand: 'UserId' }`
|
|
141
|
+
const primitives = type.types.filter(member => member.kind === 'primitive' || member.kind === 'literal')
|
|
142
|
+
const [primitive] = primitives
|
|
143
|
+
if (primitives.length === 1 && primitive !== undefined) {
|
|
144
|
+
const inner = scalarPlan(ctx, primitive)
|
|
145
|
+
return 'reason' in inner ? inner : { plan: inner.plan, cast: true }
|
|
146
|
+
}
|
|
147
|
+
return { reason: 'only branded primitives (`string & { ... }`) can be coerced' }
|
|
148
|
+
}
|
|
149
|
+
case 'array':
|
|
150
|
+
return { reason: 'nested arrays cannot be coerced' }
|
|
151
|
+
default:
|
|
152
|
+
return { reason: 'this type cannot be read from a request value' }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function primitiveCoercion(name: string): ScalarCoercion | undefined {
|
|
157
|
+
switch (name) {
|
|
158
|
+
case 'string':
|
|
159
|
+
case 'number':
|
|
160
|
+
case 'boolean':
|
|
161
|
+
case 'bigint':
|
|
162
|
+
return name
|
|
163
|
+
default:
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function failure(ctx: Context, type: TypeInfo, reason: string): CoercionResult {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
reason: `type '${ctx.types.format(type)}' cannot be coerced (${reason})`,
|
|
172
|
+
hints: [
|
|
173
|
+
'Supported types: string, number, boolean, bigint, Date, literal unions, enums, branded primitives and arrays of them',
|
|
174
|
+
'Or validate the value with a Standard Schema',
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Member values of a TypeScript enum, `undefined` when `identity` is not an evaluable enum. */
|
|
180
|
+
function enumValues(ctx: Context, identity: string): EnumValue[] | undefined {
|
|
181
|
+
const symbol = ctx.project.lookupIdentity(identity)
|
|
182
|
+
if (symbol.kind !== 'declaration' || symbol.declaration.kind !== 'enum') {
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
const declaration = symbol.declaration
|
|
186
|
+
const node = declaration.node as unknown
|
|
187
|
+
const id = isNode(node) ? child(node, 'id') : undefined
|
|
188
|
+
const file = ctx.project.getFile(declaration.file)
|
|
189
|
+
if (!id || !file) {
|
|
190
|
+
return undefined
|
|
191
|
+
}
|
|
192
|
+
const scope = { file, namespace: declaration.namespace } as unknown as ScopeInput
|
|
193
|
+
const result = ctx.project.evaluate(scope, id as unknown as ExpressionInput, { refs: 'none' })
|
|
194
|
+
if (result.kind !== 'value' || typeof result.value !== 'object' || result.value === null) {
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
return Object.values(result.value).filter(
|
|
198
|
+
(value): value is string | number => typeof value === 'string' || typeof value === 'number',
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function inferFromDefault(
|
|
203
|
+
ctx: Context,
|
|
204
|
+
cls: ClassInfo,
|
|
205
|
+
method: MethodInfo,
|
|
206
|
+
param: ParameterInfo,
|
|
207
|
+
): TypeInfo | undefined {
|
|
208
|
+
const node = defaultValueOf(cls, method, param)
|
|
209
|
+
if (!node) {
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
const inner = unwrap(node)
|
|
213
|
+
if (inner.type === 'NewExpression') {
|
|
214
|
+
const callee = child(inner, 'callee')
|
|
215
|
+
if (callee?.type === 'Identifier') {
|
|
216
|
+
const resolved = ctx.project.resolveIdentifier(
|
|
217
|
+
cls.file,
|
|
218
|
+
callee as unknown as Parameters<Project['resolveIdentifier']>[1],
|
|
219
|
+
'value',
|
|
220
|
+
)
|
|
221
|
+
return resolved.identity?.identity === DATE_IDENTITY ? DATE : undefined
|
|
222
|
+
}
|
|
223
|
+
return undefined
|
|
224
|
+
}
|
|
225
|
+
const result = ctx.project.evaluate(cls.file, node as unknown as ExpressionInput, { refs: 'none' })
|
|
226
|
+
return result.kind === 'value' ? typeOfValue(result.value) : undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function typeOfValue(value: unknown): TypeInfo | undefined {
|
|
230
|
+
switch (typeof value) {
|
|
231
|
+
case 'string':
|
|
232
|
+
return STRING
|
|
233
|
+
case 'number':
|
|
234
|
+
return NUMBER
|
|
235
|
+
case 'boolean':
|
|
236
|
+
return BOOLEAN
|
|
237
|
+
case 'bigint':
|
|
238
|
+
return BIGINT
|
|
239
|
+
default: {
|
|
240
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
const items = value.map(typeOfValue)
|
|
244
|
+
const [first] = items
|
|
245
|
+
if (first === undefined || first.kind !== 'primitive' || items.some(item => item !== first)) {
|
|
246
|
+
return undefined
|
|
247
|
+
}
|
|
248
|
+
return { kind: 'array', element: first }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hasNullish(type: TypeInfo): boolean {
|
|
254
|
+
return type.kind === 'union' && type.types.some(isNullish)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Whether a union accepts `undefined` (`T | undefined`, `T | void`). */
|
|
258
|
+
export function acceptsUndefined(type: TypeInfo): boolean {
|
|
259
|
+
return (
|
|
260
|
+
type.kind === 'union' &&
|
|
261
|
+
type.types.some(member => member.kind === 'primitive' && (member.name === 'undefined' || member.name === 'void'))
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isNullish(type: TypeInfo): boolean {
|
|
266
|
+
return type.kind === 'primitive' && (type.name === 'undefined' || type.name === 'null' || type.name === 'void')
|
|
267
|
+
}
|