lopata 0.2.1 → 0.3.1
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/dist/dashboard/{chunk-jrv1mhg9.css → chunk-paesqsyf.css} +33 -17
- package/dist/dashboard/{chunk-5j5em7qa.js → chunk-ymq225fp.js} +190 -98
- package/dist/dashboard/index.html +1 -1
- package/package.json +13 -16
- package/src/api/dispatch.ts +2 -0
- package/src/api/generate-sql.ts +51 -0
- package/src/api/handlers/d1.ts +8 -0
- package/src/api/handlers/do.ts +8 -0
- package/src/api/handlers/email.ts +1 -1
- package/src/api/handlers/queue.ts +1 -1
- package/src/api/handlers/warnings.ts +8 -0
- package/src/api/index.ts +6 -1
- package/src/api/r2.ts +1 -1
- package/src/api/types.ts +2 -0
- package/src/bindings/browser.ts +0 -3
- package/src/bindings/cache.ts +1 -1
- package/src/bindings/container.ts +4 -4
- package/src/bindings/crypto-extras.ts +1 -1
- package/src/bindings/do-executor-inprocess.ts +4 -0
- package/src/bindings/do-executor-worker.ts +4 -0
- package/src/bindings/do-executor.ts +3 -0
- package/src/bindings/durable-object.ts +32 -2
- package/src/bindings/html-rewriter.ts +26 -1
- package/src/bindings/images.ts +237 -32
- package/src/bindings/queue.ts +17 -1
- package/src/bindings/scheduled.ts +141 -22
- package/src/bindings/service-binding.ts +10 -5
- package/src/bindings/static-assets.ts +13 -1
- package/src/bindings/workflow.ts +5 -2
- package/src/cli/dev.ts +2 -1
- package/src/cli.ts +0 -0
- package/src/config.ts +17 -3
- package/src/dashboard-serve.ts +1 -1
- package/src/db.ts +6 -0
- package/src/env.ts +40 -2
- package/src/execution-context.ts +5 -0
- package/src/generation-manager.ts +7 -3
- package/src/generation.ts +31 -2
- package/src/lopata-config.ts +7 -0
- package/src/plugin.ts +77 -4
- package/src/request-cf.ts +2 -0
- package/src/tracing/store.ts +1 -1
- package/src/tsconfig.json +1 -0
- package/src/vite-plugin/dev-server-plugin.ts +0 -1
- package/src/warnings.ts +30 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite'
|
|
2
|
+
import type { LopataConfig } from '../lopata-config'
|
|
3
|
+
|
|
4
|
+
export async function generateSqlFromPrompt(db: Database, prompt: string, lopataConfig: LopataConfig | null): Promise<string> {
|
|
5
|
+
const aiConfig = lopataConfig?.ai
|
|
6
|
+
const apiKey = aiConfig?.apiKey ?? process.env.OPENROUTER_API_KEY
|
|
7
|
+
if (!apiKey) throw new Error('OPENROUTER_API_KEY environment variable is not set (or set ai.apiKey in lopata.config.ts)')
|
|
8
|
+
if (!prompt?.trim()) throw new Error('Missing prompt')
|
|
9
|
+
|
|
10
|
+
const model = aiConfig?.model ?? 'anthropic/claude-haiku-4.5'
|
|
11
|
+
|
|
12
|
+
let schema: string
|
|
13
|
+
try {
|
|
14
|
+
const tables = db.query<{ sql: string }, []>(
|
|
15
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
|
|
16
|
+
).all()
|
|
17
|
+
schema = tables.map(t => t.sql).join(';\n')
|
|
18
|
+
} finally {
|
|
19
|
+
db.close()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
model,
|
|
30
|
+
messages: [
|
|
31
|
+
{
|
|
32
|
+
role: 'system',
|
|
33
|
+
content:
|
|
34
|
+
`You are a SQL assistant. Given the following SQLite database schema, generate a SQL query for the user's request. Return ONLY the raw SQL query, no explanations, no markdown fences.\n\nSchema:\n${schema}`,
|
|
35
|
+
},
|
|
36
|
+
{ role: 'user', content: prompt },
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
const body = await res.text()
|
|
43
|
+
throw new Error(`OpenRouter API error (${res.status}): ${body}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const data = await res.json() as { choices: { message: { content: string } }[] }
|
|
47
|
+
let sql = data.choices[0]?.message?.content ?? ''
|
|
48
|
+
sql = sql.replace(/^```(?:sql)?\n?/, '').replace(/\n?```$/, '').trim()
|
|
49
|
+
|
|
50
|
+
return sql
|
|
51
|
+
}
|
package/src/api/handlers/d1.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite'
|
|
|
2
2
|
import { existsSync, readdirSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { getDataDir } from '../../db'
|
|
5
|
+
import { generateSqlFromPrompt } from '../generate-sql'
|
|
5
6
|
import type { D1Database as D1DatabaseInfo, D1Table, HandlerContext, QueryResult } from '../types'
|
|
6
7
|
import { getAllConfigs } from '../types'
|
|
7
8
|
|
|
@@ -60,6 +61,13 @@ export const handlers = {
|
|
|
60
61
|
}
|
|
61
62
|
},
|
|
62
63
|
|
|
64
|
+
async 'd1.generateSql'({ dbName, prompt }: { dbName: string; prompt: string }, ctx: HandlerContext): Promise<{ sql: string }> {
|
|
65
|
+
const dbPath = join(getDataDir(), 'd1', `${dbName}.sqlite`)
|
|
66
|
+
if (!existsSync(dbPath)) throw new Error('Database not found')
|
|
67
|
+
const sql = await generateSqlFromPrompt(new Database(dbPath), prompt, ctx.lopataConfig)
|
|
68
|
+
return { sql }
|
|
69
|
+
},
|
|
70
|
+
|
|
63
71
|
'd1.query'({ dbName, sql }: { dbName: string; sql: string }): QueryResult {
|
|
64
72
|
if (!sql) throw new Error('Missing sql field')
|
|
65
73
|
|
package/src/api/handlers/do.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite'
|
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { getDatabase, getDataDir } from '../../db'
|
|
5
|
+
import { generateSqlFromPrompt } from '../generate-sql'
|
|
5
6
|
import type { D1Table, DoDetail, DoInstance, DoNamespace, HandlerContext, OkResponse, QueryResult } from '../types'
|
|
6
7
|
import { getAllConfigs, getDoNamespace } from '../types'
|
|
7
8
|
|
|
@@ -93,6 +94,13 @@ export const handlers = {
|
|
|
93
94
|
return { ok: true }
|
|
94
95
|
},
|
|
95
96
|
|
|
97
|
+
async 'do.generateSql'({ ns, id, prompt }: { ns: string; id: string; prompt: string }, ctx: HandlerContext): Promise<{ sql: string }> {
|
|
98
|
+
const dbPath = join(getDataDir(), 'do-sql', ns, `${id}.sqlite`)
|
|
99
|
+
if (!existsSync(dbPath)) throw new Error('SQL database not found for this instance')
|
|
100
|
+
const sql = await generateSqlFromPrompt(new Database(dbPath), prompt, ctx.lopataConfig)
|
|
101
|
+
return { sql }
|
|
102
|
+
},
|
|
103
|
+
|
|
96
104
|
'do.sqlQuery'({ ns, id, sql }: { ns: string; id: string; sql: string }): QueryResult {
|
|
97
105
|
if (!sql) throw new Error('Missing sql field')
|
|
98
106
|
|
package/src/api/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { WranglerConfig } from '../config'
|
|
2
2
|
import type { GenerationManager } from '../generation-manager'
|
|
3
|
+
import type { LopataConfig } from '../lopata-config'
|
|
3
4
|
import type { WorkerRegistry } from '../worker-registry'
|
|
4
5
|
import { handlePreflight, withCors } from './cors'
|
|
5
6
|
import { dispatch } from './dispatch'
|
|
6
7
|
import { handleR2Download, handleR2Upload } from './r2'
|
|
7
8
|
import type { HandlerContext } from './types'
|
|
8
9
|
|
|
9
|
-
const ctx: HandlerContext = { config: null, manager: null, registry: null }
|
|
10
|
+
const ctx: HandlerContext = { config: null, manager: null, registry: null, lopataConfig: null }
|
|
10
11
|
|
|
11
12
|
export function setDashboardConfig(config: WranglerConfig): void {
|
|
12
13
|
ctx.config = config
|
|
@@ -20,6 +21,10 @@ export function setWorkerRegistry(registry: WorkerRegistry): void {
|
|
|
20
21
|
ctx.registry = registry
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
export function setLopataConfig(config: LopataConfig): void {
|
|
25
|
+
ctx.lopataConfig = config
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
export function handleApiRequest(request: Request): Response | Promise<Response> {
|
|
24
29
|
const url = new URL(request.url)
|
|
25
30
|
|
package/src/api/r2.ts
CHANGED
|
@@ -53,7 +53,7 @@ export function handleR2Download(url: URL): Response {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const filename = key.split('/').pop() ?? key
|
|
56
|
-
const response = new Response(file as unknown as BodyInit, {
|
|
56
|
+
const response = new Response(file as unknown as Bun.BodyInit, {
|
|
57
57
|
headers: {
|
|
58
58
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
59
59
|
'Content-Type': file.type || 'application/octet-stream',
|
package/src/api/types.ts
CHANGED
|
@@ -364,12 +364,14 @@ export type { SpanData, SpanEventData, TraceDetail, TraceEvent, TraceSummary } f
|
|
|
364
364
|
|
|
365
365
|
import type { WranglerConfig } from '../config'
|
|
366
366
|
import type { GenerationManager } from '../generation-manager'
|
|
367
|
+
import type { LopataConfig } from '../lopata-config'
|
|
367
368
|
import type { WorkerRegistry } from '../worker-registry'
|
|
368
369
|
|
|
369
370
|
export interface HandlerContext {
|
|
370
371
|
config: WranglerConfig | null
|
|
371
372
|
manager: GenerationManager | null
|
|
372
373
|
registry: WorkerRegistry | null
|
|
374
|
+
lopataConfig: LopataConfig | null
|
|
373
375
|
}
|
|
374
376
|
|
|
375
377
|
/** Collect configs from all workers (registry) or fall back to single config. */
|
package/src/bindings/browser.ts
CHANGED
|
@@ -26,12 +26,10 @@ export class BrowserBinding {
|
|
|
26
26
|
/** Launch a new browser and return a puppeteer Browser instance. */
|
|
27
27
|
async launch(opts?: { keep_alive?: number }): Promise<any> {
|
|
28
28
|
if (this.config.wsEndpoint) {
|
|
29
|
-
// @ts-expect-error — puppeteer-core is an optional dependency
|
|
30
29
|
const puppeteer = await import('puppeteer-core')
|
|
31
30
|
this._browser = await puppeteer.default.connect({ browserWSEndpoint: this.config.wsEndpoint })
|
|
32
31
|
return this._browser
|
|
33
32
|
}
|
|
34
|
-
// @ts-expect-error — puppeteer is an optional dependency
|
|
35
33
|
const puppeteer = await import('puppeteer')
|
|
36
34
|
this._browser = await puppeteer.default.launch({
|
|
37
35
|
headless: this.config.headless ?? true,
|
|
@@ -43,7 +41,6 @@ export class BrowserBinding {
|
|
|
43
41
|
/** Connect to an existing browser session by sessionId. */
|
|
44
42
|
async connect(sessionId: string): Promise<any> {
|
|
45
43
|
if (this.config.wsEndpoint) {
|
|
46
|
-
// @ts-expect-error — puppeteer-core is an optional dependency
|
|
47
44
|
const puppeteer = await import('puppeteer-core')
|
|
48
45
|
return puppeteer.default.connect({ browserWSEndpoint: this.config.wsEndpoint })
|
|
49
46
|
}
|
package/src/bindings/cache.ts
CHANGED
|
@@ -95,7 +95,7 @@ export class SqliteCache {
|
|
|
95
95
|
|
|
96
96
|
const headers = new Headers(JSON.parse(row.headers))
|
|
97
97
|
headers.set('cf-cache-status', 'HIT')
|
|
98
|
-
return new Response(row.body as unknown as BodyInit, { status: row.status, headers })
|
|
98
|
+
return new Response(row.body as unknown as Bun.BodyInit, { status: row.status, headers })
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
async put(request: Request | string, response: Response): Promise<void> {
|
|
@@ -24,7 +24,7 @@ export interface ContainerConfig {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
interface TcpPort {
|
|
27
|
-
fetch(input:
|
|
27
|
+
fetch(input: Request | string | URL, init?: RequestInit): Promise<Response>
|
|
28
28
|
connect(): never
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -215,7 +215,7 @@ export class ContainerRuntime {
|
|
|
215
215
|
/**
|
|
216
216
|
* Forward an HTTP request to the container.
|
|
217
217
|
*/
|
|
218
|
-
async fetch(input:
|
|
218
|
+
async fetch(input: Request | string | URL, init?: RequestInit, port?: number): Promise<Response> {
|
|
219
219
|
const targetPort = port ?? this.defaultPort
|
|
220
220
|
const hostPort = this._hostPorts.get(targetPort)
|
|
221
221
|
if (!hostPort) {
|
|
@@ -429,7 +429,7 @@ export class ContainerContext {
|
|
|
429
429
|
getTcpPort(port: number): TcpPort {
|
|
430
430
|
const runtime = this._runtime
|
|
431
431
|
return {
|
|
432
|
-
fetch(input:
|
|
432
|
+
fetch(input: Request | string | URL, init?: RequestInit): Promise<Response> {
|
|
433
433
|
return runtime.fetch(input, init, port)
|
|
434
434
|
},
|
|
435
435
|
connect(): never {
|
|
@@ -521,7 +521,7 @@ export class ContainerBase extends DurableObjectBase {
|
|
|
521
521
|
requestOrUrl: Request | string | URL,
|
|
522
522
|
portOrInit?: number | RequestInit,
|
|
523
523
|
portParam?: number,
|
|
524
|
-
): { input:
|
|
524
|
+
): { input: Request | string | URL; init?: RequestInit; port?: number } {
|
|
525
525
|
if (requestOrUrl instanceof Request) {
|
|
526
526
|
// containerFetch(request, port?)
|
|
527
527
|
const port = typeof portOrInit === 'number' ? portOrInit : portParam
|
|
@@ -26,7 +26,7 @@ export function cfTimingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuff
|
|
|
26
26
|
export class DigestStream extends WritableStream<ArrayBuffer | ArrayBufferView> {
|
|
27
27
|
readonly digest: Promise<ArrayBuffer>
|
|
28
28
|
|
|
29
|
-
constructor(algorithm:
|
|
29
|
+
constructor(algorithm: string | { name: string }) {
|
|
30
30
|
const algo = typeof algorithm === 'string' ? algorithm : algorithm.name
|
|
31
31
|
|
|
32
32
|
// Map CF algorithm names to Bun.CryptoHasher names
|
|
@@ -116,6 +116,10 @@ export class InProcessExecutor implements DOExecutor {
|
|
|
116
116
|
return this._state.getWebSockets().length
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
isAborted(): boolean {
|
|
120
|
+
return this._state._isAborted()
|
|
121
|
+
}
|
|
122
|
+
|
|
119
123
|
async dispose(): Promise<void> {
|
|
120
124
|
if (this._containerRuntime) {
|
|
121
125
|
await this._containerRuntime.cleanup()
|
|
@@ -333,6 +333,10 @@ export class WorkerExecutor implements DOExecutor {
|
|
|
333
333
|
return this._wsCount
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
isAborted(): boolean {
|
|
337
|
+
return false // Worker-thread DOs don't support abort
|
|
338
|
+
}
|
|
339
|
+
|
|
336
340
|
async dispose(): Promise<void> {
|
|
337
341
|
this._disposed = true
|
|
338
342
|
if (this._worker) {
|
|
@@ -499,6 +499,8 @@ export class DurableObjectStateImpl {
|
|
|
499
499
|
private _instanceResolver: (() => DurableObjectBase | null) | null = null
|
|
500
500
|
private _lockTail: Promise<void> = Promise.resolve()
|
|
501
501
|
private _activeRequests = 0
|
|
502
|
+
private _aborted = false
|
|
503
|
+
private _abortReason: string | undefined
|
|
502
504
|
|
|
503
505
|
constructor(id: DurableObjectIdImpl, db: Database, namespace: string, dataDir?: string, limits?: DurableObjectLimits) {
|
|
504
506
|
this.id = id
|
|
@@ -536,6 +538,9 @@ export class DurableObjectStateImpl {
|
|
|
536
538
|
|
|
537
539
|
/** @internal Acquire the serial-execution lock. Caller awaits this, then runs work in its own async stack. */
|
|
538
540
|
async _lock(): Promise<() => void> {
|
|
541
|
+
if (this._aborted) {
|
|
542
|
+
throw new Error(this._abortReason ?? 'Durable Object has been aborted')
|
|
543
|
+
}
|
|
539
544
|
let unlockNext: () => void
|
|
540
545
|
const nextTail = new Promise<void>(r => {
|
|
541
546
|
unlockNext = r
|
|
@@ -543,6 +548,10 @@ export class DurableObjectStateImpl {
|
|
|
543
548
|
const ready = this._lockTail
|
|
544
549
|
this._lockTail = nextTail
|
|
545
550
|
await ready
|
|
551
|
+
if (this._aborted) {
|
|
552
|
+
unlockNext!()
|
|
553
|
+
throw new Error(this._abortReason ?? 'Durable Object has been aborted')
|
|
554
|
+
}
|
|
546
555
|
this._activeRequests++
|
|
547
556
|
return () => {
|
|
548
557
|
this._activeRequests--
|
|
@@ -550,6 +559,20 @@ export class DurableObjectStateImpl {
|
|
|
550
559
|
}
|
|
551
560
|
}
|
|
552
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Abort the Durable Object instance. Rejects all queued requests and
|
|
564
|
+
* marks the instance for eviction so it will be re-created fresh on next access.
|
|
565
|
+
*/
|
|
566
|
+
abort(reason?: string): void {
|
|
567
|
+
this._aborted = true
|
|
568
|
+
this._abortReason = reason ?? 'Durable Object reset by abort()'
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** @internal Check if this state has been aborted */
|
|
572
|
+
_isAborted(): boolean {
|
|
573
|
+
return this._aborted
|
|
574
|
+
}
|
|
575
|
+
|
|
553
576
|
/** @internal Set the instance resolver for WebSocket handler delegation */
|
|
554
577
|
_setInstanceResolver(resolver: () => DurableObjectBase | null) {
|
|
555
578
|
this._instanceResolver = resolver
|
|
@@ -855,9 +878,16 @@ export class DurableObjectNamespaceImpl {
|
|
|
855
878
|
private _evictIdle() {
|
|
856
879
|
const now = Date.now()
|
|
857
880
|
for (const [idStr, lastActivity] of this._lastActivity) {
|
|
858
|
-
if (now - lastActivity < this._evictionTimeoutMs) continue
|
|
859
881
|
const executor = this._executors.get(idStr)
|
|
860
882
|
if (!executor) continue
|
|
883
|
+
// Evict aborted instances immediately (once they have no active requests)
|
|
884
|
+
if (executor.isAborted() && !executor.isActive()) {
|
|
885
|
+
executor.dispose().catch(() => {})
|
|
886
|
+
this._executors.delete(idStr)
|
|
887
|
+
this._lastActivity.delete(idStr)
|
|
888
|
+
continue
|
|
889
|
+
}
|
|
890
|
+
if (now - lastActivity < this._evictionTimeoutMs) continue
|
|
861
891
|
if (executor.isBlocked()) continue
|
|
862
892
|
if (executor.isActive()) continue
|
|
863
893
|
if (executor.activeWebSocketCount() > 0) continue
|
|
@@ -967,7 +997,7 @@ export class DurableObjectNamespaceImpl {
|
|
|
967
997
|
|
|
968
998
|
// stub.fetch() — calls the DO's fetch() handler
|
|
969
999
|
if (prop === 'fetch') {
|
|
970
|
-
return async (input:
|
|
1000
|
+
return async (input: Request | string | URL, init?: RequestInit) => {
|
|
971
1001
|
const executor = self._getOrCreateExecutor(idStr, id)!
|
|
972
1002
|
self._lastActivity.set(idStr, Date.now())
|
|
973
1003
|
const request = input instanceof Request ? input : new Request(input instanceof URL ? input.href : input, init)
|
|
@@ -1,4 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { DocumentHandlers, ElementHandlers } from 'html-rewriter-wasm'
|
|
2
|
+
|
|
3
|
+
type RawHTMLRewriterType = new(sink: (chunk: Uint8Array) => void) => {
|
|
4
|
+
on(selector: string, handler: ElementHandlers): void
|
|
5
|
+
onDocument(handler: DocumentHandlers): void
|
|
6
|
+
write(chunk: Uint8Array): Promise<void>
|
|
7
|
+
end(): Promise<void>
|
|
8
|
+
free(): void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let RawHTMLRewriter: RawHTMLRewriterType | null = null
|
|
12
|
+
try {
|
|
13
|
+
;({ HTMLRewriter: RawHTMLRewriter } = await import('html-rewriter-wasm'))
|
|
14
|
+
} catch {
|
|
15
|
+
// html-rewriter-wasm not installed — passthrough mode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let _warned = false
|
|
2
19
|
|
|
3
20
|
/**
|
|
4
21
|
* Cloudflare-compatible HTMLRewriter that wraps html-rewriter-wasm.
|
|
@@ -28,6 +45,14 @@ export class HTMLRewriter {
|
|
|
28
45
|
})
|
|
29
46
|
}
|
|
30
47
|
|
|
48
|
+
if (!RawHTMLRewriter) {
|
|
49
|
+
if (!_warned) {
|
|
50
|
+
console.warn('[lopata] html-rewriter-wasm is not installed — HTMLRewriter is a passthrough. Install it: bun add html-rewriter-wasm')
|
|
51
|
+
_warned = true
|
|
52
|
+
}
|
|
53
|
+
return response
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
const elementHandlers = this.elementHandlers
|
|
32
57
|
const documentHandlers = this.documentHandlers
|
|
33
58
|
|