@minimalcorp/tsunagi 0.1.11 → 0.1.13
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/.next/standalone/apps/web/.next/BUILD_ID +1 -1
- package/.next/standalone/apps/web/.next/build-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +12 -12
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +7 -7
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.rsc +14 -14
- package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +7 -7
- package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.rsc +14 -14
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +14 -14
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +7 -7
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +4 -4
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__05isylw._.js +2 -2
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0ro8-ey._.js +2 -2
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_075y~8w._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g.cd6a._.js +1 -1
- package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
- package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/{0kzrcfsni_2f9.js → 0092f2_1y376d.js} +2 -2
- package/.next/standalone/apps/web/.next/static/chunks/{0025qph152a3w.js → 0120px2-6x5y9.js} +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/{041xteb991n6..js → 03lw1.jf3nzsp.js} +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/{03gclil1~.ilq.js → 160s33.o3y2-d.js} +2 -2
- package/.next/standalone/apps/web/package.json +2 -2
- package/dist/cli.js +6 -4
- package/dist/server/basic-auth.d.ts +10 -0
- package/dist/server/basic-auth.js +53 -0
- package/dist/server/index.js +80 -3
- package/dist/server/routes/terminal.js +32 -10
- package/npm-shrinkwrap.json +134 -81
- package/package.json +2 -1
- package/tsunagi-marketplace/plugins/tsunagi-plugin/.claude-plugin/plugin.json +23 -23
- /package/.next/standalone/apps/web/.next/static/{VrVIYPY4igpYT9v1gtRIl → ry8Ez0jPeeq0ioBJhqhRc}/_buildManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{VrVIYPY4igpYT9v1gtRIl → ry8Ez0jPeeq0ioBJhqhRc}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{VrVIYPY4igpYT9v1gtRIl → ry8Ez0jPeeq0ioBJhqhRc}/_ssgManifest.js +0 -0
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
"description": "Next.js UI for tsunagi",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"dev": "next dev -p
|
|
8
|
+
"dev": "next dev -p 2792",
|
|
9
9
|
"build": "next build",
|
|
10
|
-
"start": "next start -p
|
|
10
|
+
"start": "next start -p 2792",
|
|
11
11
|
"lint": "eslint",
|
|
12
12
|
"type-check": "tsc --noEmit"
|
|
13
13
|
},
|
package/dist/cli.js
CHANGED
|
@@ -181,16 +181,18 @@ function verifyArtifact(p, label) {
|
|
|
181
181
|
}
|
|
182
182
|
verifyArtifact(FASTIFY_ENTRY_JS, 'Fastify server artifact');
|
|
183
183
|
verifyArtifact(NEXT_STANDALONE_ENTRY, 'Next.js standalone artifact');
|
|
184
|
+
// Fastify が単一の公開ポート。Next.js は内部ポートで動かし Fastify からプロキシする。
|
|
184
185
|
const PORT = process.env.PORT ?? '2791';
|
|
186
|
+
const NEXT_PORT = '2792';
|
|
185
187
|
const fastifyChild = spawn(process.execPath, [FASTIFY_ENTRY_JS], {
|
|
186
188
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
187
189
|
cwd: PACKAGE_ROOT,
|
|
188
|
-
env: { ...process.env, NODE_ENV: 'production' },
|
|
190
|
+
env: { ...process.env, NODE_ENV: 'production', PORT, TSUNAGI_NEXT_PORT: NEXT_PORT },
|
|
189
191
|
});
|
|
190
192
|
const nextChild = spawn(process.execPath, [NEXT_STANDALONE_ENTRY], {
|
|
191
193
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
192
194
|
cwd: path.dirname(NEXT_STANDALONE_ENTRY),
|
|
193
|
-
env: { ...process.env, PORT, NODE_ENV: 'production', HOSTNAME: '0.0.0.0' },
|
|
195
|
+
env: { ...process.env, PORT: NEXT_PORT, NODE_ENV: 'production', HOSTNAME: '0.0.0.0' },
|
|
194
196
|
});
|
|
195
197
|
const docsServer = startDocsServer();
|
|
196
198
|
// ---------------------------------------------------------------------------
|
|
@@ -213,7 +215,6 @@ nextChild.stderr?.on('data', (data) => {
|
|
|
213
215
|
// ---------------------------------------------------------------------------
|
|
214
216
|
// Ready detection via /health polling
|
|
215
217
|
// ---------------------------------------------------------------------------
|
|
216
|
-
const SERVER_PORT = 2792;
|
|
217
218
|
const POLL_INTERVAL_MS = 300;
|
|
218
219
|
function pollHealth(port) {
|
|
219
220
|
return new Promise((resolve) => {
|
|
@@ -235,7 +236,8 @@ function pollHealth(port) {
|
|
|
235
236
|
poll();
|
|
236
237
|
});
|
|
237
238
|
}
|
|
238
|
-
|
|
239
|
+
// Fastify(PORT) と Next(NEXT_PORT) は各々 /health を持つ。各プロセスを直接叩く。
|
|
240
|
+
Promise.all([pollHealth(Number(PORT)), pollHealth(Number(NEXT_PORT))]).then(() => {
|
|
239
241
|
spinner.stop();
|
|
240
242
|
console.log(TSUNAGI_AA);
|
|
241
243
|
const url = `http://localhost:${PORT}`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
2
|
+
export interface BasicAuth {
|
|
3
|
+
/** クライアントへ返す WWW-Authenticate ヘッダ値 */
|
|
4
|
+
readonly challenge: string;
|
|
5
|
+
/** Authorization ヘッダが想定値と一致するか(定数時間比較) */
|
|
6
|
+
isValidHeader(header: string | undefined): boolean;
|
|
7
|
+
/** リクエストを通してよいか(認証成功 or ローカル内部連携の免除) */
|
|
8
|
+
isAuthorized(headers: IncomingHttpHeaders, url: string | undefined, remoteAddress: string | undefined): boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function createBasicAuth(env?: NodeJS.ProcessEnv): BasicAuth | null;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Basic 認証。`TSUNAGI_BASIC_AUTH_USER` と `TSUNAGI_BASIC_AUTH_PASSWORD` が
|
|
4
|
+
* 両方設定されているときだけ有効化する(cloudflared 等で外部公開する際の保護)。
|
|
5
|
+
*
|
|
6
|
+
* 単一ポート集約の都合上、cloudflared はアプリと同じ `localhost` から Fastify へ
|
|
7
|
+
* 接続するため、外部アクセスもローカルの内部連携も IP 上は loopback に見える。
|
|
8
|
+
* そこで「外部 = Cloudflare 経由」を Cloudflare が付与するヘッダで判別し、外部からは
|
|
9
|
+
* すべて認証必須にしつつ、ローカルマシン上の Claude Code プラグイン連携
|
|
10
|
+
* (hooks / MCP) や cli の死活監視だけは認証なしで通す。
|
|
11
|
+
*/
|
|
12
|
+
// ローカルからのみ認証を免除する内部連携エンドポイント。外部(cloudflared)からは
|
|
13
|
+
// これらであっても認証必須にする。
|
|
14
|
+
const LOCAL_EXEMPT_PREFIXES = ['/health', '/api/hooks', '/api/internal', '/api/mcp'];
|
|
15
|
+
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
16
|
+
export function createBasicAuth(env = process.env) {
|
|
17
|
+
const user = env.TSUNAGI_BASIC_AUTH_USER;
|
|
18
|
+
const password = env.TSUNAGI_BASIC_AUTH_PASSWORD;
|
|
19
|
+
if (!user || !password)
|
|
20
|
+
return null;
|
|
21
|
+
const expected = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
|
|
22
|
+
const expectedBuf = Buffer.from(expected);
|
|
23
|
+
const isValidHeader = (header) => {
|
|
24
|
+
if (!header)
|
|
25
|
+
return false;
|
|
26
|
+
const given = Buffer.from(header);
|
|
27
|
+
// 長さが異なると timingSafeEqual が throw するため先にガード(長さ差のみ漏れる)。
|
|
28
|
+
return given.length === expectedBuf.length && crypto.timingSafeEqual(given, expectedBuf);
|
|
29
|
+
};
|
|
30
|
+
// Cloudflare(cloudflared)経由のリクエストには Cloudflare が必ず付与するヘッダが付く。
|
|
31
|
+
// Cloudflare はクライアント指定の cf-connecting-ip を上書きするため、外部からは
|
|
32
|
+
// 「ローカルに偽装」できない。
|
|
33
|
+
const isViaCloudflare = (headers) => Boolean(headers['cf-connecting-ip'] || headers['cf-ray'] || headers['x-forwarded-for']);
|
|
34
|
+
const isLoopback = (remoteAddress) => !!remoteAddress && LOOPBACK_ADDRESSES.has(remoteAddress);
|
|
35
|
+
const isLocalExemptPath = (url) => {
|
|
36
|
+
const path = (url ?? '').split('?')[0];
|
|
37
|
+
return LOCAL_EXEMPT_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`));
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
challenge: 'Basic realm="tsunagi", charset="UTF-8"',
|
|
41
|
+
isValidHeader,
|
|
42
|
+
isAuthorized(headers, url, remoteAddress) {
|
|
43
|
+
if (isValidHeader(headers['authorization']))
|
|
44
|
+
return true;
|
|
45
|
+
// 認証情報が無くても、loopback かつ Cloudflare 経由でない(=同一マシン直結の)
|
|
46
|
+
// 内部連携エンドポイントだけは許可する。
|
|
47
|
+
if (isLoopback(remoteAddress) && !isViaCloudflare(headers) && isLocalExemptPath(url)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
1
2
|
import Fastify from 'fastify';
|
|
2
3
|
import fastifyCors from '@fastify/cors';
|
|
4
|
+
import httpProxy from '@fastify/http-proxy';
|
|
3
5
|
import { Server as SocketIOServer } from 'socket.io';
|
|
4
6
|
import { tasksRoutes } from './routes/tasks.js';
|
|
5
7
|
import { reposRoutes } from './routes/repos.js';
|
|
@@ -13,12 +15,19 @@ import { hooksRoutes } from './routes/hooks.js';
|
|
|
13
15
|
import { mcpRoutes } from './routes/mcp.js';
|
|
14
16
|
import { terminalRoutes } from './routes/terminal.js';
|
|
15
17
|
import { editorRoutes } from './routes/editor.js';
|
|
16
|
-
|
|
18
|
+
import { createBasicAuth } from './basic-auth.js';
|
|
19
|
+
// Fastify は単一の公開エンドポイント。Next.js は内部ポートで動かしプロキシする。
|
|
20
|
+
const PORT = Number(process.env.PORT) || 2791;
|
|
21
|
+
const NEXT_PORT = Number(process.env.TSUNAGI_NEXT_PORT) || 2792;
|
|
17
22
|
const extraOrigins = (process.env.TSUNAGI_EXTRA_CORS_ORIGINS ?? '')
|
|
18
23
|
.split(',')
|
|
19
24
|
.map((s) => s.trim())
|
|
20
25
|
.filter(Boolean);
|
|
21
|
-
|
|
26
|
+
// 本番(cloudflared 等)は同一オリジンで CORS 不要。dev は Next を 2792 で直接開く
|
|
27
|
+
// ため、その origin からの cross-origin リクエストを許可する。
|
|
28
|
+
const corsOrigins = [`http://localhost:${NEXT_PORT}`, ...extraOrigins];
|
|
29
|
+
// TSUNAGI_BASIC_AUTH_USER / TSUNAGI_BASIC_AUTH_PASSWORD が両方ある時だけ有効。
|
|
30
|
+
const basicAuth = createBasicAuth();
|
|
22
31
|
async function start() {
|
|
23
32
|
const fastify = Fastify({
|
|
24
33
|
logger: {
|
|
@@ -29,9 +38,31 @@ async function start() {
|
|
|
29
38
|
origin: corsOrigins,
|
|
30
39
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
31
40
|
});
|
|
41
|
+
// Basic 認証(有効時のみ)。CORS の後に登録することで preflight(OPTIONS) は
|
|
42
|
+
// @fastify/cors が先に応答し、認証で弾かれない。
|
|
43
|
+
if (basicAuth) {
|
|
44
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
45
|
+
if (basicAuth.isAuthorized(request.headers, request.url, request.socket.remoteAddress)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
reply
|
|
49
|
+
.header('WWW-Authenticate', basicAuth.challenge)
|
|
50
|
+
.code(401)
|
|
51
|
+
.send('Authentication required');
|
|
52
|
+
return reply;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
32
55
|
const io = new SocketIOServer(fastify.server, {
|
|
33
|
-
|
|
56
|
+
// polling を許可しておく。iOS Safari/Chrome(WebKit) は Basic 認証のキャッシュ
|
|
57
|
+
// 資格情報を WebSocket ハンドシェイクには付与しない既知の挙動があり、WS のみだと
|
|
58
|
+
// 認証付き公開時に iOS 端末から接続できず "connecting..." のまま固まる。polling は
|
|
59
|
+
// XHR なので Authorization が乗り、認証を通過できる(デスクトップは WS に自動昇格)。
|
|
60
|
+
transports: ['polling', 'websocket'],
|
|
34
61
|
cors: { origin: corsOrigins },
|
|
62
|
+
// Basic 認証(有効時)。polling は XHR、WS はハンドシェイクの Authorization を検証する。
|
|
63
|
+
allowRequest: basicAuth
|
|
64
|
+
? (req, callback) => callback(null, basicAuth.isAuthorized(req.headers, req.url, req.socket.remoteAddress))
|
|
65
|
+
: undefined,
|
|
35
66
|
// 死んだ接続(スリープ・ネットワーク断等)を早めに検出して、ぶら下がった socket が
|
|
36
67
|
// 保持する PTY の onData/onExit ハンドラを早く解放する。既定 (25s/20s) では検出までに
|
|
37
68
|
// 最大 ~45s かかり、その間ゾンビ socket がリスナーを溜め込み出力が多重化する。
|
|
@@ -52,6 +83,52 @@ async function start() {
|
|
|
52
83
|
await fastify.register(mcpRoutes, { prefix: '/api' });
|
|
53
84
|
await fastify.register(terminalRoutes, { prefix: '/api' });
|
|
54
85
|
await fastify.register(editorRoutes, { prefix: '/api' });
|
|
86
|
+
// catch-all リバースプロキシ: /api・/socket.io・/health 以外を内部 Next.js へ転送。
|
|
87
|
+
// - /api/* と /health は上で定義済みルートが wildcard より優先される。
|
|
88
|
+
// - /socket.io は Socket.IO が HTTP サーバ層で先取りするためここには来ない。
|
|
89
|
+
// - websocket は false。HMR は下の透過リレーで、Socket.IO は engine.io が終端する。
|
|
90
|
+
// - OPTIONS は除外。@fastify/cors が `OPTIONS *` を登録済みで、proxy が同じ
|
|
91
|
+
// `OPTIONS /*` を登録すると "Method 'OPTIONS' already declared" で起動失敗する。
|
|
92
|
+
// preflight は cors が処理するため proxy 側で OPTIONS を扱う必要はない。
|
|
93
|
+
await fastify.register(httpProxy, {
|
|
94
|
+
upstream: `http://localhost:${NEXT_PORT}`,
|
|
95
|
+
prefix: '/',
|
|
96
|
+
websocket: false,
|
|
97
|
+
httpMethods: ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'],
|
|
98
|
+
});
|
|
99
|
+
// 開発時のみ: Next.js の HMR(WebSocket = /_next/webpack-hmr) を 2791 経由でも
|
|
100
|
+
// 使えるよう透過 TCP リレーする。これにより dev も本番と同じく単一ポート(2791)で
|
|
101
|
+
// 完結し、HMR / API / Socket.IO がすべて同一オリジンで動く。
|
|
102
|
+
//
|
|
103
|
+
// 注意: @fastify/http-proxy の websocket:true は upgrade を Fastify router 経由で
|
|
104
|
+
// ディスパッチするため、/socket.io の upgrade まで catch-all ルートが拾い例外を
|
|
105
|
+
// 投げる(Socket.IO 接続毎にエラーログ)。そのため WS は自前で /_next/webpack-hmr
|
|
106
|
+
// だけを対象にし、/socket.io には一切触れず engine.io に委ねる。
|
|
107
|
+
// 本番(standalone Next)は HMR が無いので何もしない。
|
|
108
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
109
|
+
fastify.server.on('upgrade', (req, socket, head) => {
|
|
110
|
+
if (!req.url?.startsWith('/_next/webpack-hmr'))
|
|
111
|
+
return;
|
|
112
|
+
if (basicAuth && !basicAuth.isAuthorized(req.headers, req.url, req.socket.remoteAddress)) {
|
|
113
|
+
socket.destroy();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const upstream = net.connect(NEXT_PORT, '127.0.0.1', () => {
|
|
117
|
+
upstream.write(`${req.method} ${req.url} HTTP/1.1\r\n`);
|
|
118
|
+
const raw = req.rawHeaders;
|
|
119
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
120
|
+
upstream.write(`${raw[i]}: ${raw[i + 1]}\r\n`);
|
|
121
|
+
}
|
|
122
|
+
upstream.write('\r\n');
|
|
123
|
+
if (head?.length)
|
|
124
|
+
upstream.write(head);
|
|
125
|
+
socket.pipe(upstream);
|
|
126
|
+
upstream.pipe(socket);
|
|
127
|
+
});
|
|
128
|
+
upstream.on('error', () => socket.destroy());
|
|
129
|
+
socket.on('error', () => upstream.destroy());
|
|
130
|
+
});
|
|
131
|
+
}
|
|
55
132
|
await fastify.listen({ port: PORT, host: '0.0.0.0' });
|
|
56
133
|
console.log(`Fastify server running on port ${PORT}`);
|
|
57
134
|
const shutdown = async (signal) => {
|
|
@@ -13,14 +13,19 @@ export async function terminalRoutes(fastify) {
|
|
|
13
13
|
let dataHandlerDispose = null;
|
|
14
14
|
let exitHandlerDispose = null;
|
|
15
15
|
// join: roomに参加する。
|
|
16
|
-
// - mode='
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
16
|
+
// - mode='terminal': PTY の入出力を接続する。TerminalView 用。明示指定が必須。
|
|
17
|
+
// - mode='subscribe'(既定。mode 省略・不明値もここに含む): status-changed /
|
|
18
|
+
// todos-updated のブロードキャスト受信のみ。PTY IO は接続しない。
|
|
19
|
+
// タスク一覧 / プランナーの購読 hook 用。PTY が未起動でも参加できる。
|
|
20
|
+
socket.on('join', ({ room, mode }) => {
|
|
20
21
|
const sessionId = room.startsWith('tab:') ? room.slice(4) : room;
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
22
|
+
// PTY バインドは mode==='terminal' を明示した socket のみ。
|
|
23
|
+
// mode 省略・不明な値はすべて購読扱いにし、PTY IO を絶対に接続しない。
|
|
24
|
+
// (購読 hook の join が mode 省略で誤って PTY バインドし、input/onData/exit
|
|
25
|
+
// ハンドラ登録や GC 干渉で入力多重化を招く事故を構造的に防ぐ)
|
|
26
|
+
// 購読のみ: room メンバーシップだけ付与して終了。boundSessionId もセットしない
|
|
27
|
+
// (disconnect 時に他人のセッションへ GC を仕掛けないため)。
|
|
28
|
+
if (mode !== 'terminal') {
|
|
24
29
|
socket.join(room);
|
|
25
30
|
return;
|
|
26
31
|
}
|
|
@@ -42,6 +47,14 @@ export async function terminalRoutes(fastify) {
|
|
|
42
47
|
socket.join(room);
|
|
43
48
|
// 接続確立 → GCタイマーをキャンセル
|
|
44
49
|
ptyManager.cancelGc(sessionId);
|
|
50
|
+
// この socket を PTY の所有者(active socket)にする。input の書き込みは所有者の
|
|
51
|
+
// socket からのみ許可し(下記 input ハンドラのゲート参照)、複数 socket が同一 PTY に
|
|
52
|
+
// バインドしても 1 キーストロークが多重書き込みされないことを構造的に保証する。
|
|
53
|
+
const prevOwner = ptyManager.getActiveSocket(sessionId);
|
|
54
|
+
if (prevOwner && prevOwner !== socket.id) {
|
|
55
|
+
console.log(`[terminal] session ${sessionId}: owner ${prevOwner} -> ${socket.id} (previous terminal socket still bound)`);
|
|
56
|
+
}
|
|
57
|
+
ptyManager.setActiveSocket(sessionId, socket.id);
|
|
45
58
|
const { pty: ptyProcess } = session;
|
|
46
59
|
const isReused = session.scrollback.length > 0;
|
|
47
60
|
// リングバッファの内容を一括送信(再接続時の画面復元)
|
|
@@ -95,11 +108,14 @@ export async function terminalRoutes(fastify) {
|
|
|
95
108
|
}
|
|
96
109
|
}
|
|
97
110
|
});
|
|
98
|
-
// input イベント: PTY
|
|
111
|
+
// input イベント: 所有者 socket からの入力のみ PTY へ書き込む。
|
|
112
|
+
// ゾンビ/非所有 socket(再接続前の旧 socket・誤バインド socket 等)からの重複 input は
|
|
113
|
+
// 破棄し、1 キーストロークの多重書き込み(例: /clear が連続入力される)を防ぐ。
|
|
99
114
|
socket.on('input', ({ sessionId: sid, data }) => {
|
|
100
115
|
if (sid !== sessionId)
|
|
101
116
|
return;
|
|
102
|
-
ptyManager.
|
|
117
|
+
if (ptyManager.getActiveSocket(sessionId) !== socket.id)
|
|
118
|
+
return;
|
|
103
119
|
ptyProcess.write(data);
|
|
104
120
|
});
|
|
105
121
|
});
|
|
@@ -116,8 +132,14 @@ export async function terminalRoutes(fastify) {
|
|
|
116
132
|
dataHandlerDispose?.();
|
|
117
133
|
exitHandlerDispose?.();
|
|
118
134
|
if (boundSessionId) {
|
|
135
|
+
// 所有者だった socket の切断時のみ GC をスケジュールする。
|
|
136
|
+
// 再接続で新しい socket が既に所有者になっている場合や、ゾンビ/非所有 socket の
|
|
137
|
+
// 遅延切断では GC を張らない(現役セッションが誤って GC 対象になるのを防ぐ)。
|
|
138
|
+
const wasOwner = ptyManager.getActiveSocket(boundSessionId) === socket.id;
|
|
119
139
|
ptyManager.clearActiveSocket(boundSessionId, socket.id);
|
|
120
|
-
|
|
140
|
+
if (wasOwner) {
|
|
141
|
+
ptyManager.scheduleGc(boundSessionId);
|
|
142
|
+
}
|
|
121
143
|
}
|
|
122
144
|
});
|
|
123
145
|
});
|