megit-app 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -41
- package/README.md +2 -2
- package/dist/assets/{ConflictView-DGLXjOa1.js → ConflictView-C8umUTWy.js} +1 -1
- package/dist/assets/{DiffView-DHHWx0R5.js → DiffView-C-794XGt.js} +1 -1
- package/dist/assets/{TerminalPanel-DtRWBLUl.js → TerminalPanel-DMY2xwkh.js} +1 -1
- package/dist/assets/{index-Bfrt9LxG.js → index-BC7TJRRI.js} +3 -3
- package/dist/index.html +1 -1
- package/dist-server/http.js +174 -0
- package/dist-server/index.js +10 -14
- package/dist-server/term.js +29 -1
- package/package.json +5 -9
- package/scripts/fix-pty-perms.mjs +0 -21
package/dist/index.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet" />
|
|
10
10
|
<title>megit</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-BC7TJRRI.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/assets/index-C845Od_q.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// A ~150-line stand-in for the slice of express this server actually used:
|
|
2
|
+
// exact-path routing, JSON bodies, static files and an SPA fallback. express
|
|
3
|
+
// itself is fine, but it drags 67 transitive packages into the published
|
|
4
|
+
// install — most of them single-function modules untouched for five years —
|
|
5
|
+
// and every one of those is install-time attack surface for a tool people run
|
|
6
|
+
// against their own repos. Node's http module covers the rest.
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import { createReadStream, statSync } from 'node:fs';
|
|
9
|
+
import { extname, join, sep } from 'node:path';
|
|
10
|
+
// 10mb, not express's 100kb default: a resolved conflicted file goes back as a
|
|
11
|
+
// JSON string, and a few thousand lines of source blows past the default.
|
|
12
|
+
const BODY_LIMIT = 10 * 1024 * 1024;
|
|
13
|
+
const TYPES = {
|
|
14
|
+
'.html': 'text/html; charset=utf-8',
|
|
15
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
16
|
+
'.css': 'text/css; charset=utf-8',
|
|
17
|
+
'.svg': 'image/svg+xml',
|
|
18
|
+
'.json': 'application/json; charset=utf-8',
|
|
19
|
+
'.map': 'application/json; charset=utf-8',
|
|
20
|
+
'.ico': 'image/x-icon',
|
|
21
|
+
'.png': 'image/png',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
'.woff2': 'font/woff2',
|
|
24
|
+
'.wasm': 'application/wasm',
|
|
25
|
+
};
|
|
26
|
+
function decorate(res) {
|
|
27
|
+
const r = res;
|
|
28
|
+
r.status = code => { r.statusCode = code; return r; };
|
|
29
|
+
r.set = (name, value) => { r.setHeader(name, value); return r; };
|
|
30
|
+
r.type = mime => r.set('Content-Type', mime);
|
|
31
|
+
r.json = value => {
|
|
32
|
+
if (!r.hasHeader('Content-Type'))
|
|
33
|
+
r.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
34
|
+
r.end(JSON.stringify(value));
|
|
35
|
+
};
|
|
36
|
+
r.send = body => {
|
|
37
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
38
|
+
r.setHeader('Content-Length', String(buf.length));
|
|
39
|
+
r.end(buf);
|
|
40
|
+
};
|
|
41
|
+
return r;
|
|
42
|
+
}
|
|
43
|
+
// Resolves to {} unless the request carries a JSON body — matching express.json,
|
|
44
|
+
// which leaves req.body empty for any other content type.
|
|
45
|
+
function readBody(req) {
|
|
46
|
+
if (req.method === 'GET' || req.method === 'HEAD')
|
|
47
|
+
return Promise.resolve({});
|
|
48
|
+
if (!/^application\/json\b/.test(req.headers['content-type'] ?? ''))
|
|
49
|
+
return Promise.resolve({});
|
|
50
|
+
return new Promise((ok, fail) => {
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let size = 0;
|
|
53
|
+
let over = false;
|
|
54
|
+
req.on('data', (c) => {
|
|
55
|
+
size += c.length;
|
|
56
|
+
if (over)
|
|
57
|
+
return;
|
|
58
|
+
if (size > BODY_LIMIT) {
|
|
59
|
+
// Past the cap: drop what was buffered and swallow the rest. Answering
|
|
60
|
+
// now instead would mean closing the socket with an upload still in
|
|
61
|
+
// flight, and that close is a TCP reset — which is allowed to discard
|
|
62
|
+
// the 413 along with it, leaving the client an ECONNRESET (or an EPIPE
|
|
63
|
+
// on its next write) and no status at all. Reading to the end costs
|
|
64
|
+
// constant memory and buys a clean FIN with the response intact.
|
|
65
|
+
chunks.length = 0;
|
|
66
|
+
over = true;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
chunks.push(c);
|
|
70
|
+
});
|
|
71
|
+
req.on('error', fail);
|
|
72
|
+
req.on('end', () => {
|
|
73
|
+
if (over)
|
|
74
|
+
return fail(Object.assign(new Error('request entity too large'), { status: 413 }));
|
|
75
|
+
if (!chunks.length)
|
|
76
|
+
return ok({});
|
|
77
|
+
try {
|
|
78
|
+
ok(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
fail(Object.assign(new Error('invalid JSON body'), { status: 400 }));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function sendFile(file, req, res) {
|
|
87
|
+
const st = statSync(file, { throwIfNoEntry: false });
|
|
88
|
+
if (!st?.isFile())
|
|
89
|
+
return false;
|
|
90
|
+
// vite fingerprints asset filenames, so a weak validator off size+mtime is
|
|
91
|
+
// enough to keep reloads from re-sending the bundle
|
|
92
|
+
const etag = `W/"${st.size.toString(16)}-${st.mtimeMs.toString(16)}"`;
|
|
93
|
+
res.set('ETag', etag);
|
|
94
|
+
if (req.headers['if-none-match'] === etag) {
|
|
95
|
+
res.status(304).end();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
res.set('Content-Type', TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream');
|
|
99
|
+
res.set('Content-Length', String(st.size));
|
|
100
|
+
createReadStream(file).pipe(res);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
// Serves `dir`, falling back to its index.html so client-side routes survive a
|
|
104
|
+
// reload. /api/* never falls through — an unknown API path is a 404, not a page.
|
|
105
|
+
export function serveStatic(dir) {
|
|
106
|
+
const index = join(dir, 'index.html');
|
|
107
|
+
return (req, res) => {
|
|
108
|
+
const path = decodeURIComponent(new URL(req.url ?? '/', 'http://localhost').pathname);
|
|
109
|
+
const file = join(dir, path);
|
|
110
|
+
// join() collapses `..`, but only after decoding — `%2e%2e%2f` arrives here
|
|
111
|
+
// as a real traversal and this is what stops it
|
|
112
|
+
if (file !== dir && !file.startsWith(dir + sep)) {
|
|
113
|
+
res.status(403).json({ error: 'forbidden' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (sendFile(file, req, res))
|
|
117
|
+
return;
|
|
118
|
+
if (path.startsWith('/api')) {
|
|
119
|
+
res.status(404).json({ error: 'not found' });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!sendFile(index, req, res))
|
|
123
|
+
res.status(404).json({ error: 'not found' });
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function createApp() {
|
|
127
|
+
const middleware = [];
|
|
128
|
+
const routes = new Map();
|
|
129
|
+
let fallback = (_req, res) => { res.status(404).json({ error: 'not found' }); };
|
|
130
|
+
const on = (method) => (path, ...handlers) => {
|
|
131
|
+
routes.set(`${method} ${path}`, handlers);
|
|
132
|
+
};
|
|
133
|
+
const listener = async (raw, rawRes) => {
|
|
134
|
+
const req = raw;
|
|
135
|
+
const res = decorate(rawRes);
|
|
136
|
+
try {
|
|
137
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
138
|
+
req.query = Object.fromEntries(url.searchParams);
|
|
139
|
+
req.body = await readBody(req);
|
|
140
|
+
// HEAD reuses the GET handler; node drops the body for us
|
|
141
|
+
const verb = req.method === 'HEAD' ? 'GET' : req.method;
|
|
142
|
+
for (const handler of [...middleware, ...(routes.get(`${verb} ${url.pathname}`) ?? [fallback])]) {
|
|
143
|
+
let advance = false;
|
|
144
|
+
await handler(req, res, () => { advance = true; });
|
|
145
|
+
if (!advance)
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
// express 5 turns a rejected handler into a 500 rather than an unhandled
|
|
151
|
+
// rejection that takes the process down; so does this
|
|
152
|
+
const err = e;
|
|
153
|
+
if (res.headersSent) {
|
|
154
|
+
res.end();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// a client that overshot the cap gets its connection retired rather than
|
|
158
|
+
// parked for reuse. Safe only because the body was drained to the end
|
|
159
|
+
// first — closing with an upload still in flight is a reset, not a FIN.
|
|
160
|
+
if (err.status === 413)
|
|
161
|
+
res.setHeader('Connection', 'close');
|
|
162
|
+
res.status(err.status ?? 500).json({ error: err.message });
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
use: (handler) => { middleware.push(handler); },
|
|
167
|
+
get: on('GET'),
|
|
168
|
+
post: on('POST'),
|
|
169
|
+
put: on('PUT'),
|
|
170
|
+
delete: on('DELETE'),
|
|
171
|
+
fallback: (handler) => { fallback = handler; },
|
|
172
|
+
listen: (port, host) => createServer(listener).listen(port, host),
|
|
173
|
+
};
|
|
174
|
+
}
|
package/dist-server/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createApp, serveStatic } from './http.js';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { existsSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { readFile, realpath, writeFile } from 'node:fs/promises';
|
|
@@ -10,12 +10,11 @@ import { mergeMatches, parseBranchHeader, parseLog, parseMatches, parseMeta, par
|
|
|
10
10
|
import { pickOperation, STATE_FILES } from './operation.js';
|
|
11
11
|
import { subscribe } from './watch.js';
|
|
12
12
|
import { wireTerminal, hasPty } from './term.js';
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
app.use(express.json({ limit: '10mb' }));
|
|
13
|
+
// JSON bodies are capped at 10mb by createApp — a resolved conflicted file goes
|
|
14
|
+
// back as a JSON string, and a few thousand lines of source blows past a smaller
|
|
15
|
+
// limit. The GET side refuses anything over DIFF_CAP, so the client can't
|
|
16
|
+
// assemble a body larger than 1 MB.
|
|
17
|
+
const app = createApp();
|
|
19
18
|
// The server listens on loopback only, but that alone doesn't stop a page on
|
|
20
19
|
// attacker.tld from rebinding its DNS to 127.0.0.1: the browser then treats this
|
|
21
20
|
// API as same-origin and CORS never applies. Pinning Host closes that — a rebound
|
|
@@ -921,15 +920,12 @@ app.get('/api/blob', repoGuard, async (req, res) => {
|
|
|
921
920
|
}
|
|
922
921
|
});
|
|
923
922
|
const dist = join(import.meta.dirname, '..', 'dist');
|
|
924
|
-
if (existsSync(dist))
|
|
925
|
-
app.
|
|
926
|
-
app.get(/^(?!\/api).*/, (_req, res) => res.sendFile(join(dist, 'index.html')));
|
|
927
|
-
}
|
|
923
|
+
if (existsSync(dist))
|
|
924
|
+
app.fallback(serveStatic(dist));
|
|
928
925
|
const port = Number(process.env.PORT) || 3411;
|
|
929
926
|
// exported so bin/megit.js can wait for 'listening' before opening the browser.
|
|
930
|
-
// The banner hangs off the 'listening' event rather than
|
|
931
|
-
//
|
|
932
|
-
// URL that never came up.
|
|
927
|
+
// The banner hangs off the 'listening' event rather than a listen callback, which
|
|
928
|
+
// can fire even when the bind failed and would announce a URL that never came up.
|
|
933
929
|
export const server = app.listen(port, '127.0.0.1');
|
|
934
930
|
server.on('listening', () => console.log(`megit API on http://127.0.0.1:${port}`));
|
|
935
931
|
server.on('error', (e) => {
|
package/dist-server/term.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
3
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
5
|
import { loadConfig } from './config.js';
|
|
5
6
|
// node-pty is an optionalDependency: it ships prebuilds for darwin and win32 only,
|
|
@@ -35,10 +36,37 @@ export function termKey(repo, pane) {
|
|
|
35
36
|
return null;
|
|
36
37
|
return `${repo}\0${p}`;
|
|
37
38
|
}
|
|
39
|
+
// node-pty ships spawn-helper as a prebuilt binary, and some package managers drop
|
|
40
|
+
// the executable bit when extracting the tarball — the terminal then fails to spawn.
|
|
41
|
+
// This ran as a postinstall script until an install script proved to be the kind of
|
|
42
|
+
// supply-chain surface scanners flag on sight; it costs nothing to do it here, once,
|
|
43
|
+
// on the first shell. Resolve node-pty rather than guessing ./node_modules: under
|
|
44
|
+
// `npx` it is hoisted to the installing project's node_modules, not ours.
|
|
45
|
+
let helperFixed = false;
|
|
46
|
+
function fixSpawnHelper() {
|
|
47
|
+
if (helperFixed)
|
|
48
|
+
return;
|
|
49
|
+
helperFixed = true;
|
|
50
|
+
try {
|
|
51
|
+
const prebuilds = join(dirname(createRequire(import.meta.url).resolve('node-pty/package.json')), 'prebuilds');
|
|
52
|
+
for (const platform of readdirSync(prebuilds)) {
|
|
53
|
+
try {
|
|
54
|
+
chmodSync(join(prebuilds, platform, 'spawn-helper'), 0o755);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// win32 prebuilds have no spawn-helper; nothing to do
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// node-pty is optional — absent on Linux installs without build tools
|
|
63
|
+
}
|
|
64
|
+
}
|
|
38
65
|
async function getSession(key, repo) {
|
|
39
66
|
const existing = sessions.get(key);
|
|
40
67
|
if (existing)
|
|
41
68
|
return existing;
|
|
69
|
+
fixSpawnHelper();
|
|
42
70
|
// dynamic import: the native module never loads until a terminal is actually opened
|
|
43
71
|
const { spawn } = await import('node-pty');
|
|
44
72
|
const shell = process.env.SHELL || '/bin/sh';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "megit-app",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Git repository viewer in the browser: commit graph with branch lanes, diffs, stashes and WIP",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Hoang Vuong Vu",
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
"bin",
|
|
26
26
|
"dist",
|
|
27
27
|
"dist-server",
|
|
28
|
-
"scripts/fix-pty-perms.mjs",
|
|
29
28
|
"CHANGELOG.md"
|
|
30
29
|
],
|
|
31
30
|
"engines": {
|
|
@@ -37,20 +36,17 @@
|
|
|
37
36
|
"build:server": "node -e \"fs.rmSync('dist-server',{recursive:true,force:true})\" && tsc -p tsconfig.server.json",
|
|
38
37
|
"start": "PORT=4500 node server/index.ts",
|
|
39
38
|
"test": "vitest run",
|
|
40
|
-
"prepublishOnly": "pnpm test && pnpm build && pnpm build:server"
|
|
41
|
-
"postinstall": "node scripts/fix-pty-perms.mjs"
|
|
39
|
+
"prepublishOnly": "pnpm test && pnpm build && pnpm build:server"
|
|
42
40
|
},
|
|
43
41
|
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
|
|
44
42
|
"dependencies": {
|
|
45
|
-
"
|
|
46
|
-
"ws": "^8.21.2"
|
|
43
|
+
"ws": "^8.21.3"
|
|
47
44
|
},
|
|
48
45
|
"optionalDependencies": {
|
|
49
46
|
"node-pty": "^1.1.0"
|
|
50
47
|
},
|
|
51
48
|
"devDependencies": {
|
|
52
|
-
"@types/
|
|
53
|
-
"@types/node": "^26.1.2",
|
|
49
|
+
"@types/node": "^26.2.0",
|
|
54
50
|
"@types/react": "^19.2.18",
|
|
55
51
|
"@types/react-dom": "^19.2.4",
|
|
56
52
|
"@types/ws": "^8.18.1",
|
|
@@ -63,7 +59,7 @@
|
|
|
63
59
|
"react": "^19.2.8",
|
|
64
60
|
"react-dom": "^19.2.8",
|
|
65
61
|
"typescript": "^7.0.2",
|
|
66
|
-
"vite": "^8.2.
|
|
62
|
+
"vite": "^8.2.1",
|
|
67
63
|
"vitest": "^4.1.10"
|
|
68
64
|
}
|
|
69
65
|
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
// node-pty ships spawn-helper as a prebuilt binary, and some package managers drop
|
|
2
|
-
// the executable bit when extracting the tarball — the terminal then fails to spawn.
|
|
3
|
-
// Resolve node-pty rather than guessing ./node_modules: under `npx` it is hoisted to
|
|
4
|
-
// the installing project's node_modules, not ours.
|
|
5
|
-
import { chmodSync, readdirSync } from 'node:fs'
|
|
6
|
-
import { createRequire } from 'node:module'
|
|
7
|
-
import { dirname, join } from 'node:path'
|
|
8
|
-
|
|
9
|
-
try {
|
|
10
|
-
const require = createRequire(import.meta.url)
|
|
11
|
-
const prebuilds = join(dirname(require.resolve('node-pty/package.json')), 'prebuilds')
|
|
12
|
-
for (const platform of readdirSync(prebuilds)) {
|
|
13
|
-
try {
|
|
14
|
-
chmodSync(join(prebuilds, platform, 'spawn-helper'), 0o755)
|
|
15
|
-
} catch {
|
|
16
|
-
// win32 prebuilds have no spawn-helper; nothing to do
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
} catch {
|
|
20
|
-
// node-pty is optional — absent on Linux installs without build tools
|
|
21
|
-
}
|