typst-mcp 0.0.0-stage → 1.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dang Duy Toan (caphefalumi)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE(FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,181 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # Typst Web MCP
4
+
5
+ **Model Context Protocol (MCP) server for [`https://typst.app`](https://typst.app) — cloud projects, Yjs CRDT editing, and Typst compiler diagnostics**
6
+
7
+ Install from npm as [`typst-mcp`](https://www.npmjs.com/package/typst-mcp). Source repo: [`typst-web-mcp`](https://github.com/caphefalumi/typst-web-mcp).
8
+
9
+ [![npm version](https://img.shields.io/npm/v/typst-mcp.svg?style=flat-square)](https://www.npmjs.com/package/typst-mcp)
10
+ [![Downloads](https://img.shields.io/npm/dt/typst-mcp.svg?style=flat-square)](https://www.npmjs.com/package/typst-mcp)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](./LICENSE)
12
+ [![CI](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/ci.yml)
13
+ [![Node.js Package](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/npm-publish.yml/badge.svg)](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/npm-publish.yml)
14
+ [![Runtime: Bun](https://img.shields.io/badge/Runtime-Bun_%E2%89%A51.2-f9f1e1?style=flat-square&logo=bun&logoColor=black)](https://bun.sh)
15
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
16
+ [![MCP SDK](https://img.shields.io/badge/MCP-2024--11--05-10a37f?style=flat-square)](https://modelcontextprotocol.io)
17
+ [![Protocol: Yjs + lib0](https://img.shields.io/badge/CRDT-Yjs_%2B_lib0_v3-ff69b4?style=flat-square)](./docs/PROTOCOL.md)
18
+ [![Tests](https://img.shields.io/badge/Tests-7_Passing_(57_Assertions)-2ea44f?style=flat-square)](./test/typst-mcp.test.ts)
19
+
20
+ </div>
21
+
22
+ ---
23
+
24
+ ## Overview
25
+
26
+ `typst-web-mcp` connects AI coding and writing agents directly to collaborative [**typst.app**](https://typst.app) cloud projects. Instead of relying on brittle browser DOM scraping, it implements the native `typst.app` client protocol:
27
+
28
+ - **Native Yjs CRDT Text Sync:** Decodes and mutates multiplayer `Y.Doc` (`doc.getText("text")`) state vectors so surgical edits preserve collaborator cursors and version history.
29
+ - **Binary `/editing/v3` WebSocket Wire Protocol:** Encodes and decodes `lib0` variable-length binary frames (`Subscribe`, `TextUpdate`, `Pong`, `Refresh`, `Presence`, `Cursor`, `Leave`) with REST `/v1/files/:projectId/:fileId/yjs` persistence.
30
+ - **GraphQL & REST Project Management:** Full project CRUD, file tree inspection, rename/move, delete, and binary figure/asset uploads (`PNG`, `JPG`, `SVG`, `PDF`, fonts).
31
+ - **Direct Authentication + Fallback:** Authenticates via `GET /v1/to-signin` + `POST /v1/auth/signin` (`__Http-typst-session`), with persistent session caching and automatic cookie/Playwright fallback on challenge.
32
+ - **Compiler Diagnostics, Preview Screenshots & PDF Export:** Synchronizes cloud Yjs text and binary assets into an isolated workspace and runs the Typst compiler to return structured line/column diagnostics, high-DPI PNG preview screenshots (`typst_capture_screenshot`), and compiled PDFs (`typst_export_pdf`).
33
+
34
+ ---
35
+
36
+ ## Architecture
37
+
38
+ ```mermaid
39
+ flowchart LR
40
+ Agent["AI Agent\n(Pi / Claude / Cursor)"] <-->|"JSON-RPC (stdio)"| MCP["typst-mcp\n(Node / TypeScript)"]
41
+ MCP <-->|"GraphQL (/v1/graphql)\n+ X-Typst-Preflight"| API["https://api.typst.app"]
42
+ MCP <-->|"REST Yjs & Blobs\n(/v1/files/:pid/:fid/yjs)"| API
43
+ MCP <-->|"Binary lib0 WebSocket\n(wss://api.typst.app/editing/v3)"| WS["typst.app Real-Time Sync"]
44
+ MCP -->|"Workspace Sync + Compile"| TypstCLI["Typst Compiler\n(Diagnostics & PDF)"]
45
+ ```
46
+
47
+ See [`docs/PROTOCOL.md`](./docs/PROTOCOL.md) for the complete reverse-engineered protocol specification.
48
+
49
+ ---
50
+
51
+ ## MCP Tools (16 Tools)
52
+
53
+ | Category | Tool Name | Description |
54
+ |:---|:---|:---|
55
+ | **Auth** | `typst_auth_login` | Authenticate with `https://typst.app` via email/password (`POST /v1/auth/signin`) or `__Http-typst-session` cookie, with browser fallback. |
56
+ | **Auth** | `typst_auth_status` | Check active authentication state, login method, and stored session cookies. |
57
+ | **Projects** | `typst_list_projects` | List all owned and shared/external projects in the user's `typst.app` dashboard (`DashboardContent`). |
58
+ | **Projects** | `typst_get_project` | Retrieve full project metadata, access permissions, pinned compiler version, and file tree (`ProjectFiles`). |
59
+ | **Projects** | `typst_create_project` | Create a new `typst.app` cloud project with initial `.typ`/`.bib` files and pinned compiler version. |
60
+ | **Projects** | `typst_delete_project` | Permanently delete a `typst.app` project by ID (`DeleteProjectMutation`). |
61
+ | **Files & CRDT** | `typst_list_files` | List all multiplayer Yjs text files and binary asset blobs in a project. |
62
+ | **Files & CRDT** | `typst_read_file` | Read any project file — decodes binary Yjs CRDT state (`doc.getText("text")`) into UTF-8 text or base64 for binary assets. |
63
+ | **Files & CRDT** | `typst_write_file` | Create or overwrite a `.typ`/`.bib`/`.toml` file, syncing Yjs state diffs over `/editing/v3` WebSocket and REST. |
64
+ | **Files & CRDT** | `typst_edit_file` | Apply non-overlapping surgical `oldText -> newText` replacements directly to the `Y.Text("text")` CRDT. |
65
+ | **Assets & Tree** | `typst_upload_asset` | Upload or replace binary assets (`PNG`, `JPG`, `SVG`, `PDF`, fonts) from a local path or base64 payload. |
66
+ | **Assets & Tree** | `typst_rename_file` | Rename or move a file inside a `typst.app` project (`UpdatesFileMutation`). |
67
+ | **Assets & Tree** | `typst_delete_file` | Delete a file or binary asset from a project (`DeleteFilesMutation`). |
68
+ | **Compiler** | `typst_get_diagnostics` | Sync all cloud files and run `typst compile` to return structured errors, warnings, line/column spans, and hints. |
69
+ | **Compiler** | `typst_export_pdf` | Sync all cloud text & binary assets, compile the project to PDF, and save to `outputPath`. |
70
+ | **Preview** | `typst_capture_screenshot` | Capture high-DPI PNG preview screenshots (`mode="pages"` renders compiled PNG pages; `mode="browser"` screenshots the live `typst.app` web UI). |
71
+
72
+ ---
73
+
74
+ ## Quick Start
75
+
76
+ ### Prerequisites
77
+
78
+ - [**Node.js**](https://nodejs.org/) `>= 18` (for `npx` install) or [**Bun**](https://bun.sh) `>= 1.2` (for local development)
79
+ - [**Typst CLI**](https://github.com/typst/typst) (`typst` on `PATH` for `typst_get_diagnostics` and `typst_export_pdf`)
80
+
81
+ ### Installation
82
+
83
+ **Recommended — no clone, no global install:**
84
+
85
+ ```bash
86
+ npx -y typst-mcp
87
+ ```
88
+
89
+ Runs the published [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) package (Node `>= 18`). Use the same command in MCP config below. `-y` skips the npx install prompt.
90
+
91
+ **From source (development):**
92
+
93
+ ```bash
94
+ git clone https://github.com/caphefalumi/typst-web-mcp.git
95
+ cd typst-web-mcp
96
+ bun install
97
+ bun run dev
98
+ ```
99
+
100
+ ### MCP Client Configuration
101
+
102
+ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`, `claude_desktop_config.json`, or `.cursor/mcp.json`):
103
+
104
+ ```json
105
+ {
106
+ "mcpServers": {
107
+ "typst-mcp": {
108
+ "command": "npx",
109
+ "args": ["-y", "typst-mcp"],
110
+ "env": {
111
+ "TYPST_EMAIL": "your-email@example.com",
112
+ "TYPST_PASSWORD": "your-password"
113
+ }
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ > **Session Persistence:** Once authenticated via `typst_auth_login` (or environment variables), the `__Http-typst-session` cookie is cached locally at `~/.pi/agent/typst-mcp-session.json` so subsequent tool calls authenticate automatically without re-signing in.
120
+
121
+ ---
122
+
123
+ ## Development & Verification
124
+
125
+ ```bash
126
+ # Compile publishable output to dist/
127
+ bun run build
128
+
129
+ # Typecheck with strict TypeScript
130
+ bun run typecheck
131
+
132
+ # Full suite (includes test/smoke.test.ts: dist bin + npx -y typst-mcp)
133
+ bun test
134
+
135
+ # Smoke only (same as pi npx MCP config)
136
+ bun run test:smoke
137
+ ```
138
+
139
+ Push/PR to `main` runs [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) (`bun ci`, build, typecheck, `bun test`).
140
+
141
+ ### Publishing to npm
142
+
143
+ Same flow as [Canvas-CLI](https://github.com/caphefalumi/Canvas-CLI):
144
+
145
+ 1. Bump `version` in `package.json` (must not match a version already on npm).
146
+ 2. Commit, push, and create a GitHub **Release** for that tag — or run **Actions → Publish → Run workflow** (`workflow_dispatch`).
147
+
148
+ [`.github/workflows/npm-publish.yml`](./.github/workflows/npm-publish.yml) runs the same checks as CI (`bun test`, including smoke) in a `test` job; the `publish` job runs only if that passes, on the `npm-publish` environment: `bun ci`, `bun run build`, then `npm publish --access public` via [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC). The publish step clears `NODE_AUTH_TOKEN` so the CLI uses GitHub OIDC instead of a stale token from `setup-node`.
149
+
150
+ **Trusted publisher (maintainers):** package [`typst-mcp`](https://www.npmjs.com/package/typst-mcp) → Settings → Trusted publishing → GitHub Actions — user/org `caphefalumi`, repo `typst-web-mcp`, workflow file `npm-publish.yml`, environment `npm-publish`, allow **npm publish**.
151
+
152
+ ### Project Structure
153
+
154
+ ```text
155
+ typst-web-mcp/
156
+ ├── docs/
157
+ │ └── PROTOCOL.md # Reverse-engineered typst.app REST, GraphQL, Yjs & WS v3 spec
158
+ ├── src/
159
+ │ ├── protocol/
160
+ │ │ ├── ws-codec.ts # Binary lib0 /editing/v3 WebSocket encoder & decoder
161
+ │ │ ├── yjs-doc.ts # Yjs CRDT text decoding, replacement & surgical patching
162
+ │ │ └── graphql.ts # GraphQL queries, mutations, fragments & types
163
+ │ ├── client/
164
+ │ │ ├── auth.ts # Direct /v1/auth/signin login, cookie jar & Playwright fallback
165
+ │ │ ├── compiler.ts # Typst binary resolver, diagnostic parser, PDF & PNG preview compiler
166
+ │ │ ├── errors.ts # Structured TypstLoadError & error formatting helpers
167
+ │ │ └── typst-client.ts # TypstCloudClient (REST, GraphQL, WebSocket v3, Compiler facade)
168
+ │ ├── tools/
169
+ │ │ └── definitions.ts # MCP JSON Schema definitions for all 16 tools
170
+ │ └── index.ts # MCP stdio server entrypoint (dev); npm bin runs dist/index.js
171
+ ├── test/
172
+ │ └── typst-mcp.test.ts # 7 test suites (57 assertions) covering codecs, CRDTs & E2E
173
+ ├── LICENSE # MIT License
174
+ └── package.json
175
+ ```
176
+
177
+ ---
178
+
179
+ ## License
180
+
181
+ Released under the [MIT License](./LICENSE). Copyright (c) 2026 Dang Duy Toan (`caphefalumi`).
@@ -0,0 +1,310 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36";
5
+ function extractRawSetCookies(headers) {
6
+ const headerWithGetSetCookie = headers;
7
+ if (typeof headerWithGetSetCookie.getSetCookie === "function") {
8
+ return headerWithGetSetCookie.getSetCookie();
9
+ }
10
+ const singleCookie = headers.get("set-cookie");
11
+ if (singleCookie) {
12
+ return [singleCookie];
13
+ }
14
+ return [];
15
+ }
16
+ function resolveSessionFilePath(configuredPath) {
17
+ if (configuredPath === undefined) {
18
+ return join(homedir(), ".pi", "agent", "typst-mcp-session.json");
19
+ }
20
+ return configuredPath;
21
+ }
22
+ export function parseSetCookieHeaders(headers) {
23
+ const result = {};
24
+ const rawList = extractRawSetCookies(headers);
25
+ for (const raw of rawList) {
26
+ const firstPart = raw.split(";")[0]?.trim();
27
+ if (!firstPart) {
28
+ continue;
29
+ }
30
+ const eqIdx = firstPart.indexOf("=");
31
+ if (eqIdx <= 0) {
32
+ continue;
33
+ }
34
+ const name = firstPart.slice(0, eqIdx).trim();
35
+ const val = firstPart.slice(eqIdx + 1).trim();
36
+ if (val === "") {
37
+ delete result[name];
38
+ }
39
+ else {
40
+ result[name] = val;
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+ export function parseCookieString(cookieStr) {
46
+ const trimmed = cookieStr.trim();
47
+ if (!trimmed) {
48
+ return {};
49
+ }
50
+ if (!trimmed.includes("=")) {
51
+ return { "__Http-typst-session": trimmed };
52
+ }
53
+ const map = {};
54
+ for (const part of trimmed.split(";")) {
55
+ const p = part.trim();
56
+ if (!p) {
57
+ continue;
58
+ }
59
+ const eq = p.indexOf("=");
60
+ if (eq <= 0) {
61
+ continue;
62
+ }
63
+ const k = p.slice(0, eq).trim();
64
+ const v = p.slice(eq + 1).trim();
65
+ if (k && v) {
66
+ map[k] = v;
67
+ }
68
+ }
69
+ return map;
70
+ }
71
+ export class TypstAuthManager {
72
+ appUrl;
73
+ apiUrl;
74
+ sessionFilePath;
75
+ fetchImpl;
76
+ browserFallbackFn;
77
+ cookies = {};
78
+ authMethod = null;
79
+ email;
80
+ constructor(options = {}) {
81
+ const rawAppUrl = options.appUrl ?? process.env.TYPST_APP_URL ?? "https://typst.app";
82
+ const rawApiUrl = options.apiUrl ?? process.env.TYPST_API_URL ?? "https://api.typst.app";
83
+ this.appUrl = rawAppUrl.replace(/\/+$/, "");
84
+ this.apiUrl = rawApiUrl.replace(/\/+$/, "");
85
+ this.sessionFilePath = resolveSessionFilePath(options.sessionFilePath);
86
+ this.fetchImpl = options.fetchImpl ?? fetch;
87
+ this.browserFallbackFn = options.browserFallbackFn;
88
+ this.loadStoredSession();
89
+ if (Object.keys(this.cookies).length === 0 &&
90
+ process.env.TYPST_SESSION_COOKIE) {
91
+ this.setSessionCookie(process.env.TYPST_SESSION_COOKIE);
92
+ }
93
+ }
94
+ loadStoredSession() {
95
+ if (!this.sessionFilePath || !existsSync(this.sessionFilePath)) {
96
+ return;
97
+ }
98
+ try {
99
+ const raw = JSON.parse(readFileSync(this.sessionFilePath, "utf8"));
100
+ if (raw && typeof raw.cookies === "object" && raw.cookies) {
101
+ this.cookies = { ...raw.cookies };
102
+ this.authMethod = raw.authMethod ?? "cookie";
103
+ this.email = raw.email;
104
+ }
105
+ }
106
+ catch {
107
+ // Ignore corrupted session file
108
+ }
109
+ }
110
+ saveStoredSession() {
111
+ if (!this.sessionFilePath) {
112
+ return;
113
+ }
114
+ try {
115
+ mkdirSync(dirname(this.sessionFilePath), { recursive: true });
116
+ const payload = {
117
+ cookies: this.cookies,
118
+ email: this.email,
119
+ authMethod: this.authMethod ?? "cookie",
120
+ updatedAt: new Date().toISOString(),
121
+ };
122
+ writeFileSync(this.sessionFilePath, JSON.stringify(payload, null, 2), "utf8");
123
+ }
124
+ catch {
125
+ // Non-fatal if filesystem is read-only
126
+ }
127
+ }
128
+ setSessionCookie(cookieInput, email) {
129
+ const parsed = parseCookieString(cookieInput);
130
+ this.cookies = { ...this.cookies, ...parsed };
131
+ this.authMethod = "cookie";
132
+ if (email) {
133
+ this.email = email;
134
+ }
135
+ this.saveStoredSession();
136
+ }
137
+ getCookieHeader() {
138
+ return Object.entries(this.cookies)
139
+ .map(([k, v]) => `${k}=${v}`)
140
+ .join("; ");
141
+ }
142
+ hasSession() {
143
+ return Boolean(this.cookies["__Http-typst-session"] ||
144
+ this.cookies["typst-session"] ||
145
+ this.cookies["session"]);
146
+ }
147
+ getStatus() {
148
+ return {
149
+ authenticated: this.hasSession(),
150
+ authMethod: this.authMethod,
151
+ email: this.email,
152
+ cookieNames: Object.keys(this.cookies),
153
+ };
154
+ }
155
+ async login(options = {}) {
156
+ const sessionCookie = options.sessionCookie ?? process.env.TYPST_SESSION_COOKIE;
157
+ const email = options.email ?? process.env.TYPST_EMAIL;
158
+ const password = options.password ?? process.env.TYPST_PASSWORD;
159
+ if (sessionCookie && (!email || !password)) {
160
+ this.setSessionCookie(sessionCookie, email);
161
+ return {
162
+ authenticated: this.hasSession(),
163
+ authMethod: "cookie",
164
+ email: this.email,
165
+ cookieNames: Object.keys(this.cookies),
166
+ };
167
+ }
168
+ if (!email || !password) {
169
+ if (sessionCookie) {
170
+ this.setSessionCookie(sessionCookie, email);
171
+ return {
172
+ authenticated: this.hasSession(),
173
+ authMethod: "cookie",
174
+ email: this.email,
175
+ cookieNames: Object.keys(this.cookies),
176
+ };
177
+ }
178
+ throw new Error("Missing credentials: provide email & password (or TYPST_EMAIL / TYPST_PASSWORD), or sessionCookie (TYPST_SESSION_COOKIE).");
179
+ }
180
+ // Step 1: Initialize pre-auth session cookie via GET /v1/to-signin
181
+ const initRes = await this.fetchImpl(`${this.apiUrl}/v1/to-signin`, {
182
+ method: "GET",
183
+ redirect: "manual",
184
+ headers: {
185
+ "User-Agent": DEFAULT_USER_AGENT,
186
+ },
187
+ });
188
+ const preAuthCookies = parseSetCookieHeaders(initRes.headers);
189
+ const mergedCookies = {
190
+ ...this.cookies,
191
+ ...preAuthCookies,
192
+ };
193
+ const cookieHeader = Object.entries(mergedCookies)
194
+ .map(([k, v]) => `${k}=${v}`)
195
+ .join("; ");
196
+ // Step 2: Submit credentials to POST /v1/auth/signin
197
+ const form = new URLSearchParams();
198
+ form.set("email", email);
199
+ form.set("password", password);
200
+ form.set("redirect", "");
201
+ form.set("grant-id", "");
202
+ form.set("captcha-trap", ""); // Honeypot field must remain empty
203
+ const signinRes = await this.fetchImpl(`${this.apiUrl}/v1/auth/signin`, {
204
+ method: "POST",
205
+ redirect: "manual",
206
+ headers: {
207
+ "Content-Type": "application/x-www-form-urlencoded",
208
+ Cookie: cookieHeader,
209
+ Origin: this.appUrl,
210
+ Referer: `${this.appUrl}/signin/`,
211
+ "User-Agent": DEFAULT_USER_AGENT,
212
+ },
213
+ body: form.toString(),
214
+ });
215
+ const postAuthCookies = parseSetCookieHeaders(signinRes.headers);
216
+ Object.assign(mergedCookies, postAuthCookies);
217
+ const location = signinRes.headers.get("location") ?? "";
218
+ const isChallengeOrBlocked = signinRes.status === 403 ||
219
+ signinRes.status === 429 ||
220
+ location.includes("captcha") ||
221
+ location.includes("challenge");
222
+ const isInvalidCredentials = signinRes.status === 401 ||
223
+ location.includes("error=") ||
224
+ location.includes("invalid");
225
+ if (!isChallengeOrBlocked &&
226
+ !isInvalidCredentials &&
227
+ (signinRes.status === 200 ||
228
+ signinRes.status === 302 ||
229
+ signinRes.status === 303) &&
230
+ (mergedCookies["__Http-typst-session"] || mergedCookies["typst-session"])) {
231
+ this.cookies = mergedCookies;
232
+ this.authMethod = "api-password";
233
+ this.email = email;
234
+ this.saveStoredSession();
235
+ return {
236
+ authenticated: true,
237
+ authMethod: "api-password",
238
+ email,
239
+ cookieNames: Object.keys(this.cookies),
240
+ };
241
+ }
242
+ // Step 3: Automatic fallback to cookie or browser session if Turnstile/CAPTCHA challenge occurs
243
+ if (options.useBrowserFallback !== false &&
244
+ (isChallengeOrBlocked || !this.hasSession())) {
245
+ if (sessionCookie) {
246
+ this.setSessionCookie(sessionCookie, email);
247
+ return {
248
+ authenticated: this.hasSession(),
249
+ authMethod: "cookie",
250
+ email,
251
+ cookieNames: Object.keys(this.cookies),
252
+ };
253
+ }
254
+ const fallbackRunner = this.browserFallbackFn ?? defaultPlaywrightBrowserFallback;
255
+ if (fallbackRunner) {
256
+ const fallbackCookies = await fallbackRunner(this.appUrl, this.apiUrl, email, password);
257
+ this.cookies = { ...mergedCookies, ...fallbackCookies };
258
+ this.authMethod = "browser-fallback";
259
+ this.email = email;
260
+ this.saveStoredSession();
261
+ return {
262
+ authenticated: this.hasSession(),
263
+ authMethod: "browser-fallback",
264
+ email,
265
+ cookieNames: Object.keys(this.cookies),
266
+ };
267
+ }
268
+ }
269
+ let redirectDetail = "";
270
+ if (location) {
271
+ redirectDetail = `, redirect: ${location}`;
272
+ }
273
+ throw new Error(`Sign-in failed (HTTP ${signinRes.status}${redirectDetail}). Verify TYPST_EMAIL/TYPST_PASSWORD or supply TYPST_SESSION_COOKIE.`);
274
+ }
275
+ }
276
+ export const defaultPlaywrightBrowserFallback = async (appUrl, _apiUrl, email, password) => {
277
+ const { spawnSync } = await import("node:child_process");
278
+ const pyScript = `
279
+ import json, os
280
+ from playwright.sync_api import sync_playwright
281
+
282
+ with sync_playwright() as p:
283
+ browser = p.chromium.launch(channel="msedge", headless=True)
284
+ ctx = browser.new_context()
285
+ page = ctx.new_page()
286
+ page.goto(os.environ["TYPST_FB_URL"] + "/signin/", wait_until="networkidle")
287
+ page.fill("input[name='email']", os.environ["TYPST_FB_EMAIL"])
288
+ page.fill("input[name='password']", os.environ["TYPST_FB_PASS"])
289
+ page.click("input[type='submit']")
290
+ page.wait_for_load_state("networkidle")
291
+ cookies = {c["name"]: c["value"] for c in ctx.cookies()}
292
+ print(json.dumps(cookies))
293
+ browser.close()
294
+ `;
295
+ const proc = spawnSync("python", ["-c", pyScript], {
296
+ env: {
297
+ ...process.env,
298
+ TYPST_FB_URL: appUrl,
299
+ TYPST_FB_EMAIL: email,
300
+ TYPST_FB_PASS: password,
301
+ },
302
+ encoding: "utf8",
303
+ timeout: 30000,
304
+ });
305
+ if (proc.status !== 0 || !proc.stdout.trim()) {
306
+ throw new Error(`Playwright browser fallback failed: ${proc.stderr || "no output"}`);
307
+ }
308
+ const lines = proc.stdout.trim().split(/\r\n|\n/);
309
+ return JSON.parse(lines[lines.length - 1]);
310
+ };