@lovrozagar/honey 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/README.md +51 -0
- package/docs/api-ref/go.md +399 -0
- package/docs/api-ref/python.md +399 -0
- package/docs/api-ref/rust.md +399 -0
- package/docs/api-ref/typescript.md +399 -0
- package/docs/invalidate-spec.md +303 -0
- package/docs/sdk.md +722 -0
- package/package.json +120 -0
- package/src/accepts.ts +78 -0
- package/src/body-limit.ts +100 -0
- package/src/build/index.ts +174 -0
- package/src/cli-go/auth.go +15 -0
- package/src/cli-go/config.go +165 -0
- package/src/cli-go/errors.go +57 -0
- package/src/cli-go/multipart.go +55 -0
- package/src/cli-go/output.go +109 -0
- package/src/cli-go/root.go +30 -0
- package/src/cli-go/stream.go +88 -0
- package/src/cli-go/version.go +5 -0
- package/src/cli.ts +204 -0
- package/src/client/error.ts +27 -0
- package/src/client/http.ts +502 -0
- package/src/client/index.ts +127 -0
- package/src/client/sdk.ts +265 -0
- package/src/client/sse.ts +102 -0
- package/src/client/types.ts +211 -0
- package/src/client/ws.ts +115 -0
- package/src/client-go/errors.go +207 -0
- package/src/client-go/invalidation.go +373 -0
- package/src/client-go/realtime.go +317 -0
- package/src/client-go/result.go +20 -0
- package/src/client-go/runtime.go +413 -0
- package/src/client-go/sse.go +149 -0
- package/src/client-go/transport.go +452 -0
- package/src/client-go/ws.go +104 -0
- package/src/client-mcp/README.md +10 -0
- package/src/client-mcp/server.ts +94 -0
- package/src/client-mcp/types.ts +19 -0
- package/src/client-python/_errors.py +115 -0
- package/src/client-python/_invalidation.py +578 -0
- package/src/client-python/_realtime.py +156 -0
- package/src/client-python/_runtime.py +469 -0
- package/src/client-python/_sse.py +99 -0
- package/src/client-python/_transport.py +335 -0
- package/src/client-python/_ws.py +131 -0
- package/src/client-python/py.typed +0 -0
- package/src/client-rust/errors.rs +200 -0
- package/src/client-rust/invalidation.rs +441 -0
- package/src/client-rust/invalidation_sync.rs +306 -0
- package/src/client-rust/realtime.rs +695 -0
- package/src/client-rust/result.rs +22 -0
- package/src/client-rust/runtime.rs +547 -0
- package/src/client-rust/runtime_sync.rs +452 -0
- package/src/client-rust/sse.rs +167 -0
- package/src/client-rust/ws.rs +143 -0
- package/src/cloudflare/realtime.ts +236 -0
- package/src/codegen-go-cli.ts +1180 -0
- package/src/codegen-go.ts +1010 -0
- package/src/codegen-hash.ts +8 -0
- package/src/codegen-ir.ts +517 -0
- package/src/codegen-loaders.ts +53 -0
- package/src/codegen-mcp.ts +364 -0
- package/src/codegen-python.ts +1028 -0
- package/src/codegen-rust.ts +1652 -0
- package/src/codegen-sanitize.ts +67 -0
- package/src/codegen-schema-naming.ts +146 -0
- package/src/codegen.ts +5015 -0
- package/src/context.ts +210 -0
- package/src/cookie-sign.ts +58 -0
- package/src/cookie.ts +89 -0
- package/src/cors.ts +96 -0
- package/src/crypto.ts +19 -0
- package/src/csrf.ts +54 -0
- package/src/curl-logger.ts +235 -0
- package/src/detect-runtime.ts +19 -0
- package/src/error.ts +52 -0
- package/src/errors.ts +87 -0
- package/src/etag.ts +74 -0
- package/src/feature-load.ts +23 -0
- package/src/go-type-emitter.ts +404 -0
- package/src/honey-response.ts +354 -0
- package/src/i18n-slot.ts +20 -0
- package/src/i18n.ts +234 -0
- package/src/index.ts +3843 -0
- package/src/init.ts +149 -0
- package/src/input.ts +8 -0
- package/src/ip-restrict.ts +192 -0
- package/src/logger.ts +149 -0
- package/src/middleware.ts +199 -0
- package/src/node-request.ts +240 -0
- package/src/node.ts +212 -0
- package/src/openapi/register.ts +19 -0
- package/src/openapi/scalar.ts +33 -0
- package/src/openapi/spec-factory.ts +46 -0
- package/src/openapi/spec.ts +31 -0
- package/src/openapi/swagger.ts +34 -0
- package/src/plugin.ts +771 -0
- package/src/powered-by.ts +17 -0
- package/src/pretty-json.ts +39 -0
- package/src/proxy.ts +155 -0
- package/src/python-type-emitter.ts +144 -0
- package/src/realtime/buffer.ts +101 -0
- package/src/realtime/bus.ts +93 -0
- package/src/realtime/longpoll.ts +148 -0
- package/src/realtime/protocol.ts +128 -0
- package/src/realtime/route.ts +104 -0
- package/src/realtime/runtime.ts +430 -0
- package/src/request-id.ts +23 -0
- package/src/request-to-curl.ts +34 -0
- package/src/response.ts +414 -0
- package/src/rust-type-emitter.ts +412 -0
- package/src/secure-headers.ts +69 -0
- package/src/serve-register.ts +8 -0
- package/src/serve-slot.ts +20 -0
- package/src/serve.ts +141 -0
- package/src/server-timing.ts +66 -0
- package/src/static.ts +60 -0
- package/src/telemetry/otel.ts +139 -0
- package/src/testing.ts +114 -0
- package/src/timeout.ts +36 -0
- package/src/tree.ts +464 -0
- package/src/ts-type-emitter.ts +106 -0
- package/src/type-emitter.ts +524 -0
- package/src/type-extractor.ts +749 -0
- package/src/types.ts +638 -0
- package/src/validation.ts +300 -0
- package/src/ws/bun.ts +89 -0
- package/src/ws/cloudflare.ts +133 -0
- package/src/ws/deno.ts +109 -0
- package/src/ws/node.ts +161 -0
- package/src/yaml.ts +80 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { HoneyError } from "./error.ts"
|
|
2
|
+
import type {
|
|
3
|
+
FieldError,
|
|
4
|
+
InputSchemaEntry,
|
|
5
|
+
InputSchemasDef,
|
|
6
|
+
NormalizedIssue,
|
|
7
|
+
StandardSchemaIssue,
|
|
8
|
+
StandardSchemaLike,
|
|
9
|
+
} from "./types.ts"
|
|
10
|
+
import { EK, SK } from "./types.ts"
|
|
11
|
+
|
|
12
|
+
type ValidatedInput = {
|
|
13
|
+
cookies?: unknown
|
|
14
|
+
form?: unknown
|
|
15
|
+
headers?: unknown
|
|
16
|
+
json?: unknown
|
|
17
|
+
params?: unknown
|
|
18
|
+
search?: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"])
|
|
22
|
+
|
|
23
|
+
const CODE_MAP: Record<string, string> = {
|
|
24
|
+
invalid_format: "field_invalid_format",
|
|
25
|
+
invalid_value: "field_invalid_enum",
|
|
26
|
+
not_multiple: "field_not_multiple_of",
|
|
27
|
+
required: "field_required",
|
|
28
|
+
too_big: "field_too_big",
|
|
29
|
+
too_long: "field_too_long",
|
|
30
|
+
too_short: "field_too_short",
|
|
31
|
+
too_small: "field_too_small",
|
|
32
|
+
unrecognized: "field_unrecognized_keys",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mapNormalizedCode(code: string): string {
|
|
36
|
+
return CODE_MAP[code] ?? "field_invalid"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function selectParser(contentType: string | null): "form" | "json" | null {
|
|
40
|
+
if (contentType === null) return null
|
|
41
|
+
if (contentType.startsWith("application/json")) return "json"
|
|
42
|
+
if (contentType.startsWith("multipart/form-data")) return "form"
|
|
43
|
+
if (contentType.startsWith("application/x-www-form-urlencoded")) return "form"
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tryDecodeCookieValue(value: string): string {
|
|
48
|
+
try {
|
|
49
|
+
return decodeURIComponent(value)
|
|
50
|
+
} catch {
|
|
51
|
+
return value
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseCookies(header: string): Record<string, string> {
|
|
56
|
+
const result: Record<string, string> = {}
|
|
57
|
+
if (header.length === 0) return result
|
|
58
|
+
for (const pair of header.split(";")) {
|
|
59
|
+
const trimmed = pair.trim()
|
|
60
|
+
const eqIdx = trimmed.indexOf("=")
|
|
61
|
+
if (eqIdx === -1) continue
|
|
62
|
+
const name = trimmed.slice(0, eqIdx)
|
|
63
|
+
if (DANGEROUS_KEYS.has(name)) continue
|
|
64
|
+
let value = trimmed.slice(eqIdx + 1)
|
|
65
|
+
if (value.indexOf("%") !== -1) value = tryDecodeCookieValue(value)
|
|
66
|
+
/* RFC 6265: strip surrounding double quotes */
|
|
67
|
+
if (
|
|
68
|
+
value.length >= 2 &&
|
|
69
|
+
value.charCodeAt(0) === 34 &&
|
|
70
|
+
value.charCodeAt(value.length - 1) === 34
|
|
71
|
+
) {
|
|
72
|
+
value = value.slice(1, -1)
|
|
73
|
+
}
|
|
74
|
+
result[name] = value
|
|
75
|
+
}
|
|
76
|
+
return result
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toPropertyKey(segment: PropertyKey | { readonly key: PropertyKey }): PropertyKey {
|
|
80
|
+
if (typeof segment === "object" && segment !== null && "key" in segment) {
|
|
81
|
+
return segment.key
|
|
82
|
+
}
|
|
83
|
+
return segment
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type VendorIssue = StandardSchemaIssue & { code?: string; type?: string }
|
|
87
|
+
|
|
88
|
+
function extractVendorCode(issue: StandardSchemaIssue, vendor: string): string {
|
|
89
|
+
const vi = issue as VendorIssue
|
|
90
|
+
if (vendor === "zod" || vendor === "arktype") {
|
|
91
|
+
return typeof vi.code === "string" ? vi.code : "unknown"
|
|
92
|
+
}
|
|
93
|
+
if (vendor === "valibot") {
|
|
94
|
+
return typeof vi.type === "string" ? vi.type : "unknown"
|
|
95
|
+
}
|
|
96
|
+
return "unknown"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function normalizeIssues(
|
|
100
|
+
issues: ReadonlyArray<StandardSchemaIssue>,
|
|
101
|
+
vendor: string,
|
|
102
|
+
): NormalizedIssue[] {
|
|
103
|
+
return issues.map((issue) => {
|
|
104
|
+
const path = issue.path ? issue.path.map(toPropertyKey) : []
|
|
105
|
+
return {
|
|
106
|
+
code: extractVendorCode(issue, vendor),
|
|
107
|
+
message: issue.message,
|
|
108
|
+
meta: {
|
|
109
|
+
field: path.at(-1)?.toString(),
|
|
110
|
+
},
|
|
111
|
+
path,
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function issuesToFieldErrors(
|
|
117
|
+
issues: NormalizedIssue[],
|
|
118
|
+
prefix: string,
|
|
119
|
+
): Record<string, FieldError[]> {
|
|
120
|
+
const fields: Record<string, FieldError[]> = {}
|
|
121
|
+
for (const issue of issues) {
|
|
122
|
+
const fieldName = issue.path.at(-1)?.toString() ?? "unknown"
|
|
123
|
+
const fullPath = `${prefix}.${issue.path.map(String).join(".")}`
|
|
124
|
+
const error: FieldError = {
|
|
125
|
+
error_key: mapNormalizedCode(issue.code),
|
|
126
|
+
message: issue.message,
|
|
127
|
+
path: fullPath,
|
|
128
|
+
}
|
|
129
|
+
if (fields[fieldName] === undefined) {
|
|
130
|
+
fields[fieldName] = []
|
|
131
|
+
}
|
|
132
|
+
fields[fieldName].push(error)
|
|
133
|
+
}
|
|
134
|
+
return fields
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runSchema(
|
|
138
|
+
schema: StandardSchemaLike,
|
|
139
|
+
data: unknown,
|
|
140
|
+
prefix: string,
|
|
141
|
+
): Promise<unknown> {
|
|
142
|
+
const result = await schema["~standard"].validate(data)
|
|
143
|
+
if (result.issues) {
|
|
144
|
+
const normalized = normalizeIssues(result.issues, schema["~standard"].vendor)
|
|
145
|
+
const fields = issuesToFieldErrors(normalized, prefix)
|
|
146
|
+
throw new HoneyError({
|
|
147
|
+
errorKey: EK.validation_failed,
|
|
148
|
+
fields,
|
|
149
|
+
status: SK.bad_request,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
return result.value
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseSearchParams(url: string): Record<string, string | string[]> {
|
|
156
|
+
const searchParams = new URL(url).searchParams
|
|
157
|
+
const result: Record<string, string | string[]> = {}
|
|
158
|
+
for (const [key, value] of searchParams) {
|
|
159
|
+
const existing = result[key]
|
|
160
|
+
if (existing === undefined) {
|
|
161
|
+
result[key] = value
|
|
162
|
+
} else if (Array.isArray(existing)) {
|
|
163
|
+
existing.push(value)
|
|
164
|
+
} else {
|
|
165
|
+
result[key] = [existing, value]
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return result
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function headersToRecord(headers: Headers): Record<string, string> {
|
|
172
|
+
const result: Record<string, string> = {}
|
|
173
|
+
headers.forEach((value, key) => {
|
|
174
|
+
result[key] = value
|
|
175
|
+
})
|
|
176
|
+
return result
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type ResolvedSchema = {
|
|
180
|
+
mode: "readableStream" | "standard"
|
|
181
|
+
schema: StandardSchemaLike
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveSchema(entry: InputSchemaEntry): ResolvedSchema {
|
|
185
|
+
if ("_tag" in entry) {
|
|
186
|
+
return {
|
|
187
|
+
mode: "readableStream",
|
|
188
|
+
schema: entry.schema as StandardSchemaLike,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { mode: "standard", schema: entry }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function formDataToRecord(formData: FormData): Record<string, unknown> {
|
|
195
|
+
const record: Record<string, unknown> = {}
|
|
196
|
+
formData.forEach((value, key) => {
|
|
197
|
+
if (DANGEROUS_KEYS.has(key)) return
|
|
198
|
+
record[key] = value
|
|
199
|
+
})
|
|
200
|
+
return record
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function urlEncodedToRecord(body: string): Record<string, string> {
|
|
204
|
+
const params = new URLSearchParams(body)
|
|
205
|
+
const record: Record<string, string> = {}
|
|
206
|
+
params.forEach((value, key) => {
|
|
207
|
+
if (DANGEROUS_KEYS.has(key)) return
|
|
208
|
+
record[key] = value
|
|
209
|
+
})
|
|
210
|
+
return record
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function validateInput(
|
|
214
|
+
schemas: InputSchemasDef,
|
|
215
|
+
req: Request,
|
|
216
|
+
params: Record<string, string>,
|
|
217
|
+
): Promise<ValidatedInput> {
|
|
218
|
+
const result: ValidatedInput = {}
|
|
219
|
+
|
|
220
|
+
/* params */
|
|
221
|
+
if (schemas.params) {
|
|
222
|
+
const { mode, schema } = resolveSchema(schemas.params)
|
|
223
|
+
result.params = mode === "readableStream" ? params : await runSchema(schema, params, "params")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* search */
|
|
227
|
+
if (schemas.search) {
|
|
228
|
+
const { mode, schema } = resolveSchema(schemas.search)
|
|
229
|
+
const parsed = parseSearchParams(req.url)
|
|
230
|
+
result.search = mode === "readableStream" ? parsed : await runSchema(schema, parsed, "search")
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* headers */
|
|
234
|
+
if (schemas.headers) {
|
|
235
|
+
const { mode, schema } = resolveSchema(schemas.headers)
|
|
236
|
+
const headerRecord = headersToRecord(req.headers)
|
|
237
|
+
result.headers =
|
|
238
|
+
mode === "readableStream" ? headerRecord : await runSchema(schema, headerRecord, "headers")
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* cookies */
|
|
242
|
+
if (schemas.cookies) {
|
|
243
|
+
const { mode, schema } = resolveSchema(schemas.cookies)
|
|
244
|
+
const cookieHeader = req.headers.get("cookie") ?? ""
|
|
245
|
+
const parsed = parseCookies(cookieHeader)
|
|
246
|
+
result.cookies = mode === "readableStream" ? parsed : await runSchema(schema, parsed, "cookies")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* body-based: json or form — skip for methods without a request body */
|
|
250
|
+
const hasBody =
|
|
251
|
+
req.method !== "DELETE" &&
|
|
252
|
+
req.method !== "GET" &&
|
|
253
|
+
req.method !== "HEAD" &&
|
|
254
|
+
req.method !== "OPTIONS"
|
|
255
|
+
if (hasBody && (schemas.json || schemas.form)) {
|
|
256
|
+
const contentType = req.headers.get("content-type")
|
|
257
|
+
const parser = selectParser(contentType)
|
|
258
|
+
|
|
259
|
+
if (schemas.json && parser === "json") {
|
|
260
|
+
const { mode, schema } = resolveSchema(schemas.json)
|
|
261
|
+
if (mode === "readableStream") {
|
|
262
|
+
/* body untouched — handler reads ctx.req.body directly */
|
|
263
|
+
} else {
|
|
264
|
+
const body: unknown = await req.json()
|
|
265
|
+
result.json = await runSchema(schema, body, "json")
|
|
266
|
+
}
|
|
267
|
+
} else if (schemas.form && parser === "form") {
|
|
268
|
+
const { mode, schema } = resolveSchema(schemas.form)
|
|
269
|
+
if (mode === "readableStream") {
|
|
270
|
+
/* body untouched — handler reads ctx.req.body directly */
|
|
271
|
+
} else if (contentType?.startsWith("multipart/form-data")) {
|
|
272
|
+
result.form = await runSchema(schema, formDataToRecord(await req.formData()), "form")
|
|
273
|
+
} else {
|
|
274
|
+
result.form = await runSchema(schema, urlEncodedToRecord(await req.text()), "form")
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
throw new HoneyError({
|
|
278
|
+
errorKey: EK.unsupported_media_type,
|
|
279
|
+
status: SK.unsupported_media_type,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return result
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function validateOutput(
|
|
288
|
+
schema: StandardSchemaLike,
|
|
289
|
+
statusKey: string,
|
|
290
|
+
data: unknown,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
const result = await schema["~standard"].validate(data)
|
|
293
|
+
if (result.issues) {
|
|
294
|
+
throw new HoneyError({
|
|
295
|
+
errorKey: EK.output_validation_failed,
|
|
296
|
+
status: SK.internal_server_error,
|
|
297
|
+
vars: { statusKey },
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
}
|
package/src/ws/bun.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type RawSocket,
|
|
3
|
+
type WSAdapter,
|
|
4
|
+
type WSContext,
|
|
5
|
+
WSContextImpl,
|
|
6
|
+
type WSHandler,
|
|
7
|
+
} from "./cloudflare.ts"
|
|
8
|
+
|
|
9
|
+
import type { WSAdapter, WSContext, WSHandler } from "./cloudflare.ts"
|
|
10
|
+
import { WSContextImpl } from "./cloudflare.ts"
|
|
11
|
+
|
|
12
|
+
type BunRawSocket = {
|
|
13
|
+
close(code?: number, reason?: string): void
|
|
14
|
+
data: BunWSData
|
|
15
|
+
readyState: number
|
|
16
|
+
send(data: ArrayBufferLike | ArrayBufferView | string, compress?: boolean): number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type BunWSData = {
|
|
20
|
+
handler: WSHandler<unknown>
|
|
21
|
+
socket: WSContext
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type BunServer = {
|
|
25
|
+
upgrade<T>(req: Request, opts?: { data?: T }): boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type BunWSAdapter = WSAdapter & {
|
|
29
|
+
websocket: {
|
|
30
|
+
close(ws: BunRawSocket, code: number, reason: string): void
|
|
31
|
+
message(ws: BunRawSocket, data: ArrayBufferView | string): void
|
|
32
|
+
open(ws: BunRawSocket): void
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Bun WebSocket adapter.
|
|
38
|
+
* Returns adapter + websocket handler object for Bun.serve().
|
|
39
|
+
* Bun has no onError callback (noted limitation).
|
|
40
|
+
* Keepalive is not supported — Bun's WS API lacks ping/pong control.
|
|
41
|
+
* Use nodeWebSocket() for keepalive support.
|
|
42
|
+
*/
|
|
43
|
+
export function bunWebSocket(): BunWSAdapter {
|
|
44
|
+
return {
|
|
45
|
+
upgrade(req: Request, env: unknown, handler: WSHandler<unknown>) {
|
|
46
|
+
const server = (env as Record<string, unknown>).server as BunServer
|
|
47
|
+
/*
|
|
48
|
+
* Socket is undefined here — Bun creates it lazily in the open callback.
|
|
49
|
+
* WSContext cast is safe: actual socket is assigned in open/message/close
|
|
50
|
+
* before any user code accesses it.
|
|
51
|
+
*/
|
|
52
|
+
const success = server.upgrade<BunWSData>(req, {
|
|
53
|
+
data: { handler, socket: undefined as unknown as WSContext },
|
|
54
|
+
})
|
|
55
|
+
if (!success) {
|
|
56
|
+
return {
|
|
57
|
+
response: new Response("WebSocket upgrade failed", { status: 500 }),
|
|
58
|
+
socket: undefined as unknown as WSContext,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/* Node's Response rejects status 101 (Fetch spec: 200-599 only).
|
|
62
|
+
Create a valid Response and override status for the sentinel check. */
|
|
63
|
+
const response = new Response(null)
|
|
64
|
+
Object.defineProperty(response, "status", { value: 101 })
|
|
65
|
+
return {
|
|
66
|
+
response,
|
|
67
|
+
socket: undefined as unknown as WSContext,
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
websocket: {
|
|
72
|
+
close(ws: BunRawSocket, code: number, reason: string) {
|
|
73
|
+
if (!ws.data.socket) ws.data.socket = new WSContextImpl(ws)
|
|
74
|
+
ws.data.handler.onClose?.(undefined, ws.data.socket, code, reason)
|
|
75
|
+
},
|
|
76
|
+
message(ws: BunRawSocket, data: ArrayBufferView | string) {
|
|
77
|
+
if (!ws.data.socket) ws.data.socket = new WSContextImpl(ws)
|
|
78
|
+
/* Bun passes Buffer (Uint8Array) for binary — normalize to ArrayBuffer for WSHandler */
|
|
79
|
+
const normalized =
|
|
80
|
+
typeof data === "string" ? data : ((data as Uint8Array).buffer as ArrayBuffer)
|
|
81
|
+
ws.data.handler.onMessage?.(undefined, ws.data.socket, normalized)
|
|
82
|
+
},
|
|
83
|
+
open(ws: BunRawSocket) {
|
|
84
|
+
if (!ws.data.socket) ws.data.socket = new WSContextImpl(ws)
|
|
85
|
+
ws.data.handler.onOpen?.(undefined, ws.data.socket)
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { WSReadyState } from "../types.ts"
|
|
2
|
+
|
|
3
|
+
export const WS_SEND_BUFFER_MAX = 32
|
|
4
|
+
|
|
5
|
+
export type WSContext<T = unknown> = {
|
|
6
|
+
close(code?: number, reason?: string): void
|
|
7
|
+
raw: T
|
|
8
|
+
readyState: WSReadyState
|
|
9
|
+
send(data: ArrayBuffer | Uint8Array | object | string): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type WSHandler<TCtx = unknown> = {
|
|
13
|
+
onClose?(ctx: TCtx, ws: WSContext, code: number, reason: string): Promise<void> | void
|
|
14
|
+
onError?(ctx: TCtx, ws: WSContext, error: unknown): Promise<void> | void
|
|
15
|
+
onMessage?(ctx: TCtx, ws: WSContext, data: ArrayBuffer | string): Promise<void> | void
|
|
16
|
+
onOpen?(ctx: TCtx, ws: WSContext): Promise<void> | void
|
|
17
|
+
onReconnect?(ctx: TCtx, ws: WSContext, token: string): Promise<void> | void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type WSPreUpgrade = {
|
|
21
|
+
response: Response
|
|
22
|
+
socket: WSContext
|
|
23
|
+
/** Run `fn` now if the socket is already open, otherwise on the next open. */
|
|
24
|
+
whenOpen(fn: () => void): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type WSAdapter = {
|
|
28
|
+
upgrade(
|
|
29
|
+
req: Request,
|
|
30
|
+
env: unknown,
|
|
31
|
+
handler: WSHandler<unknown>,
|
|
32
|
+
): Promise<{ response: Response; socket: WSContext }> | { response: Response; socket: WSContext }
|
|
33
|
+
/**
|
|
34
|
+
* Deno requires `Deno.upgradeWebSocket` in the same turn as the serve
|
|
35
|
+
* callback. When present, Honey calls this synchronously from `fetch`
|
|
36
|
+
* and returns `response` before any middleware Promise.
|
|
37
|
+
*/
|
|
38
|
+
preUpgrade?(req: Request): WSPreUpgrade | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type RawSocket = {
|
|
42
|
+
close(code?: number, reason?: string): void
|
|
43
|
+
readyState: number
|
|
44
|
+
send(data: ArrayBuffer | Uint8Array | string): void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class WSContextImpl<T extends RawSocket = RawSocket> implements WSContext<T> {
|
|
48
|
+
raw: T
|
|
49
|
+
private sendBuffer: Array<ArrayBuffer | Uint8Array | string> = []
|
|
50
|
+
|
|
51
|
+
constructor(raw: T) {
|
|
52
|
+
this.raw = raw
|
|
53
|
+
const target = raw as T & { addEventListener?(type: string, listener: () => void): void }
|
|
54
|
+
if (raw.readyState !== 1 && typeof target.addEventListener === "function") {
|
|
55
|
+
target.addEventListener("open", () => {
|
|
56
|
+
for (const msg of this.sendBuffer) this.raw.send(msg)
|
|
57
|
+
this.sendBuffer = []
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get readyState(): WSReadyState {
|
|
63
|
+
return this.raw.readyState as WSReadyState
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
close(code?: number, reason?: string): void {
|
|
67
|
+
this.sendBuffer = []
|
|
68
|
+
this.raw.close(code, reason)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
send(data: ArrayBuffer | Uint8Array | object | string): void {
|
|
72
|
+
const payload =
|
|
73
|
+
typeof data === "string" || data instanceof ArrayBuffer || data instanceof Uint8Array
|
|
74
|
+
? data
|
|
75
|
+
: JSON.stringify(data)
|
|
76
|
+
if (this.raw.readyState === 1) {
|
|
77
|
+
this.raw.send(payload)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (this.sendBuffer.length >= WS_SEND_BUFFER_MAX) this.sendBuffer.shift()
|
|
81
|
+
this.sendBuffer.push(payload)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Cloudflare Workers WebSocket adapter.
|
|
87
|
+
* Uses WebSocketPair + server.accept() + addEventListener.
|
|
88
|
+
*/
|
|
89
|
+
export function cfWebSocket(): WSAdapter {
|
|
90
|
+
return {
|
|
91
|
+
upgrade(_req: Request, _env: unknown, handler: WSHandler<unknown>) {
|
|
92
|
+
type CFWebSocket = RawSocket & { accept(): void }
|
|
93
|
+
const Ctor = (globalThis as Record<string, unknown>)["WebSocketPair"] as new () => [
|
|
94
|
+
CFWebSocket,
|
|
95
|
+
CFWebSocket,
|
|
96
|
+
]
|
|
97
|
+
const [client, server] = new Ctor()
|
|
98
|
+
server.accept()
|
|
99
|
+
const socket = new WSContextImpl(server)
|
|
100
|
+
|
|
101
|
+
const raw = server as unknown as EventTarget & RawSocket
|
|
102
|
+
if ("addEventListener" in raw) {
|
|
103
|
+
raw.addEventListener("message", (evt: Event) => {
|
|
104
|
+
const data = (evt as MessageEvent).data
|
|
105
|
+
handler.onMessage?.(undefined, socket, data)
|
|
106
|
+
})
|
|
107
|
+
raw.addEventListener("close", (evt: Event) => {
|
|
108
|
+
const closeEvt = evt as CloseEvent
|
|
109
|
+
handler.onClose?.(undefined, socket, closeEvt.code, closeEvt.reason)
|
|
110
|
+
/* echo close frame so the client handshake completes */
|
|
111
|
+
try {
|
|
112
|
+
server.close(closeEvt.code, closeEvt.reason)
|
|
113
|
+
} catch {
|
|
114
|
+
/* already closed */
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
raw.addEventListener("error", (evt: Event) => {
|
|
118
|
+
handler.onError?.(undefined, socket, evt)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
handler.onOpen?.(undefined, socket)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
response: new Response(null, {
|
|
125
|
+
headers: { upgrade: "websocket" },
|
|
126
|
+
status: 101,
|
|
127
|
+
webSocket: client,
|
|
128
|
+
} as unknown as ResponseInit),
|
|
129
|
+
socket,
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/ws/deno.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type RawSocket,
|
|
3
|
+
type WSAdapter,
|
|
4
|
+
type WSContext,
|
|
5
|
+
type WSPreUpgrade,
|
|
6
|
+
WSContextImpl,
|
|
7
|
+
type WSHandler,
|
|
8
|
+
} from "./cloudflare.ts";
|
|
9
|
+
|
|
10
|
+
import type { WSAdapter, WSHandler, WSPreUpgrade } from "./cloudflare.ts";
|
|
11
|
+
import { WSContextImpl } from "./cloudflare.ts";
|
|
12
|
+
|
|
13
|
+
type DenoNs = {
|
|
14
|
+
Deno: {
|
|
15
|
+
upgradeWebSocket(req: Request): {
|
|
16
|
+
response: Response;
|
|
17
|
+
socket: {
|
|
18
|
+
addEventListener(
|
|
19
|
+
type: string,
|
|
20
|
+
listener: (...args: never[]) => void,
|
|
21
|
+
): void;
|
|
22
|
+
} & DenoRawSocket;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type DenoRawSocket = {
|
|
28
|
+
close(code?: number, reason?: string): void;
|
|
29
|
+
readyState: number;
|
|
30
|
+
send(data: ArrayBuffer | Uint8Array | string): void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type DenoSocket = {
|
|
34
|
+
addEventListener(type: string, listener: (...args: never[]) => void): void
|
|
35
|
+
} & DenoRawSocket
|
|
36
|
+
|
|
37
|
+
function bindHandlers(rawSocket: DenoSocket, socket: WSContextImpl<DenoRawSocket>, handler: WSHandler<unknown>): void {
|
|
38
|
+
rawSocket.addEventListener("open", () => {
|
|
39
|
+
handler.onOpen?.(undefined, socket);
|
|
40
|
+
});
|
|
41
|
+
rawSocket.addEventListener(
|
|
42
|
+
"message",
|
|
43
|
+
(evt: { data: ArrayBuffer | string }) => {
|
|
44
|
+
handler.onMessage?.(undefined, socket, evt.data);
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
rawSocket.addEventListener(
|
|
48
|
+
"close",
|
|
49
|
+
(evt: { code: number; reason: string }) => {
|
|
50
|
+
handler.onClose?.(undefined, socket, evt.code, evt.reason);
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
rawSocket.addEventListener("error", (evt: unknown) => {
|
|
54
|
+
handler.onError?.(undefined, socket, evt);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Deno WebSocket adapter.
|
|
60
|
+
* Uses Deno.upgradeWebSocket() and addEventListener for event binding.
|
|
61
|
+
* Keepalive is not supported — Deno's WebSocket lacks a ping() method.
|
|
62
|
+
* Use nodeWebSocket() for keepalive support.
|
|
63
|
+
*
|
|
64
|
+
* `preUpgrade` calls `Deno.upgradeWebSocket` in the same turn as the serve
|
|
65
|
+
* callback (Deno rejects upgrades performed after the first await).
|
|
66
|
+
*/
|
|
67
|
+
export function denoWebSocket(): WSAdapter {
|
|
68
|
+
const pending = new WeakMap<Request, { pre: WSPreUpgrade; raw: DenoSocket }>()
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
preUpgrade(req: Request): WSPreUpgrade {
|
|
72
|
+
const denoNs = globalThis as unknown as DenoNs;
|
|
73
|
+
const { response, socket: rawSocket } = denoNs.Deno.upgradeWebSocket(req);
|
|
74
|
+
const socket = new WSContextImpl(rawSocket as DenoRawSocket);
|
|
75
|
+
const pre: WSPreUpgrade = {
|
|
76
|
+
response,
|
|
77
|
+
socket,
|
|
78
|
+
whenOpen(fn: () => void) {
|
|
79
|
+
if (rawSocket.readyState === 1) {
|
|
80
|
+
fn()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
rawSocket.addEventListener("open", () => fn())
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
pending.set(req, { pre, raw: rawSocket })
|
|
87
|
+
return pre
|
|
88
|
+
},
|
|
89
|
+
upgrade(req: Request, _env: unknown, handler: WSHandler<unknown>) {
|
|
90
|
+
const held = pending.get(req)
|
|
91
|
+
if (held) {
|
|
92
|
+
pending.delete(req)
|
|
93
|
+
bindHandlers(held.raw, held.pre.socket as WSContextImpl<DenoRawSocket>, handler)
|
|
94
|
+
/* handshake may have completed before listeners were attached */
|
|
95
|
+
if (held.raw.readyState === 1) {
|
|
96
|
+
handler.onOpen?.(undefined, held.pre.socket)
|
|
97
|
+
}
|
|
98
|
+
return held.pre
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* globalThis cast: Deno namespace only exists at runtime on Deno — no way to type it statically */
|
|
102
|
+
const denoNs = globalThis as unknown as DenoNs;
|
|
103
|
+
const { response, socket: rawSocket } = denoNs.Deno.upgradeWebSocket(req);
|
|
104
|
+
const socket = new WSContextImpl(rawSocket as DenoRawSocket);
|
|
105
|
+
bindHandlers(rawSocket, socket, handler)
|
|
106
|
+
return { response, socket };
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|