codebase-onboarder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/bin/onboarder.js +14 -0
- package/bin/postinstall.js +6 -0
- package/cli/commands.js +436 -0
- package/cli/main.js +129 -0
- package/cli/prompt.js +101 -0
- package/cli/ui.js +55 -0
- package/cli/wizard.js +325 -0
- package/package.json +48 -0
- package/public/app.js +1339 -0
- package/public/index.html +455 -0
- package/public/js/about.js +284 -0
- package/public/js/aiDraft.js +163 -0
- package/public/js/analysisPanel.js +270 -0
- package/public/js/analysisReport.js +154 -0
- package/public/js/api.js +264 -0
- package/public/js/atlas.js +98 -0
- package/public/js/blameView.js +41 -0
- package/public/js/codeTab.js +256 -0
- package/public/js/codeViewer.js +184 -0
- package/public/js/components/FileChip.js +102 -0
- package/public/js/components/MetricSparkline.js +89 -0
- package/public/js/components/RiskBadge.js +105 -0
- package/public/js/deepAnalysisView.js +496 -0
- package/public/js/diagramPane.js +154 -0
- package/public/js/diffView.js +352 -0
- package/public/js/docsView.js +566 -0
- package/public/js/fileSourceBrowser.js +47 -0
- package/public/js/flameGraph.js +75 -0
- package/public/js/forceGraph.js +592 -0
- package/public/js/heatmap.js +185 -0
- package/public/js/highlight.js +122 -0
- package/public/js/html.js +50 -0
- package/public/js/insightsView.js +236 -0
- package/public/js/inspector.js +535 -0
- package/public/js/llm.js +345 -0
- package/public/js/mapView.js +159 -0
- package/public/js/markdown.js +119 -0
- package/public/js/mindmap.js +289 -0
- package/public/js/repoFiles.js +44 -0
- package/public/js/sbomView.js +142 -0
- package/public/js/scanCache.js +76 -0
- package/public/js/search.js +874 -0
- package/public/js/serverSettings.js +169 -0
- package/public/js/state.js +192 -0
- package/public/js/tour.js +17 -0
- package/public/js/transitions.js +15 -0
- package/public/js/tree.js +200 -0
- package/public/js/workflowsView.js +95 -0
- package/public/styles.css +2767 -0
- package/public/vendor/mermaid.min.js +3587 -0
- package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/public/vendor/monaco/vs/loader.js +11 -0
- package/public/vendor/monaco/worker-boot.js +9 -0
- package/server/.fuse_hidden0000000800000001 +36 -0
- package/server/apiDiff.js +25 -0
- package/server/apiDocs.js +67 -0
- package/server/apiFile.js +36 -0
- package/server/apiGitBlame.js +63 -0
- package/server/apiMcp.js +110 -0
- package/server/apiScan.js +119 -0
- package/server/apiSearch.js +253 -0
- package/server/apiSettings.js +118 -0
- package/server/apiTools.js +90 -0
- package/server/config.js +227 -0
- package/server/fileSourceNode.js +44 -0
- package/server/gitClone.js +95 -0
- package/server/gitDiff.js +184 -0
- package/server/gitHistory.js +110 -0
- package/server/htmlText.js +44 -0
- package/server/http.js +55 -0
- package/server/httpGuards.js +87 -0
- package/server/index.js +156 -0
- package/server/llmProxy.js +162 -0
- package/server/logger.js +29 -0
- package/server/mcp/analysis.js +209 -0
- package/server/mcp/http.js +213 -0
- package/server/mcp/runner.js +278 -0
- package/server/mcp/server.js +241 -0
- package/server/mcp/standalone.js +42 -0
- package/server/mcp/tools.js +683 -0
- package/server/paths.js +39 -0
- package/server/router.js +218 -0
- package/server/searchIndex.js +118 -0
- package/server/sessions.js +163 -0
- package/server/static.js +59 -0
- package/server/tools/install.js +246 -0
- package/server/tools/parse.js +170 -0
- package/server/tools/platform.js +91 -0
- package/server/tools/registry.js +212 -0
- package/server/tools/scan.js +136 -0
- package/server/tools.js +212 -0
- package/server/tunnel.js +92 -0
- package/shared/analyzer/docs.js +135 -0
- package/shared/analyzer/explainLocal.js +163 -0
- package/shared/analyzer/graph.js +461 -0
- package/shared/analyzer/health.js +215 -0
- package/shared/analyzer/history.js +146 -0
- package/shared/analyzer/languages/csharp.js +39 -0
- package/shared/analyzer/languages/generic.js +131 -0
- package/shared/analyzer/languages/go.js +70 -0
- package/shared/analyzer/languages/index.js +43 -0
- package/shared/analyzer/languages/java.js +39 -0
- package/shared/analyzer/languages/javascript.js +240 -0
- package/shared/analyzer/languages/python.js +120 -0
- package/shared/analyzer/languages/rust.js +42 -0
- package/shared/analyzer/languages/typescript.js +138 -0
- package/shared/analyzer/licenses.js +151 -0
- package/shared/analyzer/metrics.js +120 -0
- package/shared/analyzer/pathUtil.js +69 -0
- package/shared/analyzer/patterns.js +272 -0
- package/shared/analyzer/scan.js +473 -0
- package/shared/analyzer/security.js +187 -0
- package/shared/analyzer/services.js +157 -0
- package/shared/analyzer/stack.js +173 -0
- package/shared/analyzer/tour.js +69 -0
- package/shared/analyzer/util.js +123 -0
- package/shared/analyzer/workflows.js +143 -0
- package/shared/diagram/aiFacts.js +145 -0
- package/shared/diagram/aiMermaid.js +59 -0
- package/shared/diagram/atlas.js +93 -0
- package/shared/diagram/mermaid.js +459 -0
- package/shared/search/query.js +518 -0
package/server/paths.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Path containment, in one place. Anything that turns a string the client sent
|
|
2
|
+
// into a filesystem path goes through here first.
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
|
|
7
|
+
// Is `abs` the root itself, or something inside it?
|
|
8
|
+
//
|
|
9
|
+
// The obvious `abs.startsWith(root)` is the wrong check: with a root of
|
|
10
|
+
// `/home/me/repo` it also says yes to `/home/me/repo-secrets/.env`, because
|
|
11
|
+
// the prefix matches before the separator does. The separator has to be part
|
|
12
|
+
// of the comparison.
|
|
13
|
+
export function isInside(rootAbs, abs) {
|
|
14
|
+
const root = path.resolve(rootAbs);
|
|
15
|
+
const target = path.resolve(abs);
|
|
16
|
+
if (target === root) return true;
|
|
17
|
+
return target.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Resolve a repo-relative POSIX path (`src/app.js`) against a root, or return
|
|
21
|
+
// null if it doesn't land inside that root.
|
|
22
|
+
//
|
|
23
|
+
// `path.join` normalizes `..` away before we look, so this judges a path by
|
|
24
|
+
// where it ends up rather than by what it looks like — there's no spelling of
|
|
25
|
+
// `..` to outsmart, and a leading `/` becomes an empty segment instead of an
|
|
26
|
+
// absolute path.
|
|
27
|
+
export function resolveInside(rootAbs, relPath) {
|
|
28
|
+
if (typeof relPath !== 'string' || relPath === '') return null;
|
|
29
|
+
if (relPath.includes('\0')) return null; // fs throws on these; a 400 reads better
|
|
30
|
+
const abs = path.join(rootAbs, ...relPath.split('/'));
|
|
31
|
+
return isInside(rootAbs, abs) ? abs : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// `~` and `~/code/thing` are what people actually type into the path box.
|
|
35
|
+
export function expandHome(p) {
|
|
36
|
+
if (p === '~') return os.homedir();
|
|
37
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
38
|
+
return path.resolve(p);
|
|
39
|
+
}
|
package/server/router.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// The route table and the two gates in front of it.
|
|
2
|
+
//
|
|
3
|
+
// Everything arrives here: one function decides whether a request is allowed to
|
|
4
|
+
// be answered at all, then which handler answers it. The routes are a list rather
|
|
5
|
+
// than a ladder of `if` statements so that the whole surface of the server is
|
|
6
|
+
// visible in one screen — six endpoints, and everything else is a static file.
|
|
7
|
+
//
|
|
8
|
+
// Neither gate is authentication. A local server with no accounts cannot
|
|
9
|
+
// authenticate anyone; what it can do is refuse requests that a browser on some
|
|
10
|
+
// other site made on the person's behalf. `server/httpGuards.js` explains both
|
|
11
|
+
// attacks; the short version is that `Host` stops DNS rebinding from turning
|
|
12
|
+
// `evil.com` into our own origin, and `Origin`/`Sec-Fetch-Site` stops a page the
|
|
13
|
+
// person happened to have open from driving the API.
|
|
14
|
+
|
|
15
|
+
import { handleDocs } from './apiDocs.js';
|
|
16
|
+
import { handleFile } from './apiFile.js';
|
|
17
|
+
import { handleCleanup, handleScan } from './apiScan.js';
|
|
18
|
+
import { sendError, sendJSON, readBody } from './http.js';
|
|
19
|
+
import { crossOriginReason, rebindingReason } from './httpGuards.js';
|
|
20
|
+
import { proxyChat } from './llmProxy.js';
|
|
21
|
+
import { serveStatic } from './static.js';
|
|
22
|
+
import { handleSearch } from './apiSearch.js';
|
|
23
|
+
import { handleBlame } from './apiGitBlame.js';
|
|
24
|
+
import { handleDiff, handleDiffRefs } from './apiDiff.js';
|
|
25
|
+
import { handleToolsInstall, handleToolsRun, handleToolsStatus } from './apiTools.js';
|
|
26
|
+
import { handleMcpStart, handleMcpStatus, handleMcpStop, handleMcpCommand } from './apiMcp.js';
|
|
27
|
+
import { handleGetSettings, handleRotateAccessKey, handleUpdateSettings } from './apiSettings.js';
|
|
28
|
+
import { allowedHosts, authReason, DEFAULT_SETTINGS } from './config.js';
|
|
29
|
+
|
|
30
|
+
const ROUTES = [
|
|
31
|
+
{
|
|
32
|
+
method: 'GET', path: '/api/diff/refs', sameOrigin: true,
|
|
33
|
+
run: ({ res, url }) => handleDiffRefs(res, url.searchParams.get('scan')),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
method: 'GET', path: '/api/diff', sameOrigin: true,
|
|
37
|
+
run: ({ res, url }) => handleDiff(res, url.searchParams.get('scan'), url.searchParams),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
method: 'POST', path: '/api/search', body: true,
|
|
41
|
+
run: ({ res, body }) => handleSearch(res, body),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
method: 'GET', path: '/api/blame', sameOrigin: true,
|
|
45
|
+
run: ({ res, url }) => handleBlame(res, url.searchParams.get('scan'), url.searchParams.get('path')),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
method: 'POST', path: '/api/scan', body: true,
|
|
49
|
+
run: ({ res, body, config }) => handleScan(res, body, config),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
method: 'DELETE', prefix: '/api/scan/',
|
|
53
|
+
run: ({ res, rest }) => handleCleanup(res, decodeURIComponent(rest)),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
method: 'GET', path: '/api/file', sameOrigin: true,
|
|
57
|
+
run: ({ res, url }) => handleFile(res, url.searchParams.get('scan'), url.searchParams.get('path')),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
// The proxy is the handler; there is no wrapper to write. It forwards the
|
|
61
|
+
// browser's key to the endpoint the browser chose and never keeps either.
|
|
62
|
+
method: 'POST', path: '/api/explain', body: true,
|
|
63
|
+
run: ({ res, body }) => proxyChat(res, body),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
method: 'POST', path: '/api/doc', body: true,
|
|
67
|
+
run: ({ res, body }) => handleDocs(res, body),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
// Reports which analyzers are installed — environment information a foreign
|
|
71
|
+
// page has no business reading, so it is guarded like /api/file.
|
|
72
|
+
method: 'GET', path: '/api/tools', sameOrigin: true,
|
|
73
|
+
run: ({ res }) => handleToolsStatus(res),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
// Runs real analyzers over a scanned root. `body: true` applies the size
|
|
77
|
+
// limit; the session check in the handler is the capability gate.
|
|
78
|
+
method: 'POST', path: '/api/tools/run', body: true,
|
|
79
|
+
run: ({ res, body }) => handleToolsRun(res, body),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
// Installs a missing analyzer from the GUI. Same-origin like every POST;
|
|
83
|
+
// the tool name is validated against the registry and the install plans
|
|
84
|
+
// are fixed data, so the request body never reaches a command line.
|
|
85
|
+
method: 'POST', path: '/api/tools/install', body: true,
|
|
86
|
+
run: ({ res, body }) => handleToolsInstall(res, body),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
// The MCP button's state, and the command an agent harness is configured with.
|
|
90
|
+
// The config is not secret, but it is a path into this machine and a foreign
|
|
91
|
+
// page has no business reading either one.
|
|
92
|
+
method: 'GET', path: '/api/mcp', sameOrigin: true,
|
|
93
|
+
run: ({ req, res, config }) => handleMcpStatus(req, res, config),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
method: 'GET', path: '/api/mcp/command', sameOrigin: true,
|
|
97
|
+
run: ({ req, res, config }) => handleMcpCommand(req, res, config),
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
// Starting and stopping a process is a side effect on the person's machine, so
|
|
101
|
+
// these take a body like every other POST and are checked like every other POST.
|
|
102
|
+
method: 'POST', path: '/api/mcp/start', body: true,
|
|
103
|
+
run: ({ req, res, config }) => handleMcpStart(req, res, config),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
method: 'POST', path: '/api/mcp/stop', body: true,
|
|
107
|
+
run: ({ req, res, config }) => handleMcpStop(req, res, config),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
method: 'GET', path: '/api/health',
|
|
111
|
+
run: ({ res }) => sendJSON(res, 200, { ok: true }),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
// The settings drawer and the CLI read the same public shape: everything
|
|
115
|
+
// about the configuration except the access key itself.
|
|
116
|
+
method: 'GET', path: '/api/settings', sameOrigin: true,
|
|
117
|
+
run: ({ req, res, config }) => handleGetSettings(req, res, config),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
// PATCH semantics over a small allow-listed key set; the validation that
|
|
121
|
+
// rejects a bad port or a lawless mode lives in `server/config.js` and is
|
|
122
|
+
// shared with the CLI wizard, so both surfaces enforce one schema.
|
|
123
|
+
method: 'PUT', path: '/api/settings', body: true,
|
|
124
|
+
run: ({ res, body, config }) => handleUpdateSettings(res, body, config),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
// The only way the key changes: generated on the server, shown once in
|
|
128
|
+
// this response, never readable again. In self-hosted mode this endpoint
|
|
129
|
+
// is itself behind the current key, so rotation requires possession.
|
|
130
|
+
method: 'POST', path: '/api/settings/access-key', body: true,
|
|
131
|
+
run: ({ res, config }) => handleRotateAccessKey(res, config),
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// The table itself, for the test that walks it. Exported read-only: the routes
|
|
136
|
+
// are decided here, not assembled by whoever imports this.
|
|
137
|
+
export const routes = Object.freeze(ROUTES.map((r) => Object.freeze({ ...r })));
|
|
138
|
+
|
|
139
|
+
export function matchRoute(method, pathname) {
|
|
140
|
+
for (const route of ROUTES) {
|
|
141
|
+
if (route.method !== method) continue;
|
|
142
|
+
if (route.path === pathname) return { route, rest: '' };
|
|
143
|
+
if (route.prefix && pathname.startsWith(route.prefix)) {
|
|
144
|
+
return { route, rest: pathname.slice(route.prefix.length) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `config` carries the directories the handlers need — the project root to scan
|
|
151
|
+
// for the demo, and the two roots static serving maps into. Passed in rather
|
|
152
|
+
// than resolved here so a test can point the server somewhere else, and so this
|
|
153
|
+
// module has nothing to say about where it was installed.
|
|
154
|
+
//
|
|
155
|
+
// Settings arrive the same way: `config.getSettings` is read on every request,
|
|
156
|
+
// so a key rotated or a domain changed through the API takes effect on the next
|
|
157
|
+
// request without a restart. A bare `createServer()` (the tests) has no getter
|
|
158
|
+
// and sees the local-mode defaults — never somebody's home directory.
|
|
159
|
+
async function liveSettings(config) {
|
|
160
|
+
if (typeof config?.getSettings !== 'function') return DEFAULT_SETTINGS;
|
|
161
|
+
try {
|
|
162
|
+
return await config.getSettings();
|
|
163
|
+
} catch {
|
|
164
|
+
return null; // unreadable config: fail closed, every /api call gets a 500
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createRouter(config) {
|
|
169
|
+
return async function handleRequest(req, res) {
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
172
|
+
|
|
173
|
+
const settings = await liveSettings(config);
|
|
174
|
+
if (!settings) {
|
|
175
|
+
return sendError(res, 500, 'The settings file could not be read — run `onboarder doctor` to find out why.');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const wrongHost = rebindingReason(req, allowedHosts(settings));
|
|
179
|
+
if (wrongHost) {
|
|
180
|
+
const scope = settings.mode === 'self-hosted'
|
|
181
|
+
? 'Onboarder only answers to its configured host and domain. '
|
|
182
|
+
: 'Onboarder only answers to localhost. ';
|
|
183
|
+
return sendError(res, 403, scope + wrongHost);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const found = matchRoute(req.method, url.pathname);
|
|
187
|
+
if (req.method !== 'GET' || found?.route.sameOrigin) {
|
|
188
|
+
const foreign = crossOriginReason(req);
|
|
189
|
+
if (foreign) {
|
|
190
|
+
return sendError(res, 403, 'That request did not come from Onboarder’s own page. ' + foreign);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The self-hosted gate. It is authentication, unlike the two guards
|
|
195
|
+
// above: the mode says the network can reach us, so every API call
|
|
196
|
+
// proves it holds the access key. `/api/health` stays open — a tunnel
|
|
197
|
+
// or uptime check has no key and tells an attacker nothing.
|
|
198
|
+
if (found && url.pathname.startsWith('/api/') && url.pathname !== '/api/health') {
|
|
199
|
+
const denied = authReason(req, settings);
|
|
200
|
+
if (denied) return sendError(res, 401, denied);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (found) {
|
|
204
|
+
const body = found.route.body ? await readBody(req) : null;
|
|
205
|
+
return await found.route.run({ req, res, url, body, rest: found.rest, config });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (req.method === 'GET') return await serveStatic(res, url.pathname, config);
|
|
209
|
+
|
|
210
|
+
sendError(res, 404, 'Nothing lives at ' + url.pathname + '.');
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// The last resort. Handlers turn their own expected failures into a status
|
|
213
|
+
// that says something useful; anything reaching here is a bug or a broken
|
|
214
|
+
// request body, and the message is more use to the person than "500".
|
|
215
|
+
sendError(res, 500, err.message || 'Something went sideways.');
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// The search index lives with the scan, not behind a request. Building it
|
|
2
|
+
// here means:
|
|
3
|
+
//
|
|
4
|
+
// * The first /api/search call is fast. The user is searching — make it feel
|
|
5
|
+
// that way; the work that would have been a request-blocking second walk
|
|
6
|
+
// is already done.
|
|
7
|
+
// * The index reads exactly the files the scanner did. The caller hands us
|
|
8
|
+
// `scan.files`, the list of paths the scanner accepted; we never walk
|
|
9
|
+
// the tree ourselves, so the index can never disagree with the scan
|
|
10
|
+
// about which files exist.
|
|
11
|
+
// * The index has a hard cap. A repo with a single 200 MB SQL dump used to
|
|
12
|
+
// hold its full content in the process for the lifetime of the session;
|
|
13
|
+
// now it stops indexing at the cap and records what it skipped, so a
|
|
14
|
+
// future "why doesn't my file show up?" question has a real answer.
|
|
15
|
+
//
|
|
16
|
+
// The on-disk cost of the work the old /api/search did — re-listing every
|
|
17
|
+
// directory, re-reading every file, parsing its tokens — is gone. The
|
|
18
|
+
// per-request cost is one Map lookup plus the scoring pass over matching
|
|
19
|
+
// documents, which is bounded by the index size, not the repo size.
|
|
20
|
+
|
|
21
|
+
const DEFAULTS = {
|
|
22
|
+
// Aggregate content size. Files beyond this are not indexed, only counted.
|
|
23
|
+
// 200 MB is enough to index the source of a large monorepo comfortably;
|
|
24
|
+
// past that the search would be matching against vendor copies and
|
|
25
|
+
// generated code more often than against code people wrote.
|
|
26
|
+
maxTotalBytes: 200 * 1024 * 1024,
|
|
27
|
+
// Per-file ceiling. Files larger than this are skipped (the scanner has
|
|
28
|
+
// its own ceiling for the same reason).
|
|
29
|
+
maxFileBytes: 1024 * 1024,
|
|
30
|
+
// Reads in flight at once. Same shape as `scan.js`'s read-ahead — a file
|
|
31
|
+
// is held in memory whole before the next one starts.
|
|
32
|
+
width: 8,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const BINARY_HEAD_BYTES = 1000;
|
|
36
|
+
|
|
37
|
+
function looksBinary(content) {
|
|
38
|
+
// NUL in the first KB is a reliable, cheap heuristic for "this is not
|
|
39
|
+
// text the search would help with". The first KB also covers the BOM and
|
|
40
|
+
// the shebang without missing the start of the file.
|
|
41
|
+
return content.slice(0, BINARY_HEAD_BYTES).includes('\0');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Read each path's text up to width at a time. Failures are skipped, the
|
|
45
|
+
// same way `scan.js:readAhead` handles them: one unreadable file should not
|
|
46
|
+
// end an index build, and the caller counts it in `skipped.readFailed`.
|
|
47
|
+
async function* readAhead(source, paths, width) {
|
|
48
|
+
const inFlight = [];
|
|
49
|
+
let next = 0;
|
|
50
|
+
const read = (p) =>
|
|
51
|
+
Promise.resolve()
|
|
52
|
+
.then(() => source.read(p))
|
|
53
|
+
.then(
|
|
54
|
+
(text) => ({ path: p, text, failed: false }),
|
|
55
|
+
() => ({ path: p, text: '', failed: true }),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
while (next < paths.length && inFlight.length < width) inFlight.push(read(paths[next++]));
|
|
59
|
+
while (inFlight.length) {
|
|
60
|
+
const settled = inFlight.shift();
|
|
61
|
+
if (next < paths.length) inFlight.push(read(paths[next++]));
|
|
62
|
+
yield await settled;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build the search index from the file list the scanner already produced.
|
|
67
|
+
// The scanner has read these files once for parsing; indexing reads them a
|
|
68
|
+
// second time for tokenizing, which is the cheapest place to do it — a
|
|
69
|
+
// refactor that streamed text from the scanner into both consumers would
|
|
70
|
+
// save one disk pass at the cost of a much larger refactor. This is the
|
|
71
|
+
// smallest change that closes the audit's three real concerns (first-search
|
|
72
|
+
// latency, no aggregate cap, no concurrency).
|
|
73
|
+
export async function buildSearchIndex(source, codeFiles, options = {}) {
|
|
74
|
+
const opts = { ...DEFAULTS, ...options };
|
|
75
|
+
const index = new Map();
|
|
76
|
+
const docCounts = new Map();
|
|
77
|
+
let totalDocs = 0;
|
|
78
|
+
let totalBytes = 0;
|
|
79
|
+
|
|
80
|
+
// Files the indexer cannot or will not index, broken down by reason. The
|
|
81
|
+
// shape matches `scan.js:skips` so a future UI surface can render one
|
|
82
|
+
// unified "what was left out" panel without translation.
|
|
83
|
+
const skipped = {
|
|
84
|
+
tooLarge: 0, // one file over maxFileBytes
|
|
85
|
+
overCap: 0, // a later file pushed the aggregate over maxTotalBytes
|
|
86
|
+
readFailed: 0, // the file would not read
|
|
87
|
+
binary: 0, // NUL in the first KB
|
|
88
|
+
noTokens: 0, // the file read, but tokenizing produced nothing useful
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for await (const { path, text, failed } of readAhead(source, codeFiles, opts.width)) {
|
|
92
|
+
if (failed) { skipped.readFailed++; continue; }
|
|
93
|
+
|
|
94
|
+
if (text.length > opts.maxFileBytes) { skipped.tooLarge++; continue; }
|
|
95
|
+
if (totalBytes + text.length > opts.maxTotalBytes) { skipped.overCap++; continue; }
|
|
96
|
+
if (looksBinary(text)) { skipped.binary++; continue; }
|
|
97
|
+
|
|
98
|
+
const tokens = text.toLowerCase().split(/\W+/).filter((t) => t.length > 1);
|
|
99
|
+
if (!tokens.length) { skipped.noTokens++; continue; }
|
|
100
|
+
|
|
101
|
+
const tf = new Map();
|
|
102
|
+
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
103
|
+
for (const t of tf.keys()) docCounts.set(t, (docCounts.get(t) || 0) + 1);
|
|
104
|
+
|
|
105
|
+
index.set(path, { tf, content: text, tokenCount: tokens.length });
|
|
106
|
+
totalDocs++;
|
|
107
|
+
totalBytes += text.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
index,
|
|
112
|
+
docCounts,
|
|
113
|
+
totalDocs,
|
|
114
|
+
totalBytes,
|
|
115
|
+
skipped,
|
|
116
|
+
cap: { maxFileBytes: opts.maxFileBytes, maxTotalBytes: opts.maxTotalBytes },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// What the server remembers between requests: which directory each scan came
|
|
2
|
+
// from. `/api/file` needs it — the browser asks for `server/paths.js` and the
|
|
3
|
+
// server has to know which root that is relative to — and the git-URL flow needs
|
|
4
|
+
// it to find a clone again when the page says it is done with one.
|
|
5
|
+
//
|
|
6
|
+
// A scan id is the capability. Handing the browser a path and trusting it back
|
|
7
|
+
// would make `/api/file` a "read any file on this machine" endpoint; handing it
|
|
8
|
+
// an opaque id means the only readable roots are ones the person chose to scan
|
|
9
|
+
// during this run.
|
|
10
|
+
//
|
|
11
|
+
// Two things the inline Map this replaces got wrong:
|
|
12
|
+
//
|
|
13
|
+
// * It never forgot. Every scan added an entry and nothing removed one, so an
|
|
14
|
+
// afternoon of scanning left a growing list of roots still being served
|
|
15
|
+
// from. A cap with oldest-out eviction bounds it, and evicting a cloned repo
|
|
16
|
+
// deletes the clone with it.
|
|
17
|
+
// * It leaked clones on exit. A clone lives in the OS temp dir until someone
|
|
18
|
+
// removes it, and only an explicit `DELETE /api/scan/:id` did. Ctrl-C left
|
|
19
|
+
// every clone of the session behind — hundreds of megabytes, silently.
|
|
20
|
+
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { realpathSync } from 'node:fs';
|
|
24
|
+
|
|
25
|
+
import { isCloneDir, removeClone, removeCloneSync } from './gitClone.js';
|
|
26
|
+
|
|
27
|
+
// Twelve repos is far more than anyone holds open at once, and each entry is a
|
|
28
|
+
// couple of strings — the cap is about not serving from a root the person has
|
|
29
|
+
// forgotten scanning, not about memory.
|
|
30
|
+
export const MAX_SESSIONS = 12;
|
|
31
|
+
|
|
32
|
+
// scanId -> { root, cloneDir, at, searchIndex? }. Insertion-ordered, which is
|
|
33
|
+
// what makes "oldest first" a `keys().next()` rather than a sort.
|
|
34
|
+
//
|
|
35
|
+
// `searchIndex`, when present, is the TF-IDF index built at scan time
|
|
36
|
+
// (`searchIndex.js`). It is held on the session for the same reason `root`
|
|
37
|
+
// is — `/api/search` is a request that has to be answered against this
|
|
38
|
+
// particular scan — and is dropped when the session is evicted. Reopening
|
|
39
|
+
// the same repo would rebuild it; the cost of one scan's worth of work is
|
|
40
|
+
// far below the cost of re-fetching the repo.
|
|
41
|
+
const sessions = new Map();
|
|
42
|
+
|
|
43
|
+
export function openSession({ root, cloneDir = null, searchIndex = null }) {
|
|
44
|
+
const scanId = crypto.randomBytes(8).toString('hex');
|
|
45
|
+
sessions.set(scanId, { root: canonicalRoot(root), cloneDir, at: Date.now(), searchIndex });
|
|
46
|
+
evictBeyondCap();
|
|
47
|
+
return scanId;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Resolve symlinks once, so the root every consumer sees is the real one. On
|
|
51
|
+
// macOS `/tmp` is a symlink to `/private/tmp`, and an analyzer run against the
|
|
52
|
+
// alias reports paths rooted at the target — without this, `/api/tools/run`
|
|
53
|
+
// findings and `/api/file` lookups would silently miss every file in a repo
|
|
54
|
+
// scanned through a symlinked path. Failing to resolve (a path that is gone)
|
|
55
|
+
// is not fatal: the original is used and the scan's own error path reports it.
|
|
56
|
+
function canonicalRoot(root) {
|
|
57
|
+
if (typeof root !== 'string' || !root) return root;
|
|
58
|
+
try {
|
|
59
|
+
return realpathSync(root);
|
|
60
|
+
} catch {
|
|
61
|
+
return root;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Reading from a scan keeps it alive. Without the touch, opening files in the
|
|
66
|
+
// first repo you scanned is what evicts it — you would lose the one you are
|
|
67
|
+
// actually using and keep eleven you are not.
|
|
68
|
+
export function getSession(scanId) {
|
|
69
|
+
if (!scanId) return null;
|
|
70
|
+
const session = sessions.get(scanId);
|
|
71
|
+
if (!session) return null;
|
|
72
|
+
session.at = Date.now();
|
|
73
|
+
sessions.delete(scanId);
|
|
74
|
+
sessions.set(scanId, session);
|
|
75
|
+
return session;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Clone ids are the 12 hex characters `gitClone` puts after `onboarder-`. The
|
|
79
|
+
// match is on the exact directory name: `endsWith(cloneId)` treated an empty id
|
|
80
|
+
// as a match against every clone, so `DELETE /api/scan/` used to delete
|
|
81
|
+
// whichever clone the map happened to yield first.
|
|
82
|
+
export function isCloneId(id) {
|
|
83
|
+
return /^[0-9a-f]{12}$/.test(id || '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Removes the clone with this id and forgets every scan pointing at it. Returns
|
|
87
|
+
// whether anything was there — a repeat DELETE is not an error; the page sends
|
|
88
|
+
// one on unload and may well be the second to arrive.
|
|
89
|
+
export async function closeClone(cloneId) {
|
|
90
|
+
const dirName = 'onboarder-' + cloneId;
|
|
91
|
+
let found = null;
|
|
92
|
+
for (const [scanId, session] of sessions) {
|
|
93
|
+
if (session.cloneDir && path.basename(session.cloneDir) === dirName) {
|
|
94
|
+
found = session.cloneDir;
|
|
95
|
+
sessions.delete(scanId);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (found) await removeClone(found);
|
|
99
|
+
return Boolean(found);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function evictBeyondCap() {
|
|
103
|
+
while (sessions.size > MAX_SESSIONS) {
|
|
104
|
+
const [scanId, session] = sessions.entries().next().value;
|
|
105
|
+
sessions.delete(scanId);
|
|
106
|
+
if (session.cloneDir && !stillReferenced(session.cloneDir)) sweep(session.cloneDir);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Eviction happens inside a scan request, and the person is waiting on the scan,
|
|
111
|
+
// not on a directory being swept — so the removal is started and not awaited. The
|
|
112
|
+
// promises are chained rather than dropped so that a caller who does need to know
|
|
113
|
+
// when the disk is quiet can wait for it; the only one that does is a test.
|
|
114
|
+
let removals = Promise.resolve();
|
|
115
|
+
|
|
116
|
+
function sweep(dir) {
|
|
117
|
+
removals = removals.then(() => removeClone(dir));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function removalsSettled() {
|
|
121
|
+
return removals;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Two sessions can name the same clone — scan a git URL, then scan the temp path
|
|
125
|
+
// it landed in. Do not delete a directory another live session still reads from.
|
|
126
|
+
function stillReferenced(dir) {
|
|
127
|
+
for (const session of sessions.values()) if (session.cloneDir === dir) return true;
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Synchronous on purpose: this is what an exit handler can finish.
|
|
132
|
+
export function sweepClonesSync() {
|
|
133
|
+
for (const [scanId, session] of sessions) {
|
|
134
|
+
sessions.delete(scanId);
|
|
135
|
+
if (isCloneDir(session.cloneDir)) removeCloneSync(session.cloneDir);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Called from the `node server/index.js` path only, never on import. The tests
|
|
140
|
+
// drive the real router in-process, and a module that added signal handlers just
|
|
141
|
+
// by being imported would take Ctrl-C away from the test runner.
|
|
142
|
+
export function installExitCleanup(proc = process) {
|
|
143
|
+
proc.on('exit', sweepClonesSync);
|
|
144
|
+
// A signal does not run `exit` handlers on its own — the process dies where it
|
|
145
|
+
// stands. Sweeping in the signal handler and then exiting is what gets the
|
|
146
|
+
// clones removed.
|
|
147
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
148
|
+
proc.on(signal, () => {
|
|
149
|
+
sweepClonesSync();
|
|
150
|
+
proc.exit(0);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Tests only. The store is module state, so a test that opens sessions would
|
|
156
|
+
// otherwise leak them into the next one.
|
|
157
|
+
export function _resetSessions() {
|
|
158
|
+
sessions.clear();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function _sessionCount() {
|
|
162
|
+
return sessions.size;
|
|
163
|
+
}
|
package/server/static.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Serving the app itself: `public/` is the site root, and `/shared/` is mapped to
|
|
2
|
+
// the repo's `shared/` directory so the analyzer the server imports and the
|
|
3
|
+
// analyzer the browser imports are the same files. That mapping is the reason
|
|
4
|
+
// nothing under `shared/` may touch `fs` or `window` — both runtimes load it.
|
|
5
|
+
//
|
|
6
|
+
// It also means no relative specifier can point from `public/js/` into `shared/`
|
|
7
|
+
// and work in both places, which is why browser modules reach for `/shared/...`
|
|
8
|
+
// and pure logic that Node needs to test lives in `shared/` rather than beside
|
|
9
|
+
// its caller.
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { sendError } from './http.js';
|
|
15
|
+
import { resolveInside } from './paths.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
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.png': 'image/png',
|
|
25
|
+
'.ico': 'image/x-icon',
|
|
26
|
+
'.map': 'application/json',
|
|
27
|
+
'.woff2': 'font/woff2',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// `null` for anything that resolves outside the directory it was mapped into —
|
|
31
|
+
// `resolveInside` is the traversal guard, and a request for `/../../etc/passwd`
|
|
32
|
+
// has to fail here rather than reach `readFile`.
|
|
33
|
+
export function resolveAsset(urlPath, { publicDir, sharedDir }) {
|
|
34
|
+
if (urlPath === '/' || urlPath === '/index.html') return path.join(publicDir, 'index.html');
|
|
35
|
+
if (urlPath.startsWith('/shared/')) return resolveInside(sharedDir, urlPath.slice('/shared/'.length));
|
|
36
|
+
return resolveInside(publicDir, urlPath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Everything we author is served no-cache: the UI and the engine ship as loose
|
|
40
|
+
// modules with no build step and no content hashes, so a stale app.js against a
|
|
41
|
+
// fresh index.html breaks the whole page. Vendored builds are the exception —
|
|
42
|
+
// they are versioned by the directory they sit in and never edited in place.
|
|
43
|
+
export function cacheControlFor(file) {
|
|
44
|
+
return file.includes(path.sep + 'vendor' + path.sep) ? 'public, max-age=86400' : 'no-cache';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function serveStatic(res, urlPath, dirs) {
|
|
48
|
+
const file = resolveAsset(urlPath, dirs);
|
|
49
|
+
if (!file) return sendError(res, 403, 'Not from here.');
|
|
50
|
+
|
|
51
|
+
const data = await fs.readFile(file).catch(() => null);
|
|
52
|
+
if (data === null) return sendError(res, 404, 'Not found: ' + urlPath);
|
|
53
|
+
|
|
54
|
+
res.writeHead(200, {
|
|
55
|
+
'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
|
|
56
|
+
'cache-control': cacheControlFor(file),
|
|
57
|
+
});
|
|
58
|
+
res.end(data);
|
|
59
|
+
}
|