@uzuhq/code-cli 0.3.14
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 +64 -0
- package/dist/auth/browser.js +22 -0
- package/dist/auth/config.js +48 -0
- package/dist/auth/env.js +42 -0
- package/dist/auth/jwt.js +27 -0
- package/dist/auth/login-flow.js +44 -0
- package/dist/auth/loopback.js +94 -0
- package/dist/auth/pkce.js +11 -0
- package/dist/auth/publish-token.js +74 -0
- package/dist/auth/token-cache.js +70 -0
- package/dist/auth/uzu-auth.js +94 -0
- package/dist/build-server-logic.js +28 -0
- package/dist/cf-images-upload.js +52 -0
- package/dist/cli.js +303 -0
- package/dist/create-2d-game.js +56 -0
- package/dist/dev-server/game-room.js +436 -0
- package/dist/dev-server/game-types.js +11 -0
- package/dist/dev-server/json-patch.js +114 -0
- package/dist/dev-server/load-logic.js +70 -0
- package/dist/dev-server/random.js +35 -0
- package/dist/dev-server/relay-room.js +84 -0
- package/dist/dev-server/server.js +367 -0
- package/dist/dev-server/sync-room.js +268 -0
- package/dist/dev.js +235 -0
- package/dist/harness/admin-client.js +215 -0
- package/dist/harness/client-entry.js +90 -0
- package/dist/harness/dev-button.js +249 -0
- package/dist/harness/mount.js +664 -0
- package/dist/harness/page.js +46 -0
- package/dist/r2-upload.js +93 -0
- package/dist/rest-register.js +50 -0
- package/dist/upload-session.js +71 -0
- package/game-2d-template/index.html.tpl +18 -0
- package/game-2d-template/manifest.json.tpl +6 -0
- package/game-2d-template/package.json.tpl +23 -0
- package/game-2d-template/src/main.ts +49 -0
- package/game-2d-template/src/vite-env.d.ts +1 -0
- package/game-2d-template/tsconfig.json +12 -0
- package/game-2d-template/vite.config.ts +6 -0
- package/package.json +43 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev-server 版 RelayRoom (init() 単独 relay mode 用)。
|
|
3
|
+
*
|
|
4
|
+
* v3-play-server の `relay-room.ts` (Cloudflare Worker DO 版) と同一プロトコル
|
|
5
|
+
* (`__room_init` + broadcast) を喋る Node.js 版。 SDK 側の Relay client
|
|
6
|
+
* (`connectRoom` in `uzuhq-sdk/src/index.ts`) がそのまま接続できる。
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
export class RelayRoom {
|
|
10
|
+
sockets = new Set();
|
|
11
|
+
attachments = new WeakMap();
|
|
12
|
+
handleConnection(ws, url) {
|
|
13
|
+
const playerId = url.searchParams.get('playerId');
|
|
14
|
+
if (!playerId) {
|
|
15
|
+
ws.close(1008, 'Missing required query parameter: playerId');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const connectionId = randomUUID();
|
|
19
|
+
console.log(`[RelayRoom] 🔗 New connection: connectionId=${connectionId} playerId=${playerId}`);
|
|
20
|
+
this.sockets.add(ws);
|
|
21
|
+
this.attachments.set(ws, { connectionId, playerId });
|
|
22
|
+
ws.on('message', (raw) => this.handleMessage(ws, raw.toString()));
|
|
23
|
+
ws.on('close', () => this.handleClose(ws));
|
|
24
|
+
ws.on('error', () => this.handleClose(ws));
|
|
25
|
+
ws.send(JSON.stringify({ type: '__room_init', myId: playerId }));
|
|
26
|
+
}
|
|
27
|
+
handleMessage(ws, msg) {
|
|
28
|
+
const attachment = this.attachments.get(ws);
|
|
29
|
+
if (!attachment)
|
|
30
|
+
return;
|
|
31
|
+
const senderId = attachment.playerId;
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(msg);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (parsed.type === '__ping') {
|
|
40
|
+
try {
|
|
41
|
+
ws.send(JSON.stringify({ type: '__pong' }));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* disconnected */
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
console.log(`[RelayRoom] ⬅ recv from=${senderId}`, JSON.stringify(parsed));
|
|
49
|
+
const outData = JSON.stringify({ ...parsed, __from: senderId });
|
|
50
|
+
if (parsed.__to && typeof parsed.__to === 'string') {
|
|
51
|
+
for (const peer of this.sockets) {
|
|
52
|
+
const pa = this.attachments.get(peer);
|
|
53
|
+
if (pa && pa.playerId === parsed.__to) {
|
|
54
|
+
try {
|
|
55
|
+
peer.send(outData);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* disconnected */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
for (const peer of this.sockets) {
|
|
65
|
+
if (peer === ws)
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
peer.send(outData);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* disconnected */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
handleClose(ws) {
|
|
77
|
+
const attachment = this.attachments.get(ws);
|
|
78
|
+
if (attachment) {
|
|
79
|
+
console.log(`[RelayRoom] ❌ Disconnected: connectionId=${attachment.connectionId}`);
|
|
80
|
+
}
|
|
81
|
+
this.sockets.delete(ws);
|
|
82
|
+
this.attachments.delete(ws);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dev-server: harness page HTML/JS を serve しつつ、
|
|
3
|
+
* `/ws/games/:revisionId/:roomId` / `/ws/sync/:roomId` / `/ws/rooms/:roomId` を
|
|
4
|
+
* 本番 (v3-play-server) と同一プロトコルで喋る Node.js HTTP + WebSocket server。
|
|
5
|
+
*
|
|
6
|
+
* さらに parent frame の `__uzu_dev` 用管理 channel `/dev/admin` を提供する。
|
|
7
|
+
* game / sync / relay room はいずれも in-memory (CLI 停止で state 消失)。
|
|
8
|
+
*/
|
|
9
|
+
import { Agent, createServer, request as httpRequest, } from 'http';
|
|
10
|
+
import { connect as netConnect } from 'net';
|
|
11
|
+
import { hostname, networkInterfaces } from 'os';
|
|
12
|
+
import { WebSocketServer } from 'ws';
|
|
13
|
+
import { GameRoom } from './game-room.js';
|
|
14
|
+
import { SyncRoom } from './sync-room.js';
|
|
15
|
+
import { RelayRoom } from './relay-room.js';
|
|
16
|
+
const CONTENT_TYPES = {
|
|
17
|
+
html: 'text/html; charset=utf-8',
|
|
18
|
+
js: 'application/javascript; charset=utf-8',
|
|
19
|
+
json: 'application/json; charset=utf-8',
|
|
20
|
+
};
|
|
21
|
+
export function startHarnessServer(opts) {
|
|
22
|
+
const gameRooms = new Map();
|
|
23
|
+
const syncRooms = new Map();
|
|
24
|
+
const relayRooms = new Map();
|
|
25
|
+
const resolveAdmin = () => {
|
|
26
|
+
// meta.roomKey に対応する GameRoom を優先。 stale な browser tab が持つ
|
|
27
|
+
// 古い roomKey に対しては別 GameRoom が作られてしまうが、 admin.reset() は
|
|
28
|
+
// 現在の meta.roomKey (= fresh page load が接続する room) を対象にしたい。
|
|
29
|
+
// 見つからなければ Map の最初の room に fallback (sync 系や relay 系用)。
|
|
30
|
+
const preferredKey = `${opts.meta.revisionId}/${opts.meta.roomKey}`;
|
|
31
|
+
const gameRoom = gameRooms.get(preferredKey) ?? gameRooms.values().next().value;
|
|
32
|
+
if (gameRoom)
|
|
33
|
+
return { kind: 'game', game: gameRoom.admin() };
|
|
34
|
+
const syncRoom = syncRooms.values().next().value;
|
|
35
|
+
if (syncRoom)
|
|
36
|
+
return { kind: 'sync', sync: syncRoom.admin() };
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
const httpServer = createServer((req, res) => {
|
|
40
|
+
handleHttpRequest(req, res, opts);
|
|
41
|
+
});
|
|
42
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
43
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
44
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
45
|
+
const pathname = url.pathname;
|
|
46
|
+
const gameMatch = pathname.match(/^\/ws\/games\/([^/]+)\/([^/]+)$/);
|
|
47
|
+
if (gameMatch) {
|
|
48
|
+
const revisionId = gameMatch[1];
|
|
49
|
+
const roomId = gameMatch[2];
|
|
50
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
51
|
+
if (!opts.logic) {
|
|
52
|
+
ws.close(1008, 'No server logic loaded (missing serverActionLogicPath in manifest)');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const key = `${revisionId}/${roomId}`;
|
|
56
|
+
let room = gameRooms.get(key);
|
|
57
|
+
if (!room) {
|
|
58
|
+
room = new GameRoom(opts.logic, opts.meta.seats);
|
|
59
|
+
gameRooms.set(key, room);
|
|
60
|
+
}
|
|
61
|
+
room.handleConnection(ws, url);
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const syncMatch = pathname.match(/^\/ws\/sync\/([^/]+)$/);
|
|
66
|
+
if (syncMatch) {
|
|
67
|
+
const roomId = syncMatch[1];
|
|
68
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
69
|
+
let room = syncRooms.get(roomId);
|
|
70
|
+
if (!room) {
|
|
71
|
+
room = new SyncRoom();
|
|
72
|
+
syncRooms.set(roomId, room);
|
|
73
|
+
}
|
|
74
|
+
room.handleConnection(ws, url);
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const relayMatch = pathname.match(/^\/ws\/rooms\/([^/]+)$/);
|
|
79
|
+
if (relayMatch) {
|
|
80
|
+
const roomId = relayMatch[1];
|
|
81
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
82
|
+
let room = relayRooms.get(roomId);
|
|
83
|
+
if (!room) {
|
|
84
|
+
room = new RelayRoom();
|
|
85
|
+
relayRooms.set(roomId, room);
|
|
86
|
+
}
|
|
87
|
+
room.handleConnection(ws, url);
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (pathname === '/dev/admin') {
|
|
92
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
93
|
+
handleAdminConnection(ws, resolveAdmin);
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// それ以外の upgrade (vite HMR ws 等) は scenario dev server へ生 TCP で転送する。
|
|
98
|
+
// 実機 (LAN/mDNS) からのアクセスは harness port だけで完結させ、 scenario 側の
|
|
99
|
+
// vite に --host / allowedHosts を要求しない。
|
|
100
|
+
proxyUpgradeToScenario(req, socket, head, opts.meta.scenarioUrl);
|
|
101
|
+
});
|
|
102
|
+
httpServer.listen(opts.port, opts.host ?? '127.0.0.1');
|
|
103
|
+
return {
|
|
104
|
+
stop: () => new Promise((resolve) => {
|
|
105
|
+
for (const ws of wss.clients) {
|
|
106
|
+
try {
|
|
107
|
+
ws.close();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
wss.close(() => {
|
|
114
|
+
httpServer.close(() => resolve());
|
|
115
|
+
});
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function currentLanHosts() {
|
|
120
|
+
const hosts = [];
|
|
121
|
+
// mDNS (Bonjour) 名を先頭に。 DHCP で IP が変わっても URL が生き続けるので、
|
|
122
|
+
// スマホの「ホーム画面に追加」やブックマークはこちらを使うのが安定する。
|
|
123
|
+
const name = hostname();
|
|
124
|
+
if (name)
|
|
125
|
+
hosts.push(name.toLowerCase().endsWith('.local') ? name.toLowerCase() : `${name.toLowerCase()}.local`);
|
|
126
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
127
|
+
for (const entry of entries ?? []) {
|
|
128
|
+
if (entry.family === 'IPv4' && !entry.internal)
|
|
129
|
+
hosts.push(entry.address);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return hosts;
|
|
133
|
+
}
|
|
134
|
+
function handleHttpRequest(req, res, opts) {
|
|
135
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
136
|
+
const pathname = url.pathname;
|
|
137
|
+
const writeText = (body, contentType, status = 200) => {
|
|
138
|
+
res.writeHead(status, {
|
|
139
|
+
'content-type': contentType,
|
|
140
|
+
'cache-control': 'no-store',
|
|
141
|
+
'access-control-allow-origin': '*',
|
|
142
|
+
});
|
|
143
|
+
res.end(body);
|
|
144
|
+
};
|
|
145
|
+
// `?server=` 付きの `/` は scenario の index.html (iframe の中身)。
|
|
146
|
+
// それ以外の `/` は harness page。 asset 等の未知 path はすべて scenario へ proxy する
|
|
147
|
+
// (harness 資産は `_uzu_` prefix / 固定 path なので衝突しない)。
|
|
148
|
+
const isScenarioIndex = (pathname === '/' || pathname === '/index.html') && url.searchParams.has('server');
|
|
149
|
+
if ((pathname === '/' || pathname === '/index.html') && !isScenarioIndex) {
|
|
150
|
+
writeText(opts.harness.html, CONTENT_TYPES.html);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (pathname === '/_uzu_harness.js') {
|
|
154
|
+
writeText(opts.harness.js, CONTENT_TYPES.js);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (pathname === '/_uzu_meta.json') {
|
|
158
|
+
// lanHosts は DHCP 再割当で変わり得るため、 起動時の値ではなくリクエスト毎に取り直す
|
|
159
|
+
writeText(JSON.stringify({ ...opts.meta, lanHosts: currentLanHosts() }), CONTENT_TYPES.json);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (pathname === '/health') {
|
|
163
|
+
writeText(JSON.stringify({ ok: true }), CONTENT_TYPES.json);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
proxyHttpToScenario(req, res, opts.meta.scenarioUrl);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* 親 frame `__uzu_dev` からの管理 request を受け取る WebSocket handler。
|
|
170
|
+
*
|
|
171
|
+
* message shape:
|
|
172
|
+
* Client → Server: `{ type: 'call', id, method, args? }`
|
|
173
|
+
* `{ type: 'subscribe', id, kind: 'snapshot' | 'events' }`
|
|
174
|
+
* `{ type: 'unsubscribe', id }`
|
|
175
|
+
* Server → Client: `{ type: 'result', id, value }`
|
|
176
|
+
* `{ type: 'error', id, error }`
|
|
177
|
+
* `{ type: 'event', id, value }`
|
|
178
|
+
* `{ type: 'missing', id }` — method 未提供 (undefined 表現)
|
|
179
|
+
*/
|
|
180
|
+
function handleAdminConnection(ws, resolveAdmin) {
|
|
181
|
+
const unsubs = new Map();
|
|
182
|
+
ws.on('message', async (raw) => {
|
|
183
|
+
let msg;
|
|
184
|
+
try {
|
|
185
|
+
msg = JSON.parse(raw.toString());
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const type = msg.type;
|
|
191
|
+
const id = msg.id;
|
|
192
|
+
const admin = resolveAdmin();
|
|
193
|
+
if (!admin) {
|
|
194
|
+
ws.send(JSON.stringify({ type: 'error', id, error: 'no_active_room' }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (type === 'call') {
|
|
198
|
+
const method = msg.method;
|
|
199
|
+
const args = msg.args ?? [];
|
|
200
|
+
try {
|
|
201
|
+
const value = await invokeAdminMethod(admin, method, args);
|
|
202
|
+
if (value === undefined && !adminHasMethod(admin, method)) {
|
|
203
|
+
ws.send(JSON.stringify({ type: 'missing', id }));
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
ws.send(JSON.stringify({ type: 'result', id, value }));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
ws.send(JSON.stringify({
|
|
211
|
+
type: 'error',
|
|
212
|
+
id,
|
|
213
|
+
error: err instanceof Error ? err.message : String(err),
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (type === 'subscribe') {
|
|
219
|
+
const kind = msg.kind;
|
|
220
|
+
const unsub = subscribeAdmin(admin, kind, (value) => {
|
|
221
|
+
try {
|
|
222
|
+
ws.send(JSON.stringify({ type: 'event', id, value }));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* disconnected */
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
if (!unsub) {
|
|
229
|
+
ws.send(JSON.stringify({ type: 'error', id, error: 'subscription_not_supported' }));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
unsubs.set(id, unsub);
|
|
233
|
+
ws.send(JSON.stringify({ type: 'result', id, value: null }));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (type === 'unsubscribe') {
|
|
237
|
+
const unsub = unsubs.get(id);
|
|
238
|
+
if (unsub) {
|
|
239
|
+
unsub();
|
|
240
|
+
unsubs.delete(id);
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
ws.on('close', () => {
|
|
246
|
+
unsubs.forEach((unsub) => unsub());
|
|
247
|
+
unsubs.clear();
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function adminHasMethod(admin, method) {
|
|
251
|
+
if (admin.kind === 'game' && admin.game) {
|
|
252
|
+
return typeof admin.game[method] === 'function';
|
|
253
|
+
}
|
|
254
|
+
if (admin.kind === 'sync' && admin.sync) {
|
|
255
|
+
return typeof admin.sync[method] === 'function';
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
async function invokeAdminMethod(admin, method, args) {
|
|
260
|
+
const target = admin.kind === 'game' ? admin.game : admin.sync;
|
|
261
|
+
if (!target)
|
|
262
|
+
return undefined;
|
|
263
|
+
const fn = target[method];
|
|
264
|
+
if (typeof fn !== 'function')
|
|
265
|
+
return undefined;
|
|
266
|
+
return await fn.apply(target, args);
|
|
267
|
+
}
|
|
268
|
+
function subscribeAdmin(admin, kind, cb) {
|
|
269
|
+
if (kind === 'snapshot') {
|
|
270
|
+
const target = admin.kind === 'game' ? admin.game : admin.sync;
|
|
271
|
+
if (!target)
|
|
272
|
+
return null;
|
|
273
|
+
return target.subscribeSnapshot(cb);
|
|
274
|
+
}
|
|
275
|
+
if (kind === 'events') {
|
|
276
|
+
if (admin.kind !== 'game' || !admin.game)
|
|
277
|
+
return null;
|
|
278
|
+
return admin.game.subscribeEvents(cb);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* hop-by-hop ヘッダ。 そのまま転送すると chunked エンコードの二重適用や
|
|
284
|
+
* 接続管理の不整合で HTTP パイプラインが詰まるため、 proxy では必ず落とす
|
|
285
|
+
* (Node が自身の接続に合わせて再付与する)。
|
|
286
|
+
*/
|
|
287
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
288
|
+
'connection',
|
|
289
|
+
'keep-alive',
|
|
290
|
+
'proxy-authenticate',
|
|
291
|
+
'proxy-authorization',
|
|
292
|
+
'te',
|
|
293
|
+
'trailer',
|
|
294
|
+
'transfer-encoding',
|
|
295
|
+
'upgrade',
|
|
296
|
+
]);
|
|
297
|
+
function stripHopByHopHeaders(headers) {
|
|
298
|
+
const out = {};
|
|
299
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
300
|
+
if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase()))
|
|
301
|
+
out[name] = value;
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
// scenario proxy 用の接続 pool。 実機 Safari が画像 / 音声を並列ロードしても
|
|
306
|
+
// vite への接続を使い回し、 ソケットを滞留させない。
|
|
307
|
+
const scenarioProxyAgent = new Agent({ keepAlive: true, maxSockets: 32 });
|
|
308
|
+
/**
|
|
309
|
+
* HTTP リクエストを scenario dev server (vite 等) へ転送する。
|
|
310
|
+
* Host ヘッダを scenario 側の localhost origin に書き換えるので、 vite の
|
|
311
|
+
* allowedHosts 制限 (mDNS 名や LAN IP を拒否する) に触れない。
|
|
312
|
+
*/
|
|
313
|
+
function proxyHttpToScenario(req, res, scenarioUrl) {
|
|
314
|
+
const target = new URL(scenarioUrl);
|
|
315
|
+
const proxyReq = httpRequest({
|
|
316
|
+
agent: scenarioProxyAgent,
|
|
317
|
+
hostname: target.hostname,
|
|
318
|
+
port: target.port,
|
|
319
|
+
path: req.url,
|
|
320
|
+
method: req.method,
|
|
321
|
+
headers: { ...stripHopByHopHeaders(req.headers), host: target.host },
|
|
322
|
+
timeout: 30_000,
|
|
323
|
+
}, (proxyRes) => {
|
|
324
|
+
res.writeHead(proxyRes.statusCode ?? 502, stripHopByHopHeaders(proxyRes.headers));
|
|
325
|
+
proxyRes.pipe(res);
|
|
326
|
+
});
|
|
327
|
+
const fail = () => {
|
|
328
|
+
if (!res.headersSent) {
|
|
329
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
330
|
+
}
|
|
331
|
+
res.end('scenario dev server unreachable');
|
|
332
|
+
};
|
|
333
|
+
proxyReq.on('timeout', () => proxyReq.destroy(new Error('proxy timeout')));
|
|
334
|
+
proxyReq.on('error', fail);
|
|
335
|
+
// クライアント (実機 Safari 等) が切断したら vite 側への転送も打ち切り、
|
|
336
|
+
// 接続と stream を残さない。
|
|
337
|
+
res.on('close', () => proxyReq.destroy());
|
|
338
|
+
req.pipe(proxyReq);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* WebSocket upgrade (vite HMR 等) を scenario dev server へ生 TCP で転送する。
|
|
342
|
+
* handshake リクエストの Host だけ書き換えて、 以降は双方向 pipe。
|
|
343
|
+
*/
|
|
344
|
+
function proxyUpgradeToScenario(req, socket, head, scenarioUrl) {
|
|
345
|
+
const target = new URL(scenarioUrl);
|
|
346
|
+
const proxySocket = netConnect(Number(target.port), target.hostname, () => {
|
|
347
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
348
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
349
|
+
const name = req.rawHeaders[i];
|
|
350
|
+
const value = name.toLowerCase() === 'host' ? target.host : req.rawHeaders[i + 1];
|
|
351
|
+
lines.push(`${name}: ${value}`);
|
|
352
|
+
}
|
|
353
|
+
proxySocket.write(lines.join('\r\n') + '\r\n\r\n');
|
|
354
|
+
if (head.length)
|
|
355
|
+
proxySocket.write(head);
|
|
356
|
+
proxySocket.pipe(socket);
|
|
357
|
+
socket.pipe(proxySocket);
|
|
358
|
+
});
|
|
359
|
+
const destroyBoth = () => {
|
|
360
|
+
proxySocket.destroy();
|
|
361
|
+
socket.destroy();
|
|
362
|
+
};
|
|
363
|
+
proxySocket.on('error', destroyBoth);
|
|
364
|
+
socket.on('error', destroyBoth);
|
|
365
|
+
proxySocket.on('close', destroyBoth);
|
|
366
|
+
socket.on('close', destroyBoth);
|
|
367
|
+
}
|