Skip to content

Commit c5731cb

Browse files
committed
feat: generate browser-scoped session bindings
Replace the handwritten Node browser-scoped façade with deterministic generated bindings from the browser resource graph, and enforce regeneration during lint and build. Made-with: Cursor
1 parent a9057ac commit c5731cb

File tree

7 files changed

+748
-263
lines changed

7 files changed

+748
-263
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"prepublishOnly": "echo 'to publish, run yarn build && (cd dist; yarn publish)' && exit 1",
2424
"format": "./scripts/format",
2525
"prepare": "if ./scripts/utils/check-is-in-git-install.sh; then ./scripts/build && ./scripts/utils/git-swap.sh; fi",
26+
"generate:browser-session": "ts-node -T scripts/generate-browser-session.ts",
2627
"tsn": "ts-node -r tsconfig-paths/register",
2728
"lint": "./scripts/lint",
2829
"fix": "./scripts/format"
@@ -45,6 +46,7 @@
4546
"prettier": "^3.0.0",
4647
"publint": "^0.2.12",
4748
"ts-jest": "^29.1.0",
49+
"ts-morph": "^28.0.0",
4850
"ts-node": "^10.5.0",
4951
"tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.9/tsc-multi.tgz",
5052
"tsconfig-paths": "^4.0.0",

scripts/build

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ cd "$(dirname "$0")/.."
66

77
node scripts/utils/check-version.cjs
88

9+
./node_modules/.bin/ts-node -T scripts/generate-browser-session.ts
10+
./node_modules/.bin/prettier --write src/lib/generated/browser-session-bindings.ts >/dev/null
11+
912
# Build into dist and will publish the package from there,
1013
# so that src/resources/foo.ts becomes <package root>/resources/foo.js
1114
# This way importing from `"@onkernel/sdk/resources/foo"` works
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
#!/usr/bin/env -S node
2+
3+
import fs from 'fs';
4+
import path from 'path';
5+
import {
6+
Node,
7+
Project,
8+
SyntaxKind,
9+
type MethodDeclaration,
10+
type ParameterDeclaration,
11+
type PropertyDeclaration,
12+
} from 'ts-morph';
13+
14+
type ChildMeta = {
15+
propName: string;
16+
targetClass: string;
17+
};
18+
19+
type MethodMeta = {
20+
name: string;
21+
signature: string;
22+
returnType: string;
23+
implArgs: string;
24+
};
25+
26+
type ResourceMeta = {
27+
className: string;
28+
filePath: string;
29+
importPath: string;
30+
alias: string;
31+
exportedTypeNames: Set<string>;
32+
children: ChildMeta[];
33+
methods: MethodMeta[];
34+
};
35+
36+
const repoRoot = path.resolve(__dirname, '..');
37+
const resourcesDir = path.join(repoRoot, 'src', 'resources', 'browsers');
38+
const outputFile = path.join(repoRoot, 'src', 'lib', 'generated', 'browser-session-bindings.ts');
39+
40+
const project = new Project({
41+
skipAddingFilesFromTsConfig: true,
42+
});
43+
44+
project.addSourceFilesAtPaths(path.join(resourcesDir, '**/*.ts'));
45+
46+
const sourceFiles = project
47+
.getSourceFiles()
48+
.filter((sf) => !sf.getBaseName().endsWith('.test.ts') && sf.getBaseName() !== 'index.ts');
49+
50+
const resourceByClass = new Map<string, ResourceMeta>();
51+
52+
for (const sf of sourceFiles) {
53+
for (const classDecl of sf.getClasses()) {
54+
if (classDecl.getName() == null) continue;
55+
if (!extendsAPIResource(classDecl)) continue;
56+
57+
const className = classDecl.getNameOrThrow();
58+
const importPath = toImportPath(path.relative(path.dirname(outputFile), sf.getFilePath()));
59+
const alias = `${className}API`;
60+
const exportedTypeNames = new Set<string>(
61+
sf
62+
.getStatements()
63+
.filter(
64+
(stmt) =>
65+
Node.isInterfaceDeclaration(stmt) ||
66+
Node.isTypeAliasDeclaration(stmt) ||
67+
Node.isEnumDeclaration(stmt),
68+
)
69+
.map((stmt) => {
70+
if (
71+
Node.isInterfaceDeclaration(stmt) ||
72+
Node.isTypeAliasDeclaration(stmt) ||
73+
Node.isEnumDeclaration(stmt)
74+
) {
75+
return stmt.getName();
76+
}
77+
return '';
78+
})
79+
.filter(Boolean),
80+
);
81+
82+
const meta: ResourceMeta = {
83+
className,
84+
filePath: sf.getFilePath(),
85+
importPath,
86+
alias,
87+
exportedTypeNames,
88+
children: extractChildren(classDecl),
89+
methods: [],
90+
};
91+
resourceByClass.set(className, meta);
92+
}
93+
}
94+
95+
for (const sf of sourceFiles) {
96+
for (const classDecl of sf.getClasses()) {
97+
const className = classDecl.getName();
98+
if (!className) continue;
99+
const meta = resourceByClass.get(className);
100+
if (!meta) continue;
101+
102+
for (const method of classDecl.getMethods()) {
103+
const parsed = parseMethod(meta, method);
104+
if (parsed) meta.methods.push(parsed);
105+
}
106+
}
107+
}
108+
109+
const browserMeta = resourceByClass.get('Browsers');
110+
if (!browserMeta) {
111+
throw new Error('Could not find Browsers resource');
112+
}
113+
114+
const ordered = orderResources(browserMeta, resourceByClass);
115+
const importLines = ordered
116+
.map((meta) => `import type * as ${meta.alias} from '${meta.importPath}';`)
117+
.join('\n');
118+
119+
const interfaces = ordered.map((meta) => emitInterface(meta, resourceByClass)).join('\n\n');
120+
121+
const constructorAssignments = browserMeta.children
122+
.map((child) => emitAssignment(child, resourceByClass))
123+
.join('\n ');
124+
125+
const rootMethods = browserMeta.methods.map((method) => emitMethod(method, 'browsers')).join('\n\n');
126+
127+
const output = `// This file is generated by scripts/generate-browser-session.ts.\n// Do not edit by hand.\n\nimport type { Kernel } from '../../client';\nimport type { APIPromise } from '../../core/api-promise';\nimport type { RequestOptions } from '../../internal/request-options';\nimport type { Stream } from '../../core/streaming';\nimport type * as Shared from '../../resources/shared';\n${importLines}\n\n${interfaces}\n\nexport class GeneratedBrowserSessionBindings {\n protected readonly sessionClient: Kernel;\n readonly sessionId: string;\n\n readonly ${browserMeta.children
128+
.map((child) => `${child.propName}: ${bindingName(child.targetClass)}`)
129+
.join(
130+
'\n readonly ',
131+
)};\n\n constructor(sessionClient: Kernel, sessionId: string) {\n this.sessionClient = sessionClient;\n this.sessionId = sessionId;\n ${constructorAssignments}\n }\n\n protected opt(options?: RequestOptions): RequestOptions | undefined {\n return options;\n }\n\n${indent(
132+
rootMethods,
133+
2,
134+
)}\n}\n`;
135+
136+
fs.mkdirSync(path.dirname(outputFile), { recursive: true });
137+
fs.writeFileSync(outputFile, output);
138+
139+
function extendsAPIResource(classDecl: import('ts-morph').ClassDeclaration): boolean {
140+
const ext = classDecl.getExtends();
141+
if (!ext) return false;
142+
const text = ext.getExpression().getText();
143+
return text === 'APIResource';
144+
}
145+
146+
function extractChildren(classDecl: import('ts-morph').ClassDeclaration): ChildMeta[] {
147+
return classDecl
148+
.getProperties()
149+
.filter((prop) => !prop.getName().startsWith('with_'))
150+
.map((prop) => {
151+
const targetClass = childClassName(prop);
152+
if (!targetClass) return null;
153+
return { propName: prop.getName(), targetClass };
154+
})
155+
.filter((v): v is ChildMeta => v !== null);
156+
}
157+
158+
function childClassName(prop: PropertyDeclaration): string | null {
159+
const init = prop.getInitializer();
160+
if (!init || !Node.isNewExpression(init)) return null;
161+
const expr = init.getExpression().getText();
162+
const last = expr.split('.').pop() || expr;
163+
return last;
164+
}
165+
166+
function parseMethod(meta: ResourceMeta, method: MethodDeclaration): MethodMeta | null {
167+
if (!isPublicMethod(method)) return null;
168+
const pathText = getPathTemplate(method);
169+
if (!pathText) return null;
170+
if (meta.className === 'Browsers' && !pathText.includes('/browsers/${id}/')) return null;
171+
172+
const params = method.getParameters();
173+
const idParam = params[0]?.getName() === 'id' ? params[0] : undefined;
174+
const paramsIdName = detectParamsIdParam(method);
175+
if (!idParam && !paramsIdName) return null;
176+
177+
const publicParams = params
178+
.filter((param) => param !== idParam)
179+
.map((param) => formatParam(meta, param, paramsIdName, true))
180+
.join(', ');
181+
182+
const implArgs = params
183+
.map((param) => {
184+
const name = param.getName();
185+
if (param === idParam) return 'this.sessionId';
186+
if (paramsIdName && name === paramsIdName) return `{ ...${name}, id: this.sessionId }`;
187+
if (name === 'options') return 'this.opt(options)';
188+
return name;
189+
})
190+
.join(', ');
191+
192+
return {
193+
name: method.getName(),
194+
signature: publicParams,
195+
returnType: rewriteType(meta, method.getReturnTypeNodeOrThrow().getText()),
196+
implArgs,
197+
};
198+
}
199+
200+
function isPublicMethod(method: MethodDeclaration): boolean {
201+
const name = method.getName();
202+
if (name.startsWith('_')) return false;
203+
if (method.isStatic()) return false;
204+
return true;
205+
}
206+
207+
function getPathTemplate(method: MethodDeclaration): string | null {
208+
const tag = method
209+
.getDescendantsOfKind(SyntaxKind.TaggedTemplateExpression)
210+
.find((node) => node.getTag().getText() === 'path');
211+
return tag?.getTemplate().getText() ?? null;
212+
}
213+
214+
function detectParamsIdParam(method: MethodDeclaration): string | null {
215+
const body = method.getBodyText() ?? '';
216+
const match = body.match(/const\s+\{\s*id(?:\s*,\s*\.\.\.\w+)?\s*\}\s*=\s*(\w+)/);
217+
return match?.[1] ?? null;
218+
}
219+
220+
function formatParam(
221+
meta: ResourceMeta,
222+
param: ParameterDeclaration,
223+
paramsIdName: string | null,
224+
includeInitializer: boolean,
225+
): string {
226+
const name = param.getName();
227+
let typeText = param.getTypeNodeOrThrow().getText();
228+
typeText = rewriteType(meta, typeText);
229+
if (paramsIdName && name === paramsIdName) {
230+
typeText = `Omit<${typeText}, 'id'>`;
231+
}
232+
const initializer = includeInitializer ? param.getInitializer()?.getText() : undefined;
233+
const question = param.hasQuestionToken() ? '?' : '';
234+
return `${name}${question}: ${typeText}${initializer ? ` = ${initializer}` : ''}`;
235+
}
236+
237+
function rewriteType(meta: ResourceMeta, text: string): string {
238+
let out = text;
239+
const typeNames = Array.from(meta.exportedTypeNames).sort((a, b) => b.length - a.length);
240+
for (const name of typeNames) {
241+
out = out.replace(new RegExp(`\\b${name}\\b`, 'g'), `${meta.alias}.${name}`);
242+
}
243+
return out;
244+
}
245+
246+
function orderResources(root: ResourceMeta, all: Map<string, ResourceMeta>): ResourceMeta[] {
247+
const out: ResourceMeta[] = [];
248+
const seen = new Set<string>();
249+
const visit = (meta: ResourceMeta) => {
250+
if (seen.has(meta.className)) return;
251+
seen.add(meta.className);
252+
for (const child of meta.children) {
253+
const childMeta = all.get(child.targetClass);
254+
if (childMeta) visit(childMeta);
255+
}
256+
out.push(meta);
257+
};
258+
visit(root);
259+
return out.filter((meta) => meta.className !== 'Browsers').concat(root);
260+
}
261+
262+
function emitInterface(meta: ResourceMeta, all: Map<string, ResourceMeta>): string {
263+
const lines: string[] = [];
264+
for (const method of meta.methods) {
265+
const noInitSignature = method.signature.replace(/\s*=\s*[^,)+]+/g, '');
266+
lines.push(` ${method.name}(${noInitSignature}): ${method.returnType};`);
267+
}
268+
for (const child of meta.children) {
269+
if (all.has(child.targetClass)) {
270+
lines.push(` ${child.propName}: ${bindingName(child.targetClass)};`);
271+
}
272+
}
273+
return `export interface ${bindingName(meta.className)} {\n${lines.join('\n')}\n}`;
274+
}
275+
276+
function bindingName(className: string): string {
277+
return `BrowserSession${className}Bindings`;
278+
}
279+
280+
function emitAssignment(child: ChildMeta, all: Map<string, ResourceMeta>): string {
281+
const meta = all.get(child.targetClass)!;
282+
const methodLines = meta.methods.map((method) => {
283+
return `${method.name}: (${method.signature}) => this.sessionClient.browsers.${resourceCallPath(
284+
meta.filePath,
285+
)}.${method.name}(${method.implArgs}),`;
286+
});
287+
const childLines = meta.children.map((nested) => emitNestedObject(nested, all));
288+
return `this.${child.propName} = {\n ${[...methodLines, ...childLines].join('\n ')}\n };`;
289+
}
290+
291+
function emitNestedObject(child: ChildMeta, all: Map<string, ResourceMeta>): string {
292+
const meta = all.get(child.targetClass)!;
293+
const methodLines = meta.methods.map((method) => {
294+
return `${method.name}: (${method.signature}) => this.sessionClient.browsers.${resourceCallPath(
295+
meta.filePath,
296+
)}.${method.name}(${method.implArgs}),`;
297+
});
298+
const nestedLines = meta.children.map((nested) => emitNestedObject(nested, all));
299+
return `${child.propName}: {\n ${[...methodLines, ...nestedLines].join('\n ')}\n },`;
300+
}
301+
302+
function resourceCallPath(filePath: string): string {
303+
const rel = path.relative(resourcesDir, filePath).replace(/\\/g, '/').replace(/\.ts$/, '');
304+
const segments = rel.split('/');
305+
if (segments[segments.length - 1] === 'fs') {
306+
return 'fs';
307+
}
308+
if (segments[0] === 'fs') {
309+
return ['fs', ...segments.slice(1)].join('.');
310+
}
311+
if (segments[0] === 'browsers') {
312+
return segments.slice(1).join('.');
313+
}
314+
return segments.join('.');
315+
}
316+
317+
function emitMethod(method: MethodMeta, resourcePrefix: string): string {
318+
return `${method.name}(${method.signature}): ${method.returnType} {\n return this.sessionClient.${resourcePrefix}.${method.name}(${method.implArgs});\n }`;
319+
}
320+
321+
function toImportPath(relPath: string): string {
322+
const normalized = relPath.replace(/\\/g, '/').replace(/\.ts$/, '');
323+
return normalized.startsWith('.') ? normalized : `./${normalized}`;
324+
}
325+
326+
function indent(value: string, depth: number): string {
327+
const prefix = ' '.repeat(depth);
328+
return value
329+
.split('\n')
330+
.map((line) => (line.length ? `${prefix}${line}` : line))
331+
.join('\n');
332+
}

scripts/lint

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ set -e
44

55
cd "$(dirname "$0")/.."
66

7+
echo "==> Regenerating browser session bindings"
8+
./node_modules/.bin/ts-node -T scripts/generate-browser-session.ts
9+
./node_modules/.bin/prettier --write src/lib/generated/browser-session-bindings.ts >/dev/null
10+
11+
echo "==> Verifying generated browser session bindings are committed"
12+
git diff --exit-code -- src/lib/generated/browser-session-bindings.ts
13+
714
echo "==> Running eslint"
815
./node_modules/.bin/eslint .
916

0 commit comments

Comments
 (0)