Skip to content

Commit

Permalink
feat: realtime support (#2)
Browse files Browse the repository at this point in the history
* feat(wip): initial realtime impl

* chore: move sample app to samples folder

* feat: realtime + pencilkit sample

* chore: update readme
  • Loading branch information
drochetti authored Nov 28, 2023
1 parent b56e8d2 commit 85d298c
Show file tree
Hide file tree
Showing 30 changed files with 864 additions and 475 deletions.
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/SwiftFormat",
"state" : {
"revision" : "d37a477177d5d4ff2a3ae6328eaaab5bf793e702",
"version" : "0.52.9"
"revision" : "cac06079ce883170ab44cb021faad298daeec2a5",
"version" : "0.52.10"
}
}
],
Expand Down
20 changes: 14 additions & 6 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import PackageDescription
let package = Package(
name: "FalClient",
platforms: [
.iOS(.v13),
.macOS(.v11),
.iOS(.v15),
.macOS(.v12),
.macCatalyst(.v13),
.tvOS(.v13),
.watchOS(.v8),
Expand All @@ -20,17 +20,25 @@ let package = Package(
),
],
dependencies: [
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.4"),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.52.10"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "FalClient"),
.target(name: "FalClient"),
.testTarget(
name: "FalClientTests",
dependencies: ["FalClient"]
),
.target(name: "FalSampleApp", dependencies: ["FalClient"]),
.target(
name: "FalSampleApp",
dependencies: ["FalClient"],
path: "Sources/Samples/FalSampleApp"
),
.target(
name: "FalRealtimeSampleApp",
dependencies: ["FalClient"],
path: "Sources/Samples/FalRealtimeSampleApp"
),
]
)
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ This Swift client library is crafted as a lightweight layer atop Swift's network
2. Set up the client instance:
```swift
import FalClient
let fal = FalClient(withCredentials: "FAL_KEY_ID:FAL_KEY_SECRET")
let fal = FalClient.withCredentials(.keyPair("FAL_KEY_ID:FAL_KEY_SECRET"))

// You can also use a proxy to protect your credentials
// let fal = FalClient.withProxy("http://localhost:3333/api/fal/proxy")
```

3. Use `fal.subscribe` to dispatch requests to the model API:
Expand All @@ -40,11 +43,49 @@ This Swift client library is crafted as a lightweight layer atop Swift's network
```

**Notes:**

- Replace `text-to-image` with a valid model id. Check [fal.ai/models](https://fal.ai/models) for all available models.
- It fully relies on `async/await` for asynchronous programming.
- The result type in Swift will be a `[String: Any]` and the entries depend on the API output schema.
- The Swift client also supports typed inputs and outputs through `Codable`.

## Real-time

The client supports real-time model APIs. Checkout the [FalRealtimeSampleApp](./Sources/Samples/FalRealtimeSampleApp/) for more details.

```swift
let connection = try fal.realtime.connect(
to: OptimizedLatentConsistency,
connectionKey: "PencilKitDemo",
throttleInterval: .milliseconds(128)
) { (result: Result<LcmResponse, Error>) in
if case let .success(data) = result,
let image = data.images.first {
let data = try? Data(contentsOf: URL(string: image.url)!)
DispatchQueue.main.async {
self.currentImage = data
}
}
}

try connection.send(LcmInput(
prompt: prompt,
imageUrl: "data:image/jpeg;base64,\(drawing.base64EncodedString())",
seed: 6_252_023,
syncMode: true
))
```

## Sample apps

Check the `Sources/Samples` folder for a handful of sample applications using the `FalClient`.

Open them with `xed` to quickly start playing with

```bash
xed Sources/Sample/FalSampleApp
```

## Roadmap

See the [open feature requests](https://github.com/fal-ai/serverless-client-swift/labels/enhancement) for a list of proposed features and join the discussion.
Expand Down
19 changes: 10 additions & 9 deletions Sources/FalClient/Client+Codable.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Dispatch
import Foundation

public struct EmptyInput: Encodable {
Expand All @@ -18,7 +19,7 @@ public extension Client {
}

func run<Output: Decodable>(
_ id: String,
_ app: String,
input: (some Encodable) = EmptyInput.empty,
options: RunOptions = DefaultRunOptions
) async throws -> Output {
Expand All @@ -27,25 +28,25 @@ public extension Client {
? try JSONSerialization.jsonObject(with: inputData!) as? [String: Any]
: nil

let data = try await sendRequest(id, input: inputData, queryParams: queryParams, options: options)
let url = buildUrl(fromId: app, path: options.path)
let data = try await sendRequest(url, input: inputData, queryParams: queryParams, options: options)
return try decoder.decode(Output.self, from: data)
}

func subscribe<Output: Decodable>(
_ id: String,
to app: String,
input: (some Encodable) = EmptyInput.empty,
pollInterval: FalTimeInterval = .seconds(1),
timeout: FalTimeInterval = .minutes(3),
pollInterval: DispatchTimeInterval = .seconds(1),
timeout: DispatchTimeInterval = .minutes(3),
includeLogs: Bool = false,
options _: RunOptions = DefaultRunOptions,
onQueueUpdate: OnQueueUpdate? = nil
) async throws -> Output {
let requestId = try await queue.submit(id, input: input)
let requestId = try await queue.submit(app, input: input)
let start = Int(Date().timeIntervalSince1970 * 1000)
var elapsed = 0
var isCompleted = false
while elapsed < timeout.milliseconds {
let update = try await queue.status(id, of: requestId, includeLogs: includeLogs)
let update = try await queue.status(app, of: requestId, includeLogs: includeLogs)
if let onQueueUpdateCallback = onQueueUpdate {
onQueueUpdateCallback(update)
}
Expand All @@ -59,6 +60,6 @@ public extension Client {
if !isCompleted {
throw FalError.queueTimeout
}
return try await queue.response(id, of: requestId)
return try await queue.response(app, of: requestId)
}
}
5 changes: 2 additions & 3 deletions Sources/FalClient/Client+Request.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import Foundation

extension Client {
func sendRequest(_ id: String, input: Data?, queryParams: [String: Any]? = nil, options: RequestOptions) async throws -> Data {
let urlString = buildUrl(fromId: id, path: options.path)
func sendRequest(_ urlString: String, input: Data?, queryParams: [String: Any]? = nil, options: RunOptions) async throws -> Data {
guard var url = URL(string: urlString) else {
throw FalError.invalidUrl(url: urlString)
}
Expand Down Expand Up @@ -49,6 +48,6 @@ extension Client {

var userAgent: String {
let osVersion = ProcessInfo.processInfo.operatingSystemVersionString
return "fal.ai/swift-client 0.0.1 - \(osVersion)"
return "fal.ai/swift-client 0.1.0 - \(osVersion)"
}
}
36 changes: 18 additions & 18 deletions Sources/FalClient/Client.swift
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
import Dispatch
import Foundation

enum HttpMethod: String {
public enum HttpMethod: String {
case get
case post
case put
case delete
}

protocol RequestOptions {
public protocol RequestOptions {
var httpMethod: HttpMethod { get }
var path: String { get }
}

public struct RunOptions: RequestOptions {
let path: String
let httpMethod: HttpMethod
public let path: String
public let httpMethod: HttpMethod

static func withMethod(_ method: HttpMethod) -> Self {
static func withMethod(_ method: HttpMethod) -> RunOptions {
RunOptions(path: "", httpMethod: method)
}

static func route(_ path: String, withMethod method: HttpMethod = .post) -> Self {
static func route(_ path: String, withMethod method: HttpMethod = .post) -> RunOptions {
RunOptions(path: path, httpMethod: method)
}
}
Expand All @@ -34,39 +35,38 @@ public protocol Client {

var queue: Queue { get }

var realtime: Realtime { get }

func run(_ id: String, input: [String: Any]?, options: RunOptions) async throws -> [String: Any]

func subscribe(
_ id: String,
to app: String,
input: [String: Any]?,
pollInterval: FalTimeInterval,
timeout: FalTimeInterval,
pollInterval: DispatchTimeInterval,
timeout: DispatchTimeInterval,
includeLogs: Bool,
options: RunOptions,
onQueueUpdate: OnQueueUpdate?
) async throws -> [String: Any]
}

public extension Client {
func run(_ id: String, input: [String: Any]? = nil, options: RunOptions = DefaultRunOptions) async throws -> [String: Any] {
return try await run(id, input: input, options: options)
func run(_ app: String, input: [String: Any]? = nil, options: RunOptions = DefaultRunOptions) async throws -> [String: Any] {
return try await run(app, input: input, options: options)
}

func subscribe(
_ id: String,
to app: String,
input: [String: Any]? = nil,
pollInterval: FalTimeInterval = .seconds(1),
timeout: FalTimeInterval = .minutes(3),
pollInterval: DispatchTimeInterval = .seconds(1),
timeout: DispatchTimeInterval = .minutes(3),
includeLogs: Bool = false,
options: RunOptions = DefaultRunOptions,
onQueueUpdate: OnQueueUpdate? = nil
) async throws -> [String: Any] {
return try await subscribe(id,
return try await subscribe(to: app,
input: input,
pollInterval: pollInterval,
timeout: timeout,
includeLogs: includeLogs,
options: options,
onQueueUpdate: onQueueUpdate)
}
}
27 changes: 17 additions & 10 deletions Sources/FalClient/FalClient.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Dispatch
import Foundation

func buildUrl(fromId id: String, path: String? = nil) -> String {
Expand Down Expand Up @@ -28,31 +29,33 @@ public struct FalClient: Client {

public var queue: Queue { QueueClient(client: self) }

public func run(_ id: String, input: [String: Any]?, options: RunOptions) async throws -> [String: Any] {
public var realtime: Realtime { RealtimeClient(client: self) }

public func run(_ app: String, input: [String: Any]?, options: RunOptions) async throws -> [String: Any] {
let inputData = input != nil ? try JSONSerialization.data(withJSONObject: input as Any) : nil
let queryParams = options.httpMethod == .get ? input : nil
let data = try await sendRequest(id, input: inputData, queryParams: queryParams, options: options)
let url = buildUrl(fromId: app, path: options.path)
let data = try await sendRequest(url, input: inputData, queryParams: queryParams, options: options)
guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw FalError.invalidResultFormat
}
return result
}

public func subscribe(
_ id: String,
to app: String,
input: [String: Any]?,
pollInterval: FalTimeInterval,
timeout: FalTimeInterval,
pollInterval: DispatchTimeInterval,
timeout: DispatchTimeInterval,
includeLogs: Bool,
options _: RunOptions,
onQueueUpdate: OnQueueUpdate?
) async throws -> [String: Any] {
let requestId = try await queue.submit(id, input: input)
let requestId = try await queue.submit(app, input: input)
let start = Int(Date().timeIntervalSince1970 * 1000)
var elapsed = 0
var isCompleted = false
while elapsed < timeout.milliseconds {
let update = try await queue.status(id, of: requestId, includeLogs: includeLogs)
let update = try await queue.status(app, of: requestId, includeLogs: includeLogs)
if let onQueueUpdateCallback = onQueueUpdate {
onQueueUpdateCallback(update)
}
Expand All @@ -66,12 +69,16 @@ public struct FalClient: Client {
if !isCompleted {
throw FalError.queueTimeout
}
return try await queue.response(id, of: requestId)
return try await queue.response(app, of: requestId)
}
}

public extension FalClient {
static func withProxy(_ url: String) -> FalClient {
static func withProxy(_ url: String) -> Client {
return FalClient(config: ClientConfig(requestProxy: url))
}

static func withCredentials(_ credentials: ClientCredentials) -> Client {
return FalClient(config: ClientConfig(credentials: credentials))
}
}
19 changes: 0 additions & 19 deletions Sources/FalClient/FalTimeInterval.swift

This file was deleted.

30 changes: 30 additions & 0 deletions Sources/FalClient/Realtime+Codable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Dispatch
import Foundation

class CodableRealtimeConnection<Input: Encodable>: RealtimeConnection<Input> {
override public func send(_ data: Input) throws {
let json = try JSONEncoder().encode(data)
try sendReference(json)
}
}

public extension Realtime {
func connect<Input: Encodable, Output: Decodable>(
to app: String,
connectionKey: String,
throttleInterval: DispatchTimeInterval,
onResult completion: @escaping (Result<Output, Error>) -> Void
) throws -> RealtimeConnection<Input> {
return handleConnection(
to: app, connectionKey: connectionKey, throttleInterval: throttleInterval,
resultConverter: { data in
let result = try JSONDecoder().decode(Output.self, from: data)
return result
},
connectionFactory: { send, close in
CodableRealtimeConnection(send, close)
},
onResult: completion
)
}
}
Loading

0 comments on commit 85d298c

Please sign in to comment.