| // Licensed to the Software Freedom Conservancy (SFC) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The SFC licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| /** |
| * Generate the shared WebDriver BiDi artifacts and TypeScript bindings from a |
| * merged CDDL spec, as a three-stage pipeline — one stage per invocation: |
| * |
| * 1. parse --cddl <f> --dump-ast <f> CDDL → AST |
| * 2. model --ast <f> --dump-model <f> AST → command/event model |
| * 3. generate --ast <f> --model <f> --output-dir <d> AST + model → one TS module per domain |
| * [--enhancements <f>] [--spec-version <v>] |
| */ |
| |
| import { parse } from 'cddl' |
| import { transform } from 'cddl2ts' |
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' |
| import { join, resolve } from 'node:path' |
| import { parseArgs } from 'node:util' |
| |
| // ============================================================ |
| // Domain configuration |
| // ============================================================ |
| |
| // Maps the domain segment in a BiDi method string (e.g. "browsingContext" |
| // from "browsingContext.activate") to a canonical domain key. |
| const METHOD_DOMAIN_MAP = { |
| browser: 'browser', |
| browsingContext: 'browsingContext', |
| emulation: 'emulation', |
| input: 'input', |
| log: 'log', |
| network: 'network', |
| permissions: 'permissions', |
| script: 'script', |
| session: 'session', |
| speculation: 'speculation', |
| storage: 'storage', |
| userAgentClientHints: 'userAgentClientHints', |
| webExtension: 'webExtension', |
| bluetooth: 'bluetooth', |
| } |
| |
| // Maps TypeScript export name prefixes to domain keys. |
| // Ordered longest-first so the most specific prefix always wins. |
| const NAME_PREFIX_TO_DOMAIN = [ |
| ['UserAgentClientHints', 'userAgentClientHints'], |
| ['BrowsingContext', 'browsingContext'], |
| ['WebExtension', 'webExtension'], |
| ['Permissions', 'permissions'], |
| ['Bluetooth', 'bluetooth'], |
| ['Emulation', 'emulation'], |
| ['Speculation', 'speculation'], |
| ['Storage', 'storage'], |
| ['Session', 'session'], |
| ['Network', 'network'], |
| ['Script', 'script'], |
| ['Input', 'input'], |
| ['Browser', 'browser'], |
| ['Log', 'log'], |
| ] |
| |
| // Output filename for each domain key. |
| const DOMAIN_FILES = { |
| browser: 'browser.ts', |
| browsingContext: 'browsing_context.ts', |
| emulation: 'emulation.ts', |
| input: 'input.ts', |
| log: 'log.ts', |
| network: 'network.ts', |
| permissions: 'permissions.ts', |
| script: 'script.ts', |
| session: 'session.ts', |
| speculation: 'speculation.ts', |
| storage: 'storage.ts', |
| userAgentClientHints: 'user_agent_client_hints.ts', |
| webExtension: 'webextension.ts', |
| bluetooth: 'bluetooth.ts', |
| common: 'common.ts', |
| } |
| |
| // Implementation class name for each domain key. |
| // Domains absent from this map only receive type definitions (no class). |
| const DOMAIN_CLASSES = { |
| browser: 'Browser', |
| browsingContext: 'BrowsingContext', |
| emulation: 'Emulation', |
| input: 'Input', |
| log: 'Log', |
| network: 'Network', |
| permissions: 'Permissions', |
| script: 'Script', |
| session: 'Session', |
| speculation: 'Speculation', |
| storage: 'Storage', |
| userAgentClientHints: 'UserAgentClientHints', |
| webExtension: 'WebExtension', |
| bluetooth: 'Bluetooth', |
| } |
| |
| // ============================================================ |
| // Path helpers |
| // ============================================================ |
| |
| /** |
| * Resolve a path that came from a Bazel $(location …) expansion. |
| * |
| * When a js_binary runs inside a js_run_binary action Bazel sets BAZEL_BINDIR |
| * and the js_binary wrapper calls process.chdir(BAZEL_BINDIR) before handing |
| * control to the script. $(location) values are relative to the *execroot*, |
| * so they already contain the BAZEL_BINDIR prefix. Stripping that prefix |
| * makes them relative to the CWD, after which path.resolve() works correctly. |
| * Outside Bazel (BAZEL_BINDIR unset) paths are resolved normally. |
| */ |
| function resolveInputPath(p) { |
| if (!p) return null |
| if (!process.env.BAZEL_BINDIR) return resolve(p) |
| // Normalize both strings to forward slashes before prefix-stripping so that |
| // mixed separators on Windows (BAZEL_BINDIR uses '\', $(location) uses '/') |
| // do not cause the startsWith check to silently fail. |
| const normalizedP = p.replaceAll('\\', '/') |
| const normalizedBindir = process.env.BAZEL_BINDIR.replaceAll('\\', '/') |
| const prefix = normalizedBindir + '/' |
| return resolve(normalizedP.startsWith(prefix) ? normalizedP.slice(prefix.length) : normalizedP) |
| } |
| |
| // ============================================================ |
| // Main |
| // ============================================================ |
| |
| async function main() { |
| const { values: args } = parseArgs({ |
| options: { |
| cddl: { type: 'string' }, |
| ast: { type: 'string' }, |
| model: { type: 'string' }, |
| 'dump-ast': { type: 'string' }, |
| 'dump-model': { type: 'string' }, |
| enhancements: { type: 'string' }, |
| 'output-dir': { type: 'string' }, |
| 'spec-version': { type: 'string', default: '1.0' }, |
| }, |
| }) |
| |
| // One pipeline stage per invocation; the flags select the stage. |
| if (args['dump-ast'] && args.cddl) { |
| writeJson(args['dump-ast'], parseCddl(args.cddl), 'ast') |
| } else if (args['dump-model'] && args.ast) { |
| writeJson(args['dump-model'], buildModel(readJson(args.ast, 'AST')), 'model', true) |
| } else if (args['output-dir'] && args.ast && args.model) { |
| generateTypeScript(readJson(args.ast, 'AST'), readJson(args.model, 'model'), args) |
| } else { |
| console.error( |
| 'Usage (one stage per invocation):\n' + |
| ' generate_bidi.mjs --cddl <file> --dump-ast <file>\n' + |
| ' generate_bidi.mjs --ast <file> --dump-model <file>\n' + |
| ' generate_bidi.mjs --ast <file> --model <file> --output-dir <dir> [--enhancements <file>] [--spec-version <v>]', |
| ) |
| process.exit(1) |
| } |
| } |
| |
| function parseCddl(cddlArg) { |
| const cddlPath = resolveInputPath(cddlArg) |
| if (!existsSync(cddlPath)) { |
| console.error(`Error: CDDL file not found: ${cddlPath}`) |
| process.exit(1) |
| } |
| console.log(`Parsing CDDL: ${cddlPath}`) |
| const ast = parse(cddlPath) |
| console.log(` ${ast.length} top-level definitions`) |
| return ast |
| } |
| |
| function readJson(fileArg, label) { |
| const path = resolveInputPath(fileArg) |
| if (!existsSync(path)) { |
| console.error(`Error: ${label} file not found: ${path}`) |
| process.exit(1) |
| } |
| return JSON.parse(readFileSync(path, 'utf8')) |
| } |
| |
| function writeJson(fileArg, data, label, pretty = false) { |
| const out = resolve(fileArg) |
| writeFileSync(out, pretty ? JSON.stringify(data, null, 2) + '\n' : JSON.stringify(data), 'utf8') |
| console.log(` → ${out} (${label})`) |
| } |
| |
| /** Emit one TS module per domain: types from the AST (cddl2ts), methods from the model. */ |
| function generateTypeScript(ast, model, args) { |
| const outputDir = resolve(args['output-dir']) |
| const specVersion = args['spec-version'] |
| const enhancements = loadEnhancements(args.enhancements) |
| |
| console.log('Pass 1: generating types via cddl2ts…') |
| const rawTypes = transform(ast) |
| const cleanTypes = postProcessTypes(rawTypes) |
| const typesByDomain = splitTypesByDomain(cleanTypes) |
| const typeNameToDomain = buildTypeNameToDomainMap(typesByDomain) |
| |
| console.log('Pass 2: building commands and events from model…') |
| const allCommands = modelToCommands(model) |
| const allEvents = modelToEvents(model) |
| console.log(` ${allCommands.length} commands, ${allEvents.length} events`) |
| |
| mkdirSync(outputDir, { recursive: true }) |
| |
| for (const [domainKey, filename] of Object.entries(DOMAIN_FILES)) { |
| const types = typesByDomain[domainKey] ?? '' |
| const commands = allCommands.filter((c) => c.domain === domainKey) |
| const events = allEvents.filter((e) => e.domain === domainKey) |
| const enhancement = enhancements[domainKey] ?? {} |
| const className = DOMAIN_CLASSES[domainKey] |
| |
| const content = generateDomainFile({ |
| domain: domainKey, |
| className, |
| types, |
| commands, |
| events, |
| enhancement, |
| specVersion, |
| typeNameToDomain, |
| }) |
| |
| const outPath = join(outputDir, filename) |
| writeFileSync(outPath, content, 'utf8') |
| console.log(` → ${outPath}`) |
| } |
| |
| console.log('Done.') |
| } |
| |
| // ============================================================ |
| // Enhancements manifest |
| // ============================================================ |
| |
| function loadEnhancements(manifestPath) { |
| if (!manifestPath) return {} |
| const fullPath = resolveInputPath(manifestPath) |
| if (!existsSync(fullPath)) { |
| console.warn(`Warning: enhancements manifest not found: ${fullPath}`) |
| return {} |
| } |
| let parsed |
| try { |
| parsed = JSON.parse(readFileSync(fullPath, 'utf8')) |
| } catch (err) { |
| throw new Error(`Failed to parse enhancements manifest at ${fullPath}: ${err.message}`) |
| } |
| if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { |
| throw new Error( |
| `Enhancements manifest at ${fullPath} must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`, |
| ) |
| } |
| return parsed |
| } |
| |
| // ============================================================ |
| // Pass 1: type post-processing |
| // ============================================================ |
| |
| /** |
| * Remove duplicate export declarations (cddl2ts emits them when the |
| * `*-all.cddl` input concatenates local + remote definitions that both |
| * define the same shared types) and replace `any` with `unknown`. |
| */ |
| function postProcessTypes(rawTs) { |
| const seen = new Set() |
| const output = [] |
| const lines = rawTs.split('\n') |
| let i = 0 |
| |
| while (i < lines.length) { |
| const line = lines[i] |
| const match = line.match(/^export (?:type|interface) (\w+)/) |
| |
| if (match) { |
| const name = match[1] |
| |
| if (seen.has(name)) { |
| // Determine end of this declaration before skipping it. |
| if (line.includes('{') && !line.endsWith('{}') && !line.endsWith('{};')) { |
| // Multi-line block: skip until braces balance back to zero. |
| let depth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length |
| i++ |
| while (i < lines.length && depth > 0) { |
| depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length |
| i++ |
| } |
| } else { |
| i++ // single-line declaration |
| } |
| // Consume the trailing blank line that follows every declaration. |
| if (i < lines.length && lines[i] === '') i++ |
| continue |
| } |
| |
| seen.add(name) |
| } |
| |
| // Replace any → unknown. |
| const cleaned = line |
| .replace(/Record<string, any>/g, 'Record<string, unknown>') |
| .replace(/: any([;,)\s\[])/g, ': unknown$1') |
| |
| output.push(cleaned) |
| i++ |
| } |
| |
| return output.join('\n') |
| } |
| |
| // ============================================================ |
| // Domain splitting |
| // ============================================================ |
| |
| function getDomainForExportName(name) { |
| for (const [prefix, domain] of NAME_PREFIX_TO_DOMAIN) { |
| if (name.startsWith(prefix)) return domain |
| } |
| return 'common' |
| } |
| |
| /** |
| * Partition the flat cddl2ts TypeScript output into per-domain strings, |
| * treating each blank-line-separated block as one export declaration. |
| */ |
| function splitTypesByDomain(cleanTypes) { |
| const domainLines = {} |
| |
| const lines = cleanTypes.split('\n') |
| let blockLines = [] |
| |
| // Flush one accumulated block, splitting it further by individual exports |
| // so that consecutive single-line declarations (no blank line between them) |
| // each land in the correct domain rather than all being bucketed under the |
| // first declaration's domain. |
| const flushBlock = () => { |
| if (blockLines.length === 0) return |
| |
| let exportLines = [] |
| let exportDomain = null |
| |
| const commitExport = () => { |
| if (exportLines.length === 0) return |
| const domain = exportDomain ?? 'common' |
| if (!domainLines[domain]) domainLines[domain] = [] |
| domainLines[domain].push(...exportLines, '') |
| exportLines = [] |
| exportDomain = null |
| } |
| |
| for (const line of blockLines) { |
| const m = line.match(/^export (?:type|interface) (\w+)/) |
| if (m) { |
| commitExport() |
| exportDomain = getDomainForExportName(m[1]) |
| } |
| exportLines.push(line) |
| } |
| commitExport() |
| |
| blockLines = [] |
| } |
| |
| for (const line of lines) { |
| // Skip cddl2ts source comment headers. |
| if (line.startsWith('// GENERATED CONTENT') || line.startsWith('// Source:')) { |
| flushBlock() |
| continue |
| } |
| if (line === '' && blockLines.length > 0) { |
| flushBlock() |
| } else if (line !== '') { |
| blockLines.push(line) |
| } |
| } |
| flushBlock() |
| |
| const result = {} |
| for (const [domain, dl] of Object.entries(domainLines)) { |
| result[domain] = dl.join('\n').trimEnd() |
| } |
| return result |
| } |
| |
| // ============================================================ |
| // Pass 2: AST analysis |
| // ============================================================ |
| |
| /** |
| * Returns the set of group names that carry no named parameters. |
| * This includes truly empty groups AND groups whose only properties are |
| * anonymous inclusions (e.g. `EmptyParams = { Extensible }`) — those are |
| * extensibility markers with no protocol fields of their own. |
| */ |
| function buildEmptyParamTypes(ast) { |
| const empty = new Set() |
| for (const def of ast) { |
| if (def.Type !== 'group' || !Array.isArray(def.Properties)) continue |
| const flat = def.Properties.flatMap((p) => (Array.isArray(p) ? p : [p])) |
| const hasNamedProp = flat.some((p) => p.Name && p.Name !== '') |
| if (!hasNamedProp) empty.add(def.Name) |
| } |
| return empty |
| } |
| |
| /** |
| * Convert a dotted CDDL name to PascalCase TypeScript name. |
| * "browsingContext.Info" → "BrowsingContextInfo" |
| */ |
| function normalizeDottedName(name) { |
| return name |
| .split('.') |
| .map((part) => { |
| const titled = part.charAt(0).toUpperCase() + part.slice(1) |
| // Normalize acronym runs to match cddl2ts output: |
| // CSPParameters → CspParameters HTMLCollection → HtmlCollection |
| // Rule: 2+ uppercase letters followed by an uppercase+lowercase pair (or end |
| // of string) → keep only the first uppercase and lowercase the rest. |
| return titled.replace(/([A-Z]{2,})(?=[A-Z][a-z]|$)/g, (m) => m[0] + m.slice(1).toLowerCase()) |
| }) |
| .join('') |
| } |
| |
| /** |
| * Walk the `CommandData` or `EventData` union type hierarchy and collect all |
| * leaf definition names (the actual command/event group names). |
| * |
| * The CDDL AST represents union groups with Properties that can be: |
| * - An array of choice objects (each with a Type.Value pointing to the next level) |
| * - A single property object with Type as an array or direct object |
| * |
| * A leaf is a definition that itself has a `method` property (string literal). |
| */ |
| function collectUnionMembers(rootName, defMap, visited = new Set()) { |
| if (visited.has(rootName)) return new Set() |
| visited.add(rootName) |
| |
| const def = defMap.get(rootName) |
| if (!def) return new Set() |
| |
| const members = new Set() |
| |
| // Flatten Properties — each element is either a choice-array or a property object. |
| const rawProps = def.Properties ?? [] |
| const allChoices = [] |
| for (const prop of rawProps) { |
| if (Array.isArray(prop)) { |
| allChoices.push(...prop) |
| } else { |
| allChoices.push(prop) |
| } |
| } |
| |
| for (const choice of allChoices) { |
| // choice.Type can be a single object or an array of type alternatives. |
| const typeEntries = Array.isArray(choice.Type) ? choice.Type : [choice.Type] |
| |
| for (const entry of typeEntries) { |
| if (entry?.Type !== 'group' || !entry.Value) continue |
| const childName = entry.Value |
| const childDef = defMap.get(childName) |
| if (!childDef) continue |
| |
| // A leaf has a `method` property — it is the actual command or event definition. |
| const childProps = childDef.Properties ?? [] |
| const flat = childProps.flatMap((p) => (Array.isArray(p) ? p : [p])) |
| if (flat.some((p) => p.Name === 'method')) { |
| members.add(childName) |
| } else { |
| // Intermediate union — recurse. |
| for (const m of collectUnionMembers(childName, defMap, visited)) { |
| members.add(m) |
| } |
| } |
| } |
| } |
| |
| return members |
| } |
| |
| /** |
| * Build a name → definition map from the AST (deduplicated — first wins). |
| */ |
| function buildDefMap(ast) { |
| const map = new Map() |
| for (const def of ast) { |
| if (def.Name && !map.has(def.Name)) map.set(def.Name, def) |
| } |
| return map |
| } |
| |
| /** Extract {domain, methodStr, operationName, paramsCddl} from a command/event leaf def. */ |
| function parseLeafDef(def) { |
| const flatProps = (def.Properties ?? []).flatMap((p) => (Array.isArray(p) ? p : [p])) |
| |
| const methodProp = flatProps.find((p) => p.Name === 'method') |
| const paramsProp = flatProps.find((p) => p.Name === 'params') |
| if (!methodProp || !paramsProp) return null |
| |
| const methodLiteral = Array.isArray(methodProp.Type) ? methodProp.Type : [methodProp.Type] |
| if (methodLiteral[0]?.Type !== 'literal') return null |
| |
| const methodStr = methodLiteral[0].Value // e.g. "browser.createUserContext" |
| const dotIdx = methodStr.indexOf('.') |
| if (dotIdx === -1) return null |
| |
| const domainRaw = methodStr.slice(0, dotIdx) |
| const operationName = methodStr.slice(dotIdx + 1) |
| const domain = METHOD_DOMAIN_MAP[domainRaw] ?? 'common' |
| |
| const paramsTypeEntries = Array.isArray(paramsProp.Type) ? paramsProp.Type : [paramsProp.Type] |
| let paramsCddl = null |
| if (paramsTypeEntries[0]?.Type === 'group' && paramsTypeEntries[0]?.Value) { |
| paramsCddl = paramsTypeEntries[0].Value |
| } |
| |
| return { domain, methodStr, operationName, paramsCddl } |
| } |
| |
| /** |
| * Collect all leaf command/event names from every XxxCommand / XxxEvent |
| * union that can be reached from either the core BiDi root (`CommandData` / |
| * `EventData`) or from extension-spec roots (e.g. `PermissionsCommand`, |
| * `SpeculationEvent`). Extension specs are not wired into `CommandData` / |
| * `EventData` inside the core BiDi CDDL, so a second pass is required. |
| */ |
| function collectAllMembers(defMap, rootSuffix) { |
| const members = new Set() |
| |
| // Primary traversal from the core BiDi root. |
| const rootName = rootSuffix === 'Command' ? 'CommandData' : 'EventData' |
| for (const m of collectUnionMembers(rootName, defMap)) members.add(m) |
| |
| // Secondary traversal: pick up any XxxCommand / XxxEvent unions in |
| // extension specs whose members were not already found above. |
| for (const [name, def] of defMap) { |
| if (!name.endsWith(rootSuffix) || name === rootName) continue |
| if (def.Type !== 'variable' && def.Type !== 'group') continue |
| for (const m of collectUnionMembers(name, defMap)) members.add(m) |
| } |
| |
| return members |
| } |
| |
| /** Extract all BiDi commands by traversing CommandData and extension XxxCommand unions. */ |
| function extractCommands(ast) { |
| const defMap = buildDefMap(ast) |
| const emptyParamTypes = buildEmptyParamTypes(ast) |
| const commandNames = collectAllMembers(defMap, 'Command') |
| const commands = [] |
| |
| for (const name of commandNames) { |
| const def = defMap.get(name) |
| if (!def) continue |
| |
| const parsed = parseLeafDef(def) |
| if (!parsed) continue |
| |
| const { domain, methodStr, operationName: methodName, paramsCddl } = parsed |
| // emptyParamTypes holds raw CDDL group names, so compare the raw name (not the normalized one). |
| const hasParams = paramsCddl !== null && !emptyParamTypes.has(paramsCddl) |
| |
| commands.push({ |
| domain, |
| cddlName: name, |
| methodStr, |
| methodName, |
| paramsCddl, |
| hasParams, |
| }) |
| } |
| |
| return commands |
| } |
| |
| /** Extract all BiDi events by traversing EventData and extension XxxEvent unions. */ |
| function extractEvents(ast) { |
| const defMap = buildDefMap(ast) |
| const eventNames = collectAllMembers(defMap, 'Event') |
| const events = [] |
| |
| for (const name of eventNames) { |
| const def = defMap.get(name) |
| if (!def) continue |
| |
| const parsed = parseLeafDef(def) |
| if (!parsed) continue |
| |
| const { domain, methodStr, operationName: eventName, paramsCddl } = parsed |
| |
| events.push({ |
| domain, |
| methodStr, |
| eventName, |
| paramsCddl, |
| }) |
| } |
| |
| return events |
| } |
| |
| // ============================================================ |
| // Binding-neutral model |
| // ============================================================ |
| |
| /** |
| * Build the binding-neutral model from the AST. Type refs are CDDL names. |
| * Shape per domain key: |
| * { commands: [{ method, name, params, result }], |
| * events: [{ method, name, params }] } |
| * `params`/`result` are null when there are no params / no return value. |
| */ |
| function buildModel(ast) { |
| const model = {} |
| const resultTypes = buildResultTypeNames(ast) |
| const ensure = (domain) => (model[domain] ??= { commands: [], events: [] }) |
| |
| for (const c of extractCommands(ast)) { |
| const result = c.cddlName + 'Result' |
| ensure(c.domain).commands.push({ |
| method: c.methodStr, |
| name: c.methodName, |
| params: c.hasParams ? c.paramsCddl : null, |
| result: resultTypes.has(result) ? result : null, |
| }) |
| } |
| |
| for (const e of extractEvents(ast)) { |
| ensure(e.domain).events.push({ |
| method: e.methodStr, |
| name: e.eventName, |
| params: e.paramsCddl || null, |
| }) |
| } |
| |
| return model |
| } |
| |
| /** Result type names the spec defines with a value; an absent or `EmptyResult`-aliased result is void. */ |
| function buildResultTypeNames(ast) { |
| const emptyAlias = new Set() |
| for (const d of ast) { |
| const pt = d.PropertyType |
| if (d.Name && d.Type === 'variable' && Array.isArray(pt) && pt.length === 1 && pt[0]?.Value === 'EmptyResult') { |
| emptyAlias.add(d.Name) |
| } |
| } |
| const names = new Set() |
| for (const d of ast) { |
| if (d.Name && d.Name.endsWith('Result') && !emptyAlias.has(d.Name)) names.add(d.Name) |
| } |
| return names |
| } |
| |
| /** Map model commands to the generator's command-entry shape. */ |
| function modelToCommands(model) { |
| const commands = [] |
| for (const [domain, entry] of Object.entries(model)) { |
| for (const c of entry.commands) { |
| commands.push({ |
| domain, |
| methodStr: c.method, |
| methodName: c.name, |
| paramsTypeName: c.params !== null ? normalizeDottedName(c.params) : null, |
| hasParams: c.params !== null, |
| resultTypeName: c.result !== null ? normalizeDottedName(c.result) : null, |
| }) |
| } |
| } |
| return commands |
| } |
| |
| /** Map model events to the generator's event-entry shape. */ |
| function modelToEvents(model) { |
| const events = [] |
| for (const [domain, entry] of Object.entries(model)) { |
| for (const e of entry.events) { |
| events.push({ |
| domain, |
| methodStr: e.method, |
| eventName: e.name, |
| paramsTypeName: e.params !== null ? normalizeDottedName(e.params) : null, |
| onMethodName: 'on' + e.name.charAt(0).toUpperCase() + e.name.slice(1), |
| }) |
| } |
| } |
| return events |
| } |
| |
| // ============================================================ |
| // Code generation |
| // ============================================================ |
| |
| const LICENSE_HEADER = `\ |
| // Licensed to the Software Freedom Conservancy (SFC) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The SFC licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License.` |
| |
| // ============================================================ |
| // Type-map helpers for cross-domain import generation |
| // ============================================================ |
| |
| /** |
| * Returns a Map from exported type name → domain key. |
| * Used to generate cross-domain import statements. |
| */ |
| function buildTypeNameToDomainMap(typesByDomain) { |
| const map = new Map() |
| for (const [domain, typeBlock] of Object.entries(typesByDomain)) { |
| for (const line of typeBlock.split('\n')) { |
| const m = line.match(/^export (?:type|interface) (\w+)/) |
| if (m) map.set(m[1], domain) |
| } |
| } |
| return map |
| } |
| |
| /** |
| * Scans a domain's type block for references to types that live in OTHER |
| * domains and returns the import statements needed to make the file compile. |
| * |
| * Only PascalCase identifiers that exist in typeNameToDomain and belong to a |
| * different domain are considered. Built-in TypeScript types (string, number, |
| * boolean, …) never appear in the map, so they are naturally excluded. |
| */ |
| function computeCrossDomainImports(typeBlock, domain, typeNameToDomain) { |
| if (!typeBlock) return [] |
| |
| // Collect all PascalCase identifiers referenced in the type block. |
| const referenced = new Set() |
| for (const match of typeBlock.matchAll(/\b([A-Z][A-Za-z0-9]*)\b/g)) { |
| referenced.add(match[1]) |
| } |
| |
| // Group by source domain (skip same-domain types and unknown types). |
| const bySourceDomain = new Map() |
| for (const name of referenced) { |
| const sourceDomain = typeNameToDomain.get(name) |
| if (!sourceDomain || sourceDomain === domain) continue |
| if (!bySourceDomain.has(sourceDomain)) bySourceDomain.set(sourceDomain, new Set()) |
| bySourceDomain.get(sourceDomain).add(name) |
| } |
| |
| // Emit sorted import lines. |
| const imports = [] |
| for (const [sourceDomain, names] of [...bySourceDomain.entries()].sort()) { |
| const sourceFile = DOMAIN_FILES[sourceDomain].replace('.ts', '.js') |
| const sorted = [...names].sort() |
| imports.push(`import type { ${sorted.join(', ')} } from './${sourceFile}'`) |
| } |
| return imports |
| } |
| |
| function generateDomainFile({ |
| domain, |
| className, |
| types, |
| commands, |
| events, |
| enhancement, |
| specVersion, |
| typeNameToDomain, |
| }) { |
| const parts = [LICENSE_HEADER, ''] |
| |
| parts.push(`// Auto-generated from WebDriver BiDi CDDL spec (v${specVersion}) — DO NOT EDIT MANUALLY`) |
| parts.push(`// Source: https://github.com/w3c/webref/tree/main/ed/cddl`) |
| parts.push('') |
| |
| const filteredCommands = commands.filter((c) => !enhancement.excludeMethods?.includes(c.methodName)) |
| const filteredEvents = events.filter((e) => !enhancement.excludeMethods?.includes(e.eventName)) |
| const hasImplementation = className != null && (filteredCommands.length > 0 || filteredEvents.length > 0) |
| |
| // Filter out excluded types before emitting. |
| let typeBlock = types |
| if (enhancement.excludeTypes?.length) { |
| typeBlock = filterExcludedTypes(typeBlock, enhancement.excludeTypes) |
| } |
| |
| // Compute cross-domain imports needed by this domain's type block. |
| // Types from other domains are referenced by name but live in separate files. |
| const crossDomainImports = computeCrossDomainImports(typeBlock, domain, typeNameToDomain) |
| |
| if (crossDomainImports.length > 0) { |
| for (const line of crossDomainImports) { |
| parts.push(line) |
| } |
| parts.push('') |
| } |
| |
| if (hasImplementation) { |
| // Define the BiDi connection interface inline so the generated file is |
| // self-contained for tsc and doesn't need to resolve ../index.js. |
| parts.push(`/** Minimal BiDi transport interface (satisfied structurally by bidi/index.js). */`) |
| parts.push(`interface BidiConnection {`) |
| parts.push(` send(command: Record<string, unknown>): Promise<unknown>`) |
| parts.push(` subscribe(event: string | string[], contexts?: string[]): Promise<void>`) |
| parts.push(` on(event: string, listener: (params: unknown) => void): void`) |
| parts.push(`}`) |
| parts.push('') |
| } |
| |
| if (typeBlock) { |
| parts.push(`// --- Types ---`) |
| parts.push('') |
| parts.push(typeBlock) |
| parts.push('') |
| } |
| |
| if (enhancement.extraTypes) { |
| parts.push(`// --- Additional Types ---`) |
| parts.push('') |
| parts.push(enhancement.extraTypes) |
| parts.push('') |
| } |
| |
| if (hasImplementation) { |
| parts.push(`// --- Implementation ---`) |
| parts.push('') |
| parts.push( |
| generateClass({ |
| className, |
| commands: filteredCommands, |
| events: filteredEvents, |
| enhancement, |
| }), |
| ) |
| } |
| |
| return parts.join('\n') + '\n' |
| } |
| |
| function filterExcludedTypes(typeBlock, excludeTypes) { |
| const lines = typeBlock.split('\n') |
| const output = [] |
| let i = 0 |
| |
| while (i < lines.length) { |
| const line = lines[i] |
| const match = line.match(/^export (?:type|interface) (\w+)/) |
| if (match && excludeTypes.includes(match[1])) { |
| if (line.includes('{') && !line.endsWith('{}') && !line.endsWith('{};')) { |
| let depth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length |
| i++ |
| while (i < lines.length && depth > 0) { |
| depth += (lines[i].match(/\{/g) ?? []).length - (lines[i].match(/\}/g) ?? []).length |
| i++ |
| } |
| } else { |
| i++ |
| } |
| if (i < lines.length && lines[i] === '') i++ |
| continue |
| } |
| output.push(line) |
| i++ |
| } |
| |
| return output.join('\n').trimEnd() |
| } |
| |
| function generateClass({ className, commands, events, enhancement }) { |
| const lines = [] |
| |
| lines.push(`export class ${className} {`) |
| lines.push(` private constructor(private readonly bidi: BidiConnection) {}`) |
| lines.push('') |
| lines.push(` static async create(driver: unknown): Promise<${className}> {`) |
| lines.push( |
| ` const caps = await (driver as { getCapabilities(): Promise<{ get(key: string): unknown }> }).getCapabilities()`, |
| ) |
| lines.push(` if (!caps.get('webSocketUrl')) {`) |
| lines.push(` throw new Error('WebDriver instance must support BiDi protocol')`) |
| lines.push(` }`) |
| lines.push(` const bidi = await (driver as { getBidi(): Promise<BidiConnection> }).getBidi()`) |
| lines.push(` return new ${className}(bidi)`) |
| lines.push(` }`) |
| |
| for (const cmd of commands) { |
| const override = enhancement.extraMethods?.[cmd.methodName] |
| lines.push('') |
| lines.push(override ?? generateCommandMethod(cmd)) |
| } |
| |
| for (const evt of events) { |
| const override = enhancement.extraMethods?.[evt.onMethodName] |
| lines.push('') |
| lines.push(override ?? generateEventMethod(evt)) |
| } |
| |
| if (enhancement.extraMethods) { |
| const knownNames = new Set([...commands.map((c) => c.methodName), ...events.map((e) => e.onMethodName)]) |
| for (const [name, body] of Object.entries(enhancement.extraMethods)) { |
| if (!knownNames.has(name)) { |
| // Purely additive method not tied to a command or event. |
| lines.push('') |
| lines.push(body) |
| } |
| } |
| } |
| |
| lines.push(`}`) |
| return lines.join('\n') |
| } |
| |
| function generateCommandMethod(cmd) { |
| const { methodName, methodStr, paramsTypeName, hasParams, resultTypeName } = cmd |
| const isVoid = resultTypeName === null |
| const returnType = isVoid ? 'void' : resultTypeName |
| |
| // Use a double-cast (T as unknown as Record<string,unknown>) so TypeScript |
| // accepts the conversion even when the params type has no index signature. |
| const paramsCast = hasParams ? '(params as unknown as Record<string, unknown>)' : '{}' |
| |
| const lines = [] |
| if (hasParams) { |
| lines.push(` async ${methodName}(params: ${paramsTypeName}): Promise<${returnType}> {`) |
| } else { |
| lines.push(` async ${methodName}(): Promise<${returnType}> {`) |
| } |
| |
| // Both void and non-void commands go through the same error-check pattern. |
| // bidi/index.js always resolves (never rejects) regardless of response type, |
| // so we must inspect the payload ourselves and throw on error responses. |
| lines.push(` const response = await this.bidi.send({`) |
| lines.push(` method: '${methodStr}',`) |
| lines.push(` params: ${paramsCast},`) |
| lines.push(` }) as Record<string, unknown>`) |
| lines.push(` if (response['type'] === 'error') {`) |
| lines.push(` throw new Error(\`\${response['error']}: \${response['message']}\`)`) |
| lines.push(` }`) |
| if (!isVoid) { |
| lines.push(` return (response as unknown as { result: ${resultTypeName} }).result`) |
| } |
| |
| lines.push(` }`) |
| return lines.join('\n') |
| } |
| |
| function generateEventMethod(evt) { |
| const { onMethodName, methodStr, paramsTypeName } = evt |
| const cbType = paramsTypeName ? `(params: ${paramsTypeName}) => void` : `(params: unknown) => void` |
| |
| const lines = [] |
| lines.push(` async ${onMethodName}(callback: ${cbType}): Promise<void> {`) |
| lines.push(` await this.bidi.subscribe('${methodStr}')`) |
| // bidi/index.js emits BiDi events by method name through its single shared |
| // message dispatcher (which already handles JSON parsing and closed-state |
| // guards). Using bidi.on() here avoids attaching a new ws.on('message', ...) |
| // listener on every subscription call, preventing listener accumulation and |
| // MaxListeners warnings. |
| lines.push(` this.bidi.on('${methodStr}', (params: unknown) => {`) |
| lines.push(` callback(${paramsTypeName ? `params as ${paramsTypeName}` : 'params'})`) |
| lines.push(` })`) |
| lines.push(` }`) |
| return lines.join('\n') |
| } |
| |
| // ============================================================ |
| // Entry point |
| // ============================================================ |
| |
| main().catch((err) => { |
| console.error(err) |
| process.exit(1) |
| }) |