Skip to content

Commit e17e564

Browse files
Miriadresearch
andcommitted
feat: add notebooklm/rpc.ts
Co-authored-by: research <research@miriad.systems>
1 parent d8f3250 commit e17e564

1 file changed

Lines changed: 230 additions & 0 deletions

File tree

lib/services/notebooklm/rpc.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/**
2+
* NotebookLM TypeScript Client — RPC Encoding/Decoding
3+
*
4+
* Implements the Google BatchExecute protocol used by NotebookLM.
5+
* Handles encoding RPC requests, building HTTP bodies/URLs, and
6+
* decoding the chunked anti-XSSI response format.
7+
*
8+
* @module lib/services/notebooklm/rpc
9+
*/
10+
11+
import { BATCHEXECUTE_URL } from './types';
12+
13+
// ---------------------------------------------------------------------------
14+
// Error class
15+
// ---------------------------------------------------------------------------
16+
17+
export class NotebookLMRPCError extends Error {
18+
public readonly code: number | undefined;
19+
public readonly methodId: string | undefined;
20+
21+
constructor(
22+
message: string,
23+
options?: { code?: number; methodId?: string }
24+
) {
25+
super(message);
26+
this.name = 'NotebookLMRPCError';
27+
this.code = options?.code;
28+
this.methodId = options?.methodId;
29+
}
30+
}
31+
32+
// ---------------------------------------------------------------------------
33+
// Encoding
34+
// ---------------------------------------------------------------------------
35+
36+
/**
37+
* Encode an RPC request for the BatchExecute protocol.
38+
*
39+
* Format: `[[[ methodId, JSON.stringify(params), null, "generic" ]]]`
40+
*/
41+
export function encodeRpcRequest(
42+
methodId: string,
43+
params: unknown[]
44+
): unknown[][] {
45+
const paramsJson = JSON.stringify(params);
46+
const inner = [methodId, paramsJson, null, 'generic'];
47+
return [[inner]];
48+
}
49+
50+
/**
51+
* Build the URL-encoded request body for a BatchExecute POST.
52+
*
53+
* Format: `f.req=<encoded_json>&at=<csrf_token>&`
54+
*/
55+
export function buildRequestBody(
56+
rpcRequest: unknown[][],
57+
csrfToken?: string
58+
): string {
59+
const fReq = JSON.stringify(rpcRequest);
60+
let body = `f.req=${encodeURIComponent(fReq)}`;
61+
if (csrfToken) {
62+
body += `&at=${encodeURIComponent(csrfToken)}`;
63+
}
64+
body += '&';
65+
return body;
66+
}
67+
68+
/**
69+
* Build the full BatchExecute URL with query parameters.
70+
*/
71+
export function buildRpcUrl(
72+
methodId: string,
73+
sourcePath: string,
74+
sessionId: string
75+
): string {
76+
const params = new URLSearchParams({
77+
rpcids: methodId,
78+
'source-path': sourcePath,
79+
'f.sid': sessionId,
80+
rt: 'c',
81+
});
82+
return `${BATCHEXECUTE_URL}?${params.toString()}`;
83+
}
84+
85+
// ---------------------------------------------------------------------------
86+
// Decoding
87+
// ---------------------------------------------------------------------------
88+
89+
/**
90+
* Strip the anti-XSSI prefix from a Google API response.
91+
*
92+
* Google prepends `)]}'` followed by a newline to prevent JSON hijacking.
93+
*/
94+
function stripAntiXssi(response: string): string {
95+
if (response.startsWith(")]}'")) {
96+
const newlineIdx = response.indexOf('\n');
97+
if (newlineIdx !== -1) {
98+
return response.slice(newlineIdx + 1);
99+
}
100+
}
101+
return response;
102+
}
103+
104+
/**
105+
* Parse the chunked response format.
106+
*
107+
* The response body (after anti-XSSI stripping) consists of alternating
108+
* lines: a byte count (integer), then a JSON payload line.
109+
*
110+
* Returns an array of parsed JSON chunks.
111+
*/
112+
function parseChunkedResponse(response: string): unknown[][] {
113+
const stripped = stripAntiXssi(response);
114+
const lines = stripped.split('\n');
115+
const chunks: unknown[][] = [];
116+
117+
let i = 0;
118+
while (i < lines.length) {
119+
const line = lines[i].trim();
120+
// Try to parse as a byte count (integer)
121+
const byteCount = parseInt(line, 10);
122+
if (!isNaN(byteCount) && byteCount > 0 && i + 1 < lines.length) {
123+
// Next line should be the JSON payload
124+
const jsonLine = lines[i + 1];
125+
try {
126+
const parsed = JSON.parse(jsonLine) as unknown[];
127+
if (Array.isArray(parsed)) {
128+
chunks.push(parsed);
129+
}
130+
} catch {
131+
// Not valid JSON — skip this pair
132+
}
133+
i += 2;
134+
} else {
135+
i += 1;
136+
}
137+
}
138+
139+
return chunks;
140+
}
141+
142+
/**
143+
* Extract the RPC result from parsed response chunks.
144+
*
145+
* Looks for `["wrb.fr", rpcId, jsonDataString, ...]` entries.
146+
* Error responses have `["er", rpcId, errorCode]`.
147+
*
148+
* @returns The parsed result data (from the JSON string at index 2)
149+
* @throws NotebookLMRPCError if an error response is found
150+
*/
151+
function extractRpcResult(
152+
chunks: unknown[][],
153+
rpcId: string
154+
): unknown {
155+
for (const chunk of chunks) {
156+
if (!Array.isArray(chunk)) continue;
157+
158+
for (const item of chunk) {
159+
if (!Array.isArray(item)) continue;
160+
const arr = item as unknown[];
161+
162+
// Check for error response
163+
if (arr[0] === 'er') {
164+
const errorRpcId = arr[1] as string | undefined;
165+
const errorCode = arr[2] as number | undefined;
166+
if (errorRpcId === rpcId || !errorRpcId) {
167+
throw new NotebookLMRPCError(
168+
`RPC error for method ${rpcId}: code ${errorCode ?? 'unknown'}`,
169+
{ code: errorCode, methodId: rpcId }
170+
);
171+
}
172+
}
173+
174+
// Check for successful response
175+
if (arr[0] === 'wrb.fr' && arr[1] === rpcId) {
176+
const jsonDataString = arr[2];
177+
if (typeof jsonDataString === 'string' && jsonDataString.length > 0) {
178+
try {
179+
return JSON.parse(jsonDataString) as unknown;
180+
} catch {
181+
throw new NotebookLMRPCError(
182+
`Failed to parse RPC result JSON for method ${rpcId}`,
183+
{ methodId: rpcId }
184+
);
185+
}
186+
}
187+
// Empty result string — return null
188+
return null;
189+
}
190+
}
191+
}
192+
193+
throw new NotebookLMRPCError(
194+
`No RPC result found for method ${rpcId} in response`,
195+
{ methodId: rpcId }
196+
);
197+
}
198+
199+
/**
200+
* Decode a full BatchExecute response text into the RPC result.
201+
*
202+
* Handles anti-XSSI stripping, chunked parsing, and result extraction.
203+
*
204+
* @param responseText - The raw response body from the BatchExecute endpoint
205+
* @param rpcId - The RPC method ID to extract results for
206+
* @returns The parsed result data
207+
* @throws NotebookLMRPCError on errors
208+
*/
209+
export function decodeResponse(
210+
responseText: string,
211+
rpcId: string
212+
): unknown {
213+
if (!responseText || responseText.trim().length === 0) {
214+
throw new NotebookLMRPCError(
215+
`Empty response for method ${rpcId}`,
216+
{ methodId: rpcId }
217+
);
218+
}
219+
220+
const chunks = parseChunkedResponse(responseText);
221+
222+
if (chunks.length === 0) {
223+
throw new NotebookLMRPCError(
224+
`No parseable chunks in response for method ${rpcId}`,
225+
{ methodId: rpcId }
226+
);
227+
}
228+
229+
return extractRpcResult(chunks, rpcId);
230+
}

0 commit comments

Comments
 (0)