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
8 changes: 5 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
target: "wasm32-unknown-wasip1"
- os: ubuntu-24.04
toolchain:
download-url: https://download.swift.org/swift-6.3-branch/ubuntu2404/swift-6.3-DEVELOPMENT-SNAPSHOT-2025-12-05-a/swift-6.3-DEVELOPMENT-SNAPSHOT-2025-12-05-a-ubuntu24.04.tar.gz
download-url: https://download.swift.org/swift-6.3-branch/ubuntu2404/swift-6.3-DEVELOPMENT-SNAPSHOT-2026-03-05-a/swift-6.3-DEVELOPMENT-SNAPSHOT-2026-03-05-a-ubuntu24.04.tar.gz
wasi-backend: Node
target: "wasm32-unknown-wasip1"
- os: ubuntu-22.04
Expand Down Expand Up @@ -167,14 +167,16 @@ jobs:
- uses: actions/checkout@v6
- uses: ./.github/actions/install-swift
with:
download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2026-02-02-a/swift-DEVELOPMENT-SNAPSHOT-2026-02-02-a-ubuntu22.04.tar.gz
download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2026-03-09-a/swift-DEVELOPMENT-SNAPSHOT-2026-03-09-a-ubuntu22.04.tar.gz
- uses: swiftwasm/setup-swiftwasm@v2
id: setup-wasm32-unknown-wasip1
with: { target: wasm32-unknown-wasip1 }
- uses: swiftwasm/setup-swiftwasm@v2
id: setup-wasm32-unknown-wasip1-threads
with: { target: wasm32-unknown-wasip1-threads }
- run: ./Utilities/build-examples.sh
- run: |
swift --version
./Utilities/build-examples.sh
env:
SWIFT_SDK_ID_wasm32_unknown_wasip1_threads: ${{ steps.setup-wasm32-unknown-wasip1-threads.outputs.swift-sdk-id }}
SWIFT_SDK_ID_wasm32_unknown_wasip1: ${{ steps.setup-wasm32-unknown-wasip1.outputs.swift-sdk-id }}
Expand Down
2 changes: 1 addition & 1 deletion Examples/Embedded/Sources/EmbeddedApp/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import JavaScriptKit
let alert = JSObject.global.alert.object!
let document = JSObject.global.document

print("Hello from WASM, document title: \(document.title.string ?? "")")
print("Hello from Wasm, document title: \(document.title.string ?? "")")

var count = 0

Expand Down
23 changes: 23 additions & 0 deletions Examples/EmbeddedConcurrency/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// swift-tools-version:6.0

import PackageDescription

let package = Package(
name: "EmbeddedConcurrency",
dependencies: [
.package(name: "JavaScriptKit", path: "../../")
],
targets: [
.executableTarget(
name: "EmbeddedConcurrencyApp",
dependencies: [
"JavaScriptKit",
.product(name: "JavaScriptEventLoop", package: "JavaScriptKit"),
],
swiftSettings: [
.enableExperimentalFeature("Extern"),
.swiftLanguageMode(.v5),
]
)
]
)
162 changes: 162 additions & 0 deletions Examples/EmbeddedConcurrency/Sources/EmbeddedConcurrencyApp/App.swift
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a better directory for test fixtures like this one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't found a better place for it, since neither XCTest nor Swift Testing support Embedded Swift, so we can't integrate this sample code with existing tests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add a case to the PackageToJS plugin test

Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
@preconcurrency import JavaScriptKit
@preconcurrency import JavaScriptEventLoop
import _Concurrency

#if compiler(>=6.3)
typealias DefaultExecutorFactory = JavaScriptEventLoop
#endif

@MainActor var testsPassed = 0
@MainActor var testsFailed = 0

@MainActor
func check(_ condition: Bool, _ message: String) {
let console = JSObject.global.console
if condition {
testsPassed += 1
_ = console.log("PASS: \(message)")
} else {
testsFailed += 1
_ = console.log("FAIL: \(message)")
}
}

@main
struct App {
@MainActor
static func main() async throws(JSException) {
JavaScriptEventLoop.installGlobalExecutor()

// Test 1: Basic async/await with checked continuation
let value: Int = await withCheckedContinuation { cont in
cont.resume(returning: 42)
}
check(value == 42, "withCheckedContinuation returns correct value")

// Test 2: Unsafe continuation
let value2: Int = await withUnsafeContinuation { cont in
cont.resume(returning: 7)
}
check(value2 == 7, "withUnsafeContinuation returns correct value")

// Test 3: JSPromise creation and .value await
let promise = JSPromise(resolver: { resolve in
resolve(.success(JSValue.number(123)))
})
let result: JSPromise.Result = await withUnsafeContinuation { continuation in
promise.then(
success: {
continuation.resume(returning: .success($0))
return JSValue.undefined
},
failure: {
continuation.resume(returning: .failure($0))
return JSValue.undefined
}
)
}
if case .success(let val) = result {
check(val.number == 123, "JSPromise.value resolves correctly")
} else {
check(false, "JSPromise.value resolves correctly")
}

// Test 4: setTimeout-based delay via JSPromise
let startTime = JSObject.global.Date.now().number!
let delayValue: Int = await withUnsafeContinuation { cont in
_ = JSObject.global.setTimeout!(
JSOneshotClosure { _ in
cont.resume(returning: 42)
return .undefined
},
100
)
}
let elapsed = JSObject.global.Date.now().number! - startTime
check(delayValue == 42 && elapsed >= 90, "setTimeout delay works (\(elapsed)ms elapsed)")

// Test 5: Multiple concurrent tasks (using withUnsafeContinuation to avoid nonisolated hop)
var results: [Int] = []
let task1 = Task { return 1 }
let task2 = Task { return 2 }
let task3 = Task { return 3 }
let r1: Int = await withUnsafeContinuation { cont in
Task { cont.resume(returning: await task1.value) }
}
let r2: Int = await withUnsafeContinuation { cont in
Task { cont.resume(returning: await task2.value) }
}
let r3: Int = await withUnsafeContinuation { cont in
Task { cont.resume(returning: await task3.value) }
}
results.append(r1)
results.append(r2)
results.append(r3)
results.sort()
check(results == [1, 2, 3], "Concurrent tasks all complete")

// Test 6: Promise chaining with .then
let chained = JSPromise(resolver: { resolve in
resolve(.success(JSValue.number(10)))
}).then(success: { value in
return JSValue.number(value.number! * 2)
}).then(success: { value in
return JSValue.number(value.number! + 5)
})
let chainedResult: JSPromise.Result = await withUnsafeContinuation { continuation in
chained.then(
success: {
continuation.resume(returning: .success($0))
return JSValue.undefined
},
failure: {
continuation.resume(returning: .failure($0))
return JSValue.undefined
}
)
}
if case .success(let val) = chainedResult {
check(val.number == 25, "Promise chaining works (10 * 2 + 5 = 25)")
} else {
check(false, "Promise chaining should succeed")
}

// Test 7: JSPromise.value await (with async resolution)
let promise2 = JSPromise(resolver: { resolve in
_ = JSObject.global.setTimeout!(
JSOneshotClosure { _ in
resolve(.success(JSValue.number(456)))
return .undefined
},
1
)
})
let awaitedValue = try await promise2.value
check(awaitedValue.number == 456, "JSPromise.value await returns correct value")

// Test 8: JSPromise.result await (with async resolution)
let promise3 = JSPromise(resolver: { resolve in
_ = JSObject.global.setTimeout!(
JSOneshotClosure { _ in
resolve(.success(JSValue.number(789)))
return .undefined
},
1
)
})
let awaitedResult = await promise3.result
if case .success(let val) = awaitedResult {
check(val.number == 789, "JSPromise.result await resolves correctly")
} else {
check(false, "JSPromise.result await should succeed")
}

// Summary
let console = JSObject.global.console
let totalTests = testsPassed + testsFailed
_ = console.log("TOTAL: \(totalTests) tests, \(testsPassed) passed, \(testsFailed) failed")
if testsFailed > 0 {
fatalError("\(testsFailed) test(s) failed")
}
}
}
8 changes: 8 additions & 0 deletions Examples/EmbeddedConcurrency/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/bash
set -euxo pipefail
package_dir="$(cd "$(dirname "$0")" && pwd)"
swift package --package-path "$package_dir" \
--swift-sdk "${SWIFT_SDK_ID_wasm32_unknown_wasip1:-${SWIFT_SDK_ID:-wasm32-unknown-wasip1}}-embedded" \
js --default-platform node
npm -C "$package_dir/.build/plugins/PackageToJS/outputs/Package" install
node "$package_dir/run.mjs"
47 changes: 47 additions & 0 deletions Examples/EmbeddedConcurrency/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { instantiate } from
"./.build/plugins/PackageToJS/outputs/Package/instantiate.js"
import { defaultNodeSetup } from
"./.build/plugins/PackageToJS/outputs/Package/platforms/node.js"

const EXPECTED_TESTS = 8;
const TIMEOUT_MS = 30_000;

// Intercept console.log to capture test output
const originalLog = console.log;
let totalLine = null;
let resolveTotal = null;
const totalPromise = new Promise((resolve) => { resolveTotal = resolve; });
console.log = (...args) => {
const line = args.join(" ");
originalLog.call(console, ...args);
if (line.startsWith("TOTAL:")) {
totalLine = line;
resolveTotal();
}
};

const options = await defaultNodeSetup();
await instantiate(options);

// Wait for the async main to complete (tests run via microtasks/setTimeout)
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timed out waiting for test results")), TIMEOUT_MS)
);
try {
await Promise.race([totalPromise, timeout]);
} catch (e) {
originalLog.call(console, `FAIL: ${e.message}`);
process.exit(1);
}

if (!totalLine) {
originalLog.call(console, `FAIL: No test summary found — main() likely exited early`);
process.exit(1);
}
const match = totalLine.match(/TOTAL: (\d+) tests/);
const ran = match ? parseInt(match[1], 10) : 0;
if (ran !== EXPECTED_TESTS) {
originalLog.call(console,
`FAIL: Expected ${EXPECTED_TESTS} tests but only ${ran} ran`);
process.exit(1);
}
35 changes: 35 additions & 0 deletions Sources/JavaScriptEventLoop/JSSending.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,32 @@ extension JSSending {
/// - Parameter isolation: The actor isolation context for this call, used in Swift concurrency.
/// - Returns: The received object of type `T`.
/// - Throws: `JSSendingError` if the sending operation fails, or `JSException` if a JavaScript error occurs.
#if compiler(>=6.4)
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func receive(
isolation: isolated (any Actor)? = #isolation,
file: StaticString = #file,
line: UInt = #line
) async throws(JSException) -> T {
#if _runtime(_multithreaded)
let idInDestination = try await withCheckedThrowingContinuation { continuation in
let context = _JSSendingContext(continuation: continuation)
let idInSource = self.storage.idInSource
let transferring = self.storage.transferring ? [idInSource] : []
swjs_request_sending_object(
idInSource,
transferring,
Int32(transferring.count),
self.storage.sourceTid,
Unmanaged.passRetained(context).toOpaque()
)
}
return storage.construct(JSObject(id: idInDestination))
#else
return storage.construct(storage.sourceObject)
#endif
}
#else
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public func receive(
isolation: isolated (any Actor)? = #isolation,
Expand All @@ -250,6 +276,7 @@ extension JSSending {
return storage.construct(storage.sourceObject)
#endif
}
#endif

// 6.0 and below can't compile the following without a compiler crash.
#if compiler(>=6.1)
Expand Down Expand Up @@ -341,11 +368,19 @@ extension JSSending {

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
private final class _JSSendingContext: Sendable {
#if compiler(>=6.4)
let continuation: CheckedContinuation<JavaScriptObjectRef, JSException>

init(continuation: CheckedContinuation<JavaScriptObjectRef, JSException>) {
self.continuation = continuation
}
#else
let continuation: CheckedContinuation<JavaScriptObjectRef, Error>

init(continuation: CheckedContinuation<JavaScriptObjectRef, Error>) {
self.continuation = continuation
}
#endif
}

/// Error type representing failures during JavaScript object sending operations.
Expand Down
Loading
Loading