@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,17 @@
|
|
|
1
|
+
import type { MiddlewareFn } from "./middleware.ts"
|
|
2
|
+
|
|
3
|
+
type PoweredByOptions = {
|
|
4
|
+
name?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function poweredBy(options?: PoweredByOptions): MiddlewareFn<{}, {}> {
|
|
8
|
+
const name = options?.name ?? "Honey"
|
|
9
|
+
|
|
10
|
+
const mw: MiddlewareFn<{}, {}> = async (_ctx, next) => {
|
|
11
|
+
const response = await next()
|
|
12
|
+
response.headers.set("x-powered-by", name)
|
|
13
|
+
return response
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return mw
|
|
17
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { replaceResponse } from "./honey-response.ts"
|
|
2
|
+
import type { MiddlewareFn } from "./middleware.ts"
|
|
3
|
+
|
|
4
|
+
type PrettyJsonOptions = {
|
|
5
|
+
query?: string
|
|
6
|
+
space?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function prettyJson(options?: PrettyJsonOptions): MiddlewareFn<{ req: Request }, {}> {
|
|
10
|
+
const query = options?.query ?? "pretty"
|
|
11
|
+
const space = options?.space ?? 2
|
|
12
|
+
|
|
13
|
+
const mw: MiddlewareFn<{ req: Request }, {}> = async (ctx, next) => {
|
|
14
|
+
const url = ctx.req.url
|
|
15
|
+
const qIdx = url.indexOf("?")
|
|
16
|
+
if (qIdx === -1 || !url.includes(query, qIdx)) {
|
|
17
|
+
return next()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const response = await next()
|
|
21
|
+
|
|
22
|
+
const contentType = response.headers.get("content-type")
|
|
23
|
+
if (!contentType || !contentType.includes("application/json")) {
|
|
24
|
+
return response
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const text = await response.text()
|
|
28
|
+
const parsed: unknown = JSON.parse(text)
|
|
29
|
+
const formatted = JSON.stringify(parsed, null, space)
|
|
30
|
+
|
|
31
|
+
return replaceResponse(response, {
|
|
32
|
+
body: formatted,
|
|
33
|
+
headers: response.headers,
|
|
34
|
+
status: response.status,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return mw
|
|
39
|
+
}
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { HoneyError } from "./error.ts"
|
|
2
|
+
import { EK, SK } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
const BODY_METHODS = new Set(["POST", "PUT", "PATCH"])
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Proxy configuration — controls how requests are forwarded to downstream services.
|
|
8
|
+
*
|
|
9
|
+
* `destination` is the only required field. Framework builds the RequestInit
|
|
10
|
+
* (headers, body, signal, redirect) and passes it along with the resolved URL.
|
|
11
|
+
*/
|
|
12
|
+
export type ProxyConfig<TCtx> = {
|
|
13
|
+
/**
|
|
14
|
+
* Where to send the request. Framework doesn't care how — CF service binding,
|
|
15
|
+
* URL fetch, Durable Object stub, anything that returns a Response.
|
|
16
|
+
*
|
|
17
|
+
* @param ctx - honey context (middleware additions available)
|
|
18
|
+
* @param url - path + query string, after rewriteUrl if provided
|
|
19
|
+
* @param init - framework-prepared RequestInit (method, headers, body, signal, redirect)
|
|
20
|
+
*/
|
|
21
|
+
destination: (ctx: TCtx, url: string, init: RequestInit) => Response | Promise<Response>
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Hook after downstream responds, before returning to client.
|
|
25
|
+
* Return void to passthrough, or return a new Response to replace.
|
|
26
|
+
* Can mutate response.headers in place (zero allocation).
|
|
27
|
+
* NOT called for 101 WebSocket upgrades (opaque response).
|
|
28
|
+
*/
|
|
29
|
+
onResponse?: (ctx: TCtx, response: Response) => void | Response | Promise<void | Response>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Set request headers before forwarding. Called after hop-by-hop headers are stripped.
|
|
33
|
+
* Static record: entries are set on the headers object.
|
|
34
|
+
* Function: mutate headers in place.
|
|
35
|
+
*/
|
|
36
|
+
requestHeaders?: Record<string, string> | ((ctx: TCtx, headers: Headers) => void)
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Rewrite URL before passing to destination.
|
|
40
|
+
* Receives path + query string, returns transformed URL.
|
|
41
|
+
*/
|
|
42
|
+
rewriteUrl?: (url: string, ctx: TCtx) => string
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Timeout in milliseconds. Default: 30000.
|
|
46
|
+
* Accepts a number or a function that receives ctx and returns a number.
|
|
47
|
+
* Disabled for WebSocket upgrades.
|
|
48
|
+
*/
|
|
49
|
+
timeout?: number | ((ctx: TCtx) => number)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates a proxy handler function from config.
|
|
54
|
+
* Used internally by RouteBuilder.proxy() — not meant for direct consumption.
|
|
55
|
+
*
|
|
56
|
+
* TCtx is unconstrained because HandlerCtx uses Omit which breaks structural
|
|
57
|
+
* compatibility with { path, req }. At runtime ctx is always HoneyContext
|
|
58
|
+
* which has both fields — we access them via property access on the object.
|
|
59
|
+
*/
|
|
60
|
+
export function createProxyHandler<TCtx>(
|
|
61
|
+
config: ProxyConfig<TCtx>,
|
|
62
|
+
): (ctx: TCtx) => Promise<Response> {
|
|
63
|
+
const timeoutOpt = config.timeout ?? 30_000
|
|
64
|
+
|
|
65
|
+
return async (ctx: TCtx) => {
|
|
66
|
+
const timeoutMs = typeof timeoutOpt === "function" ? timeoutOpt(ctx) : timeoutOpt
|
|
67
|
+
/* HoneyContext always has path + req — safe at runtime */
|
|
68
|
+
const c = ctx as Record<string, unknown>
|
|
69
|
+
const request = c["req"] as Request
|
|
70
|
+
const method = request.method
|
|
71
|
+
const isWs = request.headers.get("upgrade") === "websocket"
|
|
72
|
+
|
|
73
|
+
/* URL resolution — path + query, no new URL() */
|
|
74
|
+
const rawUrl = request.url
|
|
75
|
+
const protoEnd = rawUrl.indexOf("//")
|
|
76
|
+
const pathStart = protoEnd === -1 ? 0 : rawUrl.indexOf("/", protoEnd + 2)
|
|
77
|
+
const qIdx = pathStart === -1 ? -1 : rawUrl.indexOf("?", pathStart)
|
|
78
|
+
const pathOnly = c["path"] as string
|
|
79
|
+
const rawPathQuery = qIdx === -1 ? pathOnly : pathOnly + rawUrl.substring(qIdx)
|
|
80
|
+
const url = config.rewriteUrl ? config.rewriteUrl(rawPathQuery, ctx) : rawPathQuery
|
|
81
|
+
|
|
82
|
+
/* headers — one copy (original is immutable), strip hop-by-hop */
|
|
83
|
+
const headers = new Headers(request.headers)
|
|
84
|
+
headers.delete("connection")
|
|
85
|
+
headers.delete("keep-alive")
|
|
86
|
+
headers.delete("transfer-encoding")
|
|
87
|
+
headers.delete("te")
|
|
88
|
+
headers.delete("trailer")
|
|
89
|
+
headers.delete("proxy-authenticate")
|
|
90
|
+
headers.delete("proxy-authorization")
|
|
91
|
+
|
|
92
|
+
if (isWs) {
|
|
93
|
+
headers.set("connection", "upgrade")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (config.requestHeaders) {
|
|
97
|
+
if (typeof config.requestHeaders === "function") {
|
|
98
|
+
config.requestHeaders(ctx, headers)
|
|
99
|
+
} else {
|
|
100
|
+
for (const [k, v] of Object.entries(config.requestHeaders)) {
|
|
101
|
+
headers.set(k, v)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* build init — plain object, no new Request() */
|
|
107
|
+
const hasBody = BODY_METHODS.has(method)
|
|
108
|
+
const init: RequestInit = {
|
|
109
|
+
body: hasBody ? request.body : undefined,
|
|
110
|
+
headers,
|
|
111
|
+
method,
|
|
112
|
+
redirect: "manual",
|
|
113
|
+
signal: isWs ? undefined : AbortSignal.timeout(timeoutMs),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* duplex required for streaming body (Node needs it, CF handles implicitly) */
|
|
117
|
+
if (hasBody && request.body) {
|
|
118
|
+
;(init as Record<string, unknown>)["duplex"] = "half"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* forward */
|
|
122
|
+
let response: Response
|
|
123
|
+
try {
|
|
124
|
+
response = await config.destination(ctx, url, init)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof DOMException && error.name === "TimeoutError") {
|
|
127
|
+
throw new HoneyError({
|
|
128
|
+
errorKey: EK.gateway_timeout,
|
|
129
|
+
status: SK.gateway_timeout,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
if (error instanceof TypeError) {
|
|
133
|
+
throw new HoneyError({
|
|
134
|
+
errorKey: EK.bad_gateway,
|
|
135
|
+
status: SK.bad_gateway,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* WS 101 — return immediately, opaque */
|
|
142
|
+
if (response.status === 101) {
|
|
143
|
+
return response
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* response hook */
|
|
147
|
+
if (config.onResponse) {
|
|
148
|
+
const replaced = await config.onResponse(ctx, response)
|
|
149
|
+
if (replaced) return replaced
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* stream through — zero wrapping */
|
|
153
|
+
return response
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { schemaToIR } from "./codegen-ir.ts"
|
|
2
|
+
import type { IRField, IRSchema } from "./codegen-ir.ts"
|
|
3
|
+
|
|
4
|
+
const PY_RESERVED = new Set([
|
|
5
|
+
"False", "None", "True", "and", "as", "assert", "async", "await",
|
|
6
|
+
"break", "class", "continue", "def", "del", "elif", "else", "except",
|
|
7
|
+
"finally", "for", "from", "global", "if", "import", "in", "is",
|
|
8
|
+
"lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try",
|
|
9
|
+
"while", "with", "yield",
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
/** Suffix reserved words with `_` per PEP 8 convention. */
|
|
13
|
+
export function pyIdent(name: string): string {
|
|
14
|
+
if (PY_RESERVED.has(name)) return `${name}_`
|
|
15
|
+
return name.replace(/[^a-zA-Z0-9_]/g, "_")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function primitiveToPy(t: string): string {
|
|
19
|
+
if (t === "string") return "str"
|
|
20
|
+
if (t === "integer") return "int"
|
|
21
|
+
if (t === "number") return "float"
|
|
22
|
+
if (t === "boolean") return "bool"
|
|
23
|
+
if (t === "null") return "None"
|
|
24
|
+
return "Any"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatConstPy(v: string | number | boolean): string {
|
|
28
|
+
if (typeof v === "string") return JSON.stringify(v)
|
|
29
|
+
if (typeof v === "boolean") return v ? "True" : "False"
|
|
30
|
+
return String(v)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function emitScalarPy(ir: Extract<IRSchema, { kind: "scalar" }>): string {
|
|
34
|
+
if (ir.enum) {
|
|
35
|
+
const members = ir.enum
|
|
36
|
+
.map((v) => (typeof v === "string" ? JSON.stringify(v) : String(v)))
|
|
37
|
+
.join(", ")
|
|
38
|
+
return `Literal[${members}]`
|
|
39
|
+
}
|
|
40
|
+
return primitiveToPy(ir.type)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emitObjectPy(ir: Extract<IRSchema, { kind: "object" }>, depth: number): string {
|
|
44
|
+
const { fields, additional } = ir
|
|
45
|
+
|
|
46
|
+
if (fields.length === 0) {
|
|
47
|
+
if (additional) {
|
|
48
|
+
return `dict[str, ${irToPython(additional, depth + 1)}]`
|
|
49
|
+
}
|
|
50
|
+
return "dict[str, Any]"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sortedFields = fields.slice().sort((a, b) => a.name.localeCompare(b.name))
|
|
54
|
+
const entries = sortedFields.map((f) => {
|
|
55
|
+
const pyType = irToPython(f.schema, depth + 1)
|
|
56
|
+
const annotation = f.required ? pyType : `NotRequired[${pyType}]`
|
|
57
|
+
return `${JSON.stringify(f.name)}: ${annotation}`
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return `TypedDict("X", {${entries.join(", ")}})`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Convert an IRSchema to a Python type string.
|
|
65
|
+
* depth > 8 returns `Any` (mirrors TS `unknown` fallback).
|
|
66
|
+
*/
|
|
67
|
+
export function irToPython(ir: IRSchema, depth = 0): string {
|
|
68
|
+
if (depth > 8) return "Any"
|
|
69
|
+
|
|
70
|
+
switch (ir.kind) {
|
|
71
|
+
case "scalar":
|
|
72
|
+
return emitScalarPy(ir)
|
|
73
|
+
|
|
74
|
+
case "const":
|
|
75
|
+
return `Literal[${formatConstPy(ir.value)}]`
|
|
76
|
+
|
|
77
|
+
case "object":
|
|
78
|
+
return emitObjectPy(ir, depth)
|
|
79
|
+
|
|
80
|
+
case "array":
|
|
81
|
+
return `list[${irToPython(ir.items, depth + 1)}]`
|
|
82
|
+
|
|
83
|
+
case "tuple":
|
|
84
|
+
return `tuple[${ir.items.map((i) => irToPython(i, depth + 1)).join(", ")}]`
|
|
85
|
+
|
|
86
|
+
case "union": {
|
|
87
|
+
/* union-of-const → single Literal[...] to match mixed enum behavior */
|
|
88
|
+
if (ir.variants.length > 0 && ir.variants.every((v) => v.kind === "const")) {
|
|
89
|
+
const members = ir.variants
|
|
90
|
+
.map((v) => formatConstPy((v as Extract<IRSchema, { kind: "const" }>).value))
|
|
91
|
+
.join(", ")
|
|
92
|
+
return `Literal[${members}]`
|
|
93
|
+
}
|
|
94
|
+
return ir.variants.map((v) => irToPython(v, depth + 1)).join(" | ")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
case "allOf": {
|
|
98
|
+
const allObjects = ir.parts.every((p) => p.kind === "object")
|
|
99
|
+
if (!allObjects) return "Any"
|
|
100
|
+
/* later parts overwrite earlier (mirrors Object.assign semantics in old jsonSchemaToPy) */
|
|
101
|
+
const mergedMap = new Map<string, IRField>()
|
|
102
|
+
const requiredSet = new Set<string>()
|
|
103
|
+
for (const part of ir.parts) {
|
|
104
|
+
const obj = part as Extract<IRSchema, { kind: "object" }>
|
|
105
|
+
for (const f of obj.fields) {
|
|
106
|
+
mergedMap.set(f.name, f)
|
|
107
|
+
if (f.required) requiredSet.add(f.name)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const mergedFields: IRField[] = Array.from(mergedMap.values()).map((f) => {
|
|
111
|
+
const mf: IRField = Object.assign({}, f)
|
|
112
|
+
mf.required = requiredSet.has(f.name)
|
|
113
|
+
return mf
|
|
114
|
+
})
|
|
115
|
+
return emitObjectPy({ fields: mergedFields, kind: "object" }, depth)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case "ref":
|
|
119
|
+
return ir.name
|
|
120
|
+
|
|
121
|
+
case "nullable":
|
|
122
|
+
return `${irToPython(ir.inner, depth + 1)} | None`
|
|
123
|
+
|
|
124
|
+
/*
|
|
125
|
+
* binary kind arises from {type:"string", format:"binary"}.
|
|
126
|
+
* dominant case matches "str"; bare {format:"binary"} with no type is
|
|
127
|
+
* a deliberate 1-case divergence from old jsonSchemaToPy (see spec §binary).
|
|
128
|
+
*/
|
|
129
|
+
case "binary":
|
|
130
|
+
return "str"
|
|
131
|
+
|
|
132
|
+
case "unknown":
|
|
133
|
+
return "Any"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Thin shim — delegates to irToPython(schemaToIR(schema), depth). */
|
|
138
|
+
export function jsonSchemaToPy(
|
|
139
|
+
schema: Record<string, unknown> | undefined,
|
|
140
|
+
depth = 0,
|
|
141
|
+
): string {
|
|
142
|
+
if (!schema || depth > 8) return "Any"
|
|
143
|
+
return irToPython(schemaToIR(schema), depth)
|
|
144
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const DEFAULT_SIZE = 100
|
|
2
|
+
const DEFAULT_TTL = 300_000
|
|
3
|
+
|
|
4
|
+
type BufferEntry = { id: number; data: unknown }
|
|
5
|
+
|
|
6
|
+
type ConnectionBuffer = {
|
|
7
|
+
entries: BufferEntry[]
|
|
8
|
+
head: number
|
|
9
|
+
count: number
|
|
10
|
+
nextId: number
|
|
11
|
+
capacity: number
|
|
12
|
+
ttlTimer: ReturnType<typeof setTimeout> | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ReconnectBuffer {
|
|
16
|
+
private readonly size: number
|
|
17
|
+
private readonly ttl: number
|
|
18
|
+
private readonly connections = new Map<string, ConnectionBuffer>()
|
|
19
|
+
|
|
20
|
+
constructor(opts?: { size?: number; ttl?: number }) {
|
|
21
|
+
const size = opts?.size ?? DEFAULT_SIZE
|
|
22
|
+
if (size <= 0) {
|
|
23
|
+
throw new Error("ReconnectBuffer size must be greater than 0")
|
|
24
|
+
}
|
|
25
|
+
this.size = size
|
|
26
|
+
this.ttl = opts?.ttl ?? DEFAULT_TTL
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
create(existingToken?: string): string {
|
|
30
|
+
const token = existingToken ?? crypto.randomUUID()
|
|
31
|
+
this.connections.set(token, {
|
|
32
|
+
capacity: this.size,
|
|
33
|
+
count: 0,
|
|
34
|
+
entries: Array.from<BufferEntry>({ length: this.size }),
|
|
35
|
+
head: 0,
|
|
36
|
+
nextId: 1,
|
|
37
|
+
ttlTimer: null,
|
|
38
|
+
})
|
|
39
|
+
return token
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
push(token: string, data: unknown): number {
|
|
43
|
+
const conn = this.connections.get(token)
|
|
44
|
+
if (!conn) {
|
|
45
|
+
throw new Error(`Unknown reconnect token: ${token}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Cancel any pending TTL timer on reconnect */
|
|
49
|
+
if (conn.ttlTimer !== null) {
|
|
50
|
+
clearTimeout(conn.ttlTimer)
|
|
51
|
+
conn.ttlTimer = null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const id = conn.nextId++
|
|
55
|
+
const index = (conn.head + conn.count) % conn.capacity
|
|
56
|
+
|
|
57
|
+
if (conn.count < conn.capacity) {
|
|
58
|
+
conn.entries[index] = { data, id }
|
|
59
|
+
conn.count++
|
|
60
|
+
} else {
|
|
61
|
+
/* Ring is full — overwrite oldest at head, advance head */
|
|
62
|
+
conn.entries[conn.head] = { data, id }
|
|
63
|
+
conn.head = (conn.head + 1) % conn.capacity
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return id
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
replay(token: string, lastId: number): Array<{ id: number; data: unknown }> | null {
|
|
70
|
+
const conn = this.connections.get(token)
|
|
71
|
+
if (!conn) return null
|
|
72
|
+
|
|
73
|
+
const result: Array<{ id: number; data: unknown }> = []
|
|
74
|
+
for (let i = 0; i < conn.count; i++) {
|
|
75
|
+
const entry = conn.entries[(conn.head + i) % conn.capacity]
|
|
76
|
+
if (entry.id > lastId) {
|
|
77
|
+
result.push(entry)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
disconnect(token: string): void {
|
|
84
|
+
const conn = this.connections.get(token)
|
|
85
|
+
if (!conn) return
|
|
86
|
+
|
|
87
|
+
conn.ttlTimer = setTimeout(() => {
|
|
88
|
+
this.connections.delete(token)
|
|
89
|
+
}, this.ttl)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
destroy(token: string): void {
|
|
93
|
+
const conn = this.connections.get(token)
|
|
94
|
+
if (!conn) return
|
|
95
|
+
|
|
96
|
+
if (conn.ttlTimer !== null) {
|
|
97
|
+
clearTimeout(conn.ttlTimer)
|
|
98
|
+
}
|
|
99
|
+
this.connections.delete(token)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface RealtimeBus {
|
|
2
|
+
subscribe(connId: string, topic: string): void
|
|
3
|
+
unsubscribe(connId: string, topic: string): void
|
|
4
|
+
unsubscribeAll(connId: string): void
|
|
5
|
+
publish(topic: string, data: unknown): void
|
|
6
|
+
onMessage(connId: string, handler: (data: unknown) => void): void
|
|
7
|
+
removeHandler(connId: string): void
|
|
8
|
+
presence(topic: string): string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createBus(): RealtimeBus {
|
|
12
|
+
/* topic -> set of connIds subscribed to it */
|
|
13
|
+
const topicSubs = new Map<string, Set<string>>()
|
|
14
|
+
/* connId -> set of topics it is subscribed to (reverse lookup for unsubscribeAll) */
|
|
15
|
+
const connTopics = new Map<string, Set<string>>()
|
|
16
|
+
/* connId -> message handler */
|
|
17
|
+
const handlers = new Map<string, (data: unknown) => void>()
|
|
18
|
+
|
|
19
|
+
function subscribe(connId: string, topic: string): void {
|
|
20
|
+
let subs = topicSubs.get(topic)
|
|
21
|
+
if (!subs) {
|
|
22
|
+
subs = new Set()
|
|
23
|
+
topicSubs.set(topic, subs)
|
|
24
|
+
}
|
|
25
|
+
subs.add(connId)
|
|
26
|
+
|
|
27
|
+
let topics = connTopics.get(connId)
|
|
28
|
+
if (!topics) {
|
|
29
|
+
topics = new Set()
|
|
30
|
+
connTopics.set(connId, topics)
|
|
31
|
+
}
|
|
32
|
+
topics.add(topic)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function unsubscribe(connId: string, topic: string): void {
|
|
36
|
+
const subs = topicSubs.get(topic)
|
|
37
|
+
if (subs) {
|
|
38
|
+
subs.delete(connId)
|
|
39
|
+
if (subs.size === 0) topicSubs.delete(topic)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const topics = connTopics.get(connId)
|
|
43
|
+
if (topics) {
|
|
44
|
+
topics.delete(topic)
|
|
45
|
+
if (topics.size === 0) connTopics.delete(connId)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function unsubscribeAll(connId: string): void {
|
|
50
|
+
const topics = connTopics.get(connId)
|
|
51
|
+
if (!topics) return
|
|
52
|
+
|
|
53
|
+
for (const topic of topics) {
|
|
54
|
+
const subs = topicSubs.get(topic)
|
|
55
|
+
if (subs) {
|
|
56
|
+
subs.delete(connId)
|
|
57
|
+
if (subs.size === 0) topicSubs.delete(topic)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
connTopics.delete(connId)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function publish(topic: string, data: unknown): void {
|
|
64
|
+
const subs = topicSubs.get(topic)
|
|
65
|
+
if (!subs) return
|
|
66
|
+
|
|
67
|
+
for (const connId of subs) {
|
|
68
|
+
const handler = handlers.get(connId)
|
|
69
|
+
if (!handler) continue
|
|
70
|
+
try {
|
|
71
|
+
handler(data)
|
|
72
|
+
} catch {
|
|
73
|
+
/* swallow — error isolation: one broken handler must not block others */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onMessage(connId: string, handler: (data: unknown) => void): void {
|
|
79
|
+
handlers.set(connId, handler)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function removeHandler(connId: string): void {
|
|
83
|
+
handlers.delete(connId)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function presence(topic: string): string[] {
|
|
87
|
+
const subs = topicSubs.get(topic)
|
|
88
|
+
if (!subs) return []
|
|
89
|
+
return [...subs]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { onMessage, presence, publish, removeHandler, subscribe, unsubscribe, unsubscribeAll }
|
|
93
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { ServerFrame } from "./protocol.ts"
|
|
2
|
+
|
|
3
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface LongPollBus {
|
|
8
|
+
subscribe(token: string, cb: (frame: ServerFrame) => void): () => void
|
|
9
|
+
deliverMessage(token: string, payload: unknown): boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface LongPollBuffer {
|
|
13
|
+
replay(token: string, lastId: number): Array<{ id: number; data: unknown }> | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type LongPollHandlerOpts = {
|
|
17
|
+
bus: LongPollBus
|
|
18
|
+
buffer: LongPollBuffer
|
|
19
|
+
defaultWait?: number
|
|
20
|
+
maxWait?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_WAIT_S = 25
|
|
24
|
+
const MAX_WAIT_S = 30
|
|
25
|
+
|
|
26
|
+
const JSON_HEADERS = { "Content-Type": "application/json" }
|
|
27
|
+
|
|
28
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
29
|
+
return new Response(JSON.stringify(body), { headers: JSON_HEADERS, status })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseWait(raw: string | null, defaultWait: number, maxWait: number): number {
|
|
33
|
+
if (raw === null) return defaultWait
|
|
34
|
+
|
|
35
|
+
const parsed = Number(raw)
|
|
36
|
+
if (!Number.isFinite(parsed)) return defaultWait
|
|
37
|
+
if (parsed < 0) return 0
|
|
38
|
+
return Math.min(parsed, maxWait)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createLongPollHandler(opts: LongPollHandlerOpts): {
|
|
42
|
+
poll(req: Request): Promise<Response>
|
|
43
|
+
send(req: Request): Promise<Response>
|
|
44
|
+
} {
|
|
45
|
+
const defaultWait = opts.defaultWait ?? DEFAULT_WAIT_S
|
|
46
|
+
const maxWait = opts.maxWait ?? MAX_WAIT_S
|
|
47
|
+
|
|
48
|
+
function poll(req: Request): Promise<Response> {
|
|
49
|
+
const url = new URL(req.url)
|
|
50
|
+
const token = url.searchParams.get("reconnectToken")
|
|
51
|
+
|
|
52
|
+
if (!token) {
|
|
53
|
+
return Promise.resolve(jsonResponse({ error: "missing reconnectToken" }, 400))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lastId = Number(url.searchParams.get("lastId") ?? "0") || 0
|
|
57
|
+
const wait = parseWait(url.searchParams.get("wait"), defaultWait, maxWait)
|
|
58
|
+
|
|
59
|
+
const entries = opts.buffer.replay(token, lastId)
|
|
60
|
+
|
|
61
|
+
if (entries === null) {
|
|
62
|
+
return Promise.resolve(jsonResponse([{ reason: "server_restart", t: "bye" }], 404))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (entries.length > 0) {
|
|
66
|
+
const frames: ServerFrame[] = entries.map((e) => ({
|
|
67
|
+
data: e.data,
|
|
68
|
+
id: e.id,
|
|
69
|
+
t: "msg" as const,
|
|
70
|
+
}))
|
|
71
|
+
return Promise.resolve(jsonResponse(frames))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (wait === 0) {
|
|
75
|
+
return Promise.resolve(jsonResponse([]))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return new Promise<Response>((resolve) => {
|
|
79
|
+
let settled = false
|
|
80
|
+
|
|
81
|
+
function settle(body: ServerFrame[]): void {
|
|
82
|
+
if (settled) return
|
|
83
|
+
settled = true
|
|
84
|
+
cleanup()
|
|
85
|
+
resolve(jsonResponse(body))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const unsubscribe = opts.bus.subscribe(token, (frame) => {
|
|
89
|
+
settle([frame])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
settle([])
|
|
94
|
+
}, wait * 1000)
|
|
95
|
+
|
|
96
|
+
function cleanup(): void {
|
|
97
|
+
unsubscribe()
|
|
98
|
+
clearTimeout(timer)
|
|
99
|
+
req.signal.removeEventListener("abort", onAbort)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function onAbort(): void {
|
|
103
|
+
settle([])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (req.signal.aborted) {
|
|
107
|
+
settle([])
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
req.signal.addEventListener("abort", onAbort)
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function send(req: Request): Promise<Response> {
|
|
115
|
+
let parsed: unknown
|
|
116
|
+
try {
|
|
117
|
+
const text = await req.text()
|
|
118
|
+
if (!text) return jsonResponse({ error: "empty body" }, 400)
|
|
119
|
+
parsed = JSON.parse(text)
|
|
120
|
+
} catch {
|
|
121
|
+
return jsonResponse({ error: "invalid JSON" }, 400)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!isRecord(parsed)) {
|
|
125
|
+
return jsonResponse({ error: "invalid body" }, 400)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const body = parsed
|
|
129
|
+
|
|
130
|
+
const token = body.reconnectToken
|
|
131
|
+
if (typeof token !== "string") {
|
|
132
|
+
return jsonResponse({ error: "missing reconnectToken" }, 400)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!("data" in body)) {
|
|
136
|
+
return jsonResponse({ error: "missing data" }, 400)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const delivered = opts.bus.deliverMessage(token, body.data)
|
|
140
|
+
if (!delivered) {
|
|
141
|
+
return jsonResponse({ error: "unknown token" }, 404)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return jsonResponse({ ok: true })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { poll, send }
|
|
148
|
+
}
|