diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f209e30
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ macos:
+ name: macOS (Swift 6.2)
+ runs-on: macos-26
+ timeout-minutes: 10
+ strategy:
+ matrix:
+ xcode: ["26.0"]
+ config: ["debug", "release"]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Select Xcode ${{ matrix.xcode }}
+ run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
+ - name: Run ${{ matrix.config }} tests
+ run: swift test -c ${{ matrix.config }} --enable-all-traits
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/javascript-core-extras.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/javascript-core-extras.xcscheme
new file mode 100644
index 0000000..7c7c1fd
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/javascript-core-extras.xcscheme
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index ce24e81..049a5da 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,98 @@
# JavaScriptCoreExtras
-Extensions to Apple's JavaScriptCore framework.
+[](https://github.com/mhayes853/javascript-core-extras/actions/workflows/ci.yml)
+[](https://swiftpackageindex.com/mhayes853/javascript-core-extras)
+[](https://swiftpackageindex.com/mhayes853/javascript-core-extras)
+
+Additions to Apple's JavaScriptCore framework.
## Overview
-JavaScriptCore is a great framework for allowing extensibility in your apps, at least if your users are technically inclined, as users can extend your app’s functionality with Javascript. This works great, but there’s a problem: JavaScriptCore provides no implementation for many common Javascript functions including `console.log` and `fetch`.
+JavaScriptCore is a great framework for allowing extensibility in your apps, at least if your users are technically inclined, as users can extend your app’s functionality with Javascript. This works well, but there’s quite a few problems:
+- The framework provides no implementation for many common JavaScript APIs including `console.log` and `fetch`.
+- Usage with Swift Concurrency is ambiguous, and is easy to get wrong.
+- Converting between pure Swift types and `JSValue` instances can be tedious and error prone.
+
+This package provides:
+- Implementations of many common JavaScript APIs including `console.log`, `fetch`, with more advanced JavaScript APIs to come in the future.
+- A universal mechanism for installing JavaScript into a `JSContext` through the `JSContextInstallable` protocol.
+- A proper integration with Swift Concurrency through `JSActor`, `JSGlobalActor`, `JSVirtualMachineExecutor`, and `JSVirtualMachineExecutorPool`.
+- Support for converting Codable types to and from `JSValue` instances.
+- Type-safe functions through `JSFunctionValue`.
+
+### Concurrency
+
+You can execute JavaScript in the background with Swift Concurrency by ensuring that all `JSContext` and `JSValue` instances you create are isolated to `@JSGlobalActor`. The global actor schedules work on a dedicated thread for executing JavaScript.
+
+```swift
+@JSGlobalActor
+class JavaScriptRuntime {
+ var context = JSContext(virtualMachine: JSGlobalActor.virtualMachine)!
+
+ func execute(_ code: String) {
+ context.evaluateScript(code)
+ }
+}
+```
+
+However, this won't let you execute JavaScript concurrently on different `JSContext` instances in the background. To execute JavaScript concurrently in the background, you can use `JSVirtualMachineExecutorPool` to manage an object pool of `JSVirtualMachineExecutor` instances. Then, you can create `JSActor` instances with an executor to isolate a specific value to a thread that can execute JavaScript.
+
+```swift
+let pool = JSVirtualMachineExecutorPool(count: 4)
+let executor = await pool.executor()
-This package provides implementations of both `console.log`, `fetch`, and much more to come in the future. It also provides a protocol that acts as a universal mechanism for adding functions and code to a `JSContext` called `JSContextInstallable`.
+// An actor that safely isolates a JSContext.
-## Console Logging
+let contextActor: JSActor = await executor.contextActor()
+
+await contextActor.withIsolation { @Sendable contextActor in
+ _ = contextActor.value.evaluateScript("console.log('Hello, World!')")
+}
+
+// JSActor allows you to isolate any value you specify to a thread with an active JSVirtualMachine.
+
+struct JSIsolatedPayload {
+ let a: String
+ let b: Int
+}
+
+let payloadActor = JSActor(JSIsolatedPayload(a: "Hello", b: 42), executor: executor)
+```
+
+`JSVirtualMachineExecutor` also conforms to `TaskExecutor`, which means that you can use it as an executor preference for a task.
+
+```swift
+let pool = JSVirtualMachineExecutorPool(count: 4)
+let executor = await pool.executor()
+
+Task(executorPreference: executor) {
+ print(JSVirtualMachineExecutor.current() === executor) // true
+}
+```
+
+### Type-Safe Functions
+
+You can create functions that are type-safe provided that the arguments and return value conform to `JSValueConvertible`.
+
+```swift
+// Codable values get a synthesized implementation to JSValueConvertible.
+struct ReturnValue: Codable, JSValueConvertible {
+ let a: String
+ let b: Date
+}
+
+let context = JSContext()!
+
+// Returns a JavaScript object with fields `a` and `b`.
+context.setFunction(forKey: "produce", Int.self, String.self) {
+ ReturnValue(a: "Hello \($1)", b: Date() + TimeInterval($0))
+}
+
+let value = context.evaluateScript("produce(10, 'blob')")
+let returnValue = try ReturnValue(jsValue: value)
+```
+
+### Console Logging
You can add the console logger functions to a `JSContext` via:
```swift
@@ -47,7 +131,7 @@ let context = JSContext()!
try context.install([SwiftLogLogger(logger: logger)])
```
-## Fetch
+### Fetch
You can add Javascript’s `fetch` function to a `JSContext` like so.
```swift
@@ -68,7 +152,7 @@ try context.install([.fetch(session: session)])
```
> 📱 `fetch(session:)` is only available on iOS 15+ because the fetch implementation uses data task specific delegates under the hood. On older versions, you can use `fetch(sessionConfiguration:)` where `sessionConfiguration` is a `URLSessionConfiguration`.
-## JSContextInstallable
+### JSContextInstallable
The previous examples show how to easily add Javascript code to a `JSContext`, and this functionality is brought to you by the `JSContextInstallable` protocol. You can conform a type to the protocol to specify how specific Javascript code should be added to a context.
```swift
@@ -101,3 +185,30 @@ try context.install([
])
])
```
+
+## Documentation
+
+The documentation for releases and main are available here.
+* [main](https://swiftpackageindex.com/mhayes853/javascript-core-extras/main/documentation/javascriptcoreextras/)
+* [0.x.x](https://swiftpackageindex.com/mhayes853/javascript-core-extras/~/documentation/javascriptcoreextras/)
+
+## Installation
+
+You can add JavaScriptCore Extras to an Xcode project by adding it to your project as a package.
+
+> [https://github.com/mhayes853/javascript-core-extras](https://github.com/mhayes853/javascript-core-extras)
+
+If you want to use JavaScriptCore Extras in a [SwiftPM](https://swift.org/package-manager/) project, it’s as simple as adding it to your `Package.swift`:
+
+```swift
+dependencies: [
+ .package(
+ url: "https://github.com/mhayes853/javascript-core-extras",
+ branch: "main"
+ ),
+]
+```
+
+## License
+
+This library is licensed under an MIT License. See [LICENSE](https://github.com/mhayes853/javascript-core-extras/blob/main/LICENSE) for details.
diff --git a/Sources/JavaScriptCoreExtras/Concurrency/JSGlobalActor.swift b/Sources/JavaScriptCoreExtras/Concurrency/JSGlobalActor.swift
index 8ba96f6..ea27006 100644
--- a/Sources/JavaScriptCoreExtras/Concurrency/JSGlobalActor.swift
+++ b/Sources/JavaScriptCoreExtras/Concurrency/JSGlobalActor.swift
@@ -52,13 +52,13 @@ public final actor JSGlobalActor {
// NB: Since this actor executes on the virtual machine thread, unwrapping is fine.
JSVirtualMachine.threadLocal!
}
-
+
/// The ``JSVirtualMachineExecutor`` used by the JavaScript global actor.
@JSGlobalActor
public static var executor: JSVirtualMachineExecutor {
Self.shared.executor
}
-
+
/// Execute the given body closure on the JavaScript global actor.
@JSGlobalActor
public static func run(
diff --git a/Sources/JavaScriptCoreExtras/Internal/UnsafeTransfer.swift b/Sources/JavaScriptCoreExtras/Internal/UnsafeTransfer.swift
index d879e92..477aecf 100644
--- a/Sources/JavaScriptCoreExtras/Internal/UnsafeTransfer.swift
+++ b/Sources/JavaScriptCoreExtras/Internal/UnsafeTransfer.swift
@@ -1,3 +1,7 @@
-struct UnsafeTransfer: @unchecked Sendable {
- let value: Value
+package struct UnsafeTransfer: @unchecked Sendable {
+ package let value: Value
+
+ package init(value: Value) {
+ self.value = value
+ }
}
diff --git a/Sources/JavaScriptCoreExtras/Values/Codable/Decodable+JSValueConvertible.swift b/Sources/JavaScriptCoreExtras/Values/Codable/Decodable+JSValueConvertible.swift
index dcb7107..7106e38 100644
--- a/Sources/JavaScriptCoreExtras/Values/Codable/Decodable+JSValueConvertible.swift
+++ b/Sources/JavaScriptCoreExtras/Values/Codable/Decodable+JSValueConvertible.swift
@@ -358,6 +358,9 @@ extension Decodable {
guard let initializer = Self.self as? any ConvertibleFromJSValue.Type else {
return nil
}
- self = try initializer.init(jsValue: jsValue) as! Self
+ func open(_ t: T.Type) throws -> T {
+ try t.init(jsValue: jsValue)
+ }
+ self = try open(initializer) as! Self
}
}
diff --git a/Sources/JavaScriptCoreExtras/Values/Functions/JSFunctionValue.swift b/Sources/JavaScriptCoreExtras/Values/Functions/JSFunctionValue.swift
index 6d7dd30..b66fd5c 100644
--- a/Sources/JavaScriptCoreExtras/Values/Functions/JSFunctionValue.swift
+++ b/Sources/JavaScriptCoreExtras/Values/Functions/JSFunctionValue.swift
@@ -71,11 +71,13 @@ extension JSFunctionValue: ConvertibleToJSValue {
}
private var argsCount: Int {
- var count = 0
- for _ in repeat (each Arguments).self {
- count += 1
+ // NB: An array of types prevents the loop from being optimized in release builds, which would
+ // always make the count 1.
+ var types = [Any.Type]()
+ for t in repeat (each Arguments).self {
+ types.append(t)
}
- return count
+ return types.count
}
}
diff --git a/Tests/JavaScriptCoreExtrasTests/ConcurrencyTests/DeprecatedTests/JSVirtualMachinePoolTests.swift b/Tests/JavaScriptCoreExtrasTests/ConcurrencyTests/DeprecatedTests/JSVirtualMachinePoolTests.swift
index 48feb3b..4b7dd80 100644
--- a/Tests/JavaScriptCoreExtrasTests/ConcurrencyTests/DeprecatedTests/JSVirtualMachinePoolTests.swift
+++ b/Tests/JavaScriptCoreExtrasTests/ConcurrencyTests/DeprecatedTests/JSVirtualMachinePoolTests.swift
@@ -3,9 +3,10 @@ import IssueReporting
@preconcurrency import JavaScriptCoreExtras
import Testing
-@Suite("JSVirtualMachinePool tests")
+@Suite("JSVirtualMachinePool tests", .disabled("Deprecated"))
struct JSVirtualMachinePoolTests {
@Test("Uses Same Virtual Machine For Contexts When Only 1 Machine Allowed")
+ @available(*, deprecated)
func singleMachinePool() async {
let pool = JSVirtualMachinePool(machines: 1)
let (c1, c2) = await (JSContext(in: pool), JSContext(in: pool))
@@ -13,6 +14,7 @@ struct JSVirtualMachinePoolTests {
}
@Test("Performs A Round Robin When Pool Has Multiple Virtual Machines")
+ @available(*, deprecated)
func roundRobin() async {
let pool = JSVirtualMachinePool(machines: 3)
let (c1, c2, c3, c4) = await (
@@ -25,6 +27,7 @@ struct JSVirtualMachinePoolTests {
}
@Test("Supports Custom Virtual Machines")
+ @available(*, deprecated)
func customMachines() async {
let pool = JSVirtualMachinePool(machines: 2) { CustomVM() }
let (c1, c2) = await (JSContext(in: pool), JSContext(in: pool))
@@ -34,6 +37,7 @@ struct JSVirtualMachinePoolTests {
}
@Test("Allows Concurrent Execution With Separate Virtual Machines")
+ @available(*, deprecated)
func concurrentExecution() async {
let pool = JSVirtualMachinePool(machines: 2)
let (c1, c2) = await (SendableContext(in: pool), SendableContext(in: pool))
@@ -81,6 +85,7 @@ struct JSVirtualMachinePoolTests {
}
@Test("Does Not Create More Threads Than Machines")
+ @available(*, deprecated)
func doesNotCreateMoreThreadsThanMachines() async {
let pool = JSVirtualMachinePool(machines: 1) { CustomVM() }
await withTaskGroup(of: CustomVM.self) { group in
@@ -98,6 +103,7 @@ struct JSVirtualMachinePoolTests {
}
@Test("Garbage Collects Any Virtual Machine That Has No References")
+ @available(*, deprecated)
func garbageCollection() async {
let pool = JSVirtualMachinePool(machines: 3) { CounterVM() }
let vm1 = await pool.virtualMachine()
@@ -147,6 +153,7 @@ private func expectDifferentVMs(_ c1: JSContext, _ c2: JSContext) {
private final class SendableContext: JSContext, @unchecked Sendable {}
+@available(*, deprecated)
extension JSContext {
convenience init(in pool: JSVirtualMachinePool) async {
await self.init(virtualMachine: pool.virtualMachine())
diff --git a/Tests/JavaScriptCoreExtrasTests/TestURLSession.swift b/Tests/JavaScriptCoreExtrasTests/TestURLSession.swift
index e494c62..972bd33 100644
--- a/Tests/JavaScriptCoreExtrasTests/TestURLSession.swift
+++ b/Tests/JavaScriptCoreExtrasTests/TestURLSession.swift
@@ -1,4 +1,5 @@
import Foundation
+import JavaScriptCoreExtras
// MARK: - ResponseBody
@@ -14,8 +15,8 @@ extension ResponseBody {
extension ResponseBody {
fileprivate func data() throws -> Data {
switch self {
- case let .data(data): data
- case let .json(encodable): try JSONEncoder().encode(encodable)
+ case .data(let data): data
+ case .json(let encodable): try JSONEncoder().encode(encodable)
}
}
}
@@ -37,9 +38,10 @@ func withTestURLSessionHandler(
}
func withTestURLSessionHandlerAndHeaders(
- handler: @Sendable @escaping (URLRequest) async throws -> (
- StatusCode, ResponseBody, [String: String]
- ),
+ handler:
+ @Sendable @escaping (URLRequest) async throws -> (
+ StatusCode, ResponseBody, [String: String]
+ ),
perform task: @Sendable @escaping (URLSession) async throws -> T
) async throws -> T {
let configuration = URLSessionConfiguration.ephemeral
@@ -107,14 +109,30 @@ private final class TestURLProtocol: URLProtocol, @unchecked Sendable {
}
override func startLoading() {
+ let transfer = UnsafeTransfer(value: self)
+ self.load { result in
+ switch result {
+ case .success(let (response, data)):
+ transfer.value.client?
+ .urlProtocol(transfer.value, didReceive: response, cacheStoragePolicy: .notAllowed)
+ transfer.value.client?.urlProtocol(transfer.value, didLoad: data)
+ transfer.value.client?.urlProtocolDidFinishLoading(transfer.value)
+ case .failure(let error):
+ transfer.value.client?.urlProtocol(transfer.value, didFailWithError: error)
+ }
+ }
+ }
+
+ private func load(
+ completion: @escaping @Sendable (Result<(URLResponse, Data), any Error>) -> Void
+ ) {
+ let request = self.request
Task {
do {
let (response, data) = try await TestURLSessionHandler.shared(request: request)
- client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
- client?.urlProtocol(self, didLoad: data)
- client?.urlProtocolDidFinishLoading(self)
+ completion(.success((response, data)))
} catch {
- client?.urlProtocol(self, didFailWithError: error)
+ completion(.failure(error))
}
}
}