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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
# Unreleased (0.4.0)

**Declared change — Apple platforms only:**

- The library now builds against **native Combine** on Apple platforms via
`#if canImport(Combine)`; WASI (and any platform without Combine) keeps OpenCombine.
Apple consumers receive native `Future`, `AnyCancellable`, `Scheduler`, and
`TopLevelDecoder` types from `JSPromise.publisher`, `JSScheduler`, and `JSValueDecoder`.
This changes type identity on Apple platforms (e.g. `PromisePublisher` now conforms to
`Combine.Publisher` instead of `OpenCombine.Publisher`) and is a declared break for any
Apple-platform consumer that pipelined these types into OpenCombine operators. WASI/WASM
consumers are unaffected. The package still depends on OpenCombine unconditionally;
full removal is tracked for 1.0 ([#11](https://github.com/IGRSoft/OpenCombineJS/issues/11),
[#15](https://github.com/IGRSoft/OpenCombineJS/issues/15))

**Additions:**

- Async/await bridge APIs alongside the Combine publishers (strictly additive — nothing is
deprecated): `JSScheduler.sleep(for:)` suspends via a one-shot `setTimeout`, and
`JSScheduler.timer(interval:)` returns an `AsyncStream` of repeating `setInterval` ticks
whose termination cancels the underlying JS timer. JavaScriptKit's existing
`JSPromise.value` (`JavaScriptEventLoop` module) is documented as the async counterpart
of `.publisher`. New wasm-gated differential tests prove the legacy Combine path and the
new async path yield identical outcomes for the same inputs
([#13](https://github.com/IGRSoft/OpenCombineJS/issues/13))
- `CONTRIBUTING.md` with host/wasm build-and-test instructions (including the
toolchain-mixing warning), the dependency version policy (minimum JavaScriptKit 0.54.1,
weekly upstream canaries, the `JSValueDecoder` deletion trigger), and the release/semver
policy ([#12](https://github.com/IGRSoft/OpenCombineJS/issues/12))

# 0.3.0 (2026-06-11)

This release fixes four long-standing `JSScheduler` bugs and adds the package's first automated
Expand Down
59 changes: 59 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Contributing to OpenCombineJS

## Build & test

Two lanes must stay green ([ci.yml](.github/workflows/ci.yml)). The host lane builds against
native Combine, the wasm lane against OpenCombine (`#if canImport(Combine)`, #11).

**Host (macOS, Swift 6.3+ via Xcode):**

```console
swift build
swift test # host-runnable tests; wasm-only suites self-gate via #if os(WASI)
```

**Wasm (official swift.org toolchain + version-matched wasm Swift SDK, Node ≥ 20):**

```console
# One-time: install the wasm SDK matching your swift.org toolchain version, e.g.
swift sdk install "https://download.swift.org/swift-6.3.2-release/wasm-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_wasm.artifactbundle.tar.gz" --checksum <official-sha256>

swift build --swift-sdk swift-6.3.2-RELEASE_wasm --build-tests
swift package --disable-sandbox --swift-sdk swift-6.3.2-RELEASE_wasm js test
```

Notes:

- The Xcode toolchain has no wasm backend — select a swift.org toolchain for wasm builds
(e.g. `TOOLCHAINS=org.swift.<id> swift build …` on macOS). Toolchain and SDK versions must
match exactly; checksums live in [ci.yml](.github/workflows/ci.yml).
- **Never mix Xcode and swift.org toolchains in one `.build`** — their host-tool modules are
incompatible. Run `swift package clean` whenever you switch between host and wasm invocations.
- `js test` needs `--disable-sandbox` (the PackageToJS plugin runs npm) and the default scratch
path (no `--scratch-path`).
- Run `swiftformat --lint .` (repo `.swiftformat`: 2-space indent) before committing; new Swift
files carry the Apache header.

## Dependency version policy

- Minimum **JavaScriptKit 0.54.1** (ships `JSPromise.value`, required by the async bridge, #13).
- Weekly canaries ([canary-upstreams.yml](.github/workflows/canary-upstreams.yml)) build against
the latest JavaScriptKit release and OpenCombine main. They are allowed to fail and auto-file
`canary-failure` issues instead of breaking CI.
- The moment JavaScriptKit ships a native `TopLevelDecoder` conformance, our retroactive one
becomes a duplicate-conformance build error: delete `Sources/OpenCombineJS/JSValueDecoder.swift`
and tag a minor release (#9). The JSKit canary exists to catch this early.

## Release & semver policy

- Every PR is gated by [api-contract.yml](.github/workflows/api-contract.yml):
`swift package diagnose-api-breaking-changes` against the **PR's base branch** — the gate
catches breakage introduced by the PR, not historical breakage.
- Intentional breaking changes must be **declared** in `CHANGELOG.md` (under
`# Unreleased (x.y.z)`) with the matching semver bump — never hidden.
- Cutting a release: run the digester against the last release tag, declare every reported
change, stamp the `Unreleased` CHANGELOG section with version + date, then tag.

## Code of conduct

This project follows the [SwiftWasm Code of Conduct](https://github.com/swiftwasm/.github/blob/main/CODE_OF_CONDUCT.md).
6 changes: 6 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ let package = Package(
package: "JavaScriptKit",
condition: .when(platforms: [.wasi])
),
// Provides `JSPromise.value` (async) consumed by the differential tests (#13).
.product(
name: "JavaScriptEventLoop",
package: "JavaScriptKit",
condition: .when(platforms: [.wasi])
),
]
),
],
Expand Down
56 changes: 50 additions & 6 deletions Sources/OpenCombineJS/Documentation.docc/OpenCombineJS.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,55 @@
# ``OpenCombineJS``

OpenCombine helpers for JavaScriptKit/WebAssembly APIs.
Combine and async/await helpers for JavaScriptKit/WebAssembly APIs.

## Overview

OpenCombineJS bridges [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit) and
[OpenCombine](https://github.com/OpenCombine/OpenCombine) so you can use idiomatic Combine
pipelines in browser-targeting Swift packages compiled to WebAssembly (WASM).
OpenCombineJS bridges [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit) and the
Combine ecosystem so you can use idiomatic Combine pipelines — and their async/await
counterparts — in browser-targeting Swift packages compiled to WebAssembly (WASM).

The library provides three components:
The library provides these components:

| Component | Description |
|---|---|
| ``JSScheduler`` | A Combine `Scheduler` backed by `setTimeout`/`setInterval` that enables time-based operators in the browser. |
| `JSPromise.publisher` | A `Publisher` property on `JSPromise` that resolves or rejects in lock-step with the underlying JavaScript `Promise`. |
| ``JSScheduler/sleep(for:)`` / ``JSScheduler/timer(interval:)`` | Async/await counterparts of the scheduling APIs: a one-shot JS-timer delay and an `AsyncStream` of repeating ticks. |
| `JSPromise.publisher` | A `Publisher` property on `JSPromise` that resolves or rejects in lock-step with the underlying JavaScript `Promise`. Its async counterpart is JavaScriptKit's `JSPromise.value` (`import JavaScriptEventLoop`). |
| `JSValueDecoder` (`TopLevelDecoder`) | A retroactive conformance that lets `JSValueDecoder` work with the `.decode(type:decoder:)` Combine operator. |

### Combine on Apple platforms, OpenCombine on WASI

The library selects its Combine backend at compile time (`#if canImport(Combine)`): on
Apple platforms it builds against the **native Combine framework**, so `JSPromise.publisher`,
``JSScheduler``, and the `JSValueDecoder` conformance vend native `Combine` types; on WASI
(and any platform without Combine) it builds against
[OpenCombine](https://github.com/OpenCombine/OpenCombine). The two modules mirror each other
symbol-for-symbol — only the module identity differs, so the same consumer code compiles on
both backends.

### Async/await usage

`await`-based APIs require the `JavaScriptEventLoop` global executor on WASI
(`JavaScriptEventLoop.installGlobalExecutor()`), so suspended tasks are resumed by JS
timers and promises:

```swift
import JavaScriptEventLoop

JavaScriptEventLoop.installGlobalExecutor()

let value = try await promise.value // async counterpart of .publisher

let scheduler = JSScheduler()
await scheduler.sleep(for: .milliseconds(300)) // one-shot setTimeout delay

for await _ in scheduler.timer(interval: .seconds(1)) { // repeating setInterval ticks
refresh()
}
```

The Combine surface is not deprecated: publishers and async APIs are supported side by side.

### JavaScript event-loop context

All code in OpenCombineJS runs on the single-threaded JavaScript event loop. There is no
Expand Down Expand Up @@ -88,9 +122,19 @@ let timer = JSTimer(millisecondsDelay: 1000, isRepeating: true) {
- ``JSScheduler/SchedulerTimeType/Stride``
- ``JSScheduler/SchedulerOptions``

### Async Scheduling

- ``JSScheduler/sleep(for:)``
- ``JSScheduler/timer(interval:)``

### JavaScript Promise Integration

OpenCombineJS extends `JSPromise` (from JavaScriptKit) with a `publisher` property and two
nested types: `JSPromise.PromisePublisher` (the Combine publisher) and `JSPromise.PromiseError`
(the typed rejection wrapper). Because `JSPromise` is declared in JavaScriptKit, its extension
members appear in the inherited-symbols section of the generated archive rather than here.

The async counterpart of `.publisher` is `JSPromise.value` (`get async throws(JSException)`),
shipped by JavaScriptKit's `JavaScriptEventLoop` module — not by this package. Both paths
observe the same promise settlement; on rejection the raw JS reason is available as
`PromiseError.value` (publisher path) or `JSException.thrownValue` (async path).
27 changes: 27 additions & 0 deletions Sources/OpenCombineJS/JSPromise.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,18 @@
// limitations under the License.

import JavaScriptKit

// Dual Combine backend (issue #11): on Apple platforms the library is built against the
// native Combine framework, so consumers receive native `Future`, `AnyCancellable`,
// `Scheduler`, and `TopLevelDecoder` types; on WASI (and any platform without Combine)
// it is built against OpenCombine. All consumed symbols are mirrored 1:1 between the two
// modules — only the module identity differs. The host (macOS) test lane exercises the
// Combine backend; the wasm test lane exercises the OpenCombine backend.
#if canImport(Combine)
import Combine
#else
import OpenCombine
#endif

/// Extensions that expose a Combine `Publisher` interface on `JSPromise`.
///
Expand Down Expand Up @@ -104,6 +115,22 @@ public extension JSPromise {
/// .flatMap { $0.json().publisher } // chain further JS promises
/// .sink { ... }
/// ```
///
/// ## Async/await counterpart
///
/// JavaScriptKit's `JavaScriptEventLoop` module ships `JSPromise.value`
/// (`get async throws(JSException)`), the async counterpart of this publisher:
///
/// ```swift
/// import JavaScriptEventLoop
///
/// let value = try await promise.value
/// ```
///
/// Both paths observe the same settlement: the resolved `JSValue`, or — on rejection —
/// the same raw JS reason (`PromiseError.value` here, `JSException.thrownValue` there).
/// `.publisher` is not deprecated and remains fully supported alongside the async path;
/// any soft-deprecation decision is deferred to 1.0 (issue #13).
var publisher: PromisePublisher {
.init(promise: self)
}
Expand Down
115 changes: 115 additions & 0 deletions Sources/OpenCombineJS/JSScheduler+Async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright 2020 OpenCombineJS contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Async/await bridge over JSScheduler's JS timer semantics (issue #13).
// Strictly additive: the Combine `Scheduler` surface is unchanged and not deprecated.

// Dual Combine backend — see JSPromise.swift for the canonical rationale (issue #11).
#if canImport(Combine)
import Combine
#else
import OpenCombine
#endif

public extension JSScheduler {
/// Suspends the current task for the given interval, driven by a JavaScript timer.
///
/// This is the async/await counterpart of ``JSScheduler/schedule(after:tolerance:options:_:)``.
/// The delay is implemented with `setTimeout` — **not** `Task.sleep` — so it follows exactly
/// the same JS macrotask semantics as the Combine scheduling APIs and shares the scheduler's
/// internal timer bookkeeping (the timer entry is removed when it fires; nothing leaks).
///
/// The method never resumes early: the continuation is resumed by the JS timer callback, which
/// the JS runtime fires no earlier than the requested delay. Zero or negative intervals resume
/// on the next event-loop turn (JS timers clamp negative delays to `0`).
///
/// ## Executor requirement (WASI)
///
/// On WASI, `await`-ing requires the `JavaScriptEventLoop` global executor so suspended tasks
/// are resumed by JS timers/promises. Call `JavaScriptEventLoop.installGlobalExecutor()` once
/// at startup (the `JavaScriptEventLoopTestSupport` target installs it automatically in tests).
///
/// - Parameter interval: How long to suspend, e.g. `.milliseconds(300)`.
func sleep(for interval: SchedulerTimeType.Stride) async {
await withCheckedContinuation { (continuation: CheckedContinuation<(), Never>) in
schedule(
after: now.advanced(by: interval),
tolerance: minimumTolerance,
options: nil
) {
continuation.resume()
}
}
}

/// Returns an `AsyncStream` that yields once per `interval`, backed by the same repeating
/// JS timer (`setInterval`) semantics as
/// ``JSScheduler/schedule(after:interval:tolerance:options:_:)``.
///
/// The first tick arrives one full `interval` after the call — the same first-fire semantics
/// as the Combine repeating schedule. The stream never finishes on its own; iteration ends
/// when the consumer breaks out of the loop or the consuming task is cancelled. Stream
/// termination cancels the underlying timer through the scheduler's token bookkeeping
/// (both the pending timeout and the interval timer are released; nothing leaks):
///
/// ```swift
/// for await _ in scheduler.timer(interval: .milliseconds(500)) {
/// refresh()
/// if done { break } // cancels the underlying JS timer
/// }
/// ```
///
/// If the consumer is slower than the tick cadence, pending ticks are coalesced: the stream
/// buffers at most one tick (`bufferingNewest(1)`), mirroring how a UI-driven `setInterval`
/// callback would observe time, instead of queueing stale ticks unboundedly.
///
/// ## Executor requirement (WASI)
///
/// On WASI, iterating the stream requires the `JavaScriptEventLoop` global executor — see
/// ``JSScheduler/sleep(for:)``.
///
/// - Parameter interval: The period between successive ticks.
/// - Returns: An infinite `AsyncStream` of `Void` ticks.
func timer(interval: SchedulerTimeType.Stride) -> AsyncStream<()> {
AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
let cancellable = SendableCancellableBox(
schedule(
after: now,
interval: interval,
tolerance: minimumTolerance,
options: nil
) {
continuation.yield(())
}
)
// `onTermination` must be `@Sendable`; the boxed `Cancellable` is not. This is safe on
// the single-threaded JS event loop (the same invariant documented on `JSScheduler`):
// termination handlers run on the only thread there is.
continuation.onTermination = { _ in
cancellable.cancellable.cancel()
}
}
}
}

/// Wrapper that carries a non-`Sendable` `Cancellable` into the `@Sendable` stream-termination
/// handler. Safe because `JSScheduler` (and everything it schedules) lives on the
/// single-threaded JS event loop; see the `JSScheduler` class documentation.
private final class SendableCancellableBox: @unchecked Sendable {
let cancellable: any Cancellable

init(_ cancellable: any Cancellable) {
self.cancellable = cancellable
}
}
6 changes: 6 additions & 0 deletions Sources/OpenCombineJS/JSScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@
// limitations under the License.

import JavaScriptKit

// Dual Combine backend — see JSPromise.swift for the canonical rationale (issue #11).
#if canImport(Combine)
import Combine
#else
import OpenCombine
#endif

/// A Combine `Scheduler` backed by JavaScript timers (`setTimeout`/`setInterval`).
///
Expand Down
6 changes: 6 additions & 0 deletions Sources/OpenCombineJS/JSValueDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@
// limitations under the License.

import JavaScriptKit

// Dual Combine backend — see JSPromise.swift for the canonical rationale (issue #11).
#if canImport(Combine)
import Combine
#else
import OpenCombine
#endif

/// Retroactive conformance that bridges JavaScriptKit's `JSValueDecoder` into the
/// OpenCombine / Combine ecosystem.
Expand Down
8 changes: 7 additions & 1 deletion Sources/OpenCombineJSExample/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@
// to register state and return — execution continues via the event loop thereafter.

import JavaScriptKit
import OpenCombine
import OpenCombineJS

// Dual Combine backend — see OpenCombineJS/JSPromise.swift for the rationale (issue #11).
#if canImport(Combine)
import Combine
#else
import OpenCombine
#endif

// MARK: - Helpers

/// Wraps the browser's `fetch` global as a typed Swift function.
Expand Down
Loading
Loading