@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,128 @@
|
|
|
1
|
+
export type ServerFrame =
|
|
2
|
+
| { data: unknown; id: number; t: "msg" }
|
|
3
|
+
| { t: "pong" }
|
|
4
|
+
| { reason: string; t: "bye" }
|
|
5
|
+
| { reconnectToken: string; t: "ready" }
|
|
6
|
+
|
|
7
|
+
export type ClientFrame =
|
|
8
|
+
| { t: "ping" }
|
|
9
|
+
| { data: unknown; t: "msg" }
|
|
10
|
+
| { lastId: number; reconnectToken: string; t: "resume" }
|
|
11
|
+
|
|
12
|
+
export type ProtocolBuffer = {
|
|
13
|
+
push(frame: { data: unknown; id: number; t: "msg" }): void
|
|
14
|
+
getAfter(lastId: number): Array<{ data: unknown; id: number; t: "msg" }>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Protocol = {
|
|
18
|
+
handleClientFrame(raw: string): ServerFrame | null
|
|
19
|
+
handleConnect(): ServerFrame
|
|
20
|
+
handleResume(lastId: number, reconnectToken: string): ServerFrame[]
|
|
21
|
+
readonly lastId: number
|
|
22
|
+
readonly reconnectToken: string
|
|
23
|
+
sendBye(reason: string): ServerFrame
|
|
24
|
+
sendMessage(data: unknown): ServerFrame
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encodeServerFrame(frame: ServerFrame): string {
|
|
28
|
+
return JSON.stringify(frame)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function decodeClientFrame(raw: string): ClientFrame | null {
|
|
36
|
+
let parsed: unknown
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(raw)
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!isRecord(parsed)) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const obj = parsed
|
|
48
|
+
|
|
49
|
+
if (obj.t === "ping") {
|
|
50
|
+
return { t: "ping" }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (obj.t === "msg") {
|
|
54
|
+
if (!("data" in obj)) return null
|
|
55
|
+
return { data: obj.data, t: "msg" }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (obj.t === "resume") {
|
|
59
|
+
if (
|
|
60
|
+
typeof obj.lastId !== "number" ||
|
|
61
|
+
obj.lastId < 0 ||
|
|
62
|
+
!Number.isInteger(obj.lastId) ||
|
|
63
|
+
typeof obj.reconnectToken !== "string"
|
|
64
|
+
) {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
return { lastId: obj.lastId, reconnectToken: obj.reconnectToken, t: "resume" }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createProtocol(opts?: { reconnectBuffer?: ProtocolBuffer }): Protocol {
|
|
74
|
+
const token = crypto.randomUUID()
|
|
75
|
+
const buffer = opts?.reconnectBuffer ?? null
|
|
76
|
+
let idCounter = 0
|
|
77
|
+
|
|
78
|
+
function handleConnect(): ServerFrame {
|
|
79
|
+
return { reconnectToken: token, t: "ready" }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleResume(lastId: number, reconnectToken: string): ServerFrame[] {
|
|
83
|
+
if (reconnectToken !== token || !buffer) {
|
|
84
|
+
return [{ reason: "server_restart", t: "bye" }]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const missed = buffer.getAfter(lastId)
|
|
88
|
+
return [...missed, { reconnectToken: token, t: "ready" }]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handleClientFrame(raw: string): ServerFrame | null {
|
|
92
|
+
const frame = decodeClientFrame(raw)
|
|
93
|
+
if (!frame) return null
|
|
94
|
+
|
|
95
|
+
if (frame.t === "ping") {
|
|
96
|
+
return { t: "pong" }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sendMessage(data: unknown): ServerFrame {
|
|
103
|
+
idCounter++
|
|
104
|
+
const frame: ServerFrame = { data, id: idCounter, t: "msg" }
|
|
105
|
+
if (buffer) {
|
|
106
|
+
buffer.push({ data, id: idCounter, t: "msg" })
|
|
107
|
+
}
|
|
108
|
+
return frame
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sendBye(reason: string): ServerFrame {
|
|
112
|
+
return { reason, t: "bye" }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
handleClientFrame,
|
|
117
|
+
handleConnect,
|
|
118
|
+
handleResume,
|
|
119
|
+
get lastId() {
|
|
120
|
+
return idCounter
|
|
121
|
+
},
|
|
122
|
+
get reconnectToken() {
|
|
123
|
+
return token
|
|
124
|
+
},
|
|
125
|
+
sendBye,
|
|
126
|
+
sendMessage,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type ConnContext = {
|
|
2
|
+
readonly id: string
|
|
3
|
+
readonly userId: string | null
|
|
4
|
+
readonly transport: "ws" | "sse" | "longpoll"
|
|
5
|
+
readonly state: Record<string, unknown>
|
|
6
|
+
join(topic: string): void
|
|
7
|
+
leave(topic: string): void
|
|
8
|
+
send(payload: unknown): void
|
|
9
|
+
publish(topic: string, payload: unknown): void
|
|
10
|
+
close(reason?: string): void
|
|
11
|
+
on(event: "message", handler: (payload: unknown) => void): void
|
|
12
|
+
on(event: "close", handler: (reason: string) => void): void
|
|
13
|
+
readonly _handlers: { message: ((payload: unknown) => void) | null; close: ((reason: string) => void) | null }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RealtimeRouteOpts = {
|
|
17
|
+
use?: Array<(ctx: unknown, next: () => Promise<Response>) => Promise<Response>>
|
|
18
|
+
reconnectBuffer?: number
|
|
19
|
+
handler: (c: unknown, conn: ConnContext) => void | Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RealtimeRouteHandler = {
|
|
23
|
+
handler: RealtimeRouteOpts["handler"]
|
|
24
|
+
middlewares: RealtimeRouteOpts["use"]
|
|
25
|
+
reconnectBuffer: number
|
|
26
|
+
path: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createConnContext(opts: {
|
|
30
|
+
id: string
|
|
31
|
+
userId: string | null
|
|
32
|
+
transport: "ws" | "sse" | "longpoll"
|
|
33
|
+
bus: {
|
|
34
|
+
subscribe(connId: string, topic: string): void
|
|
35
|
+
unsubscribe(connId: string, topic: string): void
|
|
36
|
+
unsubscribeAll(connId: string): void
|
|
37
|
+
publish(topic: string, data: unknown): void
|
|
38
|
+
}
|
|
39
|
+
sendFn: (payload: unknown) => void
|
|
40
|
+
closeFn: (reason?: string) => void
|
|
41
|
+
}): ConnContext {
|
|
42
|
+
const state: Record<string, unknown> = {}
|
|
43
|
+
const handlers: { message: ((payload: unknown) => void) | null; close: ((reason: string) => void) | null } = {
|
|
44
|
+
close: null,
|
|
45
|
+
message: null,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const conn: ConnContext = Object.create(null)
|
|
49
|
+
|
|
50
|
+
Object.defineProperty(conn, "id", { enumerable: true, value: opts.id, writable: false })
|
|
51
|
+
Object.defineProperty(conn, "userId", { enumerable: true, value: opts.userId, writable: false })
|
|
52
|
+
Object.defineProperty(conn, "transport", { enumerable: true, value: opts.transport, writable: false })
|
|
53
|
+
Object.defineProperty(conn, "state", { enumerable: true, get: () => state })
|
|
54
|
+
Object.defineProperty(conn, "_handlers", { enumerable: false, get: () => handlers })
|
|
55
|
+
|
|
56
|
+
Object.defineProperty(conn, "join", {
|
|
57
|
+
enumerable: true,
|
|
58
|
+
value(topic: string) {
|
|
59
|
+
opts.bus.subscribe(opts.id, topic)
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
Object.defineProperty(conn, "leave", {
|
|
64
|
+
enumerable: true,
|
|
65
|
+
value(topic: string) {
|
|
66
|
+
opts.bus.unsubscribe(opts.id, topic)
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
Object.defineProperty(conn, "send", {
|
|
71
|
+
enumerable: true,
|
|
72
|
+
value(payload: unknown) {
|
|
73
|
+
opts.sendFn(payload)
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
Object.defineProperty(conn, "publish", {
|
|
78
|
+
enumerable: true,
|
|
79
|
+
value(topic: string, payload: unknown) {
|
|
80
|
+
opts.bus.publish(topic, payload)
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
Object.defineProperty(conn, "close", {
|
|
85
|
+
enumerable: true,
|
|
86
|
+
value(reason?: string) {
|
|
87
|
+
opts.bus.unsubscribeAll(opts.id)
|
|
88
|
+
opts.closeFn(reason)
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
Object.defineProperty(conn, "on", {
|
|
93
|
+
enumerable: true,
|
|
94
|
+
value(event: "message" | "close", handler: ((...args: unknown[]) => void)) {
|
|
95
|
+
if (event === "message") {
|
|
96
|
+
handlers.message = handler as (payload: unknown) => void
|
|
97
|
+
} else {
|
|
98
|
+
handlers.close = handler as (reason: string) => void
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return conn
|
|
104
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import type { ServerFrame } from "./protocol.ts"
|
|
2
|
+
|
|
3
|
+
/* ------------------------------------------------------------------ */
|
|
4
|
+
/* Error hierarchy */
|
|
5
|
+
/* ------------------------------------------------------------------ */
|
|
6
|
+
|
|
7
|
+
export class RealtimeError extends Error {
|
|
8
|
+
readonly reason: string
|
|
9
|
+
|
|
10
|
+
constructor(message: string, reason: string) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.reason = reason
|
|
13
|
+
this.name = "RealtimeError"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class RealtimeConnectError extends RealtimeError {
|
|
18
|
+
constructor(message: string, reason: string) {
|
|
19
|
+
super(message, reason)
|
|
20
|
+
this.name = "RealtimeConnectError"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class RealtimeAuthError extends RealtimeError {
|
|
25
|
+
constructor(message: string, reason: string) {
|
|
26
|
+
super(message, reason)
|
|
27
|
+
this.name = "RealtimeAuthError"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class RealtimeKickedError extends RealtimeError {
|
|
32
|
+
constructor(message: string, reason: string) {
|
|
33
|
+
super(message, reason)
|
|
34
|
+
this.name = "RealtimeKickedError"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class RealtimeAbortError extends RealtimeError {
|
|
39
|
+
constructor(message: string, reason: string) {
|
|
40
|
+
super(message, reason)
|
|
41
|
+
this.name = "RealtimeAbortError"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* ------------------------------------------------------------------ */
|
|
46
|
+
/* Types */
|
|
47
|
+
/* ------------------------------------------------------------------ */
|
|
48
|
+
|
|
49
|
+
export type Transport = "ws" | "sse" | "longpoll"
|
|
50
|
+
export type ConnectionState = "idle" | "connecting" | "connected" | "draining" | "reconnecting" | "closed"
|
|
51
|
+
|
|
52
|
+
export type TransportAdapter = {
|
|
53
|
+
connect(url: string, opts: TransportOpts): TransportConnection
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type TransportConnection = {
|
|
57
|
+
send(data: string): void
|
|
58
|
+
close(): void
|
|
59
|
+
onFrame: (frame: ServerFrame) => void
|
|
60
|
+
onClose: (reason: string) => void
|
|
61
|
+
onError: (err: unknown) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type TransportOpts = {
|
|
65
|
+
signal?: AbortSignal
|
|
66
|
+
headers?: Record<string, string>
|
|
67
|
+
lastId?: number
|
|
68
|
+
reconnectToken?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ------------------------------------------------------------------ */
|
|
72
|
+
/* KeepaliveLoop */
|
|
73
|
+
/* ------------------------------------------------------------------ */
|
|
74
|
+
|
|
75
|
+
export function createKeepaliveLoop(opts: {
|
|
76
|
+
transport: Transport
|
|
77
|
+
sendPing: () => void
|
|
78
|
+
onDead: () => void
|
|
79
|
+
interval?: number
|
|
80
|
+
timeout?: number
|
|
81
|
+
}): { start(): void; stop(): void; onPong(): void; onFrame(): void } {
|
|
82
|
+
const interval = opts.interval ?? 25_000
|
|
83
|
+
const timeout = opts.timeout ?? 60_000
|
|
84
|
+
const transport = opts.transport
|
|
85
|
+
|
|
86
|
+
let pingTimer: ReturnType<typeof setInterval> | null = null
|
|
87
|
+
let deadTimer: ReturnType<typeof setTimeout> | null = null
|
|
88
|
+
|
|
89
|
+
function clearAll(): void {
|
|
90
|
+
if (pingTimer !== null) {
|
|
91
|
+
clearInterval(pingTimer)
|
|
92
|
+
pingTimer = null
|
|
93
|
+
}
|
|
94
|
+
if (deadTimer !== null) {
|
|
95
|
+
clearTimeout(deadTimer)
|
|
96
|
+
deadTimer = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resetDeadTimer(): void {
|
|
101
|
+
if (deadTimer !== null) {
|
|
102
|
+
clearTimeout(deadTimer)
|
|
103
|
+
}
|
|
104
|
+
deadTimer = setTimeout(() => {
|
|
105
|
+
opts.onDead()
|
|
106
|
+
}, timeout)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function start(): void {
|
|
110
|
+
/* Idempotent — stop first to avoid stacking timers */
|
|
111
|
+
clearAll()
|
|
112
|
+
|
|
113
|
+
if (transport === "longpoll") return
|
|
114
|
+
|
|
115
|
+
if (transport === "ws") {
|
|
116
|
+
pingTimer = setInterval(() => {
|
|
117
|
+
opts.sendPing()
|
|
118
|
+
}, interval)
|
|
119
|
+
resetDeadTimer()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (transport === "sse") {
|
|
124
|
+
/* SSE: no pings, just idle timer based on frames from server */
|
|
125
|
+
resetDeadTimer()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stop(): void {
|
|
130
|
+
clearAll()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function onPong(): void {
|
|
134
|
+
/* Only meaningful for WS — reset the dead timer */
|
|
135
|
+
if (deadTimer !== null) {
|
|
136
|
+
resetDeadTimer()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function onFrame(): void {
|
|
141
|
+
/* Meaningful for SSE — reset idle timer on any frame */
|
|
142
|
+
if (transport === "sse" && deadTimer !== null) {
|
|
143
|
+
resetDeadTimer()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { onFrame, onPong, start, stop }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ------------------------------------------------------------------ */
|
|
151
|
+
/* FallbackChain */
|
|
152
|
+
/* ------------------------------------------------------------------ */
|
|
153
|
+
|
|
154
|
+
const DEFAULT_FALLBACK_TIMEOUT = 3000
|
|
155
|
+
|
|
156
|
+
export function createFallbackChain(opts: {
|
|
157
|
+
transports: TransportAdapter[]
|
|
158
|
+
timeout?: number
|
|
159
|
+
}): {
|
|
160
|
+
connect(url: string, transportOpts: TransportOpts): Promise<{ conn: TransportConnection; transport: Transport }>
|
|
161
|
+
readonly provenTransport: Transport | null
|
|
162
|
+
} {
|
|
163
|
+
const timeout = opts.timeout ?? DEFAULT_FALLBACK_TIMEOUT
|
|
164
|
+
let proven: Transport | null = null
|
|
165
|
+
|
|
166
|
+
function tryTransport(
|
|
167
|
+
adapter: TransportAdapter,
|
|
168
|
+
url: string,
|
|
169
|
+
transportOpts: TransportOpts,
|
|
170
|
+
): Promise<TransportConnection> {
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const conn = adapter.connect(url, transportOpts)
|
|
173
|
+
|
|
174
|
+
const timer = setTimeout(() => {
|
|
175
|
+
conn.close()
|
|
176
|
+
reject(new Error("timeout"))
|
|
177
|
+
}, timeout)
|
|
178
|
+
|
|
179
|
+
const originalOnFrame = conn.onFrame
|
|
180
|
+
conn.onFrame = (frame: ServerFrame) => {
|
|
181
|
+
if (frame.t === "ready") {
|
|
182
|
+
clearTimeout(timer)
|
|
183
|
+
conn.onFrame = originalOnFrame
|
|
184
|
+
resolve(conn)
|
|
185
|
+
} else {
|
|
186
|
+
originalOnFrame(frame)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
conn.onError = (err: unknown) => {
|
|
191
|
+
clearTimeout(timer)
|
|
192
|
+
conn.close()
|
|
193
|
+
reject(err)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
conn.onClose = (reason: string) => {
|
|
197
|
+
clearTimeout(timer)
|
|
198
|
+
reject(new Error(reason))
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function connect(
|
|
204
|
+
url: string,
|
|
205
|
+
transportOpts: TransportOpts,
|
|
206
|
+
): Promise<{ conn: TransportConnection; transport: Transport }> {
|
|
207
|
+
const transports = opts.transports
|
|
208
|
+
|
|
209
|
+
if (transports.length === 0) {
|
|
210
|
+
const p = Promise.reject(new RealtimeConnectError("No transports configured", "no_transports"))
|
|
211
|
+
p.catch(() => {})
|
|
212
|
+
return p
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const p = connectInternal(url, transportOpts, transports)
|
|
216
|
+
p.catch(() => {})
|
|
217
|
+
return p
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function connectInternal(
|
|
221
|
+
url: string,
|
|
222
|
+
transportOpts: TransportOpts,
|
|
223
|
+
transports: TransportAdapter[],
|
|
224
|
+
): Promise<{ conn: TransportConnection; transport: Transport }> {
|
|
225
|
+
|
|
226
|
+
/* If we have a proven transport, try it first (it's at a specific index) */
|
|
227
|
+
if (proven !== null) {
|
|
228
|
+
/* proven refers to index position — try that adapter directly */
|
|
229
|
+
const provenIndex = provenIndexStore
|
|
230
|
+
if (provenIndex !== null && provenIndex < transports.length) {
|
|
231
|
+
try {
|
|
232
|
+
const conn = await tryTransport(transports[provenIndex], url, transportOpts)
|
|
233
|
+
return { conn, transport: proven }
|
|
234
|
+
} catch {
|
|
235
|
+
/* Proven transport failed — fall through to full chain */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
for (let i = 0; i < transports.length; i++) {
|
|
241
|
+
try {
|
|
242
|
+
const conn = await tryTransport(transports[i], url, transportOpts)
|
|
243
|
+
/* Record proven transport — we use the index to map to a transport name */
|
|
244
|
+
let transportName: Transport = "longpoll"
|
|
245
|
+
if (i === 0) transportName = "ws"
|
|
246
|
+
else if (i === 1) transportName = "sse"
|
|
247
|
+
proven = transportName
|
|
248
|
+
provenIndexStore = i
|
|
249
|
+
return { conn, transport: transportName }
|
|
250
|
+
} catch {
|
|
251
|
+
/* Try next */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
throw new RealtimeConnectError("All transports failed", "all_failed")
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let provenIndexStore: number | null = null
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
connect,
|
|
262
|
+
get provenTransport() {
|
|
263
|
+
return proven
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* ------------------------------------------------------------------ */
|
|
269
|
+
/* ResumableConnection */
|
|
270
|
+
/* ------------------------------------------------------------------ */
|
|
271
|
+
|
|
272
|
+
export type ResumableConnectionOpts = {
|
|
273
|
+
url: string
|
|
274
|
+
token?: () => string | Promise<string>
|
|
275
|
+
onAuthExpired?: () => Promise<string | null>
|
|
276
|
+
onReconnecting?: (attempt: number, transport: Transport) => void
|
|
277
|
+
onReconnected?: () => void
|
|
278
|
+
signal?: AbortSignal
|
|
279
|
+
transports?: Transport[]
|
|
280
|
+
keepaliveInterval?: number
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type QueueEntry =
|
|
284
|
+
| { type: "value"; value: unknown }
|
|
285
|
+
| { type: "done" }
|
|
286
|
+
| { type: "error"; error: unknown }
|
|
287
|
+
|
|
288
|
+
export function createResumableConnection(opts: ResumableConnectionOpts): {
|
|
289
|
+
readonly state: ConnectionState
|
|
290
|
+
send(data: unknown): void
|
|
291
|
+
close(reason?: string): void
|
|
292
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<unknown>
|
|
293
|
+
} {
|
|
294
|
+
let state: ConnectionState = "idle"
|
|
295
|
+
let currentConn: TransportConnection | null = null
|
|
296
|
+
let started = false
|
|
297
|
+
|
|
298
|
+
/* Check if already aborted */
|
|
299
|
+
if (opts.signal?.aborted) {
|
|
300
|
+
state = "closed"
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* Queue-based async iterator infrastructure */
|
|
304
|
+
const queue: QueueEntry[] = []
|
|
305
|
+
let pending: {
|
|
306
|
+
resolve: (result: IteratorResult<unknown>) => void
|
|
307
|
+
reject: (err: unknown) => void
|
|
308
|
+
} | null = null
|
|
309
|
+
|
|
310
|
+
function enqueue(entry: QueueEntry): void {
|
|
311
|
+
if (pending) {
|
|
312
|
+
const { resolve, reject } = pending
|
|
313
|
+
pending = null
|
|
314
|
+
|
|
315
|
+
if (entry.type === "value") {
|
|
316
|
+
resolve({ done: false, value: entry.value })
|
|
317
|
+
} else if (entry.type === "done") {
|
|
318
|
+
resolve({ done: true, value: undefined })
|
|
319
|
+
} else {
|
|
320
|
+
reject(entry.error)
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
queue.push(entry)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function terminate(entry: QueueEntry): void {
|
|
328
|
+
enqueue(entry)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function handleClose(): void {
|
|
332
|
+
if (state === "closed") return
|
|
333
|
+
state = "closed"
|
|
334
|
+
if (currentConn) {
|
|
335
|
+
currentConn.close()
|
|
336
|
+
currentConn = null
|
|
337
|
+
}
|
|
338
|
+
terminate({ type: "done" })
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function handleAbort(): void {
|
|
342
|
+
if (state === "closed") return
|
|
343
|
+
state = "closed"
|
|
344
|
+
if (currentConn) {
|
|
345
|
+
currentConn.close()
|
|
346
|
+
currentConn = null
|
|
347
|
+
}
|
|
348
|
+
terminate({ error: new RealtimeAbortError("Connection aborted", "aborted"), type: "error" })
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* Listen for abort signal */
|
|
352
|
+
if (opts.signal && !opts.signal.aborted) {
|
|
353
|
+
opts.signal.addEventListener("abort", () => {
|
|
354
|
+
handleAbort()
|
|
355
|
+
}, { once: true })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function startConnection(): void {
|
|
359
|
+
if (started || state === "closed") return
|
|
360
|
+
started = true
|
|
361
|
+
state = "connecting"
|
|
362
|
+
|
|
363
|
+
/* Kick off connection in a microtask */
|
|
364
|
+
queueMicrotask(() => {
|
|
365
|
+
if (state === "closed") return
|
|
366
|
+
|
|
367
|
+
/* In a real implementation, this would use createFallbackChain.
|
|
368
|
+
For now, state transitions are driven by external transport frames. */
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function send(data: unknown): void {
|
|
373
|
+
if (state === "closed" || !currentConn) return
|
|
374
|
+
currentConn.send(JSON.stringify(data))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function close(_reason?: string): void {
|
|
378
|
+
handleClose()
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function next(): Promise<IteratorResult<unknown>> {
|
|
382
|
+
/* First next() call triggers connection */
|
|
383
|
+
if (!started && state !== "closed") {
|
|
384
|
+
startConnection()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* Check queue first */
|
|
388
|
+
if (queue.length > 0) {
|
|
389
|
+
const entry = queue.shift()
|
|
390
|
+
if (!entry || entry.type === "done") {
|
|
391
|
+
return Promise.resolve({ done: true, value: undefined })
|
|
392
|
+
}
|
|
393
|
+
if (entry.type === "value") {
|
|
394
|
+
return Promise.resolve({ done: false, value: entry.value })
|
|
395
|
+
}
|
|
396
|
+
return Promise.reject(entry.error)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* If already closed, return done */
|
|
400
|
+
if (state === "closed") {
|
|
401
|
+
return Promise.resolve({ done: true, value: undefined })
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* Wait for next entry — attach a no-op catch to suppress Node's
|
|
405
|
+
unhandled-rejection warning while the caller awaits the promise */
|
|
406
|
+
const promise = new Promise<IteratorResult<unknown>>((resolve, reject) => {
|
|
407
|
+
pending = { reject, resolve }
|
|
408
|
+
})
|
|
409
|
+
promise.catch(() => {})
|
|
410
|
+
return promise
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function asyncIterator(): AsyncIterableIterator<unknown> {
|
|
414
|
+
return {
|
|
415
|
+
next,
|
|
416
|
+
[Symbol.asyncIterator]() {
|
|
417
|
+
return this
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
[Symbol.asyncIterator]: asyncIterator,
|
|
424
|
+
close,
|
|
425
|
+
send,
|
|
426
|
+
get state() {
|
|
427
|
+
return state
|
|
428
|
+
},
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { MiddlewareFn } from "./middleware.ts"
|
|
2
|
+
|
|
3
|
+
type RequestIdOptions = {
|
|
4
|
+
generator?: () => string
|
|
5
|
+
header?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function requestId(
|
|
9
|
+
options?: RequestIdOptions,
|
|
10
|
+
): MiddlewareFn<{ req: Request }, { requestId: string }> {
|
|
11
|
+
const headerName = options?.header ?? "x-request-id"
|
|
12
|
+
const generate = options?.generator ?? (() => crypto.randomUUID())
|
|
13
|
+
|
|
14
|
+
const mw: MiddlewareFn<{ req: Request }, { requestId: string }> = async (ctx, next) => {
|
|
15
|
+
const existing = ctx.req.headers.get(headerName)
|
|
16
|
+
const id = existing ?? generate()
|
|
17
|
+
const response = await next({ requestId: id })
|
|
18
|
+
response.headers.set(headerName, id)
|
|
19
|
+
return response
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return mw
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type RequestToCurlOptions = {
|
|
2
|
+
excludeHeader?: (name: string, value: string) => boolean
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function shellEscape(value: string): string {
|
|
6
|
+
return value.replace(/'/g, "'\\''")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert a native Request into a curl command string.
|
|
11
|
+
* The request is cloned so reading the body does not consume the original stream.
|
|
12
|
+
*/
|
|
13
|
+
export async function requestToCurl(
|
|
14
|
+
request: Request,
|
|
15
|
+
options?: RequestToCurlOptions,
|
|
16
|
+
): Promise<string> {
|
|
17
|
+
const clonedRequest = request.clone()
|
|
18
|
+
const parts: string[] = [`curl -X ${clonedRequest.method}`]
|
|
19
|
+
|
|
20
|
+
for (const [name, value] of clonedRequest.headers.entries()) {
|
|
21
|
+
if (options?.excludeHeader?.(name, value) === true) continue
|
|
22
|
+
parts.push(`-H '${shellEscape(name)}: ${shellEscape(value)}'`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const body = await clonedRequest.text()
|
|
26
|
+
|
|
27
|
+
if (body.length > 0) {
|
|
28
|
+
parts.push(`--data-raw '${shellEscape(body)}'`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
parts.push(`'${shellEscape(clonedRequest.url)}'`)
|
|
32
|
+
|
|
33
|
+
return parts.join(" ")
|
|
34
|
+
}
|