@bprp/flockcode 0.0.2
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/package.json +45 -0
- package/src/app.ts +153 -0
- package/src/diagnose-stream.ts +305 -0
- package/src/env.ts +35 -0
- package/src/event-discovery.ts +355 -0
- package/src/event-driven-test.ts +72 -0
- package/src/index.ts +223 -0
- package/src/opencode.ts +278 -0
- package/src/prompt.ts +127 -0
- package/src/router/agents.ts +57 -0
- package/src/router/base.ts +10 -0
- package/src/router/commands.ts +57 -0
- package/src/router/context.ts +22 -0
- package/src/router/diffs.ts +46 -0
- package/src/router/index.ts +24 -0
- package/src/router/models.ts +55 -0
- package/src/router/permissions.ts +28 -0
- package/src/router/projects.ts +175 -0
- package/src/router/sessions.ts +316 -0
- package/src/router/snapshot.ts +9 -0
- package/src/server.ts +15 -0
- package/src/spawn-opencode.ts +166 -0
- package/src/sprite-configure-services.ts +302 -0
- package/src/sprite-sync.ts +413 -0
- package/src/sprites.ts +328 -0
- package/src/start-server.ts +49 -0
- package/src/state-stream.ts +711 -0
- package/src/transcribe.ts +100 -0
- package/src/types.ts +430 -0
- package/src/voice-prompt.ts +222 -0
- package/src/worktree-name.ts +62 -0
- package/src/worktree.ts +549 -0
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bprp/flockcode",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"bin": {
|
|
5
|
+
"flockcode": "./src/index.ts"
|
|
6
|
+
},
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"!src/**/*.test.ts",
|
|
10
|
+
"!src/**/*.spec.ts"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"bun": ">=1.2.17"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "bun --hot src/server.ts",
|
|
17
|
+
"start": "bun src/server.ts",
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"build:diff-page": "bun build src/diff-page/index.html --outdir=src/diff-page/dist --minify"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@crustjs/core": "^0.0.15",
|
|
23
|
+
"@crustjs/plugins": "^0.0.19",
|
|
24
|
+
"@crustjs/store": "^0.0.4",
|
|
25
|
+
"@crustjs/validate": "^0.0.13",
|
|
26
|
+
"@durable-streams/server": "0.2.1",
|
|
27
|
+
"@fly/sprites": "^0.0.1",
|
|
28
|
+
"@opencode-ai/sdk": "^1.2.10",
|
|
29
|
+
"@orpc/client": "^1.13.10",
|
|
30
|
+
"@orpc/server": "^1.13.10",
|
|
31
|
+
"@pierre/diffs": "^1.0.11",
|
|
32
|
+
"@tanstack/ai": "^0.5.1",
|
|
33
|
+
"@tanstack/ai-gemini": "^0.5.0",
|
|
34
|
+
"durable-streams-web-standard": "github:ben-pr-p/durable-streams-web-standard",
|
|
35
|
+
"envalid": "^8.1.1",
|
|
36
|
+
"hono": "4.12.5",
|
|
37
|
+
"nanoid": "^5.1.6",
|
|
38
|
+
"react": "^19.2.4",
|
|
39
|
+
"react-dom": "^19.2.4"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/bun": "latest",
|
|
43
|
+
"typescript": "~5.9.2"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Hono, type Context } from "hono"
|
|
2
|
+
import { DurableStreamServer } from "durable-streams-web-standard"
|
|
3
|
+
import { FileBackedStreamStore } from "@durable-streams/server"
|
|
4
|
+
import { dataDir } from "@crustjs/store"
|
|
5
|
+
import { customAlphabet } from "nanoid"
|
|
6
|
+
import { RPCHandler } from "@orpc/server/fetch"
|
|
7
|
+
import { onError } from "@orpc/server"
|
|
8
|
+
import { createClient, Opencode, handleOpencodeEvent } from "./opencode"
|
|
9
|
+
import { env } from "./env"
|
|
10
|
+
import { StateStream } from "./state-stream"
|
|
11
|
+
import { router } from "./router"
|
|
12
|
+
import type { RouterContext } from "./router"
|
|
13
|
+
import { logger } from 'hono/logger'
|
|
14
|
+
|
|
15
|
+
/** Hono handler that strips a prefix from the URL and forwards to a DurableStreamServer. */
|
|
16
|
+
function rewriteToDs(prefix: string, ds: DurableStreamServer) {
|
|
17
|
+
return (c: Context) => {
|
|
18
|
+
const url = new URL(c.req.url)
|
|
19
|
+
url.pathname = url.pathname.slice(prefix.length) || "/"
|
|
20
|
+
const offset = url.searchParams.get("offset")
|
|
21
|
+
if (offset && offset !== "-1") {
|
|
22
|
+
console.log(`[stream-offset] Resuming from offset ${offset} for ${prefix}`)
|
|
23
|
+
}
|
|
24
|
+
return ds.fetch(new Request(url.toString(), c.req.raw))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const generateInstanceId = customAlphabet("abcdefghijklmnopqrstuvwxyz", 12)
|
|
29
|
+
|
|
30
|
+
export async function createApp(opencodeUrl: string) {
|
|
31
|
+
const client = createClient(opencodeUrl)
|
|
32
|
+
const opencode = new Opencode(opencodeUrl)
|
|
33
|
+
|
|
34
|
+
// Unique instance ID for this server boot — clients use this to detect restarts
|
|
35
|
+
const instanceId = generateInstanceId()
|
|
36
|
+
|
|
37
|
+
// Persistent app state stream — survives server restarts
|
|
38
|
+
const appDataDir = dataDir("flockcode")
|
|
39
|
+
const appStore = new FileBackedStreamStore({ dataDir: appDataDir })
|
|
40
|
+
const appDs = new DurableStreamServer({ store: appStore })
|
|
41
|
+
await appDs.createStream("/", { contentType: "application/json" })
|
|
42
|
+
|
|
43
|
+
// In-memory index of session→worktree mappings, rebuilt from the persistent
|
|
44
|
+
// app state stream on startup so worktree cleanup survives server restarts.
|
|
45
|
+
const sessionWorktrees = new Map<string, { worktreePath: string; projectWorktree: string }>()
|
|
46
|
+
try {
|
|
47
|
+
const { messages } = appDs.readStream("/")
|
|
48
|
+
const decoder = new TextDecoder()
|
|
49
|
+
for (const msg of messages) {
|
|
50
|
+
const event = JSON.parse(decoder.decode(msg.data))
|
|
51
|
+
if (event.type === "sessionWorktree" && event.value) {
|
|
52
|
+
sessionWorktrees.set(event.key, {
|
|
53
|
+
worktreePath: event.value.worktreePath,
|
|
54
|
+
projectWorktree: event.value.projectWorktree,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Stream may be empty on first boot — that's fine
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Instance stream — finalized, replayable state events.
|
|
63
|
+
// Created after sessionWorktrees so initialization can emit merge status.
|
|
64
|
+
const instanceDs = new DurableStreamServer()
|
|
65
|
+
|
|
66
|
+
// Ephemeral stream — live-only UI state (session status, in-progress messages, worktree status).
|
|
67
|
+
const ephemeralDs = new DurableStreamServer()
|
|
68
|
+
|
|
69
|
+
const stateStream = new StateStream(instanceDs, ephemeralDs, client, sessionWorktrees)
|
|
70
|
+
stateStream.initialize().catch((err) => {
|
|
71
|
+
console.error("Failed to initialize state stream:", err)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Subscribe to opencode events and route them to the state stream
|
|
75
|
+
opencode.spawnListener((event) => handleOpencodeEvent(event, stateStream), opencodeUrl).catch((err) => {
|
|
76
|
+
console.error("Failed to start opencode event listener:", err)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const app = new Hono()
|
|
80
|
+
app.use(logger())
|
|
81
|
+
|
|
82
|
+
// Optional bearer token auth — required when FLOCK_AUTH_TOKEN is set
|
|
83
|
+
// (e.g., on a publicly accessible Fly Sprite). No-op when running locally.
|
|
84
|
+
const authToken = env.FLOCK_AUTH_TOKEN
|
|
85
|
+
if (authToken) {
|
|
86
|
+
app.use('*', async (c, next) => {
|
|
87
|
+
const header = c.req.header('Authorization')
|
|
88
|
+
if (header !== `Bearer ${authToken}`) {
|
|
89
|
+
return c.json({ error: 'Unauthorized' }, 401)
|
|
90
|
+
}
|
|
91
|
+
return next()
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Returns the current instance ID so clients know where to connect
|
|
96
|
+
app.get("/", (c) => {
|
|
97
|
+
return c.json({ instanceId, appStreamUrl: "/app" })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Ephemeral stream — live-only state, no catch-up replay
|
|
101
|
+
// Must be mounted before the instance stream catch-all so it matches first.
|
|
102
|
+
const ephemeralPrefix = `/${instanceId}/ephemeral`
|
|
103
|
+
app.all(`${ephemeralPrefix}/*`, rewriteToDs(ephemeralPrefix, ephemeralDs))
|
|
104
|
+
app.all(ephemeralPrefix, rewriteToDs(ephemeralPrefix, ephemeralDs))
|
|
105
|
+
|
|
106
|
+
// Instance stream — finalized, replayable state events
|
|
107
|
+
const instancePrefix = `/${instanceId}`
|
|
108
|
+
app.all(`${instancePrefix}/*`, rewriteToDs(instancePrefix, instanceDs))
|
|
109
|
+
app.all(instancePrefix, rewriteToDs(instancePrefix, instanceDs))
|
|
110
|
+
|
|
111
|
+
// Persistent app state stream — fixed path, never resets
|
|
112
|
+
app.all("/app/*", rewriteToDs("/app", appDs))
|
|
113
|
+
app.all("/app", rewriteToDs("/app", appDs))
|
|
114
|
+
|
|
115
|
+
app.get("/health", async (c) => {
|
|
116
|
+
return c.json({ healthy: true, opencodeUrl, instanceId })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// -----------------------------------------------------------------------
|
|
120
|
+
// oRPC handler — serves all typed API procedures at /api/*
|
|
121
|
+
// -----------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
const routerContext: RouterContext = {
|
|
124
|
+
client,
|
|
125
|
+
appDs,
|
|
126
|
+
ephemeralDs,
|
|
127
|
+
sessionWorktrees,
|
|
128
|
+
stateStream,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const rpcHandler = new RPCHandler(router, {
|
|
132
|
+
interceptors: [
|
|
133
|
+
onError((error) => {
|
|
134
|
+
console.error("[oRPC]", error)
|
|
135
|
+
}),
|
|
136
|
+
],
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
app.use("/api/*", async (c, next) => {
|
|
140
|
+
const { matched, response } = await rpcHandler.handle(c.req.raw, {
|
|
141
|
+
prefix: "/api",
|
|
142
|
+
context: routerContext,
|
|
143
|
+
})
|
|
144
|
+
if (matched) {
|
|
145
|
+
return c.newResponse(response.body, response)
|
|
146
|
+
}
|
|
147
|
+
await next()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return { app, instanceDs, ephemeralDs, appDs, stateStream, instanceId }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type { Router } from "./router"
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic script: fetches all data from the durable stream and reports
|
|
3
|
+
* which events are largest, helping identify what causes the
|
|
4
|
+
* "String length exceeds limit" RangeError on the client.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bun packages/server/src/diagnose-stream.ts [--url http://localhost:3000] [--token TOKEN]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { stream } from "@durable-streams/client"
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// CLI args
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2)
|
|
17
|
+
function getArg(name: string, fallback: string): string {
|
|
18
|
+
const idx = args.indexOf(name)
|
|
19
|
+
return idx >= 0 && args[idx + 1] ? args[idx + 1]! : fallback
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const BASE_URL = getArg("--url", "http://localhost:3000")
|
|
23
|
+
const AUTH_TOKEN = getArg("--token", process.env.FLOCK_AUTH_TOKEN ?? "")
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface StateEvent {
|
|
30
|
+
type: string
|
|
31
|
+
key: string
|
|
32
|
+
value?: unknown
|
|
33
|
+
headers: { operation: string }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function byteLen(s: string): number {
|
|
37
|
+
return new TextEncoder().encode(s).byteLength
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatBytes(n: number): string {
|
|
41
|
+
if (n < 1024) return `${n} B`
|
|
42
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
43
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Summarize the parts of a message value to show which part is large. */
|
|
47
|
+
function summarizeMessageParts(value: any): string[] {
|
|
48
|
+
if (!value?.parts) return []
|
|
49
|
+
return (value.parts as any[]).map((p: any, i: number) => {
|
|
50
|
+
const json = JSON.stringify(p)
|
|
51
|
+
const size = byteLen(json)
|
|
52
|
+
let label = ` part[${i}] type=${p.type}`
|
|
53
|
+
if (p.type === "tool") {
|
|
54
|
+
label += ` tool=${p.tool}`
|
|
55
|
+
if (p.state?.status) label += ` status=${p.state.status}`
|
|
56
|
+
if (p.state?.output) {
|
|
57
|
+
const outLen = byteLen(p.state.output)
|
|
58
|
+
label += ` output=${formatBytes(outLen)}`
|
|
59
|
+
}
|
|
60
|
+
if (p.state?.input) {
|
|
61
|
+
const inLen = byteLen(JSON.stringify(p.state.input))
|
|
62
|
+
label += ` input=${formatBytes(inLen)}`
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (p.type === "text" && p.text) {
|
|
66
|
+
label += ` text=${formatBytes(byteLen(p.text))}`
|
|
67
|
+
}
|
|
68
|
+
label += ` → total=${formatBytes(size)}`
|
|
69
|
+
return label
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Main
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
async function main() {
|
|
78
|
+
console.log(`Connecting to ${BASE_URL} ...`)
|
|
79
|
+
|
|
80
|
+
// 1. Discover the instanceId
|
|
81
|
+
const headers: Record<string, string> = {}
|
|
82
|
+
if (AUTH_TOKEN) headers["Authorization"] = `Bearer ${AUTH_TOKEN}`
|
|
83
|
+
|
|
84
|
+
const rootRes = await fetch(BASE_URL, { headers })
|
|
85
|
+
if (!rootRes.ok) {
|
|
86
|
+
console.error(`Failed to reach server: ${rootRes.status} ${rootRes.statusText}`)
|
|
87
|
+
process.exit(1)
|
|
88
|
+
}
|
|
89
|
+
const { instanceId, appStreamUrl } = (await rootRes.json()) as {
|
|
90
|
+
instanceId: string
|
|
91
|
+
appStreamUrl: string
|
|
92
|
+
}
|
|
93
|
+
console.log(`Instance ID: ${instanceId}`)
|
|
94
|
+
|
|
95
|
+
// 2. Fetch the instance state stream (catch-up only, no live)
|
|
96
|
+
const stateUrl = `${BASE_URL}/${instanceId}/`
|
|
97
|
+
console.log(`\nFetching instance stream: ${stateUrl}`)
|
|
98
|
+
|
|
99
|
+
const stateRes = await stream<StateEvent>({
|
|
100
|
+
url: stateUrl,
|
|
101
|
+
offset: "-1",
|
|
102
|
+
live: false,
|
|
103
|
+
headers: AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : undefined,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const events = await stateRes.json()
|
|
107
|
+
console.log(`Total events: ${events.length}`)
|
|
108
|
+
|
|
109
|
+
// 3. Compute sizes
|
|
110
|
+
type SizedEvent = {
|
|
111
|
+
index: number
|
|
112
|
+
type: string
|
|
113
|
+
key: string
|
|
114
|
+
operation: string
|
|
115
|
+
jsonSize: number
|
|
116
|
+
json: string
|
|
117
|
+
event: StateEvent
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sized: SizedEvent[] = events.map((e, i) => {
|
|
121
|
+
const json = JSON.stringify(e)
|
|
122
|
+
return {
|
|
123
|
+
index: i,
|
|
124
|
+
type: e.type,
|
|
125
|
+
key: e.key,
|
|
126
|
+
operation: e.headers.operation,
|
|
127
|
+
jsonSize: byteLen(json),
|
|
128
|
+
json,
|
|
129
|
+
event: e,
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// 4. Overall stats
|
|
134
|
+
const totalBytes = sized.reduce((s, e) => s + e.jsonSize, 0)
|
|
135
|
+
console.log(`Total payload size: ${formatBytes(totalBytes)}`)
|
|
136
|
+
|
|
137
|
+
// Size by type
|
|
138
|
+
const byType = new Map<string, { count: number; bytes: number }>()
|
|
139
|
+
for (const e of sized) {
|
|
140
|
+
const entry = byType.get(e.type) ?? { count: 0, bytes: 0 }
|
|
141
|
+
entry.count++
|
|
142
|
+
entry.bytes += e.jsonSize
|
|
143
|
+
byType.set(e.type, entry)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log(`\n--- Breakdown by event type ---`)
|
|
147
|
+
for (const [type, { count, bytes }] of [...byType.entries()].sort(
|
|
148
|
+
(a, b) => b[1].bytes - a[1].bytes,
|
|
149
|
+
)) {
|
|
150
|
+
console.log(` ${type}: ${count} events, ${formatBytes(bytes)} total, ${formatBytes(bytes / count)} avg`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 5. Top 20 largest events
|
|
154
|
+
const sorted = [...sized].sort((a, b) => b.jsonSize - a.jsonSize)
|
|
155
|
+
console.log(`\n--- Top 20 largest events ---`)
|
|
156
|
+
for (const e of sorted.slice(0, 20)) {
|
|
157
|
+
console.log(
|
|
158
|
+
`#${e.index} type=${e.type} key=${e.key} op=${e.operation} size=${formatBytes(e.jsonSize)}`,
|
|
159
|
+
)
|
|
160
|
+
if (e.type === "message") {
|
|
161
|
+
const parts = summarizeMessageParts(e.event.value)
|
|
162
|
+
for (const line of parts) console.log(line)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 6. Identify sessions with the most data
|
|
167
|
+
const bySession = new Map<string, { messageCount: number; totalBytes: number; largestMessage: number }>()
|
|
168
|
+
for (const e of sized) {
|
|
169
|
+
if (e.type !== "message") continue
|
|
170
|
+
const sessionId = (e.event.value as any)?.sessionId
|
|
171
|
+
if (!sessionId) continue
|
|
172
|
+
const entry = bySession.get(sessionId) ?? { messageCount: 0, totalBytes: 0, largestMessage: 0 }
|
|
173
|
+
entry.messageCount++
|
|
174
|
+
entry.totalBytes += e.jsonSize
|
|
175
|
+
entry.largestMessage = Math.max(entry.largestMessage, e.jsonSize)
|
|
176
|
+
bySession.set(sessionId, entry)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
console.log(`\n--- Sessions by total message data ---`)
|
|
180
|
+
const sessionsSorted = [...bySession.entries()].sort(
|
|
181
|
+
(a, b) => b[1].totalBytes - a[1].totalBytes,
|
|
182
|
+
)
|
|
183
|
+
for (const [sessionId, { messageCount, totalBytes, largestMessage }] of sessionsSorted.slice(0, 10)) {
|
|
184
|
+
console.log(
|
|
185
|
+
` session=${sessionId} messages=${messageCount} total=${formatBytes(totalBytes)} largest=${formatBytes(largestMessage)}`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 7. Check the full serialized stream size (what the client receives on initial load)
|
|
190
|
+
// The server wraps all events in a single JSON array for the initial GET
|
|
191
|
+
const fullPayload = JSON.stringify(events)
|
|
192
|
+
const fullPayloadSize = byteLen(fullPayload)
|
|
193
|
+
console.log(`\n--- Initial load payload ---`)
|
|
194
|
+
console.log(`Full JSON array size: ${formatBytes(fullPayloadSize)}`)
|
|
195
|
+
console.log(`String length (chars): ${fullPayload.length.toLocaleString()}`)
|
|
196
|
+
|
|
197
|
+
// JS string length limit is ~512MB on V8 / ~1GB on JSC but some RN
|
|
198
|
+
// implementations may have lower limits. Flag if over 100MB.
|
|
199
|
+
if (fullPayload.length > 100_000_000) {
|
|
200
|
+
console.log(`⚠️ WARNING: Payload string length exceeds 100M chars — likely cause of RangeError!`)
|
|
201
|
+
} else if (fullPayload.length > 50_000_000) {
|
|
202
|
+
console.log(`⚠️ WARNING: Payload string length exceeds 50M chars — may cause issues on mobile`)
|
|
203
|
+
} else if (fullPayload.length > 10_000_000) {
|
|
204
|
+
console.log(`⚠️ CAUTION: Payload string length exceeds 10M chars — large for mobile`)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 8. Also check the ephemeral stream
|
|
208
|
+
console.log(`\n--- Ephemeral stream ---`)
|
|
209
|
+
const ephemeralUrl = `${BASE_URL}/${instanceId}/ephemeral/`
|
|
210
|
+
console.log(`Fetching: ${ephemeralUrl}`)
|
|
211
|
+
try {
|
|
212
|
+
const ephRes = await stream<StateEvent>({
|
|
213
|
+
url: ephemeralUrl,
|
|
214
|
+
offset: "-1",
|
|
215
|
+
live: false,
|
|
216
|
+
headers: AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : undefined,
|
|
217
|
+
})
|
|
218
|
+
const ephEvents = await ephRes.json()
|
|
219
|
+
const ephPayload = JSON.stringify(ephEvents)
|
|
220
|
+
console.log(`Ephemeral events: ${ephEvents.length}`)
|
|
221
|
+
console.log(`Ephemeral payload size: ${formatBytes(byteLen(ephPayload))}`)
|
|
222
|
+
console.log(`Ephemeral string length: ${ephPayload.length.toLocaleString()}`)
|
|
223
|
+
|
|
224
|
+
// Breakdown by type
|
|
225
|
+
const ephByType = new Map<string, { count: number; bytes: number }>()
|
|
226
|
+
for (const e of ephEvents) {
|
|
227
|
+
const json = JSON.stringify(e)
|
|
228
|
+
const entry = ephByType.get(e.type) ?? { count: 0, bytes: 0 }
|
|
229
|
+
entry.count++
|
|
230
|
+
entry.bytes += byteLen(json)
|
|
231
|
+
ephByType.set(e.type, entry)
|
|
232
|
+
}
|
|
233
|
+
for (const [type, { count, bytes }] of [...ephByType.entries()].sort(
|
|
234
|
+
(a, b) => b[1].bytes - a[1].bytes,
|
|
235
|
+
)) {
|
|
236
|
+
console.log(` ${type}: ${count} events, ${formatBytes(bytes)} total`)
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.log(`Could not fetch ephemeral stream: ${err}`)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 9. Also check the app stream
|
|
243
|
+
console.log(`\n--- App state stream ---`)
|
|
244
|
+
const appUrl = `${BASE_URL}${appStreamUrl}/`
|
|
245
|
+
console.log(`Fetching: ${appUrl}`)
|
|
246
|
+
try {
|
|
247
|
+
const appRes = await stream<StateEvent>({
|
|
248
|
+
url: appUrl,
|
|
249
|
+
offset: "-1",
|
|
250
|
+
live: false,
|
|
251
|
+
headers: AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : undefined,
|
|
252
|
+
})
|
|
253
|
+
const appEvents = await appRes.json()
|
|
254
|
+
const appPayload = JSON.stringify(appEvents)
|
|
255
|
+
console.log(`App events: ${appEvents.length}`)
|
|
256
|
+
console.log(`App payload size: ${formatBytes(byteLen(appPayload))}`)
|
|
257
|
+
console.log(`App string length: ${appPayload.length.toLocaleString()}`)
|
|
258
|
+
} catch (err) {
|
|
259
|
+
console.log(`Could not fetch app stream: ${err}`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 10. Check for duplicate message events (same key emitted many times)
|
|
263
|
+
// The stream is append-only: every messagePartDelta and update re-emits
|
|
264
|
+
// the full message, so the same message key appears many times.
|
|
265
|
+
const keyCounts = new Map<string, { count: number; totalBytes: number; lastSize: number }>()
|
|
266
|
+
for (const e of sized) {
|
|
267
|
+
const compoundKey = `${e.type}:${e.key}`
|
|
268
|
+
const entry = keyCounts.get(compoundKey) ?? { count: 0, totalBytes: 0, lastSize: 0 }
|
|
269
|
+
entry.count++
|
|
270
|
+
entry.totalBytes += e.jsonSize
|
|
271
|
+
entry.lastSize = e.jsonSize
|
|
272
|
+
keyCounts.set(compoundKey, entry)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const duplicates = [...keyCounts.entries()]
|
|
276
|
+
.filter(([, v]) => v.count > 1)
|
|
277
|
+
.sort((a, b) => b[1].totalBytes - a[1].totalBytes)
|
|
278
|
+
|
|
279
|
+
console.log(`\n--- Most-duplicated keys (events re-emitted as stream appends) ---`)
|
|
280
|
+
console.log(`(Each update re-appends the full object. These accumulate in the stream.)`)
|
|
281
|
+
for (const [key, { count, totalBytes, lastSize }] of duplicates.slice(0, 15)) {
|
|
282
|
+
console.log(
|
|
283
|
+
` ${key}: emitted ${count}x, accumulated ${formatBytes(totalBytes)}, final size ${formatBytes(lastSize)}`,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 11. Estimate the "effective" payload — only last version of each key matters
|
|
288
|
+
// for state, but ALL versions are in the stream and sent to the client.
|
|
289
|
+
const lastVersion = new Map<string, number>()
|
|
290
|
+
for (const e of sized) {
|
|
291
|
+
lastVersion.set(`${e.type}:${e.key}`, e.jsonSize)
|
|
292
|
+
}
|
|
293
|
+
const effectiveBytes = [...lastVersion.values()].reduce((s, v) => s + v, 0)
|
|
294
|
+
console.log(`\n--- Stream efficiency ---`)
|
|
295
|
+
console.log(`Total stream bytes (all appends): ${formatBytes(totalBytes)}`)
|
|
296
|
+
console.log(`Effective state bytes (latest per key): ${formatBytes(effectiveBytes)}`)
|
|
297
|
+
console.log(`Overhead from duplicates: ${formatBytes(totalBytes - effectiveBytes)} (${((1 - effectiveBytes / totalBytes) * 100).toFixed(1)}%)`)
|
|
298
|
+
|
|
299
|
+
console.log(`\nDone.`)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
main().catch((err) => {
|
|
303
|
+
console.error(err)
|
|
304
|
+
process.exit(1)
|
|
305
|
+
})
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validated environment variables for the server.
|
|
3
|
+
*
|
|
4
|
+
* Import `env` from this module instead of reading `process.env` directly.
|
|
5
|
+
* Validation runs once on first import — missing required variables cause an
|
|
6
|
+
* immediate, descriptive error.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { cleanEnv, str, port, url } from "envalid"
|
|
10
|
+
|
|
11
|
+
export const env = cleanEnv(process.env, {
|
|
12
|
+
// --- Server ---
|
|
13
|
+
/** Port for the Hono HTTP server. */
|
|
14
|
+
PORT: port({ default: 3000 }),
|
|
15
|
+
/** URL of the OpenCode server to bridge. Empty string means "spawn one automatically". */
|
|
16
|
+
OPENCODE_URL: str({ default: "" }),
|
|
17
|
+
|
|
18
|
+
// --- Auth ---
|
|
19
|
+
/** Bearer token for authenticating mobile clients. Optional when running locally. */
|
|
20
|
+
FLOCK_AUTH_TOKEN: str({ default: "" }),
|
|
21
|
+
|
|
22
|
+
// --- Gemini (transcription) ---
|
|
23
|
+
/** Google / Gemini API key for audio transcription. Read by @tanstack/ai-gemini. */
|
|
24
|
+
GEMINI_API_KEY: str({ default: "" }),
|
|
25
|
+
/** Gemini model for audio transcription. Either "gemini-3-flash-preview" or "gemini-3.1-flash-lite-preview". */
|
|
26
|
+
TRANSCRIPTION_MODEL: str({ default: "gemini-3.1-flash-lite-preview" }),
|
|
27
|
+
|
|
28
|
+
// --- Fly Sprites ---
|
|
29
|
+
/** Name of the Fly Sprite to sync projects to. Required for `sync` command. */
|
|
30
|
+
SPRITE_NAME: str({ default: "" }),
|
|
31
|
+
/** Sprites API authentication token. Required for `sync` command. */
|
|
32
|
+
SPRITES_TOKEN: str({ default: "" }),
|
|
33
|
+
/** Sprites API base URL override. */
|
|
34
|
+
SPRITES_API_URL: url({ default: "https://api.sprites.dev" }),
|
|
35
|
+
})
|