Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/expo-native-module-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/expo/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
12 changes: 12 additions & 0 deletions packages/expo/src/specs/__tests__/nativeContract.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
208 changes: 208 additions & 0 deletions packages/expo/src/specs/__tests__/nativeModuleContract.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { '(': ')', '<': '>', '[': ']', '{': '}' };

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<string, number>();
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<string, { arity: number; optional: boolean }>();
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<string, number> };

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}@<version> 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;
}
Loading