@tomflow/proflow-execution-browser-extension 0.1.12 → 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.
@@ -0,0 +1,225 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { readModuleSharedFacts } from "@tomflow/proflow-module-contract";
4
+ import { materializeCustomGptKnowledgeBundle, } from "./custom-gpt-knowledge.js";
5
+ import { createCustomGptProvisioningBridgeServer, } from "./provisioning-bridge.js";
6
+ function packageAsset(packageRoot, asset, name) {
7
+ if (asset.length === 0 || isAbsolute(asset))
8
+ throw new Error(`${name}_INVALID`);
9
+ const root = resolve(packageRoot);
10
+ const path = resolve(root, asset);
11
+ const rel = relative(root, path);
12
+ if (rel === "" ||
13
+ rel === ".." ||
14
+ rel.startsWith(`..${sep}`) ||
15
+ isAbsolute(rel))
16
+ throw new Error(`${name}_OUTSIDE_PACKAGE`);
17
+ return path;
18
+ }
19
+ function publicGatewayUrl(value) {
20
+ let url;
21
+ try {
22
+ url = new URL(value);
23
+ }
24
+ catch {
25
+ throw new Error("GATEWAY_URL_INVALID");
26
+ }
27
+ if (url.protocol !== "https:" ||
28
+ url.username !== "" ||
29
+ url.password !== "" ||
30
+ url.search !== "" ||
31
+ url.hash !== "")
32
+ throw new Error("GATEWAY_URL_INVALID");
33
+ return url.toString().replace(/\/$/, "");
34
+ }
35
+ function hydratedSchema(schema, gatewayUrl) {
36
+ const gateway = publicGatewayUrl(gatewayUrl);
37
+ if (schema.includes("https://GATEWAY_PUBLIC_HOST"))
38
+ return schema.replaceAll("https://GATEWAY_PUBLIC_HOST", gateway);
39
+ if (!schema.includes(gateway))
40
+ throw new Error("ACTION_SCHEMA_GATEWAY_MISMATCH");
41
+ return schema;
42
+ }
43
+ function fileEvidence(files) {
44
+ return files.map(({ name, mime, sizeBytes, sha256 }) => ({
45
+ name,
46
+ mime,
47
+ sizeBytes,
48
+ sha256,
49
+ }));
50
+ }
51
+ function liveResult(value, material) {
52
+ if (typeof value !== "object" || value === null || Array.isArray(value))
53
+ throw new Error("PROVISIONING_RESULT_INVALID");
54
+ const result = value;
55
+ if (result.status !== "LIVE_CREATED" ||
56
+ result.packageName !== material.packageName ||
57
+ result.version !== material.version ||
58
+ typeof result.gptId !== "string" ||
59
+ !/^g-[A-Za-z0-9_-]+$/.test(result.gptId) ||
60
+ typeof result.carrierUrl !== "string")
61
+ throw new Error("PROVISIONING_RESULT_INVALID");
62
+ const expected = `https://chatgpt.com/g/${result.gptId}`;
63
+ if (result.carrierUrl !== expected)
64
+ throw new Error("PROVISIONING_RESULT_INVALID");
65
+ return {
66
+ status: "LIVE_CREATED",
67
+ packageName: material.packageName,
68
+ version: material.version,
69
+ gptId: result.gptId,
70
+ carrierUrl: result.carrierUrl,
71
+ };
72
+ }
73
+ function carrierGptId(carrierUrl) {
74
+ const url = new URL(carrierUrl);
75
+ const match = /^\/g\/(g-[A-Za-z0-9_-]+)$/.exec(url.pathname);
76
+ if (url.origin !== "https://chatgpt.com" ||
77
+ url.username !== "" ||
78
+ url.password !== "" ||
79
+ url.search !== "" ||
80
+ url.hash !== "" ||
81
+ !match?.[1])
82
+ throw new Error("PROVISIONING_CARRIER_URL_INVALID");
83
+ return match[1];
84
+ }
85
+ function authResult(value, carrierUrl) {
86
+ if (typeof value !== "object" || value === null || Array.isArray(value))
87
+ throw new Error("PROVISIONING_AUTH_RESULT_INVALID");
88
+ const result = value;
89
+ const expectedGptId = carrierGptId(carrierUrl);
90
+ if (result.status !== "AUTH_UPDATED" || result.gptId !== expectedGptId)
91
+ throw new Error("PROVISIONING_AUTH_RESULT_INVALID");
92
+ return { status: "AUTH_UPDATED", gptId: expectedGptId, carrierUrl };
93
+ }
94
+ const sleep = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds));
95
+ export async function createCustomGptProvisioningHost(options) {
96
+ const bridge = await createCustomGptProvisioningBridgeServer(options);
97
+ const onlineTimeoutMs = options.onlineTimeoutMs ?? 30_000;
98
+ async function waitUntilOnline() {
99
+ const deadline = Date.now() + onlineTimeoutMs;
100
+ while (Date.now() < deadline) {
101
+ if (bridge.status().online)
102
+ return;
103
+ await sleep(100);
104
+ }
105
+ throw new Error("PROVISIONING_EXTENSION_OFFLINE");
106
+ }
107
+ async function provisionPackage(input) {
108
+ await waitUntilOnline();
109
+ if (input.credential !== undefined && input.credential.length < 32)
110
+ throw new Error("PROVISIONING_ROLE_CREDENTIAL_INVALID");
111
+ const schemaPath = packageAsset(input.packageRoot, input.material.actionSchema, "ACTION_SCHEMA_PATH");
112
+ const bundlePath = packageAsset(input.packageRoot, input.material.knowledgeBundle, "KNOWLEDGE_BUNDLE_PATH");
113
+ const schema = hydratedSchema(await readFile(schemaPath, "utf8"), input.gatewayUrl);
114
+ const bundle = await materializeCustomGptKnowledgeBundle({
115
+ bundlePath,
116
+ stagingRoot: input.stagingRoot,
117
+ });
118
+ try {
119
+ const relayFiles = await bridge.provisioning.registerFiles(bundle.files.map((file) => ({
120
+ name: file.name,
121
+ path: file.path,
122
+ mime: file.mime,
123
+ })));
124
+ const request = {
125
+ ...input.material,
126
+ actionSchema: schema,
127
+ ...(input.credential === undefined
128
+ ? {}
129
+ : { bearerCredential: input.credential }),
130
+ knowledgeFiles: relayFiles.map(({ name, mime, sizeBytes, sha256, url }) => ({
131
+ name,
132
+ mime,
133
+ sizeBytes,
134
+ sha256,
135
+ url,
136
+ })),
137
+ };
138
+ if (JSON.stringify(request).length >= 100_000)
139
+ throw new Error("PROVISIONING_REQUEST_BUDGET_EXCEEDED");
140
+ const value = await bridge.provisioning.request({
141
+ type: "PROVISION_CUSTOM_GPT",
142
+ request,
143
+ });
144
+ return {
145
+ ...liveResult(value, input.material),
146
+ knowledgeBundleSha256: bundle.bundleSha256,
147
+ knowledgeFiles: fileEvidence(bundle.files),
148
+ };
149
+ }
150
+ finally {
151
+ await rm(bundle.stagingDirectory, { recursive: true, force: true });
152
+ }
153
+ }
154
+ async function finalizeRoleAuth(input) {
155
+ await waitUntilOnline();
156
+ carrierGptId(input.carrierUrl);
157
+ if (input.credential.length < 32)
158
+ throw new Error("PROVISIONING_ROLE_CREDENTIAL_INVALID");
159
+ let credential = input.credential;
160
+ try {
161
+ const value = await bridge.provisioning.request({
162
+ type: "FINALIZE_CUSTOM_GPT_AUTH",
163
+ request: { carrierUrl: input.carrierUrl, credential },
164
+ });
165
+ return authResult(value, input.carrierUrl);
166
+ }
167
+ finally {
168
+ credential = "";
169
+ }
170
+ }
171
+ return Object.freeze({
172
+ endpoint: bridge.endpoint,
173
+ status: bridge.status,
174
+ provisionPackage,
175
+ finalizeRoleAuth,
176
+ close: bridge.close,
177
+ });
178
+ }
179
+ function sharedFactString(facts, name) {
180
+ const value = facts?.[name];
181
+ if (typeof value !== "string" || value.length === 0)
182
+ throw new Error(`PROVISIONING_SHARED_FACT_MISSING:${name}`);
183
+ return value;
184
+ }
185
+ export async function createWorkspaceCustomGptProvisioningHost(input) {
186
+ const moduleRef = "execution-browser-extension";
187
+ const facts = await readModuleSharedFacts({ workspaceRoot: input.workspaceRoot }, moduleRef);
188
+ const extensionId = sharedFactString(facts, "extensionId");
189
+ if (!/^[a-z]{32}$/.test(extensionId))
190
+ throw new Error("PROVISIONING_EXTENSION_ID_INVALID");
191
+ const tokenFile = sharedFactString(facts, "provisioningBridgeTokenFile");
192
+ const endpointText = sharedFactString(facts, "provisioningBridgeEndpoint");
193
+ let endpoint;
194
+ try {
195
+ endpoint = new URL(endpointText);
196
+ }
197
+ catch {
198
+ throw new Error("PROVISIONING_ENDPOINT_INVALID");
199
+ }
200
+ const port = Number(endpoint.port);
201
+ if (endpoint.protocol !== "http:" ||
202
+ endpoint.hostname !== "127.0.0.1" ||
203
+ endpoint.pathname !== "/" ||
204
+ endpoint.search !== "" ||
205
+ endpoint.hash !== "" ||
206
+ !Number.isInteger(port) ||
207
+ port <= 0 ||
208
+ port > 65_535)
209
+ throw new Error("PROVISIONING_ENDPOINT_INVALID");
210
+ const token = (await readFile(tokenFile, "utf8")).trim();
211
+ if (token.length < 32)
212
+ throw new Error("PROVISIONING_TOKEN_INVALID");
213
+ return createCustomGptProvisioningHost({
214
+ token,
215
+ extensionId,
216
+ host: "127.0.0.1",
217
+ port,
218
+ ...(input.commandTimeoutMs === undefined
219
+ ? {}
220
+ : { commandTimeoutMs: input.commandTimeoutMs }),
221
+ ...(input.onlineTimeoutMs === undefined
222
+ ? {}
223
+ : { onlineTimeoutMs: input.onlineTimeoutMs }),
224
+ });
225
+ }
@@ -0,0 +1,66 @@
1
+ import { type CustomGptPackageProvisioningMaterial, type CustomGptProvisioningResult } from "./custom-gpt-provisioner.ts";
2
+ export type CreateCustomGptRoleInput = {
3
+ workspaceRoot: string;
4
+ packageRoot: string;
5
+ stagingRoot: string;
6
+ gatewayUrl: string;
7
+ material: CustomGptPackageProvisioningMaterial;
8
+ commandTimeoutMs?: number;
9
+ onlineTimeoutMs?: number;
10
+ };
11
+ export type CustomGptRoleRecordInput = {
12
+ agentPackageRef: string;
13
+ registeredPackageVersion: string;
14
+ roleRef: string;
15
+ carrierUrl: string;
16
+ };
17
+ export type CustomGptRoleRegistryPort = {
18
+ prepareCredential(): Promise<{
19
+ credential: string;
20
+ }> | {
21
+ credential: string;
22
+ };
23
+ saveRole(input: CustomGptRoleRecordInput, preparedCredential: string): Promise<{
24
+ credential: string;
25
+ rollback(): Promise<void>;
26
+ }>;
27
+ deleteRole(roleRef: string): Promise<void>;
28
+ inspectRole(input: {
29
+ agentPackageRef: string;
30
+ expectedPackageVersion: string;
31
+ }): {
32
+ status: string;
33
+ role?: {
34
+ roleRef: string;
35
+ carrierUrl: string;
36
+ };
37
+ };
38
+ };
39
+ type CustomGptProvisioningHostPort = {
40
+ provisionPackage(input: {
41
+ packageRoot: string;
42
+ stagingRoot: string;
43
+ gatewayUrl: string;
44
+ material: CustomGptPackageProvisioningMaterial;
45
+ credential: string;
46
+ }): Promise<CustomGptProvisioningResult>;
47
+ close(): Promise<void>;
48
+ };
49
+ export type CreateCustomGptRolePorts = {
50
+ roleRegistry: CustomGptRoleRegistryPort;
51
+ verifyCarrier(input: {
52
+ agentPackageRef: string;
53
+ registeredPackageVersion: string;
54
+ roleRef: string;
55
+ carrierUrl: string;
56
+ gatewayUrl: string;
57
+ credential: string;
58
+ }): Promise<void>;
59
+ createProvisioningHost?: (input: {
60
+ workspaceRoot: string;
61
+ commandTimeoutMs?: number;
62
+ onlineTimeoutMs?: number;
63
+ }) => Promise<CustomGptProvisioningHostPort>;
64
+ };
65
+ export declare function createCustomGptRole(input: CreateCustomGptRoleInput, ports: CreateCustomGptRolePorts): Promise<CustomGptProvisioningResult>;
66
+ export {};
@@ -0,0 +1,98 @@
1
+ import { resolve } from "node:path";
2
+ import { createWorkspaceCustomGptProvisioningHost, } from "./custom-gpt-provisioner.js";
3
+ const workspaceCreateTails = new Map();
4
+ function enqueueWorkspaceCreate(workspaceRoot, operation) {
5
+ const key = resolve(workspaceRoot);
6
+ const previous = workspaceCreateTails.get(key) ?? Promise.resolve();
7
+ const current = previous.catch(() => undefined).then(operation);
8
+ const settled = current.then(() => undefined, () => undefined);
9
+ workspaceCreateTails.set(key, settled);
10
+ return current.finally(() => {
11
+ if (workspaceCreateTails.get(key) === settled)
12
+ workspaceCreateTails.delete(key);
13
+ });
14
+ }
15
+ function assertLiveCreated(result, material) {
16
+ if (result.status !== "LIVE_CREATED" ||
17
+ result.packageName !== material.packageName ||
18
+ result.version !== material.version ||
19
+ !/^g-[A-Za-z0-9_-]+$/.test(result.gptId) ||
20
+ result.carrierUrl !== `https://chatgpt.com/g/${result.gptId}`)
21
+ throw new Error("LIVE_CREATED_RESULT_INVALID");
22
+ }
23
+ export async function createCustomGptRole(input, ports) {
24
+ return enqueueWorkspaceCreate(input.workspaceRoot, async () => {
25
+ const createHost = ports.createProvisioningHost ?? createWorkspaceCustomGptProvisioningHost;
26
+ const host = await createHost({
27
+ workspaceRoot: input.workspaceRoot,
28
+ ...(input.commandTimeoutMs === undefined
29
+ ? {}
30
+ : { commandTimeoutMs: input.commandTimeoutMs }),
31
+ ...(input.onlineTimeoutMs === undefined
32
+ ? {}
33
+ : { onlineTimeoutMs: input.onlineTimeoutMs }),
34
+ });
35
+ let result;
36
+ let rollbackSavedRole;
37
+ let credential = "";
38
+ try {
39
+ const prepared = await ports.roleRegistry.prepareCredential();
40
+ if (typeof prepared.credential !== "string" ||
41
+ prepared.credential.length < 32)
42
+ throw new Error("WORKSPACE_ROLE_CREDENTIAL_INVALID");
43
+ credential = prepared.credential;
44
+ result = await host.provisionPackage({
45
+ packageRoot: input.packageRoot,
46
+ stagingRoot: input.stagingRoot,
47
+ gatewayUrl: input.gatewayUrl,
48
+ material: input.material,
49
+ credential,
50
+ });
51
+ assertLiveCreated(result, input.material);
52
+ const saved = await ports.roleRegistry.saveRole({
53
+ agentPackageRef: result.packageName,
54
+ registeredPackageVersion: result.version,
55
+ roleRef: result.gptId,
56
+ carrierUrl: result.carrierUrl,
57
+ }, credential);
58
+ rollbackSavedRole = saved.rollback;
59
+ if (typeof saved.credential !== "string" || saved.credential.length < 32)
60
+ throw new Error("WORKSPACE_ROLE_CREDENTIAL_INVALID");
61
+ if (saved.credential !== credential)
62
+ throw new Error("WORKSPACE_ROLE_CREDENTIAL_MISMATCH");
63
+ credential = saved.credential;
64
+ const persisted = ports.roleRegistry.inspectRole({
65
+ agentPackageRef: result.packageName,
66
+ expectedPackageVersion: result.version,
67
+ });
68
+ if (persisted.status !== "READY" ||
69
+ persisted.role?.roleRef !== result.gptId ||
70
+ persisted.role.carrierUrl !== result.carrierUrl)
71
+ throw new Error("WORKSPACE_ROLE_PERSISTENCE_NOT_READY");
72
+ await ports.verifyCarrier({
73
+ agentPackageRef: result.packageName,
74
+ registeredPackageVersion: result.version,
75
+ roleRef: result.gptId,
76
+ carrierUrl: result.carrierUrl,
77
+ gatewayUrl: input.gatewayUrl,
78
+ credential,
79
+ });
80
+ return result;
81
+ }
82
+ catch (error) {
83
+ if (rollbackSavedRole) {
84
+ try {
85
+ await rollbackSavedRole();
86
+ }
87
+ catch {
88
+ throw new Error("WORKSPACE_ROLE_ROLLBACK_FAILED");
89
+ }
90
+ }
91
+ throw error;
92
+ }
93
+ finally {
94
+ credential = "";
95
+ await host.close();
96
+ }
97
+ });
98
+ }
@@ -0,0 +1,29 @@
1
+ export type BrowserExtensionDesktop = {
2
+ copyText(value: string): void | Promise<void>;
3
+ openExtensionsPage(): void | Promise<void>;
4
+ showInstruction(message: string): void | Promise<void>;
5
+ };
6
+ export type BrowserExtensionPair = (context: {
7
+ workspaceRoot: string;
8
+ }, options: {
9
+ timeoutMs?: number;
10
+ onWaiting?: (input: {
11
+ loadDir: string;
12
+ endpoint: string;
13
+ }) => void | Promise<void>;
14
+ }) => Promise<{
15
+ extensionId: string;
16
+ extensionInstanceId: string;
17
+ }>;
18
+ export declare function browserExtensionInstallInstruction(loadDir: string): string;
19
+ export declare function browserExtensionSetupSuccessMessage(): string;
20
+ export declare function browserExtensionSetupFailureMessage(error: unknown): string;
21
+ export declare function runInteractiveBrowserExtensionSetup(input: {
22
+ workspaceRoot: string;
23
+ timeoutMs?: number;
24
+ desktop?: BrowserExtensionDesktop;
25
+ pair?: BrowserExtensionPair;
26
+ }): Promise<{
27
+ extensionId: string;
28
+ extensionInstanceId: string;
29
+ }>;
@@ -0,0 +1,64 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { pairBrowserExtensionSetup } from "../deployment/adapter.js";
3
+ export function browserExtensionInstallInstruction(loadDir) {
4
+ return `\nChrome 浏览器扩展\n\nProFlow 已准备好扩展文件,并已复制安装目录:\n${loadDir}\n\n请在已经打开的 Chrome 扩展管理页完成以下安装操作:\n1. 如果“开发者模式”尚未开启,请开启\n2. 点击“加载未打包的扩展程序”\n3. 在目录选择窗口中粘贴并确认刚刚复制的目录\n\n完成后无需返回输入任何内容。ProFlow 会自动检测扩展并继续。\n`;
5
+ }
6
+ export function browserExtensionSetupSuccessMessage() {
7
+ return "\n✓ Chrome 浏览器扩展已连接\n✓ 配置与真实 heartbeat 验证通过\n\n浏览器扩展:READY\n";
8
+ }
9
+ export function browserExtensionSetupFailureMessage(error) {
10
+ const detail = error instanceof Error ? error.message : String(error);
11
+ if (detail === "PAIRING_TIMEOUT") {
12
+ return "✕ 暂未检测到浏览器扩展连接\n\n请确认扩展已经在 Chrome 中完成加载。已准备好的扩展文件和机器配置不会丢失。\n下一步:重新执行 platform setup\n错误代码:PAIRING_TIMEOUT\n";
13
+ }
14
+ const code = /^[A-Z][A-Z0-9_:-]*$/.test(detail)
15
+ ? detail
16
+ : "BROWSER_EXTENSION_SETUP_FAILED";
17
+ return `✕ 浏览器扩展配置失败\n\n详情:${detail}\n错误代码:${code}\n`;
18
+ }
19
+ function systemDesktop() {
20
+ return {
21
+ copyText(value) {
22
+ const command = process.platform === "darwin"
23
+ ? "pbcopy"
24
+ : process.platform === "win32"
25
+ ? "clip"
26
+ : "xclip";
27
+ const parameters = process.platform === "linux" ? ["-selection", "clipboard"] : [];
28
+ spawnSync(command, parameters, { input: value, encoding: "utf8" });
29
+ },
30
+ openExtensionsPage() {
31
+ const url = "chrome://extensions";
32
+ const command = process.platform === "darwin"
33
+ ? "open"
34
+ : process.platform === "win32"
35
+ ? "cmd"
36
+ : "xdg-open";
37
+ const parameters = process.platform === "darwin"
38
+ ? ["-a", "Google Chrome", url]
39
+ : process.platform === "win32"
40
+ ? ["/c", "start", "", url]
41
+ : [url];
42
+ const child = spawn(command, parameters, {
43
+ detached: true,
44
+ stdio: "ignore",
45
+ });
46
+ child.unref();
47
+ },
48
+ showInstruction(message) {
49
+ process.stdout.write(message);
50
+ },
51
+ };
52
+ }
53
+ export async function runInteractiveBrowserExtensionSetup(input) {
54
+ const desktop = input.desktop ?? systemDesktop();
55
+ const pair = input.pair ?? pairBrowserExtensionSetup;
56
+ return pair({ workspaceRoot: input.workspaceRoot }, {
57
+ timeoutMs: input.timeoutMs ?? 120_000,
58
+ async onWaiting({ loadDir }) {
59
+ await desktop.copyText(loadDir);
60
+ await desktop.openExtensionsPage();
61
+ await desktop.showInstruction(browserExtensionInstallInstruction(loadDir));
62
+ },
63
+ });
64
+ }
@@ -0,0 +1,20 @@
1
+ export interface BrowserExtensionPairingOptions {
2
+ token: string;
3
+ host?: "127.0.0.1";
4
+ port?: number;
5
+ pairingTimeoutMs?: number;
6
+ }
7
+ export type BrowserExtensionPairingResult = {
8
+ extensionId: string;
9
+ extensionInstanceId: string;
10
+ };
11
+ export declare function createBrowserExtensionPairingServer(options: BrowserExtensionPairingOptions): Promise<Readonly<{
12
+ endpoint: string;
13
+ status(): {
14
+ paired: boolean;
15
+ extensionId: string | null;
16
+ extensionInstanceId: string | null;
17
+ };
18
+ waitForPairing(): Promise<BrowserExtensionPairingResult>;
19
+ close(): Promise<void>;
20
+ }>>;
@@ -0,0 +1,176 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { createServer, } from "node:http";
3
+ const jsonHeaders = {
4
+ "content-type": "application/json; charset=utf-8",
5
+ "cache-control": "no-store",
6
+ };
7
+ function safeEqual(left, right) {
8
+ const leftBytes = Buffer.from(left);
9
+ const rightBytes = Buffer.from(right);
10
+ return (leftBytes.length === rightBytes.length &&
11
+ timingSafeEqual(leftBytes, rightBytes));
12
+ }
13
+ function send(response, status, value) {
14
+ response.writeHead(status, jsonHeaders);
15
+ response.end(value === undefined ? "" : JSON.stringify(value));
16
+ }
17
+ async function readJson(request) {
18
+ let body = "";
19
+ for await (const chunk of request) {
20
+ body += String(chunk);
21
+ if (body.length > 10_000)
22
+ throw new Error("PAIRING_INPUT_INVALID");
23
+ }
24
+ const parsed = body.length === 0 ? {} : JSON.parse(body);
25
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
26
+ throw new Error("PAIRING_INPUT_INVALID");
27
+ }
28
+ return parsed;
29
+ }
30
+ function text(value) {
31
+ if (typeof value !== "string" || value.length === 0)
32
+ throw new Error("PAIRING_INPUT_INVALID");
33
+ return value;
34
+ }
35
+ function extensionIdentity(request) {
36
+ const origin = request.headers.origin;
37
+ const match = typeof origin === "string"
38
+ ? /^chrome-extension:\/\/([a-z]{32})$/.exec(origin)
39
+ : null;
40
+ if (!match)
41
+ throw new Error("PAIRING_AUTH_INVALID");
42
+ return match[1];
43
+ }
44
+ function authenticate(request, token) {
45
+ const authorization = request.headers.authorization;
46
+ if (!authorization?.startsWith("Bearer ") ||
47
+ !safeEqual(authorization.slice(7), token)) {
48
+ throw new Error("PAIRING_AUTH_INVALID");
49
+ }
50
+ return extensionIdentity(request);
51
+ }
52
+ export async function createBrowserExtensionPairingServer(options) {
53
+ if (options.token.length < 32) {
54
+ throw new TypeError("pairing token must contain at least 32 characters");
55
+ }
56
+ const timeoutMs = options.pairingTimeoutMs ?? 120_000;
57
+ let identity;
58
+ let paired;
59
+ let closed = false;
60
+ const waiters = new Set();
61
+ const resolveWaiters = (value) => {
62
+ for (const waiter of waiters) {
63
+ clearTimeout(waiter.timer);
64
+ waiter.resolve(value);
65
+ }
66
+ waiters.clear();
67
+ };
68
+ const rejectWaiters = (error) => {
69
+ for (const waiter of waiters) {
70
+ clearTimeout(waiter.timer);
71
+ waiter.reject(error);
72
+ }
73
+ waiters.clear();
74
+ };
75
+ const server = createServer(async (request, response) => {
76
+ try {
77
+ const originId = extensionIdentity(request);
78
+ response.setHeader("access-control-allow-origin", `chrome-extension://${originId}`);
79
+ response.setHeader("vary", "origin");
80
+ if (request.method === "OPTIONS") {
81
+ response.setHeader("access-control-allow-headers", "authorization, content-type");
82
+ response.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
83
+ response.writeHead(204);
84
+ response.end();
85
+ return;
86
+ }
87
+ authenticate(request, options.token);
88
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
89
+ if (request.method === "POST" && url.pathname === "/v1/session/hello") {
90
+ const body = await readJson(request);
91
+ const extensionId = text(body.extensionId);
92
+ const extensionInstanceId = text(body.extensionInstanceId);
93
+ if (extensionId !== originId)
94
+ throw new Error("PAIRING_AUTH_INVALID");
95
+ if (identity && identity.extensionId !== extensionId) {
96
+ throw new Error("PAIRING_AUTH_INVALID");
97
+ }
98
+ identity = { extensionId, extensionInstanceId };
99
+ send(response, 200, { accepted: true });
100
+ return;
101
+ }
102
+ if (!identity || originId !== identity.extensionId) {
103
+ throw new Error("PAIRING_AUTH_INVALID");
104
+ }
105
+ if (url.searchParams.get("extensionInstanceId") !==
106
+ identity.extensionInstanceId) {
107
+ throw new Error("PAIRING_AUTH_INVALID");
108
+ }
109
+ if (request.method === "POST" &&
110
+ url.pathname === "/v1/session/heartbeat") {
111
+ paired = { ...identity };
112
+ resolveWaiters(paired);
113
+ send(response, 200, { accepted: true });
114
+ return;
115
+ }
116
+ if (request.method === "GET" && url.pathname === "/v1/commands/next") {
117
+ send(response, 204);
118
+ return;
119
+ }
120
+ send(response, 404, { error: "NOT_FOUND" });
121
+ }
122
+ catch (error) {
123
+ const code = error instanceof Error ? error.message : "PAIRING_INPUT_INVALID";
124
+ const status = code === "PAIRING_AUTH_INVALID" ? 401 : 400;
125
+ send(response, status, { error: code });
126
+ }
127
+ });
128
+ await new Promise((resolve, reject) => {
129
+ server.once("error", reject);
130
+ server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
131
+ server.off("error", reject);
132
+ resolve();
133
+ });
134
+ });
135
+ const address = server.address();
136
+ if (!address || typeof address === "string")
137
+ throw new Error("pairing address missing");
138
+ const endpoint = `http://127.0.0.1:${address.port}`;
139
+ return Object.freeze({
140
+ endpoint,
141
+ status() {
142
+ return {
143
+ paired: paired !== undefined,
144
+ extensionId: identity?.extensionId ?? null,
145
+ extensionInstanceId: identity?.extensionInstanceId ?? null,
146
+ };
147
+ },
148
+ waitForPairing() {
149
+ if (paired)
150
+ return Promise.resolve({ ...paired });
151
+ if (closed)
152
+ return Promise.reject(new Error("PAIRING_CLOSED"));
153
+ return new Promise((resolve, reject) => {
154
+ const waiter = {
155
+ resolve,
156
+ reject,
157
+ timer: setTimeout(() => {
158
+ waiters.delete(waiter);
159
+ reject(new Error("PAIRING_TIMEOUT"));
160
+ }, timeoutMs),
161
+ };
162
+ waiters.add(waiter);
163
+ });
164
+ },
165
+ async close() {
166
+ if (closed)
167
+ return;
168
+ closed = true;
169
+ rejectWaiters(new Error("PAIRING_CLOSED"));
170
+ await new Promise((resolve, reject) => {
171
+ server.close((error) => (error ? reject(error) : resolve()));
172
+ server.closeAllConnections();
173
+ });
174
+ },
175
+ });
176
+ }