@uzuhq/code-cli 0.5.6 → 0.5.8

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.
@@ -1,114 +0,0 @@
1
- /**
2
- * JSON Patch ユーティリティ。
3
- * SDK `src/json-patch.ts` および uzu-code play-server `game-worker-template/json-patch.ts`
4
- * と同一。 3 か所いずれかを変更したら他 2 か所も同期させること。
5
- */
6
- function escapePointer(key) {
7
- return key.replace(/~/g, '~0').replace(/\//g, '~1');
8
- }
9
- function unescapePointer(token) {
10
- return token.replace(/~1/g, '/').replace(/~0/g, '~');
11
- }
12
- export function compare(oldObj, newObj, basePath = '') {
13
- if (oldObj === newObj)
14
- return [];
15
- if (oldObj === null ||
16
- newObj === null ||
17
- typeof oldObj !== 'object' ||
18
- typeof newObj !== 'object') {
19
- return [{ op: 'replace', path: basePath || '/', value: newObj }];
20
- }
21
- if (Array.isArray(oldObj) || Array.isArray(newObj)) {
22
- if (JSON.stringify(oldObj) === JSON.stringify(newObj))
23
- return [];
24
- return [{ op: 'replace', path: basePath || '/', value: newObj }];
25
- }
26
- const ops = [];
27
- const oldKeys = Object.keys(oldObj);
28
- const newKeys = Object.keys(newObj);
29
- for (const key of oldKeys) {
30
- if (!(key in newObj)) {
31
- ops.push({ op: 'remove', path: `${basePath}/${escapePointer(key)}` });
32
- }
33
- }
34
- for (const key of newKeys) {
35
- const childPath = `${basePath}/${escapePointer(key)}`;
36
- if (!(key in oldObj)) {
37
- ops.push({ op: 'add', path: childPath, value: newObj[key] });
38
- }
39
- else {
40
- const childOps = compare(oldObj[key], newObj[key], childPath);
41
- ops.push(...childOps);
42
- }
43
- }
44
- return ops;
45
- }
46
- export function applyPatch(doc, ops) {
47
- for (const op of ops) {
48
- const tokens = op.path.split('/').slice(1).map(unescapePointer);
49
- if (tokens.length === 0)
50
- return false;
51
- if (op.op === 'replace' || op.op === 'add') {
52
- let target = doc;
53
- for (let i = 0; i < tokens.length - 1; i++) {
54
- target = target?.[tokens[i]];
55
- if (target === undefined || target === null)
56
- return false;
57
- }
58
- const lastKey = tokens[tokens.length - 1];
59
- target[lastKey] = op.value;
60
- }
61
- else if (op.op === 'remove') {
62
- let target = doc;
63
- for (let i = 0; i < tokens.length - 1; i++) {
64
- target = target?.[tokens[i]];
65
- if (target === undefined || target === null)
66
- return false;
67
- }
68
- const lastKey = tokens[tokens.length - 1];
69
- if (Array.isArray(target)) {
70
- target.splice(Number(lastKey), 1);
71
- }
72
- else {
73
- delete target[lastKey];
74
- }
75
- }
76
- }
77
- return true;
78
- }
79
- const MERGE_PATCH_ARRAY_REJECT = '[applyJsonMergePatch] cannot merge a non-array patch into an array target';
80
- export function applyJsonMergePatch(target, patch) {
81
- if (patch === null || typeof patch !== 'object' || Array.isArray(patch))
82
- return;
83
- for (const [key, value] of Object.entries(patch)) {
84
- if (value === undefined)
85
- continue;
86
- if (value === null) {
87
- target[key] = null;
88
- continue;
89
- }
90
- if (Array.isArray(value)) {
91
- target[key] = value;
92
- continue;
93
- }
94
- if (typeof value === 'object') {
95
- const existing = target[key];
96
- if (Array.isArray(existing)) {
97
- throw new Error(MERGE_PATCH_ARRAY_REJECT);
98
- }
99
- if (existing === null || typeof existing !== 'object') {
100
- target[key] = value;
101
- continue;
102
- }
103
- applyJsonMergePatch(existing, value);
104
- continue;
105
- }
106
- target[key] = value;
107
- }
108
- }
109
- export function applyJsonPatch(target, ops) {
110
- const ok = applyPatch(target, ops);
111
- if (!ok) {
112
- throw new Error('[applyJsonPatch] failed to apply one or more operations');
113
- }
114
- }
@@ -1,70 +0,0 @@
1
- /**
2
- * scenario の `serverActionLogicPath` (TS ファイル) を esbuild で bundle し、
3
- * Node 側で dynamic import して `GameLogic` を返す。
4
- *
5
- * `@uzuhq/code-sdk` は browser 依存 (window global 参照) を top-level に持つため
6
- * Node runtime で直接 import すると `ReferenceError: window is not defined` で落ちる。
7
- * esbuild の virtual module plugin で SDK を server-safe な stub に置換して bundle に
8
- * inline する (`serverOnly` / `isServerOnlyAction` / `SERVER_TIME` だけを提供)。
9
- */
10
- import { build } from 'esbuild';
11
- import { mkdtempSync, rmSync } from 'fs';
12
- import { tmpdir } from 'os';
13
- import { join, resolve } from 'path';
14
- import { pathToFileURL } from 'url';
15
- const uzuSdkServerStub = {
16
- name: 'uzu-sdk-server-stub',
17
- setup(build) {
18
- // @uzupj/uzu-sdk は旧パッケージ名。移行が済んでいない scenario でも動くよう両対応する
19
- build.onResolve({ filter: /^(@uzuhq\/code-sdk|@uzupj\/uzu-sdk)$/ }, (args) => ({
20
- path: args.path,
21
- namespace: 'uzu-sdk-stub',
22
- }));
23
- build.onLoad({ filter: /.*/, namespace: 'uzu-sdk-stub' }, () => ({
24
- contents: `
25
- export function serverOnly(fn) {
26
- fn.__serverOnly = true;
27
- return fn;
28
- }
29
- export function isServerOnlyAction(handler) {
30
- return handler && handler.__serverOnly === true;
31
- }
32
- export const SERVER_TIME = '__SERVER_TIME__';
33
- export const DEFAULT_ICON_URLS = [];
34
- `,
35
- loader: 'js',
36
- }));
37
- },
38
- };
39
- export async function loadLogicFromPath(logicPath) {
40
- const absPath = resolve(logicPath);
41
- const tempDir = mkdtempSync(join(tmpdir(), 'uzu-dev-logic-'));
42
- const outPath = join(tempDir, 'logic.mjs');
43
- await build({
44
- entryPoints: [absPath],
45
- outfile: outPath,
46
- bundle: true,
47
- format: 'esm',
48
- target: 'es2022',
49
- platform: 'neutral',
50
- plugins: [uzuSdkServerStub],
51
- });
52
- const mod = (await import(pathToFileURL(outPath).href));
53
- const logic = mod.default ?? mod.logic;
54
- if (!logic) {
55
- throw new Error(`${logicPath} must export a GameLogic object.\n` +
56
- ` Use either: export default logic\n` +
57
- ` Or: export const logic: GameLogic<State> = { ... }`);
58
- }
59
- return {
60
- logic,
61
- dispose: () => {
62
- try {
63
- rmSync(tempDir, { recursive: true, force: true });
64
- }
65
- catch {
66
- /* ignore */
67
- }
68
- },
69
- };
70
- }
@@ -1,35 +0,0 @@
1
- export class SeededRandomImpl {
2
- _state;
3
- constructor(seed) {
4
- this._state = seed | 0;
5
- }
6
- get state() {
7
- return this._state;
8
- }
9
- static fromState(state) {
10
- const r = new SeededRandomImpl(0);
11
- r._state = state;
12
- return r;
13
- }
14
- float() {
15
- this._state |= 0;
16
- this._state = (this._state + 0x6d2b79f5) | 0;
17
- let t = Math.imul(this._state ^ (this._state >>> 15), 1 | this._state);
18
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
19
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
20
- }
21
- int(max) {
22
- return Math.floor(this.float() * max);
23
- }
24
- pick(array) {
25
- return array[this.int(array.length)];
26
- }
27
- shuffle(array) {
28
- const a = [...array];
29
- for (let i = a.length - 1; i > 0; i--) {
30
- const j = this.int(i + 1);
31
- [a[i], a[j]] = [a[j], a[i]];
32
- }
33
- return a;
34
- }
35
- }
@@ -1,86 +0,0 @@
1
- /**
2
- * dev-server 版 RelayRoom (init() 単独 relay mode 用)。
3
- *
4
- * uzu-code 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
- // SDK は生文字列で送受信する (本番の setWebSocketAutoResponse と同じ wire 形式)。
29
- // JSON.parse より前に返さないと heartbeat が落ちて 35 秒ごとに全席が再接続する。
30
- if (msg === '__ping') {
31
- try {
32
- ws.send('__pong');
33
- }
34
- catch {
35
- /* disconnected */
36
- }
37
- return;
38
- }
39
- const attachment = this.attachments.get(ws);
40
- if (!attachment)
41
- return;
42
- const senderId = attachment.playerId;
43
- let parsed;
44
- try {
45
- parsed = JSON.parse(msg);
46
- }
47
- catch {
48
- return;
49
- }
50
- console.log(`[RelayRoom] ⬅ recv from=${senderId}`, JSON.stringify(parsed));
51
- const outData = JSON.stringify({ ...parsed, __from: senderId });
52
- if (parsed.__to && typeof parsed.__to === 'string') {
53
- for (const peer of this.sockets) {
54
- const pa = this.attachments.get(peer);
55
- if (pa && pa.playerId === parsed.__to) {
56
- try {
57
- peer.send(outData);
58
- }
59
- catch {
60
- /* disconnected */
61
- }
62
- }
63
- }
64
- }
65
- else {
66
- for (const peer of this.sockets) {
67
- if (peer === ws)
68
- continue;
69
- try {
70
- peer.send(outData);
71
- }
72
- catch {
73
- /* disconnected */
74
- }
75
- }
76
- }
77
- }
78
- handleClose(ws) {
79
- const attachment = this.attachments.get(ws);
80
- if (attachment) {
81
- console.log(`[RelayRoom] ❌ Disconnected: connectionId=${attachment.connectionId}`);
82
- }
83
- this.sockets.delete(ws);
84
- this.attachments.delete(ws);
85
- }
86
- }
@@ -1,367 +0,0 @@
1
- /**
2
- * dev-server: harness page HTML/JS を serve しつつ、
3
- * `/ws/games/:revisionId/:roomId` / `/ws/sync/:roomId` / `/ws/rooms/:roomId` を
4
- * 本番 (uzu-code 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.players);
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
- }