Skip to content
Merged
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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "JavaScriptCoreExtras"
BuildableName = "JavaScriptCoreExtras"
BlueprintName = "JavaScriptCoreExtras"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "JavaScriptCoreExtrasTests"
BuildableName = "JavaScriptCoreExtrasTests"
BlueprintName = "JavaScriptCoreExtrasTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "JavaScriptCoreExtras"
BuildableName = "JavaScriptCoreExtras"
BlueprintName = "JavaScriptCoreExtras"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
123 changes: 117 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,98 @@
# JavaScriptCoreExtras

Extensions to Apple's JavaScriptCore framework.
[![CI](https://github.com/mhayes853/javascript-core-extras/actions/workflows/ci.yml/badge.svg)](https://github.com/mhayes853/javascript-core-extras/actions/workflows/ci.yml)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmhayes853%2Fjavascript-core-extras%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/mhayes853/javascript-core-extras)
[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fmhayes853%2Fjavascript-core-extras%2Fbadge%3Ftype%3Dplatforms)](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<JSContext> = 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions Sources/JavaScriptCoreExtras/Concurrency/JSGlobalActor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, E: Error>(
Expand Down
8 changes: 6 additions & 2 deletions Sources/JavaScriptCoreExtras/Internal/UnsafeTransfer.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
struct UnsafeTransfer<Value>: @unchecked Sendable {
let value: Value
package struct UnsafeTransfer<Value>: @unchecked Sendable {
package let value: Value

package init(value: Value) {
self.value = value
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: ConvertibleFromJSValue>(_ t: T.Type) throws -> T {
try t.init(jsValue: jsValue)
}
self = try open(initializer) as! Self
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ 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))
expectIdenticalVMs(c1, c2)
}

@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 (
Expand All @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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())
Expand Down
Loading