diff --git a/.changeset/expo-native-module-contract.md b/.changeset/expo-native-module-contract.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-native-module-contract.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6377781ef65..9919789d6c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -280,6 +280,13 @@ jobs: filter: 'blob:none' show-progress: false + - name: Fetch Expo native release tags + run: | + git fetch --depth=1 --no-tags origin \ + 'refs/tags/@clerk/expo-native-components@*:refs/tags/@clerk/expo-native-components@*' \ + 'refs/tags/@clerk/expo-google-signin@*:refs/tags/@clerk/expo-google-signin@*' \ + 'refs/tags/@clerk/expo-biometrics@*:refs/tags/@clerk/expo-biometrics@*' + - name: Setup id: config uses: $/.github/actions/init-blacksmith diff --git a/packages/expo/AGENTS.md b/packages/expo/AGENTS.md index 4e344934be7..76ef10db792 100644 --- a/packages/expo/AGENTS.md +++ b/packages/expo/AGENTS.md @@ -1,7 +1,8 @@ # @clerk/expo -These rules apply to everything in this package, including the Swift and Kotlin -code under `ios/`, `apple/` and `android/`. They add to the root `AGENTS.md`. +These rules apply to everything in this package, and to the Swift and Kotlin +code under `packages/expo-native-components`, `packages/expo-google-signin` and +`packages/expo-biometrics`. They add to the root `AGENTS.md`. ## Comments diff --git a/packages/expo/src/specs/__tests__/nativeContract.test.ts b/packages/expo/src/specs/__tests__/nativeContract.test.ts new file mode 100644 index 00000000000..bf84648d8b9 --- /dev/null +++ b/packages/expo/src/specs/__tests__/nativeContract.test.ts @@ -0,0 +1,12 @@ +import { resolve } from 'node:path'; + +import { expect, test } from 'vitest'; + +import { NATIVE_MODULES, nativeModuleContractErrors, shippedNativeFunctions } from './nativeModuleContract'; + +const root = resolve(__dirname, '../../../../..'); + +test.each(NATIVE_MODULES)('$spec matches $native and every $release release', ({ module, release, spec, native }) => { + const releases = shippedNativeFunctions(root, release, native); + expect(nativeModuleContractErrors({ root, module, spec, native, releases })).toEqual([]); +}); diff --git a/packages/expo/src/specs/__tests__/nativeModuleContract.ts b/packages/expo/src/specs/__tests__/nativeModuleContract.ts new file mode 100644 index 00000000000..8663afdb723 --- /dev/null +++ b/packages/expo/src/specs/__tests__/nativeModuleContract.ts @@ -0,0 +1,208 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import ts from 'typescript'; + +const OPENERS: Record = { '(': ')', '<': '>', '[': ']', '{': '}' }; + +function splitTopLevel(text: string) { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const char of text) { + if (char in OPENERS) { + depth++; + } else if (Object.values(OPENERS).includes(char) && depth > 0) { + depth--; + } + if (char === ',' && depth === 0) { + parts.push(current); + current = ''; + } else { + current += char; + } + } + parts.push(current); + return parts.map(part => part.trim()).filter(Boolean); +} + +function balanced(text: string, start: number) { + let depth = 0; + for (let i = start; i < text.length; i++) { + if (text[i] === '(') { + depth++; + } else if (text[i] === ')') { + depth--; + if (depth === 0) { + return text.slice(start + 1, i); + } + } + } + throw new Error(`Unbalanced parentheses at ${start}`); +} + +function closureParams(source: string, braceIndex: number, language: 'swift' | 'kotlin') { + let i = braceIndex + 1; + while (/\s/.test(source[i])) { + i++; + } + let params: string[]; + if (language === 'swift') { + params = source[i] === '(' ? splitTopLevel(balanced(source, i)) : []; + } else { + const end = source.indexOf('->', i); + params = end === -1 ? [] : splitTopLevel(source.slice(i, end)); + } + return params.filter(param => !/:\s*Promise$/.test(param)); +} + +export const NATIVE_MODULES = [ + { + module: 'ClerkExpo', + platform: 'ios', + release: '@clerk/expo-native-components', + spec: 'packages/expo/src/specs/NativeClerkModule.ts', + native: 'packages/expo-native-components/ios/ClerkExpoModule.swift', + }, + { + module: 'ClerkExpo', + platform: 'android', + release: '@clerk/expo-native-components', + spec: 'packages/expo/src/specs/NativeClerkModule.android.ts', + native: 'packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt', + }, + { + module: 'ClerkGoogleSignIn', + platform: 'ios', + release: '@clerk/expo-google-signin', + spec: 'packages/expo/src/specs/NativeClerkGoogleSignIn.ts', + native: 'packages/expo-google-signin/ios/ClerkGoogleSignInModule.swift', + }, + { + module: 'ClerkGoogleSignIn', + platform: 'android', + release: '@clerk/expo-google-signin', + spec: 'packages/expo/src/specs/NativeClerkGoogleSignIn.android.ts', + native: + 'packages/expo-google-signin/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt', + }, + { + module: 'ClerkExpoBiometrics', + platform: 'ios', + release: '@clerk/expo-biometrics', + spec: 'packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts', + native: 'packages/expo-biometrics/ios/ClerkExpoBiometricsModule.swift', + }, + { + module: 'ClerkExpoBiometrics', + platform: 'android', + release: '@clerk/expo-biometrics', + spec: 'packages/expo-biometrics/src/ClerkExpoBiometricsModule.ts', + native: 'packages/expo-biometrics/android/src/main/java/expo/modules/clerk/biometrics/ClerkExpoBiometricsModule.kt', + }, +] as const; + +export function parseNativeFunctions(source: string, file: string) { + const language = file.endsWith('.swift') ? 'swift' : 'kotlin'; + const functions = new Map(); + for (const match of source.matchAll(/\b(?:Async)?Function\("([^"]+)"\)\s*\{/g)) { + functions.set(match[1], closureParams(source, match.index + match[0].length - 1, language).length); + } + return functions; +} + +export function readSpecFunctions(file: string) { + const program = ts.createProgram([file], { noEmit: true, skipLibCheck: true, strict: true }); + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(file)!; + const moduleSymbol = checker.getSymbolAtLocation(sourceFile)!; + const defaultExport = checker.getExportsOfModule(moduleSymbol).find(symbol => symbol.name === 'default')!; + const type = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(defaultExport, sourceFile)); + const functions = new Map(); + for (const property of checker.getPropertiesOfType(type)) { + if (property.name === 'addListener' || property.name === 'removeListeners') { + continue; + } + const signatures = checker + .getNonNullableType(checker.getTypeOfSymbolAtLocation(property, sourceFile)) + .getCallSignatures(); + if (signatures.length > 0) { + functions.set(property.name, { + arity: Math.max(...signatures.map(signature => signature.parameters.length)), + optional: Boolean(property.flags & ts.SymbolFlags.Optional), + }); + } + } + return functions; +} + +type Release = { tag: string; functions: Map }; + +export function shippedNativeFunctions(root: string, release: string, native: string): Release[] { + const tags = execFileSync('git', ['tag', '--list', `${release}@*`, '--sort=v:refname'], { + cwd: root, + encoding: 'utf8', + }) + .split('\n') + .filter(line => /@\d+\.\d+\.\d+$/.test(line)); + if (tags.length === 0) { + throw new Error( + `No ${release}@ release tag in this checkout. The native contract test reads the shipped native module from every release. Fetch them with: git fetch --depth=1 --no-tags origin 'refs/tags/${release}@*:refs/tags/${release}@*'`, + ); + } + return tags.map(tag => ({ + tag, + functions: parseNativeFunctions( + execFileSync('git', ['show', `${tag}:${native}`], { cwd: root, encoding: 'utf8' }), + native, + ), + })); +} + +type ContractInput = { + root: string; + module: string; + spec: string; + native: string; + releases: Release[]; +}; + +export function nativeModuleContractErrors({ root, module, spec, native, releases }: ContractInput) { + const specFunctions = readSpecFunctions(join(root, spec)); + const nativeFunctions = parseNativeFunctions(readFileSync(join(root, native), 'utf8'), native); + const errors: string[] = []; + for (const [name, { arity, optional }] of specFunctions) { + if (!nativeFunctions.has(name)) { + errors.push( + `${module}.${name} is declared in ${spec} but ${native} has no Function("${name}")/AsyncFunction("${name}"). Add it natively, or remove it from the spec and from every JS caller.`, + ); + } else if (nativeFunctions.get(name) !== arity) { + errors.push( + `${module}.${name} takes ${arity} argument(s) in ${spec} but ${nativeFunctions.get(name)} in ${native}. Change both sides together.`, + ); + } + const missingFrom = releases.filter(release => !release.functions.has(name)).map(release => release.tag); + if (missingFrom.length > 0 && !optional) { + errors.push( + `${module}.${name} is missing from ${missingFrom.join(', ')}. Apps built on those releases do not have it, and OTA JS can still reach them. Declare it optional (${name}?(...)) in ${spec} and feature-detect before calling it.`, + ); + } + for (const { tag, functions } of releases) { + const shippedArity = functions.get(name); + if (shippedArity !== undefined && shippedArity !== arity) { + errors.push( + `${module}.${name} takes ${shippedArity} argument(s) in ${tag} but ${spec} now passes ${arity}. Apps built on that release still run that binary, so keep ${name}(${shippedArity} args) as is and add a new optional function that you feature-detect (as configureWithOptions did for configure).`, + ); + } + } + } + for (const name of nativeFunctions.keys()) { + if (!specFunctions.has(name)) { + errors.push( + `${module}.${name} is exposed by ${native} but missing from ${spec}. Declare it in the spec, or delete the native function.`, + ); + } + } + return errors; +}