owlservable 0.2.1 → 0.2.3

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.
Files changed (3) hide show
  1. package/auto.cjs +1 -2
  2. package/index.cjs +400 -0
  3. package/package.json +8 -2
package/auto.cjs CHANGED
@@ -1,2 +1 @@
1
- // CJS wrapper — dynamic import works in CommonJS Node.js
2
- import('./index.js').then(function(m) { m.init() }).catch(function() {})
1
+ require('./index.cjs').init()
package/index.cjs ADDED
@@ -0,0 +1,400 @@
1
+ 'use strict'
2
+ const { EventEmitter } = require('node:events')
3
+ const http = require('node:http')
4
+ const https = require('node:https')
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+
8
+ const __dir = __dirname
9
+
10
+ const MAX_REQ_BODY = 32 * 1024
11
+ const MAX_RES_BODY = 64 * 1024
12
+ const MAX_PARSE = 512 * 1024
13
+
14
+ const emitter = new EventEmitter()
15
+ emitter.setMaxListeners(100)
16
+
17
+ const requests = []
18
+ let nextId = 1
19
+
20
+ function addRequest(entry) {
21
+ try {
22
+ const r = { id: nextId++, ...entry, timestamp: Date.now() }
23
+ requests.push(r)
24
+ if (requests.length > 500) requests.shift()
25
+ emitter.emit('request', r)
26
+ if (saveState.enabled) persistLine(r)
27
+ return r
28
+ } catch (_) {}
29
+ }
30
+
31
+ function updateRequest(id, patch) {
32
+ try {
33
+ const r = requests.find(r => r.id === id)
34
+ if (r) {
35
+ Object.assign(r, patch)
36
+ emitter.emit('update', { id, patch })
37
+ if (saveState.enabled && (patch.tokens || patch.resBody)) persistLine(r)
38
+ }
39
+ } catch (_) {}
40
+ }
41
+
42
+ function getRequests() { try { return requests.slice() } catch (_) { return [] } }
43
+
44
+ function clearRequests() {
45
+ requests.splice(0)
46
+ if (saveState.enabled) try { fs.writeFileSync(saveState.filePath, '') } catch (_) {}
47
+ emitter.emit('reload', { requests: [], save: getSaveInfo() })
48
+ }
49
+
50
+ function extractAiMeta(body) {
51
+ try {
52
+ const j = JSON.parse(body)
53
+ if (j.usage?.total_tokens != null)
54
+ return { model: j.model || null, tokens: { input: j.usage.prompt_tokens || 0, output: j.usage.completion_tokens || 0, total: j.usage.total_tokens } }
55
+ if (j.usage?.input_tokens != null) {
56
+ const i = j.usage.input_tokens || 0, o = j.usage.output_tokens || 0
57
+ return { model: j.model || null, tokens: { input: i, output: o, total: i + o } }
58
+ }
59
+ if (j.usageMetadata?.totalTokenCount != null) {
60
+ const m = j.usageMetadata
61
+ return { model: j.modelVersion || null, tokens: { input: m.promptTokenCount || 0, output: m.candidatesTokenCount || 0, total: m.totalTokenCount } }
62
+ }
63
+ if (j.meta?.billed_units) {
64
+ const b = j.meta.billed_units, i = b.input_tokens || 0, o = b.output_tokens || 0
65
+ if (i || o) return { model: null, tokens: { input: i, output: o, total: i + o } }
66
+ }
67
+ } catch (_) {}
68
+ return null
69
+ }
70
+
71
+ function extractAiMetaFromSSE(raw) {
72
+ try {
73
+ let input = 0, output = 0, model = null
74
+ for (const line of raw.split('\n')) {
75
+ if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
76
+ try {
77
+ const j = JSON.parse(line.slice(6))
78
+ if (j.usage?.total_tokens != null)
79
+ return { model: j.model || model, tokens: { input: j.usage.prompt_tokens || 0, output: j.usage.completion_tokens || 0, total: j.usage.total_tokens } }
80
+ if (j.type === 'message_start' && j.message) {
81
+ if (j.message.usage?.input_tokens) input = j.message.usage.input_tokens
82
+ if (j.message.model) model = j.message.model
83
+ }
84
+ if (j.type === 'message_delta' && j.usage?.output_tokens)
85
+ output = j.usage.output_tokens
86
+ } catch (_) {}
87
+ }
88
+ if (input || output) return { model, tokens: { input, output, total: input + output } }
89
+ } catch (_) {}
90
+ return null
91
+ }
92
+
93
+ function capStr(s, max) {
94
+ if (!s || typeof s !== 'string') return null
95
+ return s.length <= max ? s : s.slice(0, max) + '…'
96
+ }
97
+
98
+ function bodyFromInit(b) {
99
+ try {
100
+ if (typeof b === 'string') return capStr(b, MAX_REQ_BODY)
101
+ if (b instanceof URLSearchParams) return capStr(b.toString(), MAX_REQ_BODY)
102
+ if (b instanceof ArrayBuffer) return capStr(Buffer.from(b).toString('utf8'), MAX_REQ_BODY)
103
+ if (ArrayBuffer.isView(b)) return capStr(Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString('utf8'), MAX_REQ_BODY)
104
+ } catch (_) {}
105
+ return null
106
+ }
107
+
108
+ let patched = false
109
+
110
+ function handleJsonResponse(record, getText) {
111
+ getText()
112
+ .then(body => {
113
+ const meta = extractAiMeta(body)
114
+ const update = { metaPending: false, resBody: capStr(body, MAX_RES_BODY) }
115
+ if (meta) Object.assign(update, meta)
116
+ updateRequest(record.id, update)
117
+ })
118
+ .catch(() => updateRequest(record.id, { metaPending: false }))
119
+ }
120
+
121
+ function handleSseResponse(record, getText) {
122
+ getText()
123
+ .then(body => {
124
+ const meta = extractAiMetaFromSSE(body)
125
+ const update = { metaPending: false }
126
+ if (meta) Object.assign(update, meta)
127
+ updateRequest(record.id, update)
128
+ })
129
+ .catch(() => updateRequest(record.id, { metaPending: false }))
130
+ }
131
+
132
+ function patchFetch() {
133
+ const orig = globalThis.fetch
134
+ globalThis.fetch = async function interceptedFetch(input, init) {
135
+ let url = 'unknown', method = 'GET', reqBody = null
136
+ try {
137
+ url = input instanceof Request ? input.url : String(input)
138
+ method = (init?.method || (input instanceof Request ? input.method : null) || 'GET').toUpperCase()
139
+ const rawBody = init?.body ?? null
140
+ if (typeof rawBody === 'string' && rawBody.includes('"stream"')) {
141
+ try {
142
+ const parsed = JSON.parse(rawBody)
143
+ if (parsed.stream === true && !parsed.stream_options?.include_usage) {
144
+ parsed.stream_options = { ...parsed.stream_options, include_usage: true }
145
+ init = { ...init, body: JSON.stringify(parsed) }
146
+ }
147
+ } catch (_) {}
148
+ }
149
+ reqBody = bodyFromInit(init?.body ?? null)
150
+ } catch (_) {}
151
+
152
+ const start = Date.now()
153
+ try {
154
+ const res = await orig.call(this, input, init)
155
+ const ct = res.headers.get('content-type') || ''
156
+ const isJson = ct.includes('application/json')
157
+ const isSse = ct.includes('text/event-stream')
158
+ const r = addRequest({ url, method, status: res.status, latency: Date.now() - start, reqBody, metaPending: isJson || isSse })
159
+ if (r && isJson) handleJsonResponse(r, () => res.clone().text())
160
+ if (r && isSse) handleSseResponse(r, () => res.clone().text())
161
+ return res
162
+ } catch (err) {
163
+ try { addRequest({ url, method, status: 0, latency: Date.now() - start, reqBody, error: err.message }) } catch (_) {}
164
+ throw err
165
+ }
166
+ }
167
+ }
168
+
169
+ function patchHttpModule(mod, protocol) {
170
+ const orig = mod.request
171
+ mod.request = function interceptedRequest(...args) {
172
+ const start = Date.now()
173
+ let url = 'unknown', method = 'GET'
174
+ try {
175
+ const first = args[0]
176
+ if (typeof first === 'string') { url = first; method = args[1]?.method || 'GET' }
177
+ else if (first instanceof URL) { url = first.toString(); method = args[1]?.method || 'GET' }
178
+ else if (first && typeof first === 'object') {
179
+ url = `${protocol}://${first.hostname || first.host || 'localhost'}${first.port ? ':' + first.port : ''}${first.path || '/'}`
180
+ method = first.method || 'GET'
181
+ }
182
+ } catch (_) {}
183
+
184
+ const req = orig.apply(mod, args)
185
+ const reqChunks = []; let reqSize = 0
186
+ const origWrite = req.write
187
+ req.write = function(chunk, encoding, cb) {
188
+ try {
189
+ if (reqSize < MAX_REQ_BODY) {
190
+ const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : typeof chunk === 'string' ? chunk : ''
191
+ if (s) { reqChunks.push(s); reqSize += s.length }
192
+ }
193
+ } catch (_) {}
194
+ return origWrite.call(this, chunk, encoding, cb)
195
+ }
196
+
197
+ try {
198
+ req.once('response', res => {
199
+ const reqBody = reqSize > 0 ? reqChunks.join('').slice(0, MAX_REQ_BODY) : null
200
+ const ct = res.headers['content-type'] || ''
201
+ const isJson = ct.includes('application/json')
202
+ const isSse = ct.includes('text/event-stream')
203
+ const r = addRequest({ url, method: method.toUpperCase(), status: res.statusCode, latency: Date.now() - start, reqBody, metaPending: isJson || isSse })
204
+ if (r && (isJson || isSse)) {
205
+ try {
206
+ const chunks = []; let size = 0
207
+ res.on('data', chunk => { try { if (size < MAX_PARSE) { chunks.push(chunk); size += chunk.length } } catch (_) {} })
208
+ const getText = () => new Promise((resolve, reject) => {
209
+ res.once('end', () => { try { resolve(Buffer.concat(chunks).toString('utf8')) } catch (e) { reject(e) } })
210
+ res.once('error', reject)
211
+ })
212
+ if (isJson) handleJsonResponse(r, getText)
213
+ if (isSse) handleSseResponse(r, getText)
214
+ } catch (_) { updateRequest(r.id, { metaPending: false }) }
215
+ }
216
+ })
217
+ req.once('error', err => {
218
+ try { addRequest({ url, method: method.toUpperCase(), status: 0, latency: Date.now() - start, error: err.message }) } catch (_) {}
219
+ })
220
+ } catch (_) {}
221
+
222
+ return req
223
+ }
224
+ }
225
+
226
+ function patch() {
227
+ if (patched) return; patched = true
228
+ try { if (typeof globalThis.fetch === 'function') patchFetch() } catch (_) {}
229
+ try { patchHttpModule(http, 'http') } catch (_) {}
230
+ try { patchHttpModule(https, 'https') } catch (_) {}
231
+ }
232
+
233
+ const saveState = { enabled: false, filePath: null, retention: null }
234
+
235
+ function getSaveInfo() {
236
+ if (!saveState.enabled) return { enabled: false }
237
+ return { enabled: true, filePath: saveState.filePath, relPath: path.relative(process.cwd(), saveState.filePath).replace(/\\/g, '/'), retention: saveState.retention }
238
+ }
239
+
240
+ function persistLine(r) {
241
+ try { fs.appendFileSync(saveState.filePath, JSON.stringify(r) + '\n') } catch (_) {}
242
+ }
243
+
244
+ function parseLog(raw, cutoff) {
245
+ const byId = new Map()
246
+ for (const line of raw.split('\n')) {
247
+ try { const r = JSON.parse(line); if (r.id && r.timestamp > cutoff) byId.set(r.id, r) } catch (_) {}
248
+ }
249
+ return [...byId.values()].sort((a, b) => a.id - b.id)
250
+ }
251
+
252
+ function pruneFile() {
253
+ if (!saveState.filePath) return
254
+ try {
255
+ const raw = fs.readFileSync(saveState.filePath, 'utf8').trim()
256
+ if (!raw) return
257
+ const entries = parseLog(raw, Date.now() - saveState.retention)
258
+ const out = entries.map(JSON.stringify).join('\n')
259
+ fs.writeFileSync(saveState.filePath, out + (out ? '\n' : ''))
260
+ } catch (_) {}
261
+ }
262
+
263
+ function enableSave(retentionMs) {
264
+ const filePath = path.join(process.cwd(), '.owlservable', 'log.ndjson')
265
+ const dir = path.dirname(filePath)
266
+ try { fs.mkdirSync(dir, { recursive: true }) } catch (_) {}
267
+ try { const gi = path.join(dir, '.gitignore'); if (!fs.existsSync(gi)) fs.writeFileSync(gi, '*\n') } catch (_) {}
268
+ try { fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ save: true, retention: retentionMs })) } catch (_) {}
269
+ saveState.enabled = true
270
+ saveState.filePath = filePath
271
+ saveState.retention = retentionMs
272
+ try {
273
+ const raw = fs.readFileSync(filePath, 'utf8').trim()
274
+ if (raw) {
275
+ const loaded = parseLog(raw, Date.now() - retentionMs)
276
+ for (const r of loaded) {
277
+ if (!requests.find(x => x.id === r.id)) requests.push(r)
278
+ if (r.id >= nextId) nextId = r.id + 1
279
+ }
280
+ if (requests.length > 500) requests.splice(0, requests.length - 500)
281
+ }
282
+ } catch (_) {}
283
+ pruneFile()
284
+ emitter.emit('reload', { requests: getRequests(), save: getSaveInfo() })
285
+ }
286
+
287
+ function disableSave() {
288
+ saveState.enabled = false
289
+ try { fs.unlinkSync(path.join(process.cwd(), '.owlservable', 'config.json')) } catch (_) {}
290
+ emitter.emit('saveConfig', getSaveInfo())
291
+ }
292
+
293
+ setInterval(() => { if (saveState.enabled) pruneFile() }, 3_600_000).unref()
294
+
295
+ let server = null
296
+ let DASHBOARD_HTML = null
297
+
298
+ function getDashboard() {
299
+ if (!DASHBOARD_HTML) DASHBOARD_HTML = fs.readFileSync(path.join(__dir, 'dashboard.html'), 'utf8')
300
+ return DASHBOARD_HTML
301
+ }
302
+
303
+ function readBody(req) {
304
+ return new Promise((resolve, reject) => {
305
+ let body = ''
306
+ req.on('data', c => { body += c })
307
+ req.on('end', () => resolve(body))
308
+ req.on('error', reject)
309
+ })
310
+ }
311
+
312
+ function startDashboard(port) {
313
+ if (server) return
314
+
315
+ server = http.createServer((req, res) => {
316
+ try {
317
+ const urlPath = req.url?.split('?')[0] || '/'
318
+
319
+ if (urlPath === '/events') {
320
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' })
321
+ const send = d => { try { res.write('data: ' + JSON.stringify(d) + '\n\n') } catch (_) {} }
322
+ send({ type: 'init', requests: getRequests(), save: getSaveInfo() })
323
+ const onReq = r => send({ type: 'request', record: r })
324
+ const onUpdate = ({ id, patch }) => send({ type: 'update', id, patch })
325
+ const onSave = cfg => send({ type: 'saveConfig', config: cfg })
326
+ const onReload = data => send({ type: 'reload', ...data })
327
+ emitter.on('request', onReq)
328
+ emitter.on('update', onUpdate)
329
+ emitter.on('saveConfig', onSave)
330
+ emitter.on('reload', onReload)
331
+ req.on('close', () => {
332
+ emitter.off('request', onReq)
333
+ emitter.off('update', onUpdate)
334
+ emitter.off('saveConfig', onSave)
335
+ emitter.off('reload', onReload)
336
+ })
337
+ return
338
+ }
339
+
340
+ if (urlPath === '/api/save' && req.method === 'POST') {
341
+ readBody(req).then(body => {
342
+ const { action, retention } = JSON.parse(body)
343
+ if (action === 'enable' && retention > 0) enableSave(retention)
344
+ else if (action === 'disable') disableSave()
345
+ res.writeHead(200, { 'Content-Type': 'application/json' })
346
+ res.end(JSON.stringify({ ok: true, save: getSaveInfo() }))
347
+ }).catch(e => { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })) })
348
+ return
349
+ }
350
+
351
+ if (urlPath === '/api/clear' && req.method === 'POST') {
352
+ clearRequests()
353
+ res.writeHead(200, { 'Content-Type': 'application/json' })
354
+ res.end(JSON.stringify({ ok: true }))
355
+ return
356
+ }
357
+
358
+ if (urlPath === '/owl.png') {
359
+ try {
360
+ const data = fs.readFileSync(path.join(__dir, 'owl.png'))
361
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' })
362
+ res.end(data)
363
+ } catch (_) { res.writeHead(404); res.end() }
364
+ return
365
+ }
366
+
367
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
368
+ res.end(getDashboard())
369
+ } catch (_) { try { if (!res.headersSent) { res.writeHead(500); res.end() } } catch (_) {} }
370
+ })
371
+
372
+ server.on('error', e => { if (e.code === 'EADDRINUSE') console.warn('[owlservable] Port ' + port + ' in use — dashboard not started') })
373
+ server.listen(port, '127.0.0.1', () => console.log('[owlservable] Dashboard → http://localhost:' + port))
374
+ }
375
+
376
+ let initialized = false
377
+
378
+ function init({ port = 4321, dashboard = true, logging = false, save = false, retention = 86_400_000 } = {}) {
379
+ if (initialized) return; initialized = true
380
+ if (process.env.NODE_ENV === 'production') {
381
+ console.warn('[owlservable] Refusing to start in NODE_ENV=production — remove owlservable from your production bundle')
382
+ return
383
+ }
384
+ try { patch() } catch (_) {}
385
+ if (!save) {
386
+ try {
387
+ const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.owlservable', 'config.json'), 'utf8'))
388
+ if (cfg.save) { save = true; retention = cfg.retention || retention }
389
+ } catch (_) {}
390
+ }
391
+ if (save) try { enableSave(retention) } catch (_) {}
392
+ if (dashboard) try { startDashboard(port) } catch (_) {}
393
+ if (logging) {
394
+ emitter.on('request', r => {
395
+ try { console.log('[owlservable]', r.method, r.url, '→', r.status || r.error || '?', '(' + (r.latency || 0) + 'ms)') } catch (_) {}
396
+ })
397
+ }
398
+ }
399
+
400
+ module.exports = { init }
package/package.json CHANGED
@@ -1,19 +1,25 @@
1
1
  {
2
2
  "name": "owlservable",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Minimalist Observability Platform. Zero config, zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
8
8
  "exports": {
9
- ".": "./index.js",
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "import": "./index.js",
12
+ "require": "./index.cjs"
13
+ },
10
14
  "./auto": {
15
+ "types": "./index.d.ts",
11
16
  "import": "./auto.js",
12
17
  "require": "./auto.cjs"
13
18
  }
14
19
  },
15
20
  "files": [
16
21
  "index.js",
22
+ "index.cjs",
17
23
  "index.d.ts",
18
24
  "auto.js",
19
25
  "auto.cjs",