@rahmantech/knl-view 0.0.0-stage → 0.2.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/LICENSE +201 -0
- package/README.md +483 -2
- package/bin/knl-view.js +1320 -0
- package/dist/assets/abnfDiagram-WEYOIA3C-hoSbsMOz.js +1 -0
- package/dist/assets/arc-g87Np6E5.js +1 -0
- package/dist/assets/architecture-WOLXFQ4H-DOIq-0IA.js +1 -0
- package/dist/assets/architectureDiagram-ZHVYKT53-Dxwe8n7m.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-RJAE7QA3-BnGng59Z.js +140 -0
- package/dist/assets/c4Diagram-XDPPPE54-C5LjqIIo.js +38 -0
- package/dist/assets/channel-BVWkRFBw.js +1 -0
- package/dist/assets/chunk-2E7WHVMD-BJ1dyGLq.js +88 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4EMNKR2T-DXF02Sgw.js +1 -0
- package/dist/assets/chunk-55XCU76K-CM1akAbu.js +1 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-7S6XSTFO-BOUjqldL.js +2 -0
- package/dist/assets/chunk-AQDTSKZB-C4isdNLA.js +72 -0
- package/dist/assets/chunk-DSNLSHJZ-CbOTkSVM.js +129 -0
- package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/assets/chunk-HAGFOTED-CRfnketR.js +62 -0
- package/dist/assets/chunk-HNE4Q36P-rxlpGUU8.js +1 -0
- package/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/assets/chunk-NQOKRVIF-BOYCgTmV.js +213 -0
- package/dist/assets/chunk-UHW7PFBP-Cnogp1Fb.js +1 -0
- package/dist/assets/chunk-VQ5W62QI-Cu1WY5Uo.js +1 -0
- package/dist/assets/chunk-WQQL4CDK-BJc3FPE2.js +10 -0
- package/dist/assets/chunk-XXDRQBXY-BlPj4H7s.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/chunk-YZO67HFG-V85H31x4.js +1 -0
- package/dist/assets/chunk-ZAUX2LTX-C3XKXW09.js +1 -0
- package/dist/assets/chunk-ZCHMQSOR-B0mmpEgW.js +2 -0
- package/dist/assets/classDiagram-v2-4MYWR3UA-oMd7YaHP.js +217 -0
- package/dist/assets/cose-bilkent-JH36ORCC-J0kSVsDf.js +1 -0
- package/dist/assets/cynefin-EF2NZ3EQ-DK2TNhel.js +1 -0
- package/dist/assets/cynefinDiagram-WF24UDRP-DDvKgieA.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-NK4GIZVX-Ca2Fe-i-.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-CBK3MWTC-L6dmeQ2V.js +24 -0
- package/dist/assets/diagram-I5XIIV2D-DhG8R-v5.js +24 -0
- package/dist/assets/diagram-ODIL2UCW-BpYYbYBT.js +30 -0
- package/dist/assets/diagram-PFBEJUXE-DKQpHSsC.js +3 -0
- package/dist/assets/diagram-RJA3I4VV-DP7Dsnya.js +41 -0
- package/dist/assets/diagram-TYUNFSNR-CfSFewUS.js +200 -0
- package/dist/assets/dist-CQNjnrld.js +1 -0
- package/dist/assets/ebnfDiagram-V4P3TTPT-DE3MZoJO.js +1 -0
- package/dist/assets/elk-QONTBCTQ-p_aAQPHU.js +27 -0
- package/dist/assets/erDiagram-KYQIGQAA-DZO3vQrk.js +99 -0
- package/dist/assets/eventmodeling-K75KTNOO-BII9EPl5.js +1 -0
- package/dist/assets/flowDiagram-IUGLLWBU-D9uPno39.js +1 -0
- package/dist/assets/ganttDiagram-R7GPNTJY-BKiH3v2r.js +292 -0
- package/dist/assets/gitGraph-VSP46ZUC-oHL6cr73.js +1 -0
- package/dist/assets/gitGraphDiagram-PDYL7XHY-CqPVIEeo.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-CSpirV16.js +76 -0
- package/dist/assets/index-DLgMsUs2.css +1 -0
- package/dist/assets/info-OHQRW6UA-Drrbnhpd.js +1 -0
- package/dist/assets/infoDiagram-MVDRKAX5-DnQQFMAd.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-HPEHV2GE-DIpQlxRh.js +70 -0
- package/dist/assets/journeyDiagram-BOSHW7O7-EBR9I5h1.js +139 -0
- package/dist/assets/kanban-definition-FNSA4HC5-CdvNsjik.js +89 -0
- package/dist/assets/katex-B_JcIwqj.js +257 -0
- package/dist/assets/line-CDLzgxDc.js +1 -0
- package/dist/assets/linear-DfXKql9u.js +1 -0
- package/dist/assets/mermaid-parser.core-DnXGphT2.js +7 -0
- package/dist/assets/mermaid.core-h6fiCaSZ.js +44 -0
- package/dist/assets/mindmap-definition-6OY57UG3-BiNfio-j.js +98 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-JDAUHWVQ-D76byv1S.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-ECIUJNBX-Drs8Pxdt.js +1 -0
- package/dist/assets/pie-XZMESJXO-D_83z1lM.js +1 -0
- package/dist/assets/pieDiagram-GNMPY74T-wvzIphSf.js +39 -0
- package/dist/assets/quadrantDiagram-D57K4MC3-B7EfMRWb.js +7 -0
- package/dist/assets/radar-ABXABTNO-Bs6dVdAR.js +1 -0
- package/dist/assets/railroad-I3PHUGI6-lKaN4miw.js +1 -0
- package/dist/assets/railroad-abnf-LNEFI6M7-Bhh16b7Z.js +1 -0
- package/dist/assets/railroad-ebnf-DWYD2UWJ-m6ybW-Y_.js +1 -0
- package/dist/assets/railroad-peg-JR7BVNR7-DNn72e7r.js +1 -0
- package/dist/assets/railroadDiagram-Y72NRBL7-DNeop1iZ.js +1 -0
- package/dist/assets/requirementDiagram-EQYSW5RA-BVYZ2XYJ.js +84 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-SY24JHO5-B2MP51on.js +40 -0
- package/dist/assets/sequenceDiagram-BLEHNHQU-BUvxstXG.js +169 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-BrR0lqlG.js +1 -0
- package/dist/assets/stateDiagram-v2-A6IESKWZ-Ba84OGKE.js +291 -0
- package/dist/assets/swimlanes-B3ENRKFQ-BMSl2PNA.js +1 -0
- package/dist/assets/swimlanesDiagram-AYQRV4AK-M1dmvq7m.js +8 -0
- package/dist/assets/timeline-definition-JXYKGLGB-DOdW-dxq.js +120 -0
- package/dist/assets/treeView-D4JQ5SDB-DZ9KQyn5.js +1 -0
- package/dist/assets/treemap-SAJKECNS-WEC5wtDV.js +1 -0
- package/dist/assets/usecaseDiagram-WPOLBNNX-cldTztFL.js +328 -0
- package/dist/assets/vennDiagram-Q3Y3IDCW-c41KySoY.js +34 -0
- package/dist/assets/wardley-7MLQ67FV-Bf9_aSSZ.js +1 -0
- package/dist/assets/wardleyDiagram-IVAIDKWA-BeKEvEBG.js +78 -0
- package/dist/assets/xychartDiagram-4URNHDU4-tNkAxTxG.js +7 -0
- package/dist/index.html +22 -0
- package/package.json +50 -4
- package/server/config.js +389 -0
- package/server/consensus.js +180 -0
- package/server/graph/anchordb_client.js +253 -0
- package/server/graph/model.js +157 -0
- package/server/graph/parser.js +327 -0
- package/server/graph/sources.js +801 -0
- package/server/graph/syntax.js +437 -0
- package/server/index.js +742 -0
- package/server/install.js +437 -0
- package/server/map.js +126 -0
- package/server/models_registry.js +109 -0
- package/server/scaffold.js +466 -0
package/server/index.js
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import { promises as fs, createReadStream } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { readMap, normalizeMap, decorateNode } from './map.js'
|
|
5
|
+
import { buildGraphFromRoot } from './graph/parser.js'
|
|
6
|
+
import { createAnchorWireClient, syncGraphToAnchorDB, traceCausalChain, traceInboundFromMirror, mergeLineage, mirrorStillValid } from './graph/anchordb_client.js'
|
|
7
|
+
import { traceSources } from './graph/sources.js'
|
|
8
|
+
import {
|
|
9
|
+
deriveStatus,
|
|
10
|
+
readClaimsIndex,
|
|
11
|
+
readConsensusMirror,
|
|
12
|
+
inboundConsensus,
|
|
13
|
+
outboundConsensus
|
|
14
|
+
} from './consensus.js'
|
|
15
|
+
import { anchordbClientOptions, syncStatePath, DEFAULT_RELATIONS, createPathMatcher } from './config.js'
|
|
16
|
+
|
|
17
|
+
const MIME = {
|
|
18
|
+
'.html': 'text/html; charset=utf-8',
|
|
19
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
20
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
21
|
+
'.css': 'text/css; charset=utf-8',
|
|
22
|
+
'.json': 'application/json; charset=utf-8',
|
|
23
|
+
'.map': 'application/json; charset=utf-8',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.png': 'image/png',
|
|
26
|
+
'.jpg': 'image/jpeg',
|
|
27
|
+
'.jpeg': 'image/jpeg',
|
|
28
|
+
'.gif': 'image/gif',
|
|
29
|
+
'.webp': 'image/webp',
|
|
30
|
+
'.avif': 'image/avif',
|
|
31
|
+
'.ico': 'image/x-icon',
|
|
32
|
+
'.woff': 'font/woff',
|
|
33
|
+
'.woff2': 'font/woff2',
|
|
34
|
+
'.ttf': 'font/ttf',
|
|
35
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
36
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
37
|
+
'.prd': 'text/markdown; charset=utf-8',
|
|
38
|
+
'.pdf': 'application/pdf'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MD_EXTS = new Set(['md', 'markdown', 'prd'])
|
|
42
|
+
const MAX_DOC_BYTES = 8 * 1024 * 1024
|
|
43
|
+
const TITLE_READ_BYTES = 8192
|
|
44
|
+
|
|
45
|
+
export function createKnowledgeServer({ root, dist = null, host = '127.0.0.1', port = 8085, anchordb = null, consensusModel = null, sourcesModel = null, include = null, exclude = null }) {
|
|
46
|
+
const rootAbs = path.resolve(root)
|
|
47
|
+
const distAbs = dist ? path.resolve(dist) : null
|
|
48
|
+
// Live AnchorDB connection status lives here, not a snapshot: attachAnchorDB
|
|
49
|
+
// fills it after the server listens, and routes read the latest status.
|
|
50
|
+
const anchordbState = {
|
|
51
|
+
config: anchordb,
|
|
52
|
+
client: null,
|
|
53
|
+
status: anchordb ? 'connecting' : 'off',
|
|
54
|
+
error: null,
|
|
55
|
+
syncedAt: null,
|
|
56
|
+
consensusModel,
|
|
57
|
+
consensusClient: null,
|
|
58
|
+
consensusError: null,
|
|
59
|
+
sourcesModel,
|
|
60
|
+
sourcesClient: null,
|
|
61
|
+
sourcesError: null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const server = http.createServer((req, res) => {
|
|
65
|
+
route(req, res, rootAbs, distAbs, { host, port, anchordb: anchordbState, include, exclude }).catch((err) => {
|
|
66
|
+
if (!res.headersSent) sendJson(res, 500, { error: `Failed to handle request: ${err.message}` })
|
|
67
|
+
else res.end()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
server.knlRoot = rootAbs
|
|
72
|
+
server.knlHost = host
|
|
73
|
+
server.knlPort = port
|
|
74
|
+
server.knlAnchorDB = anchordbState
|
|
75
|
+
return server
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Connect knl-view to AnchorDB after the server listens. Connect or sync
|
|
79
|
+
// failures never bring the server down: the viewer keeps running fully
|
|
80
|
+
// local, and the status can be read from the state.
|
|
81
|
+
export async function attachAnchorDB(server) {
|
|
82
|
+
const state = server.knlAnchorDB
|
|
83
|
+
if (!state?.config) {
|
|
84
|
+
state.status = 'off'
|
|
85
|
+
return { ok: false, reason: 'off' }
|
|
86
|
+
}
|
|
87
|
+
const options = anchordbClientOptions(state.config)
|
|
88
|
+
if (!options) {
|
|
89
|
+
state.status = 'error'
|
|
90
|
+
state.error = 'anchordb URL is unreadable'
|
|
91
|
+
return { ok: false, reason: state.error }
|
|
92
|
+
}
|
|
93
|
+
if (!options.model) {
|
|
94
|
+
state.status = 'error'
|
|
95
|
+
state.error = 'model name is not mapped yet'
|
|
96
|
+
return { ok: false, reason: 'role "knowledges" is missing from the knl-config.json "models" map, run: knl-view model new knowledges' }
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
state.client = await createAnchorWireClient(options)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
state.client = null
|
|
102
|
+
state.status = 'error'
|
|
103
|
+
state.error = err.message
|
|
104
|
+
return { ok: false, reason: err.message }
|
|
105
|
+
}
|
|
106
|
+
state.status = 'connected'
|
|
107
|
+
state.error = null
|
|
108
|
+
state.model = options.model
|
|
109
|
+
|
|
110
|
+
// A second client for the consensus model, when the project maps that
|
|
111
|
+
// role. Best effort: a failed consensus connection only disables
|
|
112
|
+
// consensus endpoints, never the main graph sync.
|
|
113
|
+
if (state.consensusModel) {
|
|
114
|
+
try {
|
|
115
|
+
state.consensusClient = await createAnchorWireClient({ ...options, model: state.consensusModel })
|
|
116
|
+
} catch (err) {
|
|
117
|
+
state.consensusClient = null
|
|
118
|
+
state.consensusError = err.message
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Third client for the sources model (whole-project dependency graph),
|
|
123
|
+
// same best-effort policy as consensus.
|
|
124
|
+
if (state.sourcesModel) {
|
|
125
|
+
try {
|
|
126
|
+
state.sourcesClient = await createAnchorWireClient({ ...options, model: state.sourcesModel })
|
|
127
|
+
} catch (err) {
|
|
128
|
+
state.sourcesClient = null
|
|
129
|
+
state.sourcesError = err.message
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
let synced = false
|
|
133
|
+
if (state.config.sync) {
|
|
134
|
+
try {
|
|
135
|
+
const graph = await buildGraphFromRoot(server.knlRoot)
|
|
136
|
+
const previous = await readSyncState(options.model)
|
|
137
|
+
const result = await syncGraphToAnchorDB(state.client, graph, previous)
|
|
138
|
+
if (result.failed === 0) {
|
|
139
|
+
// The mirror is only written on a clean sync, so failed parts are
|
|
140
|
+
// retried automatically on the next start.
|
|
141
|
+
await writeSyncState(options.model, result.edges)
|
|
142
|
+
}
|
|
143
|
+
state.syncedAt = Date.now()
|
|
144
|
+
state.lastSync = result
|
|
145
|
+
synced = true
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// A connection is enough for trace; a sync failure is only recorded.
|
|
148
|
+
state.error = err.message
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
model: options.model,
|
|
154
|
+
synced,
|
|
155
|
+
linked: state.lastSync?.linked ?? 0,
|
|
156
|
+
unlinked: state.lastSync?.unlinked ?? 0,
|
|
157
|
+
kept: state.lastSync?.kept ?? 0,
|
|
158
|
+
failed: state.lastSync?.failed ?? 0
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Mirror of the edges from the last sync: ~/.knlview/sync-state/<model>.json.
|
|
163
|
+
// A broken/missing file is treated as "never synced" (full sync), not an
|
|
164
|
+
// error; this mirror is purely an optimization, not a source of truth.
|
|
165
|
+
async function readSyncState(model) {
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(await fs.readFile(syncStatePath(model), 'utf8'))
|
|
168
|
+
if (parsed?.model !== model || !Array.isArray(parsed.edges)) return null
|
|
169
|
+
return parsed.edges
|
|
170
|
+
} catch {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeSyncState(model, edges) {
|
|
176
|
+
try {
|
|
177
|
+
const file = syncStatePath(model)
|
|
178
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
179
|
+
await fs.writeFile(file, JSON.stringify({ model, syncedAt: new Date().toISOString(), edges }), 'utf8')
|
|
180
|
+
} catch {
|
|
181
|
+
// Mirror write failed: the sync still counts, only incrementality is lost.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function route(req, res, rootAbs, distAbs, listen) {
|
|
186
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
187
|
+
res.setHeader('Allow', 'GET, HEAD')
|
|
188
|
+
return sendJson(res, 405, { error: 'Only GET is supported.' })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const url = new URL(req.url, 'http://localhost')
|
|
192
|
+
const pathname = url.pathname
|
|
193
|
+
|
|
194
|
+
if (pathname === '/api/meta') return sendJson(res, 200, await buildMeta(rootAbs, listen))
|
|
195
|
+
if (pathname === '/api/tree') return sendJson(res, 200, await buildTree(rootAbs, '', await loadMapNorm(rootAbs), listen))
|
|
196
|
+
if (pathname === '/api/doc') return sendDoc(res, rootAbs, url)
|
|
197
|
+
if (pathname === '/api/raw') return sendRaw(res, rootAbs, url)
|
|
198
|
+
if (pathname === '/api/graph') {
|
|
199
|
+
const graph = await buildGraphFromRoot(rootAbs)
|
|
200
|
+
return sendJson(res, 200, graph.toJSON())
|
|
201
|
+
}
|
|
202
|
+
if (pathname === '/api/graph/trace') {
|
|
203
|
+
const target = url.searchParams.get('target') || url.searchParams.get('id')
|
|
204
|
+
if (!target) {
|
|
205
|
+
return sendJson(res, 400, { error: 'Parameter "target" or "id" is required.' })
|
|
206
|
+
}
|
|
207
|
+
const rawHops = parseInt(url.searchParams.get('hops') || '2', 10)
|
|
208
|
+
const hops = Math.min(Math.max(1, Number.isInteger(rawHops) ? rawHops : 2), 10)
|
|
209
|
+
const rawDirection = url.searchParams.get('direction')
|
|
210
|
+
if (rawDirection !== null && !['in', 'out', 'both'].includes(rawDirection)) {
|
|
211
|
+
return sendJson(res, 400, { error: 'Parameter "direction" must be in, out, or both.' })
|
|
212
|
+
}
|
|
213
|
+
const direction = rawDirection || 'both'
|
|
214
|
+
const graph = await buildGraphFromRoot(rootAbs)
|
|
215
|
+
const subGraph = graph.extractSubGraph(target, hops, direction)
|
|
216
|
+
return sendJson(res, 200, subGraph)
|
|
217
|
+
}
|
|
218
|
+
if (pathname === '/api/anchordb/trace') {
|
|
219
|
+
return handleAnchorTrace(res, listen.anchordb, url)
|
|
220
|
+
}
|
|
221
|
+
if (pathname === '/api/consensus') {
|
|
222
|
+
return handleConsensus(res, listen.anchordb, url)
|
|
223
|
+
}
|
|
224
|
+
if (pathname === '/api/sources/trace') {
|
|
225
|
+
return handleSourcesTrace(res, listen.anchordb, url)
|
|
226
|
+
}
|
|
227
|
+
if (pathname.startsWith('/api/')) {
|
|
228
|
+
return sendJson(res, 404, { error: `Unknown endpoint: ${pathname}` })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return sendStatic(req, res, distAbs, pathname)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Multi-hop causal chain trace on the AnchorDB side. Only active when the
|
|
235
|
+
// project's knl-config.json carries an anchordb block and the connection
|
|
236
|
+
// is alive.
|
|
237
|
+
async function handleAnchorTrace(res, state, url) {
|
|
238
|
+
if (!state?.config) {
|
|
239
|
+
return sendJson(res, 503, { error: 'AnchorDB is not active. Add an "anchordb" block to knl-config.json.' })
|
|
240
|
+
}
|
|
241
|
+
if (state.status !== 'connected' || !state.client) {
|
|
242
|
+
return sendJson(res, 503, {
|
|
243
|
+
error: `AnchorDB not connected yet (status: ${state.status}).`,
|
|
244
|
+
detail: state.error || null
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
const target = url.searchParams.get('target') || url.searchParams.get('id')
|
|
248
|
+
if (!target) {
|
|
249
|
+
return sendJson(res, 400, { error: 'Parameter "target" or "id" is required.' })
|
|
250
|
+
}
|
|
251
|
+
const relations = url.searchParams.get('relations')
|
|
252
|
+
? url.searchParams.get('relations').split(',').map((s) => s.trim()).filter(Boolean)
|
|
253
|
+
: (state.config.relations?.length ? state.config.relations : DEFAULT_RELATIONS)
|
|
254
|
+
const rawLimit = parseInt(url.searchParams.get('limit') || '20', 10)
|
|
255
|
+
const limit = Math.min(Math.max(1, Number.isInteger(rawLimit) ? rawLimit : 20), 100)
|
|
256
|
+
const rawHops = parseInt(url.searchParams.get('hops') || '2', 10)
|
|
257
|
+
const hops = Math.min(Math.max(1, Number.isInteger(rawHops) ? rawHops : 2), 10)
|
|
258
|
+
const rawDirection = url.searchParams.get('direction')
|
|
259
|
+
if (rawDirection !== null && !['in', 'out', 'both'].includes(rawDirection)) {
|
|
260
|
+
return sendJson(res, 400, { error: 'Parameter "direction" must be in, out, or both.' })
|
|
261
|
+
}
|
|
262
|
+
const direction = rawDirection || 'out'
|
|
263
|
+
|
|
264
|
+
// Mechanically directional, never cause-and-effect:
|
|
265
|
+
// out: follow the relations forward from the target (server-scored)
|
|
266
|
+
// in: who references the target: walked from the local sync mirror,
|
|
267
|
+
// because the wire protocol has no reverse query
|
|
268
|
+
// both: union of the two, best score wins per word
|
|
269
|
+
const maxHops = Number.isInteger(hops) && hops > 0 ? hops : 2
|
|
270
|
+
const limitK = Number.isInteger(limit) && limit > 0 ? limit : 20
|
|
271
|
+
try {
|
|
272
|
+
const forward = direction === 'in'
|
|
273
|
+
? []
|
|
274
|
+
: await traceCausalChain(state.client, target, relations, limitK, maxHops)
|
|
275
|
+
let inbound = []
|
|
276
|
+
let mirrored = false
|
|
277
|
+
if (direction !== 'out') {
|
|
278
|
+
const mirrorEdges = await readSyncState(state.model || state.config.model)
|
|
279
|
+
mirrored = Array.isArray(mirrorEdges) && mirrorEdges.length > 0
|
|
280
|
+
inbound = mirrored ? traceInboundFromMirror(mirrorEdges, target, relations, maxHops) : []
|
|
281
|
+
}
|
|
282
|
+
const chain = direction === 'out' ? forward : mergeLineage(forward, inbound)
|
|
283
|
+
return sendJson(res, 200, {
|
|
284
|
+
target,
|
|
285
|
+
relations,
|
|
286
|
+
limit,
|
|
287
|
+
hops,
|
|
288
|
+
direction,
|
|
289
|
+
mirrored,
|
|
290
|
+
chain
|
|
291
|
+
})
|
|
292
|
+
} catch (err) {
|
|
293
|
+
return sendJson(res, 502, { error: `AnchorDB trace failed: ${err.message}` })
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Read-only consensus status over HTTP. Mutations stay in the CLI on
|
|
298
|
+
// purpose: one write path, one mirror to keep honest.
|
|
299
|
+
async function handleConsensus(res, state, url) {
|
|
300
|
+
if (!state?.config) {
|
|
301
|
+
return sendJson(res, 503, { error: 'AnchorDB is off. Add the "anchordb" block to the global knl-config.json.' })
|
|
302
|
+
}
|
|
303
|
+
if (state.status !== 'connected' || !state.client) {
|
|
304
|
+
return sendJson(res, 503, {
|
|
305
|
+
error: `AnchorDB is not connected yet (status: ${state.status}).`,
|
|
306
|
+
detail: state.error || null
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
if (!state.consensusModel) {
|
|
310
|
+
return sendJson(res, 503, {
|
|
311
|
+
error: 'Role "consensus" is not mapped in this project\'s models map.',
|
|
312
|
+
hint: 'knl-view model new consensus'
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
const target = url.searchParams.get('target') || url.searchParams.get('id')
|
|
316
|
+
if (!target) {
|
|
317
|
+
return sendJson(res, 400, { error: 'Parameter "target" or "id" is required.' })
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
let probeClient = state.consensusClient
|
|
321
|
+
if (!probeClient) {
|
|
322
|
+
return sendJson(res, 503, {
|
|
323
|
+
error: 'Consensus model is not reachable.',
|
|
324
|
+
detail: state.consensusError || 'no consensus client'
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
const mirrorEdges = await readConsensusMirror(state.consensusModel)
|
|
328
|
+
let mirrored = Array.isArray(mirrorEdges)
|
|
329
|
+
if (mirrored && mirrorEdges.length > 0) {
|
|
330
|
+
mirrored = await mirrorStillValid(probeClient, mirrorEdges).catch(() => false)
|
|
331
|
+
}
|
|
332
|
+
const usable = mirrored ? mirrorEdges : []
|
|
333
|
+
const inbound = inboundConsensus(usable, target)
|
|
334
|
+
const stance = outboundConsensus(usable, target)
|
|
335
|
+
const { claims } = await readClaimsIndex(state.consensusModel)
|
|
336
|
+
return sendJson(res, 200, {
|
|
337
|
+
target,
|
|
338
|
+
status: deriveStatus(inbound.supporters, inbound.objectors),
|
|
339
|
+
supporters: inbound.supporters,
|
|
340
|
+
objectors: inbound.objectors,
|
|
341
|
+
stance,
|
|
342
|
+
mirrored,
|
|
343
|
+
statements: Object.fromEntries(
|
|
344
|
+
[target, ...inbound.supporters, ...inbound.objectors]
|
|
345
|
+
.filter((id) => claims[id]?.text)
|
|
346
|
+
.map((id) => [id, claims[id].text])
|
|
347
|
+
)
|
|
348
|
+
})
|
|
349
|
+
} catch (err) {
|
|
350
|
+
return sendJson(res, 502, { error: `Consensus status failed: ${err.message}` })
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Read-only lineage over the sources model (whole-project dependency
|
|
355
|
+
// graph). Sync stays in the CLI; the server only answers traces.
|
|
356
|
+
async function handleSourcesTrace(res, state, url) {
|
|
357
|
+
if (!state?.config) {
|
|
358
|
+
return sendJson(res, 503, { error: 'AnchorDB is off. Add the "anchordb" block to the global knl-config.json.' })
|
|
359
|
+
}
|
|
360
|
+
if (state.status !== 'connected' || !state.client) {
|
|
361
|
+
return sendJson(res, 503, {
|
|
362
|
+
error: `AnchorDB is not connected yet (status: ${state.status}).`,
|
|
363
|
+
detail: state.error || null
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
if (!state.sourcesModel) {
|
|
367
|
+
return sendJson(res, 503, {
|
|
368
|
+
error: 'Role "sources" is not mapped in this project\'s models map.',
|
|
369
|
+
hint: 'knl-view model new sources'
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
if (!state.sourcesClient) {
|
|
373
|
+
return sendJson(res, 503, {
|
|
374
|
+
error: 'Sources model is not reachable.',
|
|
375
|
+
detail: state.sourcesError || 'no sources client'
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
const target = url.searchParams.get('target')
|
|
379
|
+
if (!target) {
|
|
380
|
+
return sendJson(res, 400, { error: 'Parameter "target" is required.' })
|
|
381
|
+
}
|
|
382
|
+
let direction = 'both'
|
|
383
|
+
const rawDirection = url.searchParams.get('direction')
|
|
384
|
+
if (rawDirection) {
|
|
385
|
+
if (!['in', 'out', 'both'].includes(rawDirection)) {
|
|
386
|
+
return sendJson(res, 400, { error: `direction must be in, out, or both, got "${rawDirection}".` })
|
|
387
|
+
}
|
|
388
|
+
direction = rawDirection
|
|
389
|
+
}
|
|
390
|
+
const hops = Number.parseInt(url.searchParams.get('hops') || '2', 10)
|
|
391
|
+
const maxHops = Math.min(Math.max(1, Number.isInteger(hops) ? hops : 2), 10)
|
|
392
|
+
try {
|
|
393
|
+
const { out, in: inc } = await traceSources(state.sourcesClient, target, direction, maxHops)
|
|
394
|
+
return sendJson(res, 200, {
|
|
395
|
+
target,
|
|
396
|
+
maxHops,
|
|
397
|
+
direction,
|
|
398
|
+
out: out.map(([id, hop]) => ({ id, hop, via: 'out' })),
|
|
399
|
+
in: inc.map(([id, hop]) => ({ id, hop, via: 'in' }))
|
|
400
|
+
})
|
|
401
|
+
} catch (err) {
|
|
402
|
+
return sendJson(res, 502, { error: `Sources trace failed: ${err.message}` })
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function safeJoin(base, relPath) {
|
|
407
|
+
if (!relPath || typeof relPath !== 'string') return null
|
|
408
|
+
if (relPath.includes('\0')) return null
|
|
409
|
+
const abs = path.resolve(base, relPath)
|
|
410
|
+
if (abs !== base && !abs.startsWith(base + path.sep)) return null
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const realBase = await fs.realpath(base)
|
|
414
|
+
const realAbs = await fs.realpath(abs)
|
|
415
|
+
if (realAbs !== realBase && !realAbs.startsWith(realBase + path.sep)) return null
|
|
416
|
+
return realAbs
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (err.code === 'ENOENT') {
|
|
419
|
+
try {
|
|
420
|
+
const realParent = await fs.realpath(path.dirname(abs))
|
|
421
|
+
const realBase = await fs.realpath(base)
|
|
422
|
+
if (realParent !== realBase && !realParent.startsWith(realBase + path.sep)) return null
|
|
423
|
+
return abs
|
|
424
|
+
} catch {
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return null
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function buildMeta(rootAbs, listen = {}) {
|
|
433
|
+
const norm = await loadMapNorm(rootAbs)
|
|
434
|
+
const tree = await buildTree(rootAbs, '', norm, listen)
|
|
435
|
+
const counts = { files: 0, dirs: 0, newest: 0 }
|
|
436
|
+
walkStats(tree, counts)
|
|
437
|
+
const host = listen.host || '127.0.0.1'
|
|
438
|
+
const port = listen.port || 0
|
|
439
|
+
return {
|
|
440
|
+
name: path.basename(rootAbs),
|
|
441
|
+
root: rootAbs,
|
|
442
|
+
files: counts.files,
|
|
443
|
+
dirs: counts.dirs,
|
|
444
|
+
newest: counts.newest,
|
|
445
|
+
map: norm.raw ? { title: norm.title } : null,
|
|
446
|
+
address: `http://${host}:${port}`
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Read + normalize map.json once per request. No map at all is fine too:
|
|
451
|
+
// every overlay is optional and applies as annotation only.
|
|
452
|
+
async function loadMapNorm(rootAbs) {
|
|
453
|
+
const raw = await readMap(rootAbs)
|
|
454
|
+
return { raw, ...normalizeMap(raw) }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function walkStats(nodes, acc) {
|
|
458
|
+
for (const node of nodes) {
|
|
459
|
+
if (node.type === 'dir') {
|
|
460
|
+
acc.dirs += 1
|
|
461
|
+
if (node.mtime > acc.newest) acc.newest = node.mtime
|
|
462
|
+
walkStats(node.children, acc)
|
|
463
|
+
} else {
|
|
464
|
+
acc.files += 1
|
|
465
|
+
if (node.mtime > acc.newest) acc.newest = node.mtime
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function buildTree(dirAbs, rel, norm, options = {}) {
|
|
471
|
+
const excludeFilter = options.exclude ? createPathMatcher(options.exclude) : null
|
|
472
|
+
const includeFilter = options.include ? createPathMatcher(options.include) : null
|
|
473
|
+
const entries = await fs.readdir(dirAbs, { withFileTypes: true })
|
|
474
|
+
const nodes = []
|
|
475
|
+
|
|
476
|
+
for (const entry of entries) {
|
|
477
|
+
if (entry.name === '.git') continue
|
|
478
|
+
if (rel === '' && entry.name === 'map.json') continue
|
|
479
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name
|
|
480
|
+
// The map may hide entries (`ignore`). A hidden folder hides its whole
|
|
481
|
+
// contents with it: never walk into it at all.
|
|
482
|
+
if (norm?.entries?.get(childRel)?.ignore) continue
|
|
483
|
+
|
|
484
|
+
// 1. Hard exclude: knl-config exclude is truly excluded, always wins
|
|
485
|
+
if (excludeFilter && excludeFilter.matches(childRel, entry.name)) continue
|
|
486
|
+
|
|
487
|
+
// 2. Explicit include: if includeFilter is set, only included items or parent dirs are allowed
|
|
488
|
+
if (includeFilter) {
|
|
489
|
+
const isExplicitlyIncluded = includeFilter.matches(childRel, entry.name)
|
|
490
|
+
const isDirPrefixOfInclude = entry.isDirectory() && includeFilter.isPrefixOfAny(childRel)
|
|
491
|
+
if (!isExplicitlyIncluded && !isDirPrefixOfInclude) continue
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const childAbs = path.join(dirAbs, entry.name)
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
if (entry.isDirectory()) {
|
|
498
|
+
const stat = await fs.stat(childAbs)
|
|
499
|
+
const node = {
|
|
500
|
+
name: entry.name,
|
|
501
|
+
path: childRel,
|
|
502
|
+
type: 'dir',
|
|
503
|
+
mtime: stat.mtimeMs,
|
|
504
|
+
children: await buildTree(childAbs, childRel, norm, options)
|
|
505
|
+
}
|
|
506
|
+
nodes.push(decorateNode(node, norm))
|
|
507
|
+
continue
|
|
508
|
+
}
|
|
509
|
+
if (!entry.isFile()) continue
|
|
510
|
+
|
|
511
|
+
const stat = await fs.stat(childAbs)
|
|
512
|
+
const ext = path.extname(entry.name).slice(1).toLowerCase()
|
|
513
|
+
const node = {
|
|
514
|
+
name: entry.name,
|
|
515
|
+
path: childRel,
|
|
516
|
+
type: 'file',
|
|
517
|
+
ext,
|
|
518
|
+
size: stat.size,
|
|
519
|
+
mtime: stat.mtimeMs
|
|
520
|
+
}
|
|
521
|
+
if (MD_EXTS.has(ext)) {
|
|
522
|
+
const title = await readFirstHeading(childAbs, stat.size)
|
|
523
|
+
if (title) node.title = title
|
|
524
|
+
}
|
|
525
|
+
nodes.push(decorateNode(node, norm))
|
|
526
|
+
} catch {
|
|
527
|
+
// Skip unreadable or concurrently deleted entries
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// One sort rule, same as orderForIndex in the views: documents first, then
|
|
532
|
+
// folders. When the map sets `order`, ordered entries come first (ascending)
|
|
533
|
+
// regardless of type; the rest fall back to the name rule.
|
|
534
|
+
nodes.sort((a, b) => {
|
|
535
|
+
const ao = a.order ?? Number.POSITIVE_INFINITY
|
|
536
|
+
const bo = b.order ?? Number.POSITIVE_INFINITY
|
|
537
|
+
if (ao !== bo) return ao - bo
|
|
538
|
+
if (a.type !== b.type) return a.type === 'file' ? -1 : 1
|
|
539
|
+
return a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' })
|
|
540
|
+
})
|
|
541
|
+
return nodes
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function readFirstHeading(abs, size) {
|
|
545
|
+
if (size === 0 || size > 512 * 1024) return null
|
|
546
|
+
let handle
|
|
547
|
+
try {
|
|
548
|
+
handle = await fs.open(abs, 'r')
|
|
549
|
+
const buffer = Buffer.alloc(TITLE_READ_BYTES)
|
|
550
|
+
const { bytesRead } = await handle.read(buffer, 0, TITLE_READ_BYTES, 0)
|
|
551
|
+
const text = buffer.subarray(0, bytesRead).toString('utf8')
|
|
552
|
+
const match = text.match(/^#\s+(.+)$/m)
|
|
553
|
+
if (!match) return null
|
|
554
|
+
const title = cleanTitle(match[1])
|
|
555
|
+
return title || null
|
|
556
|
+
} catch {
|
|
557
|
+
return null
|
|
558
|
+
} finally {
|
|
559
|
+
if (handle) await handle.close()
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function cleanTitle(raw) {
|
|
564
|
+
const title = raw
|
|
565
|
+
.replace(/\s+#+\s*$/, '')
|
|
566
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
567
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
568
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
569
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
570
|
+
.replace(/\s+/g, ' ')
|
|
571
|
+
.trim()
|
|
572
|
+
return title.length > 160 ? `${title.slice(0, 157)}...` : title
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function sendDoc(res, rootAbs, url) {
|
|
576
|
+
const rel = url.searchParams.get('path')
|
|
577
|
+
if (!rel) return sendJson(res, 400, { error: 'Parameter path is required.' })
|
|
578
|
+
|
|
579
|
+
const abs = await safeJoin(rootAbs, rel)
|
|
580
|
+
if (!abs) return sendJson(res, 400, { error: `Path outside the knowledge base: ${rel}` })
|
|
581
|
+
|
|
582
|
+
let stat
|
|
583
|
+
try {
|
|
584
|
+
stat = await fs.stat(abs)
|
|
585
|
+
} catch {
|
|
586
|
+
return sendJson(res, 404, { error: `File not found: ${rel}` })
|
|
587
|
+
}
|
|
588
|
+
if (stat.isDirectory()) return sendJson(res, 400, { error: `That is a folder, not a file: ${rel}` })
|
|
589
|
+
if (stat.size > MAX_DOC_BYTES) {
|
|
590
|
+
return sendJson(res, 413, { error: `File too large to display (${formatBytes(stat.size)}).` })
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const content = await fs.readFile(abs, 'utf8')
|
|
594
|
+
return sendJson(res, 200, {
|
|
595
|
+
path: rel,
|
|
596
|
+
name: path.basename(rel),
|
|
597
|
+
ext: path.extname(rel).slice(1).toLowerCase(),
|
|
598
|
+
content,
|
|
599
|
+
size: stat.size,
|
|
600
|
+
mtime: stat.mtimeMs
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function sendRaw(res, rootAbs, url) {
|
|
605
|
+
const rel = url.searchParams.get('path')
|
|
606
|
+
if (!rel) return sendJson(res, 400, { error: 'Parameter path is required.' })
|
|
607
|
+
|
|
608
|
+
const abs = await safeJoin(rootAbs, rel)
|
|
609
|
+
if (!abs) return sendJson(res, 400, { error: `Path outside the knowledge base: ${rel}` })
|
|
610
|
+
|
|
611
|
+
let stat
|
|
612
|
+
try {
|
|
613
|
+
stat = await fs.stat(abs)
|
|
614
|
+
} catch {
|
|
615
|
+
return sendJson(res, 404, { error: `File not found: ${rel}` })
|
|
616
|
+
}
|
|
617
|
+
if (!stat.isFile()) return sendJson(res, 400, { error: `Not a file: ${rel}` })
|
|
618
|
+
|
|
619
|
+
const type = MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream'
|
|
620
|
+
res.writeHead(200, {
|
|
621
|
+
'Content-Type': type,
|
|
622
|
+
'Content-Length': stat.size,
|
|
623
|
+
'Cache-Control': 'no-store',
|
|
624
|
+
'X-Content-Type-Options': 'nosniff',
|
|
625
|
+
'X-Frame-Options': 'SAMEORIGIN'
|
|
626
|
+
})
|
|
627
|
+
if (res.req.method === 'HEAD') return res.end()
|
|
628
|
+
return new Promise((resolve, reject) => {
|
|
629
|
+
const stream = createReadStream(abs)
|
|
630
|
+
stream.on('error', (err) => {
|
|
631
|
+
res.destroy(err)
|
|
632
|
+
reject(err)
|
|
633
|
+
})
|
|
634
|
+
res.on('close', () => {
|
|
635
|
+
stream.destroy()
|
|
636
|
+
})
|
|
637
|
+
stream.on('end', resolve)
|
|
638
|
+
stream.pipe(res)
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function sendStatic(req, res, distAbs, pathname) {
|
|
643
|
+
if (!distAbs) {
|
|
644
|
+
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
645
|
+
res.end('UI not built yet. Run: npm run build')
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
let rel
|
|
650
|
+
try {
|
|
651
|
+
rel = decodeURIComponent(pathname)
|
|
652
|
+
} catch {
|
|
653
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
654
|
+
res.end('Bad Request: Malformed URI path.')
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
if (rel.endsWith('/')) rel += 'index.html'
|
|
658
|
+
|
|
659
|
+
let abs = await safeJoin(distAbs, rel.replace(/^\/+/, ''))
|
|
660
|
+
if (!abs) {
|
|
661
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
662
|
+
res.end('Access denied.')
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
let stat = await statOrNull(abs)
|
|
667
|
+
if (stat?.isDirectory()) {
|
|
668
|
+
abs = path.join(abs, 'index.html')
|
|
669
|
+
stat = await statOrNull(abs)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (!stat || !stat.isFile()) {
|
|
673
|
+
// No extension means an SPA route request, not a missing file.
|
|
674
|
+
if (!path.extname(rel)) {
|
|
675
|
+
abs = path.join(distAbs, 'index.html')
|
|
676
|
+
stat = await statOrNull(abs)
|
|
677
|
+
}
|
|
678
|
+
if (!stat || !stat.isFile()) {
|
|
679
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
680
|
+
res.end('File not found.')
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const ext = path.extname(abs).toLowerCase()
|
|
686
|
+
const immutable = abs.startsWith(path.join(distAbs, 'assets') + path.sep)
|
|
687
|
+
const cache = immutable ? 'public, max-age=31536000, immutable' : 'no-store'
|
|
688
|
+
|
|
689
|
+
res.writeHead(200, {
|
|
690
|
+
'Content-Type': MIME[ext] || 'application/octet-stream',
|
|
691
|
+
'Content-Length': stat.size,
|
|
692
|
+
'Cache-Control': cache,
|
|
693
|
+
'X-Content-Type-Options': 'nosniff',
|
|
694
|
+
'X-Frame-Options': 'SAMEORIGIN'
|
|
695
|
+
})
|
|
696
|
+
if (req.method === 'HEAD') return res.end()
|
|
697
|
+
return new Promise((resolve, reject) => {
|
|
698
|
+
const stream = createReadStream(abs)
|
|
699
|
+
stream.on('error', (err) => {
|
|
700
|
+
res.destroy(err)
|
|
701
|
+
reject(err)
|
|
702
|
+
})
|
|
703
|
+
res.on('close', () => {
|
|
704
|
+
stream.destroy()
|
|
705
|
+
})
|
|
706
|
+
stream.on('end', resolve)
|
|
707
|
+
stream.pipe(res)
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function statOrNull(abs) {
|
|
712
|
+
try {
|
|
713
|
+
return await fs.stat(abs)
|
|
714
|
+
} catch {
|
|
715
|
+
return null
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function sendJson(res, status, body) {
|
|
720
|
+
const payload = JSON.stringify(body)
|
|
721
|
+
res.writeHead(status, {
|
|
722
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
723
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
724
|
+
'Cache-Control': 'no-store',
|
|
725
|
+
'X-Content-Type-Options': 'nosniff',
|
|
726
|
+
'X-Frame-Options': 'SAMEORIGIN'
|
|
727
|
+
})
|
|
728
|
+
res.end(payload)
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export function formatBytes(bytes) {
|
|
732
|
+
if (bytes < 1024) return `${bytes} B`
|
|
733
|
+
const units = ['KB', 'MB', 'GB']
|
|
734
|
+
let value = bytes / 1024
|
|
735
|
+
let unit = 0
|
|
736
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
737
|
+
value /= 1024
|
|
738
|
+
unit += 1
|
|
739
|
+
}
|
|
740
|
+
const digits = value < 10 ? 1 : 0
|
|
741
|
+
return `${value.toFixed(digits).replace('.', ',')} ${units[unit]}`
|
|
742
|
+
}
|