Skill Tracer
Skills › gstack › browse › cookie-import-native-worker.ts

cookie-import-native-worker.ts

268 lines · browse/src/cookie-import-native-worker.ts at 3f79d81

1import { spawn } from 'node:child_process';
2import { createInterface } from 'node:readline';
3import { writeSync } from 'node:fs';
4import { createNativeCookieJob, joinNativeCookieJob, nativeCookieDiagnostic, parseNativeCookieDiagnostic, type NativeCookieDiagnostic, type NativeCookieJob } from './cookie-import-native-job';
5import type { PlaywrightCookie } from './cookie-import-browser';
6import { emptyBunConfig } from './bun-child-config';
7
8export interface NativeCookieRequest {
9 nodeExecutable: string;
10 nodeArchitecture: string;
11 playwrightEntry: string;
12 executablePath: string;
13 userDataDir: string;
14 profile: string;
15 domains: string[];
16 deadline: number;
17 qualifiedBunVersions: string[];
20export type NativeCookieReply =
21 | { cookies: PlaywrightCookie[] }
22 | { error: 'native_timeout' | 'native_failed' | 'native_cleanup_failed' | 'native_supervision_failed' | 'browser_running' | 'native_profile_unsupported'; diagnostic?: NativeCookieDiagnostic };
24export interface NativeCookieMember {
25 result: Promise<NativeCookieReply>;
26 closed: Promise<void>;
27 stop(): void;
30const MAX_REPLY_BYTES = 8 * 1024 * 1024;
31export const NATIVE_PROGRESS_PREFIX = 'GSTACK_NATIVE_PROGRESS ';
33function nativeProgress(diagnostic: NativeCookieDiagnostic): void {
34 try { writeSync(2, NATIVE_PROGRESS_PREFIX + JSON.stringify(diagnostic) + '\n'); } catch {}
37export function nativeCookieEnvironment(env: NodeJS.ProcessEnv): Record<string, string> {
38 const allowed = new Set(['systemroot', 'windir', 'temp', 'tmp', 'userprofile', 'localappdata', 'appdata', 'programfiles', 'programfiles(x86)', 'programdata', 'path', 'pathext']);
39 return Object.fromEntries(Object.entries(env).filter(([key, value]) => allowed.has(key.toLowerCase()) && typeof value === 'string')) as Record<string, string>;
42export const NATIVE_COOKIE_NODE_SCRIPT = String.raw`
43const fs = require('node:fs');
44let stage = 'node_input';
45const progress = () => { try { fs.writeSync(2, 'GSTACK_NATIVE_PROGRESS ' + JSON.stringify({ stage }) + '\n'); } catch {} };
46progress();
47(async () => {
48 const request = JSON.parse(fs.readFileSync(0, 'utf8'));
49 stage = 'node_load';
50 progress();
51 const { chromium } = require(request.playwrightEntry);
52 let context;
53 try {
54 const remaining = request.deadline - Date.now();
55 if (remaining <= 0) throw new Error('native_timeout');
56 stage = 'browser_launch';
57 progress();
58 context = await chromium.launchPersistentContext(request.userDataDir, {
59 executablePath: request.executablePath,
60 args: ['--profile-directory=' + request.profile],
61 headless: true,
62 chromiumSandbox: true,
63 timeout: remaining,
64 handleSIGINT: false,
65 handleSIGTERM: false,
66 handleSIGHUP: false,
67 env: process.env,
68 });
69 stage = 'cookie_read';
70 progress();
71 const selected = new Set(request.domains.map(domain => domain.toLowerCase().replace(/^\./, '').replace(/\.$/, '')));
72 const cookies = (await context.cookies()).filter(cookie => selected.has(cookie.domain.toLowerCase().replace(/^\./, '').replace(/\.$/, '')));
73 await new Promise(resolve => process.stdout.write(JSON.stringify({ cookies }) + '\n', resolve));
74 } catch (error) {
75 const message = error instanceof Error ? error.message : '';
76 const exited = message.match(/<process did exit: exitCode=(-?\d+), signal=(?:null|SIG[A-Z]+)>/);
77 const exitCode = exited ? Number(exited[1]) : undefined;
78 const code = (stage === 'browser_launch' && exitCode === 21) || /ProcessSingleton|profile.*in use|user data directory is already in use|opening in existing browser session/i.test(message)
79 ? 'browser_running'
80 : /remote debugging requires a non-default data directory/i.test(message)
81 ? 'native_profile_unsupported'
82 : /Timeout|native_timeout/.test(message) ? 'native_timeout' : 'native_failed';
83 await new Promise(resolve => process.stdout.write(JSON.stringify({ error: code, diagnostic: { stage, ...(Number.isInteger(exitCode) ? { exitCode } : {}) } }) + '\n', resolve));
84 } finally {
85 stage = 'browser_close';
86 progress();
87 await context?.close().catch(() => {});
88 }
89})().catch(() => { process.stdout.write(JSON.stringify({ error: 'native_failed', diagnostic: { stage } }) + '\n'); process.exitCode = 1; });
90`;
92export async function superviseNativeCookieImport(
93 request: NativeCookieRequest,
94 dependencies: {
95 createJob?: () => Promise<NativeCookieJob>;
96 startMember?: (request: NativeCookieRequest, jobName: string) => NativeCookieMember;
97 now?: () => number;
98 sleep?: (milliseconds: number) => Promise<void>;
99 signal?: AbortSignal;
100 } = {},
101): Promise<NativeCookieReply> {
102 const now = dependencies.now ?? Date.now;
103 const sleep = dependencies.sleep ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)));
104 const deadline = Math.min(request.deadline, now() + 25_000);
105 let job: NativeCookieJob | undefined;
106 let member: NativeCookieMember | undefined;
107 let reply: NativeCookieReply | undefined;
108 let closed = false;
109 try {
110 job = await (dependencies.createJob ?? createNativeCookieJob)();
111 if (now() >= deadline || dependencies.signal?.aborted) return { error: 'native_timeout' };
112 member = (dependencies.startMember ?? startMember)({ ...request, deadline }, job.name);
113 void member.result.then(value => { reply ??= value; }, () => { reply ??= { error: 'native_failed' }; });
114 void member.closed.then(() => { closed = true; }, () => { closed = true; });
115 while (!reply && !closed && now() < deadline && !dependencies.signal?.aborted) await sleep(Math.min(20, deadline - now()));
116 reply ??= { error: now() >= deadline ? 'native_timeout' : 'native_failed' };
117 const cleanupDeadline = now() + 5_000;
118 const graceDeadline = now() + ('cookies' in reply ? 2_000 : 0);
119 while ((!closed || job.activeProcesses() !== 0) && now() < graceDeadline) await sleep(20);
120 if (job.activeProcesses() !== 0) job.terminate();
121 while ((!closed || job.activeProcesses() !== 0) && now() < cleanupDeadline) await sleep(20);
122 if (!closed || job.activeProcesses() !== 0) return { error: 'native_cleanup_failed' };
123 return reply;
124 } catch (error) {
125 return { error: job ? 'native_cleanup_failed' : 'native_supervision_failed', diagnostic: nativeCookieDiagnostic(error, job ? 'job_query' : 'job_create') };
126 } finally {
127 let diagnostic: NativeCookieDiagnostic | undefined;
128 try { job?.close(); } catch (error) { diagnostic = nativeCookieDiagnostic(error, 'job_close'); }
129 try { member?.stop(); } catch (error) { diagnostic ??= nativeCookieDiagnostic(error, 'member_exit'); }
130 if (diagnostic) return { error: 'native_cleanup_failed', diagnostic };
131 }
134function startMember(request: NativeCookieRequest, jobName: string, mode = '--member'): NativeCookieMember {
135 const child = spawn(process.execPath, ['--no-env-file', '--no-install', '--no-macros', `--config=${emptyBunConfig()}`, import.meta.path, mode], {
136 env: nativeCookieEnvironment(process.env),
137 stdio: ['pipe', 'pipe', 'pipe'],
138 windowsHide: true,
139 });
140 const closed = new Promise<void>(resolve => child.once('close', () => resolve()));
141 let progressInput = '';
142 let lastStage: NativeCookieDiagnostic['stage'] | undefined;
143 let memberMode: boolean | undefined;
144 let nodeExitCode: number | undefined;
145 let stderrBytes = 0;
146 child.stderr.on('data', chunk => {
147 stderrBytes = Math.min(0xffffffff, stderrBytes + chunk.length);
148 progressInput += chunk.toString('utf8');
149 for (let end = progressInput.indexOf('\n'); end >= 0; end = progressInput.indexOf('\n')) {
150 const line = progressInput.slice(0, end);
151 progressInput = progressInput.slice(end + 1);
152 if (!line.startsWith(NATIVE_PROGRESS_PREFIX)) continue;
153 try {
154 const diagnostic = parseNativeCookieDiagnostic(JSON.parse(line.slice(NATIVE_PROGRESS_PREFIX.length)));
155 if (!diagnostic) continue;
156 lastStage = diagnostic.stage;
157 memberMode ??= diagnostic.memberMode;
158 if (diagnostic.stage === 'node_exit') nodeExitCode = diagnostic.exitCode;
159 } catch {}
160 }
161 if (progressInput.length > 4096) progressInput = '';
162 });
163 const result = new Promise<NativeCookieReply>(resolve => {
164 let output = '';
165 child.stdout.setEncoding('utf8');
166 child.stdout.on('data', chunk => {
167 output += chunk;
168 if (Buffer.byteLength(output) > MAX_REPLY_BYTES) {
169 resolve({ error: 'native_failed' });
170 child.stdout.destroy();
171 } else if (output.includes('\n')) {
172 try {
173 const parsed = JSON.parse(output.slice(0, output.indexOf('\n')));
174 const errors = ['native_timeout', 'native_failed', 'native_cleanup_failed', 'native_supervision_failed', 'browser_running', 'native_profile_unsupported'];
175 const diagnostic = parseNativeCookieDiagnostic(parsed.diagnostic);
176 resolve(Array.isArray(parsed.cookies) ? { cookies: parsed.cookies } : { error: errors.includes(parsed.error) ? parsed.error : 'native_failed', ...(diagnostic ? { diagnostic } : {}) });
177 } catch {
178 resolve({ error: 'native_failed' });
179 }
180 }
181 });
182 child.once('error', () => resolve({ error: 'native_failed', diagnostic: { stage: 'member_start' } }));
183 child.once('close', (code, signal) => resolve({ error: 'native_failed', diagnostic: parseNativeCookieDiagnostic({ stage: 'member_exit', exitCode: code, signal, lastStage, memberMode, nodeExitCode, stderrBytes }) }));
184 child.stdin.on('error', () => resolve({ error: 'native_failed', diagnostic: { stage: 'member_input' } }));
185 child.stdin.end(JSON.stringify({ request, jobName }));
186 });
187 return { result, closed, stop: () => { if (child.exitCode === null && child.signalCode === null) child.kill(); } };
190export async function probeNativeCookieMember(): Promise<NativeCookieReply> {
191 return superviseNativeCookieImport({
192 nodeExecutable: '', nodeArchitecture: process.arch, playwrightEntry: '', executablePath: '',
193 userDataDir: '', profile: '', domains: [], deadline: Date.now() + 5_000, qualifiedBunVersions: [Bun.version],
194 }, { startMember: (request, jobName) => startMember(request, jobName, '--member-smoke') });
197let mainStage: NativeCookieDiagnostic['stage'] = 'supervisor_input';
199async function main(): Promise<void> {
200 if (process.argv[2] === '--member' || process.argv[2] === '--member-smoke') {
201 mainStage = 'member_input';
202 nativeProgress({ stage: mainStage });
203 const serialized = await new Promise<string>((resolve, reject) => {
204 let payload = '';
205 let bytes = 0;
206 process.stdin.setEncoding('utf8');
207 process.stdin.on('data', chunk => {
208 bytes += Buffer.byteLength(chunk);
209 if (bytes > 1024 * 1024) {
210 reject(new Error('native_supervision_failed'));
211 process.stdin.destroy();
212 return;
213 }
214 payload += chunk;
215 });
216 process.stdin.once('end', () => resolve(payload));
217 process.stdin.once('error', () => reject(new Error('native_supervision_failed')));
218 process.stdin.once('close', () => reject(new Error('native_supervision_failed')));
219 process.stdin.resume();
220 });
221 const input = JSON.parse(serialized);
222 nativeProgress({ stage: 'member_decoded' });
223 await joinNativeCookieJob(input.jobName, stage => nativeProgress({ stage }));
224 nativeProgress({ stage: 'job_joined' });
225 if (process.argv[2] === '--member-smoke') {
226 process.stdout.write(JSON.stringify({ cookies: [] }) + '\n', () => process.exit(0));
227 return;
228 }
229 mainStage = 'node_start';
230 nativeProgress({ stage: mainStage });
231 const child = spawn(input.request.nodeExecutable, ['--input-type=commonjs', '-e', NATIVE_COOKIE_NODE_SCRIPT], {
232 env: nativeCookieEnvironment(process.env),
233 stdio: ['pipe', 'inherit', 'inherit'],
234 windowsHide: true,
235 });
236 nativeProgress({ stage: 'node_spawned' });
237 child.stdin.on('error', () => {});
238 child.stdin.end(JSON.stringify(input.request));
239 child.once('error', () => process.stdout.write(JSON.stringify({ error: 'native_failed', diagnostic: { stage: 'node_start' } }) + '\n', () => process.exit(1)));
240 child.once('close', (code, signal) => {
241 nativeProgress(parseNativeCookieDiagnostic({ stage: 'node_exit', exitCode: code, signal })!);
242 process.exit(code ?? 1);
243 });
244 return;
245 }
246 const cancellation = new AbortController();
247 const lines = createInterface({ input: process.stdin });
248 lines.once('close', () => cancellation.abort());
249 const input = await new Promise<NativeCookieRequest>((resolve, reject) => {
250 lines.once('line', line => {
251 try { resolve(JSON.parse(line)); } catch { reject(new Error('native_supervision_failed')); }
252 });
253 lines.once('close', () => reject(new Error('native_supervision_failed')));
254 });
255 mainStage = 'runtime_check';
256 if (input.nodeArchitecture !== process.arch || !Array.isArray(input.qualifiedBunVersions) || !input.qualifiedBunVersions.includes(Bun.version)) {
257 throw new Error('native_supervision_failed');
258 }
259 const result = await superviseNativeCookieImport(input, { signal: cancellation.signal });
260 process.stdout.write(JSON.stringify(result) + '\n', () => process.exit(0));
263if (import.meta.main) {
264 nativeProgress({ stage: 'worker_boot', memberMode: process.argv[2] === '--member' || process.argv[2] === '--member-smoke' });
265 void main().catch(error => {
266 process.stdout.write(JSON.stringify({ error: 'native_supervision_failed', diagnostic: nativeCookieDiagnostic(error, mainStage) }) + '\n', () => process.exit(1));
267 });