@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,354 @@
|
|
|
1
|
+
/** NodeRequest sets this so HoneyRes can skip `new Response()` on Node. */
|
|
2
|
+
export const NODE_OUTBOUND = Symbol.for("honey.nodeOut")
|
|
3
|
+
|
|
4
|
+
const HONEY_RESPONSE = Symbol.for("honey.response")
|
|
5
|
+
|
|
6
|
+
export function isNodeOutbound(req: Request): boolean {
|
|
7
|
+
return (req as unknown as Record<symbol, unknown>)[NODE_OUTBOUND] === true
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isHoneyResponse(value: unknown): value is HoneyResponse {
|
|
11
|
+
return typeof value === "object" && value !== null && HONEY_RESPONSE in value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Headers view over a plain map. `get`/`has` stay on the record.
|
|
16
|
+
* A real `Headers` is built only if something iterates or mutates.
|
|
17
|
+
*/
|
|
18
|
+
export class HoneyOutHeaders {
|
|
19
|
+
#map: Record<string, string | string[]>
|
|
20
|
+
#native: Headers | null = null
|
|
21
|
+
|
|
22
|
+
constructor(map: Record<string, string | string[]>) {
|
|
23
|
+
this.#map = map
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
append(name: string, value: string): void {
|
|
27
|
+
this.#ensureNative().append(name, value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
delete(name: string): void {
|
|
31
|
+
this.#ensureNative().delete(name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(name: string): string | null {
|
|
35
|
+
if (this.#native) return this.#native.get(name)
|
|
36
|
+
const raw = this.#map[name.toLowerCase()]
|
|
37
|
+
if (raw === undefined) return null
|
|
38
|
+
return Array.isArray(raw) ? raw.join(", ") : raw
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getSetCookie(): string[] {
|
|
42
|
+
if (this.#native) return this.#native.getSetCookie()
|
|
43
|
+
const raw = this.#map["set-cookie"]
|
|
44
|
+
if (raw === undefined) return []
|
|
45
|
+
return Array.isArray(raw) ? raw : [raw]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
has(name: string): boolean {
|
|
49
|
+
if (this.#native) return this.#native.has(name)
|
|
50
|
+
return this.#map[name.toLowerCase()] !== undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
set(name: string, value: string): void {
|
|
54
|
+
this.#ensureNative().set(name, value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
forEach(callback: (value: string, key: string, parent: Headers) => void, thisArg?: unknown): void {
|
|
58
|
+
this.#ensureNative().forEach(callback, thisArg)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
keys(): IterableIterator<string> {
|
|
62
|
+
return this.#ensureNative().keys()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
values(): IterableIterator<string> {
|
|
66
|
+
return this.#ensureNative().values()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
entries(): IterableIterator<[string, string]> {
|
|
70
|
+
return this.#ensureNative().entries()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
[Symbol.iterator](): IterableIterator<[string, string]> {
|
|
74
|
+
if (this.#native) return this.#native[Symbol.iterator]()
|
|
75
|
+
return iterateRecord(this.#map)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get [Symbol.toStringTag](): string {
|
|
79
|
+
return "Headers"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
toRecord(): Record<string, string | string[]> {
|
|
83
|
+
if (!this.#native) return this.#map
|
|
84
|
+
const out: Record<string, string | string[]> = {}
|
|
85
|
+
this.#native.forEach((value, key) => {
|
|
86
|
+
if (key === "set-cookie") return
|
|
87
|
+
out[key] = value
|
|
88
|
+
})
|
|
89
|
+
const cookies = this.#native.getSetCookie()
|
|
90
|
+
if (cookies.length > 0) out["set-cookie"] = cookies
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#ensureNative(): Headers {
|
|
95
|
+
if (this.#native) return this.#native
|
|
96
|
+
const headers = new Headers()
|
|
97
|
+
for (const [name, value] of iterateRecord(this.#map)) {
|
|
98
|
+
headers.append(name, value)
|
|
99
|
+
}
|
|
100
|
+
this.#native = headers
|
|
101
|
+
return headers
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function* iterateRecord(map: Record<string, string | string[]>): IterableIterator<[string, string]> {
|
|
106
|
+
for (const key in map) {
|
|
107
|
+
const value = map[key]
|
|
108
|
+
if (Array.isArray(value)) {
|
|
109
|
+
for (const item of value) yield [key, item]
|
|
110
|
+
} else if (value !== undefined) {
|
|
111
|
+
yield [key, value]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Response-shaped bag for Node. Avoids `new Response()` until something
|
|
118
|
+
* needs a real Fetch body stream (cors/etag wrap, clone).
|
|
119
|
+
*/
|
|
120
|
+
export class HoneyResponse {
|
|
121
|
+
readonly [HONEY_RESPONSE] = true
|
|
122
|
+
readonly ok: boolean
|
|
123
|
+
readonly redirected = false
|
|
124
|
+
readonly status: number
|
|
125
|
+
readonly statusText: string
|
|
126
|
+
readonly type = "default" as ResponseType
|
|
127
|
+
readonly url = ""
|
|
128
|
+
|
|
129
|
+
#headerMap: Record<string, string | string[]>
|
|
130
|
+
#headers: HoneyOutHeaders | null = null
|
|
131
|
+
#raw: string | Uint8Array | null
|
|
132
|
+
#stream: ReadableStream<Uint8Array> | null | undefined
|
|
133
|
+
#bodyUsed = false
|
|
134
|
+
|
|
135
|
+
constructor(init: {
|
|
136
|
+
headers: Record<string, string | string[]>
|
|
137
|
+
raw?: string | Uint8Array | null
|
|
138
|
+
status: number
|
|
139
|
+
statusText?: string
|
|
140
|
+
stream?: ReadableStream<Uint8Array> | null
|
|
141
|
+
}) {
|
|
142
|
+
this.status = init.status
|
|
143
|
+
this.statusText = init.statusText ?? ""
|
|
144
|
+
this.ok = init.status >= 200 && init.status < 300
|
|
145
|
+
this.#headerMap = init.headers
|
|
146
|
+
this.#raw = init.raw === undefined ? null : init.raw
|
|
147
|
+
this.#stream = init.stream
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get headers(): HoneyOutHeaders {
|
|
151
|
+
if (!this.#headers) this.#headers = new HoneyOutHeaders(this.#headerMap)
|
|
152
|
+
return this.#headers
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
get body(): ReadableStream<Uint8Array> | null {
|
|
156
|
+
if (this.#stream !== undefined) return this.#stream
|
|
157
|
+
if (this.#raw === null) {
|
|
158
|
+
this.#stream = null
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
const raw = this.#raw
|
|
162
|
+
this.#stream = new ReadableStream({
|
|
163
|
+
start(controller) {
|
|
164
|
+
controller.enqueue(typeof raw === "string" ? new TextEncoder().encode(raw) : raw)
|
|
165
|
+
controller.close()
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
return this.#stream
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get bodyUsed(): boolean {
|
|
172
|
+
return this.#bodyUsed
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Plain header map for `res.writeHead`. */
|
|
176
|
+
get plainHeaders(): Record<string, string | string[]> {
|
|
177
|
+
return this.#headers ? this.#headers.toRecord() : this.#headerMap
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Known-size body for `res.end`. */
|
|
181
|
+
get rawBody(): string | Uint8Array | null {
|
|
182
|
+
return this.#raw
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
186
|
+
this.#bodyUsed = true
|
|
187
|
+
if (this.#raw === null) {
|
|
188
|
+
if (this.#stream) return streamToArrayBuffer(this.#stream)
|
|
189
|
+
return new ArrayBuffer(0)
|
|
190
|
+
}
|
|
191
|
+
if (typeof this.#raw === "string") {
|
|
192
|
+
const bytes = new TextEncoder().encode(this.#raw)
|
|
193
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
|
194
|
+
}
|
|
195
|
+
return this.#raw.buffer.slice(this.#raw.byteOffset, this.#raw.byteOffset + this.#raw.byteLength) as ArrayBuffer
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async blob(): Promise<Blob> {
|
|
199
|
+
const buf = await this.arrayBuffer()
|
|
200
|
+
return new Blob([buf], { type: this.headers.get("content-type") ?? "" })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async bytes(): Promise<Uint8Array> {
|
|
204
|
+
const buf = await this.arrayBuffer()
|
|
205
|
+
return new Uint8Array(buf)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
clone(): Response {
|
|
209
|
+
return new HoneyResponse({
|
|
210
|
+
headers: { ...this.plainHeaders },
|
|
211
|
+
raw: this.#raw,
|
|
212
|
+
status: this.status,
|
|
213
|
+
statusText: this.statusText,
|
|
214
|
+
stream: this.#stream === undefined || this.#stream === null ? this.#stream : this.#stream.tee()[1],
|
|
215
|
+
}) as unknown as Response
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async formData(): Promise<FormData> {
|
|
219
|
+
return new Response(this.body, {
|
|
220
|
+
headers: this.plainHeaders as HeadersInit,
|
|
221
|
+
status: this.status,
|
|
222
|
+
}).formData()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async json(): Promise<unknown> {
|
|
226
|
+
const text = await this.text()
|
|
227
|
+
return text.length === 0 ? null : JSON.parse(text)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async text(): Promise<string> {
|
|
231
|
+
this.#bodyUsed = true
|
|
232
|
+
if (this.#raw === null) {
|
|
233
|
+
if (this.#stream) return new TextDecoder().decode(await streamToArrayBuffer(this.#stream))
|
|
234
|
+
return ""
|
|
235
|
+
}
|
|
236
|
+
return typeof this.#raw === "string" ? this.#raw : new TextDecoder().decode(this.#raw)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Copy this bag with new headers/status/body — keeps the Node `res.end` path. */
|
|
240
|
+
derive(init: {
|
|
241
|
+
headers?: Record<string, string | string[]> | Headers | HoneyOutHeaders
|
|
242
|
+
raw?: string | Uint8Array | null
|
|
243
|
+
status?: number
|
|
244
|
+
stream?: ReadableStream<Uint8Array> | null
|
|
245
|
+
}): HoneyResponse {
|
|
246
|
+
return new HoneyResponse({
|
|
247
|
+
headers: init.headers === undefined ? { ...this.plainHeaders } : headersInitToRecord(init.headers),
|
|
248
|
+
raw: init.raw === undefined ? this.#raw : init.raw,
|
|
249
|
+
status: init.status ?? this.status,
|
|
250
|
+
statusText: this.statusText,
|
|
251
|
+
stream: init.stream === undefined ? this.#stream : init.stream,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function createHoneyResponse(init: {
|
|
257
|
+
headers: Record<string, string | string[]>
|
|
258
|
+
raw?: string | Uint8Array | null
|
|
259
|
+
status: number
|
|
260
|
+
stream?: ReadableStream<Uint8Array> | null
|
|
261
|
+
}): Response {
|
|
262
|
+
return new HoneyResponse(init) as unknown as Response
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Rebuild a response without dropping a HoneyResponse back to `new Response()`. */
|
|
266
|
+
export function replaceResponse(
|
|
267
|
+
response: Response,
|
|
268
|
+
init: {
|
|
269
|
+
body?: BodyInit | null
|
|
270
|
+
headers?: Headers | HoneyOutHeaders | Record<string, string | string[]>
|
|
271
|
+
status?: number
|
|
272
|
+
},
|
|
273
|
+
): Response {
|
|
274
|
+
if (isHoneyResponse(response)) {
|
|
275
|
+
if (init.body === undefined) {
|
|
276
|
+
return response.derive({ headers: init.headers, status: init.status }) as unknown as Response
|
|
277
|
+
}
|
|
278
|
+
if (typeof init.body === "string" || init.body instanceof Uint8Array) {
|
|
279
|
+
return response.derive({
|
|
280
|
+
headers: init.headers,
|
|
281
|
+
raw: init.body,
|
|
282
|
+
status: init.status,
|
|
283
|
+
stream: null,
|
|
284
|
+
}) as unknown as Response
|
|
285
|
+
}
|
|
286
|
+
if (init.body instanceof ArrayBuffer) {
|
|
287
|
+
return response.derive({
|
|
288
|
+
headers: init.headers,
|
|
289
|
+
raw: new Uint8Array(init.body),
|
|
290
|
+
status: init.status,
|
|
291
|
+
stream: null,
|
|
292
|
+
}) as unknown as Response
|
|
293
|
+
}
|
|
294
|
+
if (init.body === null) {
|
|
295
|
+
return response.derive({
|
|
296
|
+
headers: init.headers,
|
|
297
|
+
raw: null,
|
|
298
|
+
status: init.status,
|
|
299
|
+
stream: null,
|
|
300
|
+
}) as unknown as Response
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return new Response(init.body !== undefined ? init.body : response.body, {
|
|
304
|
+
headers: init.headers === undefined ? undefined : headersToInit(init.headers),
|
|
305
|
+
status: init.status ?? response.status,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function headersToInit(
|
|
310
|
+
headers: Headers | HoneyOutHeaders | Record<string, string | string[]>,
|
|
311
|
+
): HeadersInit {
|
|
312
|
+
if (headers instanceof HoneyOutHeaders) return headers.toRecord() as HeadersInit
|
|
313
|
+
if (headers instanceof Headers) return headers
|
|
314
|
+
return headers as HeadersInit
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function headersInitToRecord(
|
|
318
|
+
headers: Record<string, string | string[]> | Headers | HoneyOutHeaders,
|
|
319
|
+
): Record<string, string | string[]> {
|
|
320
|
+
if (headers instanceof HoneyOutHeaders) return headers.toRecord()
|
|
321
|
+
if (!(headers instanceof Headers) && !("forEach" in headers)) {
|
|
322
|
+
return { ...headers }
|
|
323
|
+
}
|
|
324
|
+
const out: Record<string, string | string[]> = {}
|
|
325
|
+
const h = headers as Headers
|
|
326
|
+
h.forEach((value, key) => {
|
|
327
|
+
if (key === "set-cookie") return
|
|
328
|
+
out[key] = value
|
|
329
|
+
})
|
|
330
|
+
if (typeof h.getSetCookie === "function") {
|
|
331
|
+
const cookies = h.getSetCookie()
|
|
332
|
+
if (cookies.length > 0) out["set-cookie"] = cookies
|
|
333
|
+
}
|
|
334
|
+
return out
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function streamToArrayBuffer(stream: ReadableStream<Uint8Array>): Promise<ArrayBuffer> {
|
|
338
|
+
const reader = stream.getReader()
|
|
339
|
+
const chunks: Uint8Array[] = []
|
|
340
|
+
let total = 0
|
|
341
|
+
while (true) {
|
|
342
|
+
const { done, value } = await reader.read()
|
|
343
|
+
if (done) break
|
|
344
|
+
chunks.push(value)
|
|
345
|
+
total += value.byteLength
|
|
346
|
+
}
|
|
347
|
+
const out = new Uint8Array(total)
|
|
348
|
+
let offset = 0
|
|
349
|
+
for (const chunk of chunks) {
|
|
350
|
+
out.set(chunk, offset)
|
|
351
|
+
offset += chunk.byteLength
|
|
352
|
+
}
|
|
353
|
+
return out.buffer
|
|
354
|
+
}
|
package/src/i18n-slot.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type I18nRuntime = {
|
|
2
|
+
interpolate: (template: string, vars: Record<string, unknown>, locale?: string) => string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const MISSING = 'Honey.errorI18n() requires `import "@lovrozagar/honey/i18n"` in the app entry.'
|
|
6
|
+
|
|
7
|
+
let runtime: I18nRuntime | undefined
|
|
8
|
+
|
|
9
|
+
export function registerI18nRuntime(next: I18nRuntime): void {
|
|
10
|
+
runtime = next
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resetI18nRuntime(): void {
|
|
14
|
+
runtime = undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getI18nRuntime(): I18nRuntime {
|
|
18
|
+
if (!runtime) throw new Error(MISSING)
|
|
19
|
+
return runtime
|
|
20
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { registerI18nRuntime } from "./i18n-slot.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extracts {varName} patterns from ICU MessageFormat string literals at the type level.
|
|
5
|
+
* "cause" is excluded (reserved by HoneyError).
|
|
6
|
+
* Returns {} when no variables found — makes `vars` optional at call site.
|
|
7
|
+
*/
|
|
8
|
+
export type ExtractICUVars<T extends string> = T extends `${string}{${infer Var}}${infer Rest}`
|
|
9
|
+
? Var extends "cause"
|
|
10
|
+
? ExtractICUVars<Rest>
|
|
11
|
+
: { [K in Var | keyof ExtractICUVars<Rest>]: string | number }
|
|
12
|
+
: {}
|
|
13
|
+
|
|
14
|
+
type TranslationMap = Record<string, string>
|
|
15
|
+
|
|
16
|
+
export type TranslationSource = (() => Promise<TranslationMap> | TranslationMap) | TranslationMap
|
|
17
|
+
|
|
18
|
+
export class TranslationRegistry {
|
|
19
|
+
private cache = new Map<string, TranslationMap>()
|
|
20
|
+
private sources: Record<string, TranslationSource>
|
|
21
|
+
|
|
22
|
+
constructor(sources: Record<string, TranslationSource>) {
|
|
23
|
+
this.sources = sources
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async get(locale: string): Promise<TranslationMap | undefined> {
|
|
27
|
+
if (this.cache.has(locale)) {
|
|
28
|
+
return this.cache.get(locale)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const source = this.sources[locale]
|
|
32
|
+
if (source === undefined) {
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const map = typeof source === "function" ? await source() : source
|
|
37
|
+
this.cache.set(locale, map)
|
|
38
|
+
return map
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ---- ICU MessageFormat parser ---- */
|
|
43
|
+
|
|
44
|
+
type PluralCategory = "few" | "many" | "one" | "other" | "two" | "zero"
|
|
45
|
+
|
|
46
|
+
function getPluralCategory(n: number): PluralCategory {
|
|
47
|
+
if (n === 1) return "one"
|
|
48
|
+
return "other"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ParsedBlock = { branches: Record<string, string>; type: "plural" | "select"; varName: string }
|
|
52
|
+
type ParsedNumber = { type: "number"; varName: string }
|
|
53
|
+
type ParsedSimple = { type: "simple"; varName: string }
|
|
54
|
+
type ParsedToken = ParsedBlock | ParsedNumber | ParsedSimple | string
|
|
55
|
+
|
|
56
|
+
function findMatchingBrace(str: string, start: number): number {
|
|
57
|
+
let depth = 0
|
|
58
|
+
for (let i = start; i < str.length; i++) {
|
|
59
|
+
if (str[i] === "{") depth++
|
|
60
|
+
else if (str[i] === "}") {
|
|
61
|
+
depth--
|
|
62
|
+
if (depth === 0) return i
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return -1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseBranches(content: string): Record<string, string> {
|
|
69
|
+
const branches: Record<string, string> = {}
|
|
70
|
+
let i = 0
|
|
71
|
+
while (i < content.length) {
|
|
72
|
+
while (i < content.length && /\s/.test(content[i] ?? "")) i++
|
|
73
|
+
if (i >= content.length) break
|
|
74
|
+
|
|
75
|
+
let category = ""
|
|
76
|
+
while (i < content.length && content[i] !== "{" && !/\s/.test(content[i] ?? "")) {
|
|
77
|
+
category += content[i]
|
|
78
|
+
i++
|
|
79
|
+
}
|
|
80
|
+
if (!category) break
|
|
81
|
+
|
|
82
|
+
while (i < content.length && /\s/.test(content[i] ?? "")) i++
|
|
83
|
+
if (i >= content.length || content[i] !== "{") break
|
|
84
|
+
|
|
85
|
+
const end = findMatchingBrace(content, i)
|
|
86
|
+
if (end === -1) break
|
|
87
|
+
|
|
88
|
+
branches[category] = content.slice(i + 1, end)
|
|
89
|
+
i = end + 1
|
|
90
|
+
}
|
|
91
|
+
return branches
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function tokenize(message: string): ParsedToken[] {
|
|
95
|
+
const tokens: ParsedToken[] = []
|
|
96
|
+
let i = 0
|
|
97
|
+
|
|
98
|
+
while (i < message.length) {
|
|
99
|
+
const braceIdx = message.indexOf("{", i)
|
|
100
|
+
if (braceIdx === -1) {
|
|
101
|
+
tokens.push(message.slice(i))
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (braceIdx > i) {
|
|
106
|
+
tokens.push(message.slice(i, braceIdx))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const endBrace = findMatchingBrace(message, braceIdx)
|
|
110
|
+
if (endBrace === -1) {
|
|
111
|
+
tokens.push(message.slice(braceIdx))
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const inner = message.slice(braceIdx + 1, endBrace)
|
|
116
|
+
const commaIdx = inner.indexOf(",")
|
|
117
|
+
|
|
118
|
+
if (commaIdx === -1) {
|
|
119
|
+
tokens.push({ type: "simple", varName: inner.trim() })
|
|
120
|
+
} else {
|
|
121
|
+
const varName = inner.slice(0, commaIdx).trim()
|
|
122
|
+
const rest = inner.slice(commaIdx + 1).trim()
|
|
123
|
+
const secondComma = rest.indexOf(",")
|
|
124
|
+
|
|
125
|
+
if (secondComma === -1) {
|
|
126
|
+
const formatType = rest.trim()
|
|
127
|
+
if (formatType === "number") {
|
|
128
|
+
tokens.push({ type: "number", varName })
|
|
129
|
+
} else {
|
|
130
|
+
tokens.push({ type: "simple", varName })
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
const formatType = rest.slice(0, secondComma).trim()
|
|
134
|
+
const branchContent = rest.slice(secondComma + 1).trim()
|
|
135
|
+
const branches = parseBranches(branchContent)
|
|
136
|
+
|
|
137
|
+
if (formatType === "plural" || formatType === "select") {
|
|
138
|
+
tokens.push({ branches, type: formatType, varName })
|
|
139
|
+
} else {
|
|
140
|
+
tokens.push({ type: "simple", varName })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
i = endBrace + 1
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return tokens
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolveToken(
|
|
152
|
+
token: ParsedToken,
|
|
153
|
+
values: Record<string, unknown>,
|
|
154
|
+
locale?: string,
|
|
155
|
+
): string {
|
|
156
|
+
if (typeof token === "string") return token
|
|
157
|
+
|
|
158
|
+
if (token.type === "simple") {
|
|
159
|
+
const val = values[token.varName]
|
|
160
|
+
return val !== undefined && val !== null ? String(val) : `{${token.varName}}`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (token.type === "number") {
|
|
164
|
+
const val = values[token.varName]
|
|
165
|
+
if (val === undefined || val === null) return `{${token.varName}}`
|
|
166
|
+
try {
|
|
167
|
+
return new Intl.NumberFormat(locale).format(Number(val))
|
|
168
|
+
} catch {
|
|
169
|
+
return String(val)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* plural or select */
|
|
174
|
+
const val = values[token.varName]
|
|
175
|
+
|
|
176
|
+
if (token.type === "plural") {
|
|
177
|
+
const num = Number(val ?? 0)
|
|
178
|
+
const exactKey = `=${num}`
|
|
179
|
+
const category = getPluralCategory(num)
|
|
180
|
+
const template =
|
|
181
|
+
token.branches[exactKey] ?? token.branches[category] ?? token.branches["other"] ?? ""
|
|
182
|
+
const resolved = template.replace(/#/g, String(num))
|
|
183
|
+
return interpolate(resolved, values, locale)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* select */
|
|
187
|
+
const strVal = val !== undefined && val !== null ? String(val) : ""
|
|
188
|
+
const template = token.branches[strVal] ?? token.branches["other"] ?? ""
|
|
189
|
+
return interpolate(template, values, locale)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* ICU MessageFormat interpolation.
|
|
194
|
+
* Supports: {var}, {var, plural, one{...} other{...}}, {var, select, ...}, {var, number}
|
|
195
|
+
*/
|
|
196
|
+
export function interpolate(
|
|
197
|
+
template: string,
|
|
198
|
+
vars: Record<string, unknown>,
|
|
199
|
+
locale?: string,
|
|
200
|
+
): string {
|
|
201
|
+
if (!template) return template
|
|
202
|
+
const tokens = tokenize(template)
|
|
203
|
+
return tokens.map((t) => resolveToken(t, vars, locale)).join("")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function resolveTranslation(
|
|
207
|
+
registry: TranslationRegistry,
|
|
208
|
+
locale: string,
|
|
209
|
+
key: string,
|
|
210
|
+
vars: Record<string, string | number>,
|
|
211
|
+
): Promise<string> {
|
|
212
|
+
const map = await registry.get(locale)
|
|
213
|
+
if (map === undefined) return key
|
|
214
|
+
const template = map[key]
|
|
215
|
+
if (template === undefined) return key
|
|
216
|
+
return interpolate(template, vars, locale)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function resolveFieldName(
|
|
220
|
+
registry: TranslationRegistry,
|
|
221
|
+
locale: string,
|
|
222
|
+
fieldPath: string,
|
|
223
|
+
): Promise<string> {
|
|
224
|
+
const map = await registry.get(locale)
|
|
225
|
+
if (map === undefined) return fieldPath
|
|
226
|
+
const name = map[fieldPath]
|
|
227
|
+
return name ?? fieldPath
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function enableI18n(): void {
|
|
231
|
+
registerI18nRuntime({ interpolate })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
enableI18n()
|