@react-native-quickjs/quickjs 1.0.0-alpha.0 → 1.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -29,8 +29,8 @@ Nothing has been published yet. This section becomes the first release entry.
29
29
  - React Native's non-Hermes path is patched at `pod install`. Each workaround
30
30
  fails with a named error if React Native changes.
31
31
  - Expo's `getDefaultReactHost` hardcodes Hermes, so the config plugin edits
32
- `node_modules`. Until [expo#PENDING][expo-pr] lands, an `npm install` reverts
32
+ `node_modules`. Until [expo#49686][expo-pr] lands, an `npm install` reverts
33
33
  that edit and the app silently launches on Hermes.
34
34
 
35
35
  [ng]: https://github.com/quickjs-ng/quickjs
36
- [expo-pr]: https://github.com/expo/expo/pulls
36
+ [expo-pr]: https://github.com/expo/expo/pull/49686
package/README.md CHANGED
@@ -15,6 +15,14 @@
15
15
  React Native, compiled from source in every app.
16
16
  </p>
17
17
 
18
+ <p align="center">
19
+ <a href="https://www.npmjs.com/package/@react-native-quickjs/quickjs"><img
20
+ alt="@react-native-quickjs/quickjs on npm"
21
+ src="https://img.shields.io/npm/v/@react-native-quickjs/quickjs/alpha?color=cb3837"></a>
22
+ <a href="LICENSE"><img alt="MIT licensed"
23
+ src="https://img.shields.io/badge/license-MIT-blue"></a>
24
+ </p>
25
+
18
26
  Provides a `jsi::Runtime` backed by quickjs-ng, and the `JSRuntimeFactory` glue
19
27
  React Native uses to select it. The engine is compiled from source as part of
20
28
  the app build. Neither Hermes nor JavaScriptCore is linked into the result.
@@ -25,10 +25,60 @@ add_compile_options(${folly_FLAGS})
25
25
 
26
26
  include("${RNQJS_ROOT}/cmake/quickjs.cmake")
27
27
 
28
- # Every src/ translation unit has to be listed. The Apple build globs src/,
29
- # so a file added there builds on iOS and fails to link on Android -- an
30
- # asymmetry worth remembering when adding a source.
31
- add_library(quickjsinstancejni SHARED
28
+ # Before any module subdirectory is configured: each module's CMakeLists probes
29
+ # for ReactAndroid::jsi to decide whether to compile its JSI registration edge.
30
+ find_package(ReactAndroid REQUIRED CONFIG)
31
+ find_package(fbjni REQUIRED CONFIG)
32
+
33
+ # Autolinked modules (@react-native-quickjs/* declaring reactNativeQuickJSModule,
34
+ # listed by build.gradle) are folded into this .so: whole-archive so each
35
+ # module's QJS_REGISTER_MODULE static initializer runs at load and its exported
36
+ # Java_<module>_nativeAttach survives the link. RNQJS_QUICKJS_FOLDED tells a
37
+ # module's platform JNI to skip JNI_OnLoad (one per .so) and capture the JavaVM
38
+ # from its attach call instead. A module must not ship its own .so -- it would
39
+ # load before the engine and fail to resolve the JS_* API.
40
+ if(DEFINED RNQJS_QUICKJS_MODULES)
41
+ set(REACT_NATIVE_QUICKJS_DIR "${RNQJS_ROOT}" CACHE PATH "" FORCE)
42
+ add_compile_definitions(RNQJS_QUICKJS_FOLDED=1)
43
+
44
+ set(RNQJS_FOLDED_TARGETS "")
45
+ set(RNQJS_REGISTRY_DIRS "")
46
+ foreach(MODULE_DIR IN LISTS RNQJS_QUICKJS_MODULES)
47
+ get_filename_component(MODULE_NAME "${MODULE_DIR}" NAME)
48
+ list(APPEND RNQJS_REGISTRY_DIRS "--dir" "${MODULE_DIR}")
49
+ add_subdirectory("${MODULE_DIR}/android" "quickjs-modules/${MODULE_NAME}")
50
+ if(NOT RNQJS_QUICKJS_MODULE_TARGETS)
51
+ message(FATAL_ERROR
52
+ "${MODULE_DIR}/android did not declare RNQJS_QUICKJS_MODULE_TARGETS "
53
+ "(the static libraries to fold into the engine .so)")
54
+ endif()
55
+ list(APPEND RNQJS_FOLDED_TARGETS ${RNQJS_QUICKJS_MODULE_TARGETS})
56
+ unset(RNQJS_QUICKJS_MODULE_TARGETS)
57
+ endforeach()
58
+
59
+ # The registry TU names each module's install function, making a module that
60
+ # configured without its JSI edge a link error rather than a silently missing
61
+ # global. QuickJSModule.cpp declares registerGeneratedModules() weak; this
62
+ # strong definition in the same .so wins.
63
+ set(RNQJS_REGISTRY "${CMAKE_CURRENT_BINARY_DIR}/generated/QuickJSGeneratedModules.cpp")
64
+ find_program(RNQJS_NODE NAMES node REQUIRED)
65
+ add_custom_command(
66
+ OUTPUT "${RNQJS_REGISTRY}"
67
+ COMMAND "${RNQJS_NODE}"
68
+ "${RNQJS_ROOT}/scripts/generate-module-registry.js"
69
+ ${RNQJS_REGISTRY_DIRS}
70
+ --out "${RNQJS_REGISTRY}"
71
+ --quiet
72
+ DEPENDS
73
+ "${RNQJS_ROOT}/scripts/generate-module-registry.js"
74
+ COMMENT "Generating the QuickJS module registry"
75
+ VERBATIM)
76
+ endif()
77
+
78
+ # Every src/ translation unit has to be listed. The Apple build globs src/, so
79
+ # a file added there builds on iOS and fails to link on Android -- an asymmetry
80
+ # worth remembering when adding a source.
81
+ set(RNQJS_QUICKJSINSTANCE_SOURCES
32
82
  "${RNQJS_ROOT}/src/bytecode/QuickJSBytecode.cpp"
33
83
  "${RNQJS_ROOT}/src/module/QuickJSCompat.cpp"
34
84
  "${RNQJS_ROOT}/src/module/QuickJSModule.cpp"
@@ -38,6 +88,11 @@ add_library(quickjsinstancejni SHARED
38
88
  src/main/jni/JQuickJSInstance.cpp
39
89
  src/main/jni/OnLoad.cpp
40
90
  )
91
+ if(DEFINED RNQJS_QUICKJS_MODULES)
92
+ list(APPEND RNQJS_QUICKJSINSTANCE_SOURCES "${RNQJS_REGISTRY}")
93
+ endif()
94
+
95
+ add_library(quickjsinstancejni SHARED ${RNQJS_QUICKJSINSTANCE_SOURCES})
41
96
 
42
97
  target_include_directories(quickjsinstancejni PRIVATE
43
98
  "${RNQJS_ROOT}/src/bytecode"
@@ -46,17 +101,34 @@ target_include_directories(quickjsinstancejni PRIVATE
46
101
  "${CMAKE_CURRENT_SOURCE_DIR}/src/main/jni"
47
102
  )
48
103
 
49
- find_package(ReactAndroid REQUIRED CONFIG)
50
- find_package(fbjni REQUIRED CONFIG)
51
-
52
- target_link_libraries(quickjsinstancejni
53
- quickjs
54
- fbjni::fbjni
55
- ReactAndroid::jsi
56
- ReactAndroid::reactnative
57
- android
58
- log
59
- )
104
+ # The module archives and the quickjs archive are grouped so the linker
105
+ # resolves references in both directions; the modules are whole-archived because
106
+ # nothing references their platform objects except names Java resolves at
107
+ # runtime, and their initializers must run at load.
108
+ if(RNQJS_FOLDED_TARGETS)
109
+ target_link_libraries(quickjsinstancejni
110
+ -Wl,--start-group
111
+ -Wl,--whole-archive
112
+ ${RNQJS_FOLDED_TARGETS}
113
+ -Wl,--no-whole-archive
114
+ quickjs
115
+ -Wl,--end-group
116
+ fbjni::fbjni
117
+ ReactAndroid::jsi
118
+ ReactAndroid::reactnative
119
+ android
120
+ log
121
+ )
122
+ else()
123
+ target_link_libraries(quickjsinstancejni
124
+ quickjs
125
+ fbjni::fbjni
126
+ ReactAndroid::jsi
127
+ ReactAndroid::reactnative
128
+ android
129
+ log
130
+ )
131
+ endif()
60
132
 
61
133
  # --- Chrome DevTools Protocol ----------------------------------------------
62
134
  #
@@ -2,7 +2,8 @@ buildscript {
2
2
  ext.ReactNativeQuickJS = [
3
3
  kotlinVersion: "2.0.21",
4
4
  minSdkVersion: 24,
5
- compileSdkVersion: 36
5
+ compileSdkVersion: 36,
6
+ ndkVersion: "29.0.14206865"
6
7
  ]
7
8
 
8
9
  ext.getExtOrDefault = { prop ->
@@ -31,12 +32,7 @@ apply plugin: "kotlin-android"
31
32
  android {
32
33
  namespace "com.reactnativequickjs.quickjs"
33
34
 
34
- // Follow the app's NDK, or let the Android plugin choose. Pinning our own
35
- // would compile this library with a different toolchain than the app.
36
- if (rootProject.ext.has("ndkVersion")) {
37
- ndkVersion rootProject.ext.get("ndkVersion")
38
- }
39
-
35
+ ndkVersion getExtOrDefault("ndkVersion")
40
36
  compileSdkVersion getExtOrDefault("compileSdkVersion")
41
37
 
42
38
  defaultConfig {
@@ -59,6 +55,9 @@ android {
59
55
  arguments "-DANDROID_STL=c++_shared",
60
56
  "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON",
61
57
  "-DREACT_NATIVE_DIR=${reactNativeDir()}"
58
+ if (quickjsModuleDirs().size() > 0) {
59
+ arguments "-DRNQJS_QUICKJS_MODULES=${quickjsModuleDirs().join(';')}"
60
+ }
62
61
 
63
62
  // Left unset, CMakeLists.txt takes the DevTools backend from the build
64
63
  // type. -PrnqjsEnableCdp=true|false forces it either way.
@@ -142,6 +141,31 @@ def reactNativeArchitectures() {
142
141
  return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
143
142
  }
144
143
 
144
+ // Autolinked QuickJS modules (@react-native-quickjs/* declaring
145
+ // reactNativeQuickJSModule with an android/ dir), folded into this engine's
146
+ // single .so.
147
+ def quickjsModuleDirs() {
148
+ def engineRoot = projectDir.parentFile
149
+ // Consumer: engineRoot is node_modules/@react-native-quickjs/quickjs.
150
+ // Monorepo: engineRoot is the workspace root.
151
+ def scopes = [engineRoot.parentFile,
152
+ new File(engineRoot, "node_modules/@react-native-quickjs")]
153
+ def dirs = []
154
+ scopes.each { scope ->
155
+ if (scope == null || !scope.exists()) return
156
+ scope.listFiles().each { pkg ->
157
+ def manifest = new File(pkg, "package.json")
158
+ if (!manifest.exists()) return
159
+ def json = new groovy.json.JsonSlurper().parse(manifest)
160
+ if (json.reactNativeQuickJSModule && new File(pkg, "android").exists()
161
+ && !dirs.contains(pkg.absolutePath)) {
162
+ dirs << pkg.absolutePath
163
+ }
164
+ }
165
+ }
166
+ return dirs
167
+ }
168
+
145
169
  dependencies {
146
170
  implementation "com.facebook.react:react-android"
147
171
  implementation "com.facebook.fbjni:fbjni:0.7.0"
@@ -26,13 +26,17 @@
26
26
  "name": "0006-private-symbol-keys.patch",
27
27
  "sha256": "cf4ba5df3779f81c1721abab677381de5174ed34323bcb5cee68f2f5cf572edd"
28
28
  },
29
+ {
30
+ "name": "0007-stripped-source-tostring.patch",
31
+ "sha256": "5543d3f33108b3666ccc2317b65afc3ae132a8a12e223ed9f6defeaef2367b8b"
32
+ },
29
33
  {
30
34
  "name": "9999-bc-version-bump.patch",
31
35
  "sha256": "1a89bc63c09a7411d3f5d8cacbccbb7c525ad37ec4f7f07ee130f5ade736d428"
32
36
  }
33
37
  ],
34
38
  "files": {
35
- "quickjs.c": "e59a0172640094f35db7238cb9a0b4ed38152b7388142cdc9a4aa53447cfa80f",
39
+ "quickjs.c": "9016fa1b5e6682c0c45fef8d1b057aa44cfad95ff1eb50e3432ad520187a86e1",
36
40
  "libregexp.c": "27cd5fb4f4f0d8c05fea4cb0ddcd4dede7f39364e87f0f5610cfc25a89908158",
37
41
  "libunicode.c": "d88f53190ad772dd09b2beb93ffb8fc3097852f4987485aac8663a00189c43eb",
38
42
  "dtoa.c": "a8d2cf2d04db406e0c16acdd95ab0e709d309902865722f41dac07a1ba5676af",
@@ -43038,17 +43038,23 @@ static JSValue js_function_toString(JSContext *ctx, JSValueConst this_val,
43038
43038
  int argc, JSValueConst *argv)
43039
43039
  {
43040
43040
  JSObject *p;
43041
+ bool source_stripped;
43041
43042
  JSFunctionKindEnum func_kind = JS_FUNC_NORMAL;
43042
43043
 
43043
43044
  if (check_function(ctx, this_val))
43044
43045
  return JS_EXCEPTION;
43045
43046
 
43046
43047
  p = JS_VALUE_GET_OBJ(this_val);
43048
+ source_stripped = false;
43047
43049
  if (js_class_has_bytecode(p->class_id)) {
43048
43050
  JSFunctionBytecode *b = p->u.func.function_bytecode;
43049
43051
  /* `b->source` must be pure ASCII or UTF-8 encoded */
43050
43052
  if (b->source)
43051
43053
  return JS_NewStringLen(ctx, b->source, b->source_len);
43054
+ /* The embedded source was stripped at compile time (qjsc
43055
+ * --strip-source); only the declaration survives, so say so instead
43056
+ * of pretending this is a native C function. */
43057
+ source_stripped = true;
43052
43058
  }
43053
43059
  {
43054
43060
  JSValue name;
@@ -43069,7 +43075,10 @@ static JSValue js_function_toString(JSContext *ctx, JSValueConst this_val,
43069
43075
  pref = "async function *";
43070
43076
  break;
43071
43077
  }
43072
- suff = "() {\n [native code]\n}";
43078
+ if (source_stripped)
43079
+ suff = "() {\n [stripped source]\n}";
43080
+ else
43081
+ suff = "() {\n [native code]\n}";
43073
43082
  name = JS_GetProperty(ctx, this_val, JS_ATOM_name);
43074
43083
  if (JS_IsUndefined(name))
43075
43084
  name = js_empty_string(ctx->rt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-native-quickjs/quickjs",
3
- "version": "1.0.0-alpha.0",
3
+ "version": "1.0.0-alpha.2",
4
4
  "description": "QuickJS JSI runtime for React Native, built from source",
5
5
  "keywords": [
6
6
  "react-native",
@@ -35,6 +35,7 @@
35
35
  "modules/hermes-compat/src",
36
36
  "scripts/postinstall.js",
37
37
  "scripts/bytecode",
38
+ "scripts/generate-module-registry.js",
38
39
  "scripts/react_native_quickjs_pods.rb",
39
40
  "*.podspec",
40
41
  "bin",
@@ -48,6 +49,7 @@
48
49
  ],
49
50
  "workspaces": [
50
51
  "modules/text-encoding",
52
+ "modules/intl",
51
53
  "example"
52
54
  ],
53
55
  "scripts": {
@@ -66,8 +68,9 @@
66
68
  "example": "npm run --workspace @react-native-quickjs/example",
67
69
  "docs:dev": "vitepress dev docs",
68
70
  "docs:build": "vitepress build docs",
69
- "release:module": "npm publish -w @react-native-quickjs/text-encoding",
70
- "release:alpha": "npm publish --tag alpha"
71
+ "release": "node scripts/release.js",
72
+ "release:alpha": "node scripts/release.js --tag alpha",
73
+ "release:module": "node scripts/release-module.js"
71
74
  },
72
75
  "peerDependencies": {
73
76
  "react": "*",
@@ -99,6 +102,6 @@
99
102
  }
100
103
  },
101
104
  "dependencies": {
102
- "@react-native-quickjs/text-encoding": "1.0.0"
105
+ "@react-native-quickjs/text-encoding": "1.0.1"
103
106
  }
104
107
  }
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Copyright (c) Ammar Ahmed.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /*
10
+ * Emits a C++ file that calls each autolinked QuickJS module's install
11
+ * function by name.
12
+ *
13
+ * Naming the function is what stops the linker dropping a static library's
14
+ * object file, which would otherwise leave the module silently uninstalled.
15
+ *
16
+ * node scripts/generate-module-registry.js [--dir <pkg> ...] [--out <file>]
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const argv = process.argv.slice(2);
23
+ const opt = (name, dflt = null) => {
24
+ const i = argv.indexOf(`--${name}`);
25
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
26
+ };
27
+
28
+ if (argv.includes('--help') || argv.includes('-h')) {
29
+ console.log(`
30
+ generate-module-registry — autolink QuickJS modules
31
+
32
+ node scripts/generate-module-registry.js [options]
33
+
34
+ Options
35
+ --root <dir> project root to scan from (default: cwd)
36
+ --out <file> output path (default: <root>/build/QuickJSGeneratedModules.cpp)
37
+ --dir <dir> an explicit module package directory (repeatable). When given,
38
+ the registry is exactly these modules -- authoritative, for an
39
+ app build that must only reference linked modules.
40
+ --only <names> keep only these discovered modules (comma-separated names)
41
+ --json print the discovered modules as JSON and write nothing
42
+ --quiet suppress the summary`);
43
+ process.exit(0);
44
+ }
45
+
46
+ const root = path.resolve(opt('root', process.cwd()));
47
+ const outPath = path.resolve(
48
+ opt('out', path.join(root, 'build', 'QuickJSGeneratedModules.cpp'))
49
+ );
50
+ const only = opt('only', null) ? new Set(opt('only').split(',')) : null;
51
+ const asJson = argv.includes('--json');
52
+ const quiet = argv.includes('--quiet');
53
+
54
+ /**
55
+ * Directories to scan. Monorepos hoist to the root `node_modules`, and this
56
+ * repository keeps its first-party modules in `modules/`, uninstalled.
57
+ */
58
+ function candidateDirs() {
59
+ const dirs = [];
60
+ const nodeModules = path.join(root, 'node_modules');
61
+ if (fs.existsSync(nodeModules)) dirs.push(nodeModules);
62
+
63
+ const modules = path.join(root, 'modules');
64
+ if (fs.existsSync(modules)) dirs.push(modules);
65
+
66
+ return dirs;
67
+ }
68
+
69
+ /** Reads a package.json, or null when it is missing or invalid. */
70
+ function readPackage(dir) {
71
+ try {
72
+ const raw = fs.readFileSync(path.join(dir, 'package.json'), 'utf8');
73
+ return JSON.parse(raw);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function moduleFromDir(dir) {
80
+ const pkg = readPackage(dir);
81
+ const spec = pkg && pkg.reactNativeQuickJSModule;
82
+ if (!spec || !spec.install) return null;
83
+ return {
84
+ name: pkg.name || path.basename(dir),
85
+ install: spec.install,
86
+ header: spec.header || null,
87
+ priority: typeof spec.priority === 'number' ? spec.priority : 0,
88
+ dir,
89
+ };
90
+ }
91
+
92
+ function discover() {
93
+ const found = new Map(); // name -> { name, install, header, dir }
94
+
95
+ for (const base of candidateDirs()) {
96
+ let entries;
97
+ try {
98
+ entries = fs.readdirSync(base, { withFileTypes: true });
99
+ } catch {
100
+ continue;
101
+ }
102
+
103
+ for (const entry of entries) {
104
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
105
+
106
+ // Scoped packages nest one level deeper: node_modules/@scope/name.
107
+ const dirs = entry.name.startsWith('@')
108
+ ? fs
109
+ .readdirSync(path.join(base, entry.name), { withFileTypes: true })
110
+ .filter((e) => e.isDirectory() || e.isSymbolicLink())
111
+ .map((e) => path.join(base, entry.name, e.name))
112
+ : [path.join(base, entry.name)];
113
+
114
+ for (const dir of dirs) {
115
+ const m = moduleFromDir(dir);
116
+ if (m && !found.has(m.name)) found.set(m.name, m); // first wins
117
+ }
118
+ }
119
+ }
120
+
121
+ // Deterministic output, so an unchanged build does not retrigger everything
122
+ // downstream of the registry.
123
+ return [...found.values()].sort(
124
+ (a, b) => a.priority - b.priority || a.name.localeCompare(b.name)
125
+ );
126
+ }
127
+
128
+ // --dir is authoritative: the registry becomes exactly these modules, which is
129
+ // what an app build wants. node_modules holds packages that are present but not
130
+ // linked (transitive dependencies), and referencing their symbols would fail
131
+ // the link.
132
+ const explicitDirs = [];
133
+ for (let i = 0; i < argv.length; i++) {
134
+ if (argv[i] === '--dir' && argv[i + 1]) explicitDirs.push(path.resolve(argv[i + 1]));
135
+ }
136
+
137
+ let modules;
138
+ if (explicitDirs.length > 0) {
139
+ const seen = new Map();
140
+ for (const dir of explicitDirs) {
141
+ const m = moduleFromDir(dir);
142
+ if (m && !seen.has(m.name)) seen.set(m.name, m);
143
+ }
144
+ modules = [...seen.values()].sort(
145
+ (a, b) => a.priority - b.priority || a.name.localeCompare(b.name)
146
+ );
147
+ } else {
148
+ modules = discover();
149
+ if (only) {
150
+ // An allow-list over the discovered modules.
151
+ modules = modules.filter((m) => only.has(m.name));
152
+ }
153
+ }
154
+
155
+ if (asJson) {
156
+ console.log(JSON.stringify(modules, null, 2));
157
+ process.exit(0);
158
+ }
159
+
160
+ const isValidIdentifier = (s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s);
161
+
162
+ for (const m of modules) {
163
+ if (!isValidIdentifier(m.install)) {
164
+ console.error(
165
+ `[generate-module-registry] ${m.name}: reactNativeQuickJSModule.install ` +
166
+ `must be a C identifier, got ${JSON.stringify(m.install)}`
167
+ );
168
+ process.exit(1);
169
+ }
170
+ }
171
+
172
+ const lines = [];
173
+ lines.push('// Generated by scripts/generate-module-registry.js — do not edit.');
174
+ lines.push('');
175
+ lines.push('#include <jsi/jsi.h>');
176
+ lines.push('');
177
+ lines.push('#include "QuickJSModule.h"');
178
+ lines.push('');
179
+
180
+ if (modules.length === 0) {
181
+ lines.push('// No autolinked modules were found.');
182
+ lines.push('');
183
+ }
184
+
185
+ lines.push('extern "C" {');
186
+ for (const m of modules) {
187
+ lines.push(`// ${m.name}`);
188
+ lines.push(`void ${m.install}(facebook::jsi::Runtime &);`);
189
+ }
190
+ lines.push('}');
191
+ lines.push('');
192
+ lines.push('namespace qjs {');
193
+ lines.push('');
194
+ lines.push('void registerGeneratedModules() {');
195
+ if (modules.length === 0) {
196
+ lines.push(' // Nothing to register.');
197
+ }
198
+ for (const m of modules) {
199
+ lines.push(` registerModule("${m.name}", ${m.install}, ${m.priority});`);
200
+ }
201
+ lines.push('}');
202
+ lines.push('');
203
+ lines.push('} // namespace qjs');
204
+ lines.push('');
205
+
206
+ const output = lines.join('\n');
207
+
208
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
209
+
210
+ // Only write when the content differs, so an unchanged registry does not
211
+ // retrigger a rebuild of everything downstream of it.
212
+ let existing = null;
213
+ try {
214
+ existing = fs.readFileSync(outPath, 'utf8');
215
+ } catch {
216
+ /* first run */
217
+ }
218
+
219
+ if (existing !== output) {
220
+ fs.writeFileSync(outPath, output);
221
+ }
222
+
223
+ if (!quiet) {
224
+ if (modules.length === 0) {
225
+ console.log('[generate-module-registry] no QuickJS modules found');
226
+ } else {
227
+ console.log(
228
+ `[generate-module-registry] ${modules.length} module(s) -> ${path.relative(root, outPath)}`
229
+ );
230
+ for (const m of modules) {
231
+ console.log(` ${m.name} -> ${m.install}()`);
232
+ }
233
+ }
234
+ }
@@ -17,6 +17,9 @@
17
17
  # end
18
18
  # end
19
19
 
20
+ require "json"
21
+ require "pathname"
22
+
20
23
  # react-native-worklets and react-native-reanimated declare
21
24
  # `s.dependency 'React-hermes'` unconditionally, and use_quickjs! never declares
22
25
  # that pod, so `pod install` cannot resolve it. Neither uses anything from it:
@@ -134,6 +137,7 @@ end
134
137
 
135
138
  def react_native_quickjs_post_install(installer)
136
139
  react_native_quickjs_add_bytecode_phase(installer)
140
+ react_native_quickjs_add_module_registry(installer)
137
141
 
138
142
  # Debug only, matching the gate React Native puts on its own inspector.
139
143
  # QuickJSInstance::debuggerEnabledByDefault() already refuses to attach in a
@@ -235,3 +239,129 @@ def react_native_quickjs_append(installer, pod_name, setting, value, debug_only:
235
239
  config.build_settings[setting] = "#{current} #{value}"
236
240
  end
237
241
  end
242
+
243
+ # The generated module registry, compiled into the app target.
244
+ #
245
+ # A QuickJS module's QJS_REGISTER_MODULE static initializer is dropped by the
246
+ # linker from a static archive unless something references its object file, so
247
+ # the registry calls each module's install function by name. The reference must
248
+ # live in the last-linked thing -- the app target -- because CocoaPods orders
249
+ # module pods before the engine pod. A script phase regenerates the TU below
250
+ # before Sources compile; the host build exercises the same path in
251
+ # quickjs_generated_modules_tests.
252
+ MODULE_REGISTRY_PHASE = "[ReactNativeQuickJS] Generate module registry"
253
+
254
+ def react_native_quickjs_add_module_registry(installer)
255
+ engine_root = File.expand_path("..", __dir__)
256
+ generator = "scripts/generate-module-registry.js"
257
+
258
+ sandbox = Pod::Config.instance.sandbox
259
+ sandbox_root = sandbox.root.to_s
260
+
261
+ module_dirs = []
262
+ dev_pods = sandbox.send(:development_pods)
263
+ (installer.pod_targets || []).each do |pod_target|
264
+ next unless pod_target.name.start_with?("react-native-quickjs-")
265
+
266
+ # The podspec's directory is the package root; for a local (file:) pod it
267
+ # comes from CocoaPods' development_pods storage, not a layout guess.
268
+ spec_file = dev_pods[pod_target.pod_name].to_s
269
+ pkg_root = spec_file.empty? ? "" : File.dirname(spec_file)
270
+ if pkg_root.empty? || !File.exist?(File.join(pkg_root, "package.json"))
271
+ spec_file = pod_target.root_spec.defined_in_file
272
+ pkg_root = spec_file ? File.dirname(spec_file) : ""
273
+ end
274
+ next if pkg_root.empty?
275
+ pkg = File.join(pkg_root, "package.json")
276
+ next unless File.exist?(pkg)
277
+ next unless JSON.parse(File.read(pkg))["reactNativeQuickJSModule"]
278
+ module_dirs << pkg_root
279
+ end
280
+
281
+ # Warn rather than silently produce an empty registry when modules are linked.
282
+ if module_dirs.empty? && (installer.pods_project)
283
+ (installer.pods_project.targets).each do |t|
284
+ next unless t.name.start_with?("react-native-quickjs-")
285
+ Pod::UI.warn(
286
+ "[ReactNativeQuickJS] found pod #{t.name} but no package.json with " \
287
+ "reactNativeQuickJSModule next to it; module registry will be empty."
288
+ )
289
+ end
290
+ end
291
+
292
+ return if module_dirs.empty?
293
+
294
+ node_binary = ENV["NODE_BINARY"] || "node"
295
+
296
+ # The registry TU includes QuickJSModule.h (which includes jsi/jsi.h). The
297
+ # module pods expose those headers to themselves, not to the app, so the app
298
+ # target gets the engine's module headers and the Pods React-jsi headers.
299
+ jsi_headers = File.join(sandbox_root, "Headers", "Public", "React-jsi")
300
+ engine_src = File.join(engine_root, "src", "module")
301
+ engine_headers = [
302
+ "\"#{jsi_headers}\"",
303
+ "\"#{engine_src}\"",
304
+ ].select { |p| p.include?("$(PODS_ROOT)") || File.directory?(p.tr("\"", "")) }
305
+
306
+ installer.aggregate_targets.map(&:user_project).uniq(&:path).compact.each do |project|
307
+ project.native_targets.each do |target|
308
+ next unless target.product_type == "com.apple.product-type.application"
309
+
310
+ project_dir = File.dirname(project.path)
311
+ # The generated TU lives under the app project and is addressed with
312
+ # $SRCROOT, so nothing machine-specific is written into the project.
313
+ out = File.join(project_dir, "build", "generated", "QuickJSGeneratedModules.cpp")
314
+
315
+ target.build_configurations.each do |config|
316
+ paths = [config.build_settings["HEADER_SEARCH_PATHS"] || "$(inherited)"]
317
+ paths = [paths] unless paths.is_a?(Array)
318
+ paths = paths.flatten
319
+ paths.concat(engine_headers)
320
+ config.build_settings["HEADER_SEARCH_PATHS"] = paths.uniq.join(" ")
321
+ end
322
+
323
+ # Reuse an existing phase (a re-run refreshes it rather than accumulating
324
+ # another) and keep it ordered before Sources.
325
+ phase = target.build_phases.find do |p|
326
+ p.respond_to?(:display_name) && p.display_name == MODULE_REGISTRY_PHASE
327
+ end
328
+ phase ||= target.new_shell_script_build_phase(MODULE_REGISTRY_PHASE)
329
+
330
+ # The generated file is declared as an output so Compile Sources can
331
+ # consume it ("Build input file cannot be found" otherwise). Engine and
332
+ # module paths are resolved at build time through node.
333
+ phase.output_paths = ["$(SRCROOT)/build/generated/QuickJSGeneratedModules.cpp"]
334
+ rel_dirs = module_dirs.map do |d|
335
+ Pathname.new(d).relative_path_from(Pathname.new(engine_root)).to_s
336
+ end
337
+ phase.shell_script = <<~SH
338
+ set -e
339
+ [ -f "$SRCROOT/.xcode.env" ] && . "$SRCROOT/.xcode.env"
340
+ [ -f "$SRCROOT/.xcode.env.local" ] && . "$SRCROOT/.xcode.env.local"
341
+ : "${NODE_BINARY:=#{node_binary}}"
342
+ ENGINE="$("$NODE_BINARY" --print "require.resolve('@react-native-quickjs/quickjs/package.json', {paths: ['$SRCROOT']})" | xargs dirname)"
343
+ OUT="$SRCROOT/build/generated/QuickJSGeneratedModules.cpp"
344
+ mkdir -p "$(dirname "$OUT")"
345
+ "$NODE_BINARY" "$ENGINE/#{generator}" --dir "$ENGINE/#{rel_dirs.join('" --dir "$ENGINE/')}" \\
346
+ --out "$OUT" --quiet
347
+ SH
348
+
349
+ compile = target.build_phases.find do |p|
350
+ p.respond_to?(:display_name) && p.display_name == "Sources"
351
+ end
352
+ target.build_phases.delete(phase)
353
+ index = compile ? target.build_phases.index(compile) : nil
354
+ target.build_phases.insert(index, phase) if index
355
+
356
+ rel = Pathname.new(out).relative_path_from(Pathname.new(project_dir)).to_s
357
+ ref = project.main_group.files.find { |f| f.path == rel }
358
+ ref ||= project.main_group.new_file(rel)
359
+ target.source_build_phase.add_file_reference(ref)
360
+
361
+ Pod::UI.puts(
362
+ "[ReactNativeQuickJS] module registry -> #{module_dirs.map { |d| File.basename(d) }.join(', ')}".green
363
+ )
364
+ end
365
+ project.save
366
+ end
367
+ end
@@ -27,6 +27,14 @@ std::vector<ModuleRegistration> &registry() {
27
27
 
28
28
  } // namespace
29
29
 
30
+ // Weak default: linking quickjs_jsi without the generated registry TU still
31
+ // links, and a target that compiles that TU overrides this (clang/gcc).
32
+ #if defined(__GNUC__) || defined(__clang__)
33
+ __attribute__((weak)) void registerGeneratedModules() {}
34
+ #else
35
+ void registerGeneratedModules() {}
36
+ #endif
37
+
30
38
  void registerModule(const ModuleRegistration &module) {
31
39
  if (module.install == nullptr || module.name == nullptr) {
32
40
  return;
@@ -54,6 +62,7 @@ const std::vector<ModuleRegistration> &registeredModules() {
54
62
  }
55
63
 
56
64
  void installModules(jsi::Runtime &runtime) {
65
+ registerGeneratedModules();
57
66
  for (const auto &module : registry()) {
58
67
  module.install(runtime);
59
68
  }
@@ -62,6 +62,16 @@ inline void registerModule(
62
62
  registerModule(ModuleRegistration{name, install, priority});
63
63
  }
64
64
 
65
+ /**
66
+ * Installs autolinked modules. Defined in a translation unit generated by
67
+ * scripts/generate-module-registry.js, which names each module's install
68
+ * function so the linker cannot drop its static-library object file (the
69
+ * failure plain QJS_REGISTER_MODULE is subject to). Called from
70
+ * installModules(); registration is idempotent, so a module reached by both
71
+ * routes installs once.
72
+ */
73
+ void registerGeneratedModules();
74
+
65
75
  /// `makeQuickJSRuntime()` calls this. Call it yourself only for a runtime you
66
76
  /// built by other means.
67
77
  void installModules(jsi::Runtime &runtime);