@expo/expo-modules-macros-plugin 0.1.0 → 0.2.0

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.
@@ -97,7 +97,9 @@ jobs:
97
97
  run: |
98
98
  version="${{ steps.bump.outputs.version }}"
99
99
  git commit -am "Release $version"
100
- git tag "$version"
100
+ # Use an annotated tag so `git push --follow-tags` actually pushes it
101
+ # (--follow-tags ignores lightweight tags).
102
+ git tag -a "$version" -m "Release $version"
101
103
  git push --follow-tags origin HEAD
102
104
 
103
105
  - name: Create GitHub release
Binary file
@@ -0,0 +1,174 @@
1
+ import SwiftSyntax
2
+
3
+ /**
4
+ A `@JS func` collected for **direct JSI binding**. Instead of describing the function with a
5
+ `Function(...)` / `AsyncFunction(...)` DSL entry that the runtime interprets per call,
6
+ `@ExpoModule` synthesizes a `_decorateModule` that binds each such function into the module's JS object
7
+ via the closure-taking `JavaScriptObject.setProperty(_:)`, with the decode-call-encode body
8
+ inlined into the closure. This omits the `[Any]`/`toTuple` dynamic-call path: every argument is
9
+ decoded individually by its static type.
10
+
11
+ The receiver is the module's real `self` (a module is a singleton instance), so the body calls
12
+ `self.<name>(...)` directly and ignores the JS `this`. An `async` `@JS func` produces an `async`
13
+ closure body and is installed through the async `setProperty(_:)` overload (so JS gets a promise).
14
+ */
15
+ internal struct JSFunction {
16
+ let swiftName: String
17
+ let jsName: String
18
+ let parameters: [FunctionParameterSyntax]
19
+ /// The declared return type as written, or `nil` when the function returns `Void`/nothing.
20
+ let returnType: String?
21
+ let isThrowing: Bool
22
+ let isAsync: Bool
23
+
24
+ init(funcDecl: FunctionDeclSyntax, attribute: AttributeSyntax) {
25
+ self.swiftName = funcDecl.name.text
26
+ self.jsName = jsNameArgument(of: attribute) ?? funcDecl.name.text
27
+ self.parameters = Array(funcDecl.signature.parameterClause.parameters)
28
+
29
+ let declaredReturnType = funcDecl.signature.returnClause?.type
30
+ self.returnType = isVoidType(declaredReturnType) ? nil : declaredReturnType?.trimmedDescription
31
+
32
+ let effectSpecifiers = funcDecl.signature.effectSpecifiers
33
+ self.isThrowing = effectSpecifiers?.throwsClause?.throwsSpecifier != nil
34
+ self.isAsync = effectSpecifiers?.asyncSpecifier != nil
35
+ }
36
+
37
+ /**
38
+ The `#name` host-function body: a `@JavaScriptActor private func` matching the
39
+ `createFunction` closure shape `(this, arguments) throws -> JavaScriptValue`, threading
40
+ `appContext`/`runtime` in as parameters. It checks arity, decodes each argument by its static
41
+ type — primitives through a direct typed accessor (`asDouble()`, …) on a borrowed
42
+ `JavaScriptUnownedValue`, other types through the
43
+ `T.getDynamicType()` converter — calls `self.<name>(...)`, and converts the result back to JS.
44
+ */
45
+ /// The decode-call-encode statements that form the host-function body, indented with the given
46
+ /// prefix. Arity guard, then per-argument decode (primitives via a direct typed accessor like
47
+ /// `asDouble()` on a zero-copy `arguments.unownedValue(at:)`, others via `getDynamicType().cast(...)`),
48
+ /// the `self.<name>(...)` call, and the
49
+ /// result encode (primitives via `toJavaScriptValue(in:)`, others via `castToJS(...)`).
50
+ private func bodyStatements(indent: String) -> String {
51
+ var lines: [String] = []
52
+
53
+ lines.append(
54
+ """
55
+ guard arguments.count == \(parameters.count) else {
56
+ throw Exception(name: "InvalidArgumentCount", description: "Function '\(jsName)' expects \(parameters.count) argument(s), but got \\(arguments.count)")
57
+ }
58
+ """)
59
+
60
+ var callArguments: [String] = []
61
+ for (index, parameter) in parameters.enumerated() {
62
+ let type = parameter.type.trimmedDescription
63
+
64
+ // Primitives decode through a direct typed accessor (`asDouble()`, etc.) on a borrowed
65
+ // `JavaScriptUnownedValue` — no owning `JavaScriptValue` allocation, no `jsi::Value` copy, no
66
+ // `getDynamicType()` allocation, no `Any` boxing, no force-cast — while still validating and
67
+ // throwing `TypeError` on a mismatch. Other types fall back to the dynamic converter, which
68
+ // needs an owning value, so they index the buffer directly.
69
+ if let accessor = fastDecodeAccessor(for: type) {
70
+ lines.append("let arg\(index) = try arguments.unownedValue(at: \(index)).\(accessor)()")
71
+ } else {
72
+ lines.append(
73
+ "let arg\(index) = try \(type).getDynamicType().cast(jsValue: arguments[\(index)], appContext: appContext) as! \(type)")
74
+ }
75
+
76
+ let label = parameter.firstName.text
77
+ callArguments.append(label == "_" ? "arg\(index)" : "\(label): arg\(index)")
78
+ }
79
+
80
+ let tryKeyword = (isThrowing || isAsync) ? "try " : ""
81
+ let awaitKeyword = isAsync ? "await " : ""
82
+ let callExpression =
83
+ "\(tryKeyword)\(awaitKeyword)self.\(swiftName)(\(callArguments.joined(separator: ", ")))"
84
+
85
+ if let returnType {
86
+ lines.append("let result = \(callExpression)")
87
+ // Primitives encode through `toJavaScriptValue(in:)` (the typed `JavaScriptRepresentable`
88
+ // conversion) — no `Any`, no dynamic-type allocation. Others go through the dynamic converter.
89
+ if fastDecodeAccessor(for: returnType) != nil {
90
+ lines.append("return result.toJavaScriptValue(in: runtime)")
91
+ } else {
92
+ lines.append("return try \(returnType).getDynamicType().castToJS(result, appContext: appContext, in: runtime)")
93
+ }
94
+ } else {
95
+ lines.append(callExpression)
96
+ lines.append("return .undefined")
97
+ }
98
+
99
+ return lines
100
+ .flatMap { $0.split(separator: "\n", omittingEmptySubsequences: false) }
101
+ .map { indent + $0 }
102
+ .joined(separator: "\n")
103
+ }
104
+
105
+ /// The `setProperty` statement that installs this function on the JS object. The decode-call-encode
106
+ /// body is inlined directly into the closure passed to the closure-taking `setProperty` overload
107
+ /// (which creates the host function under the hood) — no separate named binding. For an `async`
108
+ /// function the body `await`s the call, which selects the async `setProperty` overload (so JS
109
+ /// receives a promise).
110
+ ///
111
+ /// Capture mirrors core's `SyncFunctionDefinition.build`: `self` (the module) is captured
112
+ /// **strong** — the host-function closure is what keeps the native callable alive for as long as
113
+ /// JS can invoke it; its lifetime is bounded by the JS VM's garbage collection of the object.
114
+ /// `appContext` is captured **weak** (and guarded) so it doesn't form a real retain cycle through
115
+ /// the app context.
116
+ var decorateStatements: String {
117
+ return """
118
+ object.setProperty("\(jsName)") { [weak appContext, self] this, arguments in
119
+ guard let appContext else {
120
+ throw Exceptions.AppContextLost()
121
+ }
122
+ \(bodyStatements(indent: " "))
123
+ }
124
+ """
125
+ }
126
+ }
127
+
128
+ /**
129
+ The single generated function that decorates the module's JS object. Core supplies the object;
130
+ this binds every `@JS func` into it via one inlined `setProperty` closure per function. Mirrors
131
+ core's `ObjectDefinition.decorate(object:)`, including its `borrowing` object parameter (it
132
+ mutates through the reference without reassigning or taking ownership). Named `_decorateModule`
133
+ with the leading-underscore convention for synthesized members the **runtime calls by name**; the
134
+ `ExpoModule` suffix names the `@ExpoModule` macro it came from (a shared object's counterpart is
135
+ `_decorateSharedObject`).
136
+ */
137
+ internal func buildDecorateJavaScriptObject(functions: [JSFunction]) -> DeclSyntax {
138
+ let body = functions.map { $0.decorateStatements }.joined(separator: "\n")
139
+ return """
140
+ @JavaScriptActor
141
+ public func _decorateModule(object: borrowing JavaScriptObject, in runtime: JavaScriptRuntime, appContext: AppContext) throws {
142
+ \(raw: body)
143
+ }
144
+ """
145
+ }
146
+
147
+ /// The throwing `JavaScriptUnownedValue` accessor that decodes the given primitive type directly,
148
+ /// bypassing the dynamic-type converter (`asDouble()` for `Double`, etc.). Returns `nil` for
149
+ /// types without a dedicated accessor — arrays, records, optionals, shared objects, other numeric
150
+ /// widths — which decode through `getDynamicType().cast(...)`.
151
+ private func fastDecodeAccessor(for type: String) -> String? {
152
+ switch type {
153
+ case "Bool":
154
+ return "asBool"
155
+ case "Int":
156
+ return "asInt"
157
+ case "Double":
158
+ return "asDouble"
159
+ case "String":
160
+ return "asString"
161
+ default:
162
+ return nil
163
+ }
164
+ }
165
+
166
+ /// True when a return clause is absent or written as `Void` / `()` — i.e. the function returns
167
+ /// nothing JS-visible, so the binding returns `.undefined`.
168
+ private func isVoidType(_ type: TypeSyntax?) -> Bool {
169
+ guard let type else {
170
+ return true
171
+ }
172
+ let text = type.trimmedDescription
173
+ return text == "Void" || text == "()"
174
+ }
@@ -42,6 +42,12 @@ public struct ExpoModuleMacro: MemberMacro {
42
42
  let moduleName = jsNameArgument(of: node) ?? classDecl.name.text
43
43
  var entries: [String] = ["Name(\"\(moduleName)\")"]
44
44
 
45
+ // `@JS func`s (sync and async) are bound directly into the JS object by the synthesized
46
+ // `_decorateModule` rather than described with a `Function(...)` / `AsyncFunction(...)` DSL entry,
47
+ // so they're collected here instead of appended to `entries`. Properties still go through
48
+ // the DSL for now.
49
+ var functions: [JSFunction] = []
50
+
45
51
  for typeName in classListArgument(of: node, label: "classes") {
46
52
  entries.append("\(typeName)._synthesizedClassDefinition()")
47
53
  }
@@ -51,7 +57,7 @@ public struct ExpoModuleMacro: MemberMacro {
51
57
 
52
58
  if let funcDecl = decl.as(FunctionDeclSyntax.self),
53
59
  let attribute = funcDecl.attributes.firstAttribute(named: "JS") {
54
- entries.append(buildFunctionEntry(funcDecl: funcDecl, attribute: attribute))
60
+ functions.append(JSFunction(funcDecl: funcDecl, attribute: attribute))
55
61
  continue
56
62
  }
57
63
 
@@ -94,6 +100,13 @@ public struct ExpoModuleMacro: MemberMacro {
94
100
  """
95
101
  emitted.append(method)
96
102
 
103
+ // Direct JSI binding: one `_decorateModule` that binds each `@JS func` into the module's JS object,
104
+ // with the decode-call-encode body inlined into each closure. Only emitted when there are
105
+ // functions to bind.
106
+ if !functions.isEmpty {
107
+ emitted.append(buildDecorateJavaScriptObject(functions: functions))
108
+ }
109
+
97
110
  return emitted
98
111
  }
99
112
  }
@@ -215,17 +228,6 @@ private func hasAppContextInitializer(_ classDecl: ClassDeclSyntax) -> Bool {
215
228
 
216
229
  // MARK: - Member builders
217
230
 
218
- private func buildFunctionEntry(
219
- funcDecl: FunctionDeclSyntax,
220
- attribute: AttributeSyntax
221
- ) -> String {
222
- let swiftName = funcDecl.name.text
223
- let jsName = jsNameArgument(of: attribute) ?? swiftName
224
- let isAsync = funcDecl.signature.effectSpecifiers?.asyncSpecifier != nil
225
- let dslEntry = isAsync ? "AsyncFunction" : "Function"
226
- return "\(dslEntry)(\"\(jsName)\", \(swiftName))"
227
- }
228
-
229
231
  private func buildPropertyEntries(
230
232
  varDecl: VariableDeclSyntax,
231
233
  attribute: AttributeSyntax
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/expo-modules-macros-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Swift macro plugin for Expo modules",
5
5
  "license": "MIT",
6
6
  "author": "650 Industries, Inc.",