├── .gitignore ├── Sources └── ECMASwift │ ├── Helpers │ ├── JSValue+toType.swift │ ├── JSValue+isNil.swift │ ├── JSValue+callFunction.swift │ └── JSContext+callFunction.swift │ ├── API │ ├── TextEncoder │ │ └── TextEncoder.swift │ ├── Fetch │ │ ├── HTTPClient.swift │ │ ├── AbortController.swift │ │ └── Fetch.swift │ ├── Console │ │ └── Console.swift │ ├── FormData │ │ └── FormData.swift │ ├── Crypto │ │ ├── Crypto.swift │ │ └── SublteCrypto.swift │ ├── Headers │ │ └── Headers.swift │ ├── Request │ │ ├── RequestBody.swift │ │ └── Request.swift │ ├── Timers │ │ └── Timers.swift │ ├── Blob │ │ └── Blob.swift │ ├── URLSearchParams │ │ └── URLSearchParams.swift │ └── URL │ │ └── URL.swift │ └── JSRuntime.swift ├── .swiftformat ├── .swiftlint.yml ├── .github └── workflows │ └── test.yml ├── Tests └── ECMASwiftTests │ ├── SubtleCryptoTests.swift │ ├── HeadersTests.swift │ ├── BlobTests.swift │ ├── TextEncoderTests.swift │ ├── CryptoTests.swift │ ├── URLSearchParamsTests.swift │ ├── TimerTests.swift │ ├── RequestTests.swift │ ├── URLTests.swift │ └── FetchTests.swift ├── LICENSE ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Packages/ 3 | Package.pins 4 | Package.resolved 5 | .swiftpm 6 | .build/ 7 | -------------------------------------------------------------------------------- /Sources/ECMASwift/Helpers/JSValue+toType.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | 3 | extension JSValue { 4 | func toType(_: T.Type) -> T? { 5 | return toObject() as? T 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # format options 2 | 3 | --comments indent 4 | --header ignore 5 | --self remove 6 | --swiftversion 4.2 7 | --trimwhitespace always 8 | --maxwidth 120 9 | 10 | # rules 11 | 12 | --enable isEmpty 13 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - opening_brace 3 | - trailing_comma 4 | identifier_name: 5 | excluded: 6 | - id 7 | line_length: 8 | warning: 120 9 | trailing_whitespace: 10 | severity: warning 11 | vertical_whitespace: 12 | severity: warning 13 | opt_in_rules: 14 | - force_unwrapping 15 | -------------------------------------------------------------------------------- /Sources/ECMASwift/Helpers/JSValue+isNil.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | 3 | public extension JSValue { 4 | 5 | /// `true` if the value is `undefined` or `null` 6 | var hasNoValue: Bool { 7 | return isUndefined || isNull 8 | } 9 | 10 | /// `true` if the value is neither `undefined` nor `null` 11 | var hasValue: Bool { 12 | return !isUndefined && !isNull 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: swift build -v 21 | - name: Run tests 22 | run: swift test -v 23 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/SubtleCryptoTests.swift: -------------------------------------------------------------------------------- 1 | import ECMASwift 2 | import JavaScriptCore 3 | import XCTest 4 | 5 | final class SubtleCryptoTests: XCTestCase { 6 | let runtime = JSRuntime() 7 | 8 | func testDigestSHA256() { 9 | let result = runtime.context.evaluateScript(""" 10 | const data = new Uint8Array([1, 2, 3, 4, 5]) 11 | crypto.subtle.digest('SHA-256', Array.from(data)) 12 | """) 13 | XCTAssertNotNil(result?.toArray()) 14 | } 15 | 16 | func testEncryptAESGCM() { 17 | let result = runtime.context.evaluateScript(""" 18 | const key = new Uint8Array(16).fill(1) 19 | const iv = new Uint8Array(16).fill(2) 20 | const data = new Uint8Array([1, 2, 3, 4, 5]) 21 | crypto.subtle.encrypt('AES-GCM', Array.from(key), Array.from(iv), Array.from(data)) 22 | """) 23 | XCTAssertNotNil(result?.toArray()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/TextEncoder/TextEncoder.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | 3 | @objc 4 | protocol TextEncoderExports: JSExport { 5 | var encoding: String { get set } 6 | func encode(_ input: String) -> [UInt8] 7 | } 8 | 9 | /// This implmenets the `TextEncoder` browser API. 10 | /// 11 | /// Reference: [TextEncoder Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) 12 | final class TextEncoder: NSObject, TextEncoderExports { 13 | var encoding: String = "utf-8" 14 | 15 | func encode(_ input: String) -> [UInt8] { 16 | return Array(input.utf8) 17 | } 18 | } 19 | 20 | /// Helper to register the ``TextEncoder`` API with a context. 21 | public struct TextEncoderAPI { 22 | public func registerAPIInto(context: JSContext) { 23 | let textEncoderClass: @convention(block) () -> TextEncoder = { 24 | TextEncoder() 25 | } 26 | context.setObject( 27 | unsafeBitCast(textEncoderClass, to: AnyObject.self), 28 | forKeyedSubscript: "TextEncoder" as NSString 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Theo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ECMASwift", 8 | platforms: [.macOS(.v11), .iOS(.v14), .tvOS(.v14), .watchOS(.v7)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "ECMASwift", 13 | targets: ["ECMASwift"] 14 | ), 15 | ], 16 | dependencies: [ 17 | // Dependencies declare other packages that this package depends on. 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "ECMASwift", 24 | dependencies: [] 25 | ), 26 | .testTarget( 27 | name: "ECMASwiftTests", 28 | dependencies: ["ECMASwift"] 29 | ), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/HeadersTests.swift: -------------------------------------------------------------------------------- 1 | import ECMASwift 2 | import JavaScriptCore 3 | import XCTest 4 | 5 | final class HeadersTests: XCTestCase { 6 | func testGetAllHeaders() async throws { 7 | let runtime = JSRuntime() 8 | let result = runtime.context.evaluateScript(""" 9 | try { 10 | let headers = new Headers() 11 | headers.set("Content-Type", "application/json"); 12 | headers.getAll() 13 | } catch(error) { 14 | error 15 | } 16 | """)!.toDictionary()!["Content-Type"] as? String 17 | 18 | XCTAssertEqual(result, "application/json") 19 | } 20 | 21 | func testGetHeader() async throws { 22 | let runtime = JSRuntime() 23 | let result = runtime.context.evaluateScript(""" 24 | try { 25 | let headers = new Headers() 26 | headers.set("Content-Type", "application/json"); 27 | headers.get("Content-Type") 28 | } catch(error) { 29 | error 30 | } 31 | """)!.toString() 32 | 33 | XCTAssertEqual(result, "application/json") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Fetch/HTTPClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for `URLSession` to be able to inject mocked responses 4 | public protocol HTTPClient: Sendable { 5 | func data(for: URLRequest) async throws -> (Data, URLResponse) 6 | } 7 | 8 | /// Conform URLSession to `HTTPClient` so we can pass it directly to the networking APIs (Fetch) 9 | extension URLSession: HTTPClient {} 10 | 11 | /// Mocking Helpers 12 | public struct MockClient: HTTPClient { 13 | let data: Data 14 | let response: URLResponse 15 | 16 | public init( 17 | url: Foundation.URL, 18 | json: String, 19 | statusCode: Int 20 | ) { 21 | self.response = HTTPURLResponse( 22 | url: url, 23 | statusCode: statusCode, 24 | httpVersion: nil, 25 | headerFields: [:] 26 | )! as URLResponse 27 | self.data = json.data(using: .utf8)! 28 | } 29 | 30 | public func data(for: URLRequest) async throws -> (Data, URLResponse) { 31 | let randomMilliseconds = Int.random(in: 100...500) 32 | 33 | let nanoseconds = UInt64(randomMilliseconds) * 1_000_000 34 | try await Task.sleep(nanoseconds: nanoseconds) 35 | return (data, response) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Console/Console.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | import os 3 | 4 | @objc protocol ConsoleExports: JSExport { 5 | static func log(_ msg: String) 6 | static func info(_ msg: String) 7 | static func warn(_ msg: String) 8 | static func error(_ msg: String) 9 | } 10 | 11 | /// This implmenets the `Console` browser API. 12 | /// 13 | /// Reference: [Console Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Console) 14 | final class Console: NSObject, ConsoleExports { 15 | static let logger = Logger(subsystem: "JSRuntime", category: "Console") 16 | 17 | public class func log(_ msg: String) { 18 | logger.log("\(msg)") 19 | } 20 | 21 | public class func info(_ msg: String) { 22 | logger.info("\(msg)") 23 | } 24 | 25 | public class func warn(_ msg: String) { 26 | logger.warning("\(msg)") 27 | } 28 | 29 | public class func error(_ msg: String) { 30 | logger.error("\(msg)") 31 | } 32 | } 33 | 34 | /// Helper to register the ``Console`` API with a context. 35 | public struct ConsoleAPI { 36 | public func registerAPIInto(context: JSContext) { 37 | context.setObject( 38 | Console.self, 39 | forKeyedSubscript: "console" as NSCopying & NSObjectProtocol 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### ECMASwift 2 | 3 | ECMASwift intends to implement a tiny subset of Browser APIs (mostly networking related) to make code sharing between iOS/macOS apps and the web easier. 4 | 5 | ### Features 6 | 7 | ECMASwift exposes the following browser APIs to JavascriptCore, some of these are incomplete, contributions welcome. 8 | 9 | - Blob 10 | - Console 11 | - Crypto 12 | - Fetch 13 | - FormData 14 | - Headers 15 | - Request 16 | - TextEncoder 17 | - Timers 18 | - URL 19 | - URLSearchParams 20 | 21 | ### Examples 22 | 23 | In Javascript: 24 | ```js 25 | // Define an async function to fetch some dummy data in Javascript 26 | async function fetchProducts() { 27 | try { 28 | const res = await fetch("https://google.com") 29 | return await res.json() 30 | } catch(error) { 31 | console.log(error) 32 | } 33 | } 34 | ``` 35 | 36 | In Swift: 37 | ```swift 38 | import ECMASwift 39 | import JavaScriptCore 40 | 41 | // Initialise the runtime 42 | let runtime = JSRuntime() 43 | 44 | // Load the javascript source file defined above, alternatively JS can be written inline. 45 | let javascriptSource = try! String(contentsOfFile: "./example.js") 46 | 47 | // Evaluate the script 48 | _ = runtime.context.evaluateScript(javascriptSource) 49 | 50 | // Call the `fetchProducts` function defined in the source file. 51 | let result = try! await runtime.context.callAsyncFunction(key: "fetchProducts") 52 | 53 | // Print the result 54 | print(result.toString()) 55 | ``` 56 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/BlobTests.swift: -------------------------------------------------------------------------------- 1 | import ECMASwift 2 | import XCTest 3 | import JavaScriptCore 4 | 5 | final class BlobAPITests: XCTestCase { 6 | let runtime = JSRuntime() 7 | 8 | func testBlobCreation() { 9 | let expectedContent = "Hello, World!" 10 | 11 | let blob = runtime.context.evaluateScript(""" 12 | Blob('\(expectedContent)'); 13 | """) 14 | 15 | XCTAssertNotNil(blob, "Blob creation failed") 16 | } 17 | 18 | func testBlobTextMethod() async { 19 | let expectedContent = "Hello, World!" 20 | _ = runtime.context.evaluateScript(""" 21 | async function getBlob() { 22 | let blob = new Blob(["\(expectedContent)"]) 23 | return await blob.text() 24 | } 25 | """) 26 | let result = try! await runtime.context.callAsyncFunction(key: "getBlob") 27 | XCTAssertEqual(result.toString(), expectedContent) 28 | } 29 | 30 | func testBlobArrayBufferMethod() async { 31 | let expectedContent = "Hello, World!" 32 | _ = runtime.context.evaluateScript(""" 33 | async function getBlob() { 34 | let blob = new Blob(["\(expectedContent)"]) 35 | let res = await blob.arrayBuffer() 36 | return res 37 | } 38 | """) 39 | let result = try! await runtime.context.callAsyncFunction(key: "getBlob") 40 | XCTAssertEqual(result.forProperty("byteLength").toNumber(), 13) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Fetch/AbortController.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | 3 | @objc 4 | protocol AbortSignalExports: JSExport { 5 | var aborted: Bool { get set } 6 | } 7 | 8 | class AbortSignal: NSObject, AbortSignalExports { 9 | private var _aborted: Bool = false 10 | var aborted: Bool { 11 | get { return _aborted } 12 | set { 13 | _aborted = newValue 14 | if newValue == true { 15 | self.onAbort?() 16 | } 17 | } 18 | } 19 | 20 | var onAbort: (() -> Void)? 21 | } 22 | 23 | @objc 24 | protocol AbortControllerExports: JSExport { 25 | var signal: AbortSignal { get set } 26 | func abort() 27 | } 28 | 29 | class AbortController: NSObject, AbortControllerExports { 30 | var signal = AbortSignal() 31 | 32 | func abort() { 33 | signal.aborted = true 34 | } 35 | } 36 | 37 | struct AbortControllerAPI { 38 | func registerAPIInto(context: JSContext) { 39 | let abortControllerClass: @convention(block) () -> AbortController = { 40 | AbortController() 41 | } 42 | let abortSignalClass: @convention(block) () -> AbortSignal = { 43 | AbortSignal() 44 | } 45 | 46 | context.setObject( 47 | abortSignalClass, 48 | forKeyedSubscript: "AbortSignal" as NSString 49 | ) 50 | context.setObject( 51 | abortControllerClass, 52 | forKeyedSubscript: "AbortController" as NSString 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/FormData/FormData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JavaScriptCore 3 | 4 | // TODO: Implementation is unfinished as it encodes into the wrong format, but is currently unsued. 5 | 6 | @objc 7 | protocol FormDataExports: JSExport { 8 | func append(_ name: String, _ value: String) 9 | func get(_ name: String) -> String? 10 | } 11 | 12 | @objc 13 | class FormData: NSObject, FormDataExports { 14 | private var queryItems: [URLQueryItem] = [] 15 | 16 | func append(_ name: String, _ value: String) { 17 | let item = URLQueryItem(name: name, value: value) 18 | queryItems.append(item) 19 | } 20 | 21 | func get(_ name: String) -> String? { 22 | return queryItems.first { $0.name == name }?.value 23 | } 24 | 25 | func getAll(_ name: String) -> [String] { 26 | return queryItems.filter { $0.name == name }.compactMap { $0.value } 27 | } 28 | 29 | var serialized: String? { 30 | var components = URLComponents() 31 | components.queryItems = queryItems 32 | return components.percentEncodedQuery 33 | } 34 | } 35 | 36 | /// Helper to register the ``FormData`` API with a context. 37 | public struct FormDataAPI { 38 | public func registerAPIInto(context: JSContext) { 39 | let formDataClass: @convention(block) (JSValue?) -> FormData = { _ in 40 | return FormData() 41 | } 42 | context.setObject( 43 | unsafeBitCast(formDataClass, to: AnyObject.self), 44 | forKeyedSubscript: "FormData" as NSString 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/ECMASwift/JSRuntime.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | 3 | /// `JSRuntime` wraps a `JSContext` and implements a few missing browser APIs 4 | /// (mostly networking related, fetch, request etc.), which are then registered with the context. 5 | /// So, a `JSRuntime` can be used as headless browser to execute ``LTCore``. 6 | /// 7 | /// The following browser APIs are implemented in Swift and added to the JSContext: 8 | /// 9 | /// - ``Blob`` 10 | /// - ``AbortController`` 11 | /// - ``Request`` 12 | /// - ``Fetch`` 13 | /// - ``Headers`` 14 | /// - ``URLSearchParams`` 15 | /// - ``URL`` 16 | /// - ``Console`` 17 | /// - ``Timer`` 18 | /// - ``TextEncoder`` 19 | /// - ``Crypto`` 20 | public struct JSRuntime { 21 | public let context: JSContext = .init() 22 | 23 | public init(client: HTTPClient = URLSession.shared) { 24 | registerAPIs(client: client) 25 | } 26 | 27 | private func registerAPIs(client: HTTPClient) { 28 | // Runtime APIs 29 | BlobAPI().registerAPIInto(context: context) 30 | AbortControllerAPI().registerAPIInto(context: context) 31 | RequestAPI().registerAPIInto(context: context) 32 | FetchAPI(client: client).registerAPIInto(context: context) 33 | HeadersAPI().registerAPIInto(context: context) 34 | URLSearchParamsAPI().registerAPIInto(context: context) 35 | URLAPI().registerAPIInto(context: context) 36 | ConsoleAPI().registerAPIInto(context: context) 37 | TimerAPI().registerAPIInto(context: context) 38 | TextEncoderAPI().registerAPIInto(context: context) 39 | CryptoAPI().registerAPIInto(context: context) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Crypto/Crypto.swift: -------------------------------------------------------------------------------- 1 | import CommonCrypto 2 | import JavaScriptCore 3 | 4 | @objc protocol CryptoExports: JSExport { 5 | func getRandomValues(_ array: [UInt]) -> [UInt] 6 | func randomUUID() -> String 7 | 8 | var subtle: SubtleCryptoExports { get } 9 | } 10 | 11 | /// This implmenets the `Crypto` browser API. 12 | /// 13 | /// Reference: [Crypto Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Crypto) 14 | @objc final class Crypto: NSObject, CryptoExports { 15 | func getRandomValues(_ array: [UInt]) -> [UInt] { 16 | let size = array.count * MemoryLayout.size 17 | 18 | var buffer = [UInt8](repeating: 0, count: size) 19 | 20 | let result = SecRandomCopyBytes(kSecRandomDefault, size, &buffer) 21 | 22 | if result == errSecSuccess { 23 | return stride(from: 0, to: buffer.count, by: MemoryLayout.size).map { i in 24 | buffer.withUnsafeBytes { ptr -> UInt in 25 | let base = ptr.baseAddress!.assumingMemoryBound(to: UInt.self) 26 | return base[i / MemoryLayout.size] 27 | } 28 | } 29 | } else { 30 | return [] 31 | } 32 | } 33 | 34 | func randomUUID() -> String { 35 | return UUID().uuidString 36 | } 37 | 38 | lazy var subtle: SubtleCryptoExports = SubtleCrypto() 39 | } 40 | 41 | /// Helper to register the ``Crypto`` API with a context. 42 | struct CryptoAPI { 43 | public func registerAPIInto(context: JSContext) { 44 | context.setObject( 45 | unsafeBitCast(Crypto(), to: AnyObject.self), 46 | forKeyedSubscript: "crypto" as NSString 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Headers/Headers.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | 3 | @objc 4 | protocol HeadersExports: JSExport { 5 | func set(_ key: String, _ value: String) 6 | func get(_ key: String) -> String? 7 | func delete(_ key: String) 8 | func getAll() -> [String: String] 9 | } 10 | 11 | /// This implmenets the `Headers` browser API. 12 | /// 13 | /// Reference: [Headers Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Headers) 14 | final class Headers: NSObject, HeadersExports { 15 | private var headers: [String: String] = [:] 16 | 17 | init(withHeaders: [String: String] = [:]) { 18 | super.init() 19 | for (key, value) in withHeaders { 20 | self.set(key, value) 21 | } 22 | } 23 | 24 | public func set(_ key: String, _ value: String) { 25 | headers[key] = value 26 | } 27 | 28 | public func get(_ key: String) -> String? { 29 | return headers[key] 30 | } 31 | 32 | public func delete(_ key: String) { 33 | headers.removeValue(forKey: key) 34 | } 35 | 36 | public func getAll() -> [String: String] { 37 | return headers 38 | } 39 | } 40 | 41 | /// Helper to register the ``Headers`` API with a context. 42 | struct HeadersAPI { 43 | func registerAPIInto(context: JSContext) { 44 | let headersClass: @convention(block) (JSValue?) -> Headers = { headers in 45 | if let headers, headers.hasValue { 46 | return Headers(withHeaders: headers.toDictionary() as! [String: String]) 47 | } else { 48 | return Headers() 49 | } 50 | } 51 | context.setObject( 52 | unsafeBitCast(headersClass, to: AnyObject.self), 53 | forKeyedSubscript: "Headers" as NSString 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Request/RequestBody.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JavaScriptCore 3 | 4 | /// Models a request body. 5 | /// 6 | /// Reference: [Request Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Request/body) 7 | enum RequestBody { 8 | case blob 9 | case arrayBuffer 10 | case typedArray 11 | case formData(FormData) 12 | case urlSearchParams(URLSearchParams) 13 | case object([AnyHashable: Any]) 14 | case string(String) 15 | 16 | static func create(from jsValue: JSValue) -> RequestBody? { 17 | guard jsValue.hasValue else { return nil } 18 | if let searchParams = jsValue.toType(URLSearchParams.self) { 19 | return .urlSearchParams(searchParams) 20 | } else if let formData = jsValue.toType(FormData.self) { 21 | return .formData(formData) 22 | } else if let string = jsValue.toString() { 23 | return .string(string) 24 | } else if let object = jsValue.toDictionary() { 25 | return .object(object) 26 | } else { 27 | return nil 28 | } 29 | } 30 | 31 | func data() -> Data? { 32 | switch self { 33 | case .urlSearchParams(let searchParams): 34 | return searchParams.toEncodedString().data(using: .utf8) 35 | case .string(let string): 36 | return string.data(using: .utf8) 37 | case .object(let object): 38 | let data = try? JSONSerialization.data(withJSONObject: object) 39 | return data 40 | default: 41 | return nil 42 | } 43 | } 44 | 45 | func string() -> String? { 46 | switch self { 47 | case .urlSearchParams(let searchParams): 48 | return searchParams.toString() 49 | case .string(let string): 50 | return string 51 | case .object(let object): 52 | let data = try! JSONSerialization.data(withJSONObject: object) 53 | return String(data: data, encoding: .utf8) 54 | default: 55 | return nil 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/TextEncoderTests.swift: -------------------------------------------------------------------------------- 1 | import ECMASwift 2 | import JavaScriptCore 3 | import XCTest 4 | 5 | final class TextEncoderTests: XCTestCase { 6 | let runtime = JSRuntime() 7 | 8 | func testEncodingSimpleText() { 9 | let result = runtime.context.evaluateScript(""" 10 | let encoder = new TextEncoder(); 11 | let encoded = encoder.encode("Hello, World!"); 12 | Array.from(encoded) 13 | """)! 14 | // The encoded value for "Hello, World!" in UTF-8 is: 15 | // [72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33] 16 | XCTAssertEqual(result.toArray() as! [UInt8], [72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33]) 17 | } 18 | 19 | func testEncodingNonAsciiText() { 20 | let result = runtime.context.evaluateScript(""" 21 | let encoder = new TextEncoder(); 22 | let encoded = encoder.encode("こんにちは"); 23 | Array.from(encoded) 24 | """)! 25 | XCTAssertEqual(result.toArray() as! [UInt8], [227, 129, 147, 227, 130, 147, 227, 129, 171, 227, 129, 161, 227, 129, 175]) 26 | } 27 | 28 | func testEncoderDefaultEncoding() { 29 | let result = runtime.context.evaluateScript(""" 30 | let encoder = new TextEncoder(); 31 | encoder.encoding 32 | """)! 33 | XCTAssertEqual(result.toString(), "utf-8") 34 | } 35 | 36 | func testEncodingEmptyText() { 37 | let result = runtime.context.evaluateScript(""" 38 | let encoder = new TextEncoder(); 39 | let encoded = encoder.encode(""); 40 | Array.from(encoded) 41 | """)! 42 | XCTAssertEqual(result.toArray() as! [UInt8], []) 43 | } 44 | 45 | func testEncodingSpecialCharacters() { 46 | let result = runtime.context.evaluateScript(""" 47 | let encoder = new TextEncoder(); 48 | let encoded = encoder.encode("🚀 & ⚡️"); 49 | Array.from(encoded) 50 | """)! 51 | XCTAssertEqual(result.toArray() as! [UInt8], [240, 159, 154, 128, 32, 38, 32, 226, 154, 161, 239, 184, 143]) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/CryptoTests.swift: -------------------------------------------------------------------------------- 1 | import ECMASwift 2 | import JavaScriptCore 3 | import XCTest 4 | 5 | final class CryptoTests: XCTestCase { 6 | let runtime = JSRuntime() 7 | 8 | func testGetRandomValues() { 9 | let result = runtime.context.evaluateScript(""" 10 | let array = new Uint32Array(10) 11 | let populatedArray = crypto.getRandomValues(array) 12 | populatedArray 13 | """)! 14 | XCTAssertTrue(result.toArray()!.allSatisfy { ($0 as! UInt) != 0 }) 15 | } 16 | 17 | func testrandomUUID() { 18 | let result = runtime.context.evaluateScript(""" 19 | crypto.randomUUID() 20 | """)! 21 | XCTAssertNotNil(UUID(uuidString: result.toString())) 22 | } 23 | 24 | func testGetRandomValuesZeroLength() { 25 | let result = runtime.context.evaluateScript(""" 26 | let array = new Uint32Array(0) 27 | let populatedArray = crypto.getRandomValues(array) 28 | populatedArray.length 29 | """)! 30 | XCTAssertEqual(result.toInt32(), 0) 31 | } 32 | 33 | func testGetRandomValuesWithDifferentTypedArrays() { 34 | // Testing with a Uint8Array 35 | let resultUint8 = runtime.context.evaluateScript(""" 36 | let array = new Uint8Array(10) 37 | let populatedArray = crypto.getRandomValues(array) 38 | populatedArray 39 | """)! 40 | XCTAssertTrue(resultUint8.toArray()!.allSatisfy { ($0 as! UInt) != 0 }) 41 | } 42 | 43 | func testGetRandomValuesLargeArray() { 44 | let result = runtime.context.evaluateScript(""" 45 | let array = new Uint32Array(1e5) // An array with a large size 46 | let populatedArray = crypto.getRandomValues(array) 47 | populatedArray 48 | """)! 49 | XCTAssertTrue(result.toArray()!.allSatisfy { ($0 as! UInt) != 0 }) 50 | } 51 | 52 | func testRandomUUIDFormat() { 53 | let result = runtime.context.evaluateScript(""" 54 | crypto.randomUUID() 55 | """)! 56 | let uuid = result.toString() 57 | let uuidRegex = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" 58 | XCTAssertTrue(NSPredicate(format: "SELF MATCHES %@", uuidRegex).evaluate(with: uuid)) 59 | } 60 | 61 | func testRandomUUIDUniqueness() { 62 | let script = """ 63 | let uuids = new Set(); 64 | for (let i = 0; i < 1000; i++) { 65 | uuids.add(crypto.randomUUID()); 66 | } 67 | uuids.size 68 | """ 69 | let result = runtime.context.evaluateScript(script)! 70 | XCTAssertEqual(result.toInt32(), 1000) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Crypto/SublteCrypto.swift: -------------------------------------------------------------------------------- 1 | import CommonCrypto 2 | import JavaScriptCore 3 | 4 | @objc protocol SubtleCryptoExports: JSExport { 5 | func digest(_ algorithm: String, _ data: [UInt8]) -> [UInt8]? 6 | func encrypt(_ algorithm: String, _ key: [UInt8], _ iv: [UInt8], _ data: [UInt8]) -> [UInt8]? 7 | func decrypt(_ algorithm: String, _ key: [UInt8], _ iv: [UInt8], _ data: [UInt8]) -> [UInt8]? 8 | } 9 | 10 | /// This implmenets the `SubtleCrypto` browser API. 11 | /// 12 | /// Reference: [SubtleCrypto Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto) 13 | @objc final class SubtleCrypto: NSObject, SubtleCryptoExports { 14 | func digest(_ algorithm: String, _ data: [UInt8]) -> [UInt8]? { 15 | guard algorithm == "SHA-256" else { return nil } 16 | var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) 17 | _ = data.withUnsafeBytes { 18 | CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) 19 | } 20 | return hash 21 | } 22 | 23 | func encrypt(_ algorithm: String, _ key: [UInt8], _ iv: [UInt8], _ data: [UInt8]) -> [UInt8]? { 24 | guard algorithm == "AES-GCM", key.count == kCCKeySizeAES128 else { return nil } 25 | var buffer = [UInt8](repeating: 0, count: data.count + kCCBlockSizeAES128) 26 | var numBytesEncrypted: size_t = 0 27 | 28 | let cryptStatus = CCCrypt( 29 | CCOperation(kCCEncrypt), 30 | CCAlgorithm(kCCAlgorithmAES), 31 | CCOptions(kCCOptionPKCS7Padding), 32 | key, 33 | key.count, 34 | iv, 35 | data, 36 | data.count, 37 | &buffer, 38 | buffer.count, 39 | &numBytesEncrypted 40 | ) 41 | 42 | if cryptStatus == kCCSuccess { 43 | return Array(buffer.prefix(numBytesEncrypted)) 44 | } 45 | return nil 46 | } 47 | 48 | func decrypt(_ algorithm: String, _ key: [UInt8], _ iv: [UInt8], _ data: [UInt8]) -> [UInt8]? { 49 | guard algorithm == "AES-GCM", key.count == kCCKeySizeAES128 else { return nil } 50 | var buffer = [UInt8](repeating: 0, count: data.count + kCCBlockSizeAES128) 51 | var numBytesDecrypted: size_t = 0 52 | 53 | let cryptStatus = CCCrypt( 54 | CCOperation(kCCDecrypt), 55 | CCAlgorithm(kCCAlgorithmAES), 56 | CCOptions(kCCOptionPKCS7Padding), 57 | key, 58 | key.count, 59 | iv, 60 | data, 61 | data.count, 62 | &buffer, 63 | buffer.count, 64 | &numBytesDecrypted 65 | ) 66 | 67 | if cryptStatus == kCCSuccess { 68 | return Array(buffer.prefix(numBytesDecrypted)) 69 | } 70 | return nil 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Timers/Timers.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | import os.lock 3 | 4 | /// This implmenets several timer related browser APIs.` 5 | /// 6 | /// References: 7 | /// - [setTimeout()](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout) 8 | /// - [setInterval()](https://developer.mozilla.org/en-US/docs/Web/API/setInterval) 9 | /// - [clearTimeout()](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout) 10 | /// - [clearInterval()](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) 11 | final class TimerAPI { 12 | var timers = [String: Timer]() 13 | 14 | private var lock = os_unfair_lock_s() 15 | 16 | func createTimer(callback: JSValue, ms: Double, repeats: Bool) -> String { 17 | let timeInterval = ms / 1000.0 18 | let uuid = UUID().uuidString 19 | let timer = Timer(timeInterval: timeInterval, repeats: repeats) { [weak self, weak callback] _ in 20 | if let callback = callback, callback.isObject { 21 | callback.call(withArguments: []) 22 | } 23 | 24 | if !repeats { 25 | os_unfair_lock_lock(&self!.lock) 26 | self?.timers[uuid] = nil 27 | os_unfair_lock_unlock(&self!.lock) 28 | } 29 | } 30 | 31 | os_unfair_lock_lock(&lock) 32 | timers[uuid] = timer 33 | os_unfair_lock_unlock(&lock) 34 | 35 | RunLoop.main.add(timer, forMode: .common) 36 | 37 | return uuid 38 | } 39 | 40 | func invalidateTimer(with id: String) { 41 | os_unfair_lock_lock(&lock) 42 | let timerInfo = timers.removeValue(forKey: id) 43 | os_unfair_lock_unlock(&lock) 44 | 45 | timerInfo?.invalidate() 46 | } 47 | 48 | func registerAPIInto(context: JSContext) { 49 | let setTimeout: @convention(block) (JSValue, Double) -> String = { callback, ms in 50 | self.createTimer(callback: callback, ms: ms, repeats: false) 51 | } 52 | let setInterval: @convention(block) (JSValue, Double) -> String = { callback, ms in 53 | self.createTimer(callback: callback, ms: ms, repeats: true) 54 | } 55 | let clearTimeout: @convention(block) (String) -> Void = { [weak self] timerId in 56 | self?.invalidateTimer(with: timerId) 57 | } 58 | let clearInterval: @convention(block) (String) -> Void = { [weak self] timerId in 59 | self?.invalidateTimer(with: timerId) 60 | } 61 | context.setObject(setTimeout, forKeyedSubscript: "setTimeout" as NSString) 62 | context.setObject(setInterval, forKeyedSubscript: "setInterval" as NSString) 63 | context.setObject(clearTimeout, forKeyedSubscript: "clearTimeout" as NSString) 64 | context.setObject(clearInterval, forKeyedSubscript: "clearInterval" as NSString) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/URLSearchParamsTests.swift: -------------------------------------------------------------------------------- 1 | import ECMASwift 2 | import JavaScriptCore 3 | import XCTest 4 | 5 | final class URLSearchParamsTests: XCTestCase { 6 | let runtime = JSRuntime() 7 | 8 | func testInitialization() { 9 | let result = runtime.context.evaluateScript(""" 10 | let params = new URLSearchParams("lang=swift&version=5.5") 11 | params.getAll() 12 | """)! 13 | } 14 | 15 | func testAppendingParams() { 16 | let result = runtime.context.evaluateScript(""" 17 | let params = new URLSearchParams(); 18 | params.append("q", "swift"); 19 | params.append("version", "5.9"); 20 | params.toString(); 21 | """)! 22 | XCTAssertEqual(result.toString(), "q=swift&version=5.9") 23 | } 24 | 25 | func testSettingParams() { 26 | let result = runtime.context.evaluateScript(""" 27 | let params = new URLSearchParams("q=swift"); 28 | params.set("q", "rust"); 29 | params.get("q") 30 | """)! 31 | XCTAssertEqual(result.toString(), "rust") 32 | } 33 | 34 | func testDeletingParams() { 35 | let result = runtime.context.evaluateScript(""" 36 | let params = new URLSearchParams("q=swift&version=5.5"); 37 | params.delete("q"); 38 | params.toString(); 39 | """)! 40 | XCTAssertEqual(result.toString(), "version=5.5") 41 | } 42 | 43 | // func testHasParam() { 44 | // let result = runtime.context.evaluateScript(""" 45 | // let params = new URLSearchParams("q=swift&version=5.5"); 46 | // params.has("q") 47 | // """)! 48 | // XCTAssertTrue(result.toBool()) 49 | // 50 | // let result2 = runtime.context.evaluateScript(""" 51 | // let params = new URLSearchParams("q=swift&version=5.5"); 52 | // params.has("lang") 53 | // """)! 54 | // XCTAssertFalse(result2.toBool()) 55 | // } 56 | 57 | func testMultipleValuesForSameKey() { 58 | let result = runtime.context.evaluateScript(""" 59 | let params = new URLSearchParams("q=swift&q=rust&q=python"); 60 | Array.from(params.getAll("q")) 61 | """)! 62 | let arrayResult = result.toArray() as! [String] 63 | XCTAssertEqual(arrayResult, ["swift", "rust", "python"]) 64 | } 65 | 66 | // func testIteratingParams() { 67 | // let result = runtime.context.evaluateScript(""" 68 | // let params = new URLSearchParams("q=swift&version=5.5"); 69 | // let output = []; 70 | // for (let pair of params.entries()) { 71 | // output.push(pair); 72 | // } 73 | // output; 74 | // """)! 75 | // let arrayResult = result.toArray() as! [[String]] 76 | // XCTAssertEqual(arrayResult, [["q", "swift"], ["version", "5.5"]]) 77 | // } 78 | } 79 | 80 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/TimerTests.swift: -------------------------------------------------------------------------------- 1 | import ECMASwift 2 | import JavaScriptCore 3 | import XCTest 4 | 5 | 6 | final class TimerAPITests: XCTestCase { 7 | let runtime = JSRuntime() 8 | 9 | func testSetTimeout() { 10 | let expectation = self.expectation(description: "setTimeout should execute") 11 | 12 | let jsFunction: @convention(block) () -> Void = { 13 | expectation.fulfill() 14 | } 15 | runtime.context.setObject(unsafeBitCast(jsFunction, to: AnyObject.self), forKeyedSubscript: "jsFunction" as NSString) 16 | 17 | _ = runtime.context.evaluateScript(""" 18 | setTimeout(jsFunction, 1000); 19 | """) 20 | 21 | waitForExpectations(timeout: 2.0, handler: nil) 22 | } 23 | 24 | func testClearTimeout() { 25 | let expectation = self.expectation(description: "setTimeout should not execute") 26 | expectation.isInverted = true 27 | 28 | let jsFunction: @convention(block) () -> Void = { 29 | expectation.fulfill() 30 | } 31 | runtime.context.setObject(unsafeBitCast(jsFunction, to: AnyObject.self), forKeyedSubscript: "jsFunction" as NSString) 32 | 33 | _ = runtime.context.evaluateScript(""" 34 | var timerId = setTimeout(jsFunction, 50); 35 | clearTimeout(timerId); 36 | """) 37 | 38 | waitForExpectations(timeout: 2.0, handler: nil) 39 | } 40 | 41 | func testSetInterval() { 42 | let expectation = self.expectation(description: "setInterval should execute twice") 43 | expectation.expectedFulfillmentCount = 2 44 | 45 | let jsFunction: @convention(block) () -> Void = { 46 | expectation.fulfill() 47 | } 48 | runtime.context.setObject(unsafeBitCast(jsFunction, to: AnyObject.self), forKeyedSubscript: "jsFunction" as NSString) 49 | 50 | _ = runtime.context.evaluateScript(""" 51 | var intervalId = setInterval(jsFunction, 50); 52 | setTimeout(() => { clearInterval(intervalId); }, 150); 53 | """) 54 | 55 | waitForExpectations(timeout: 2.0, handler: nil) 56 | } 57 | 58 | func testClearInterval() { 59 | let expectation = self.expectation(description: "setInterval should execute once") 60 | 61 | let jsFunction: @convention(block) () -> Void = { 62 | expectation.fulfill() 63 | } 64 | runtime.context.setObject(unsafeBitCast(jsFunction, to: AnyObject.self), forKeyedSubscript: "jsFunction" as NSString) 65 | 66 | _ = runtime.context.evaluateScript(""" 67 | var intervalId = setInterval(jsFunction, 50); 68 | setTimeout(() => { clearInterval(intervalId); }, 75); 69 | """) 70 | 71 | waitForExpectations(timeout: 2.0, handler: nil) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Blob/Blob.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JavaScriptCore 3 | 4 | @objc 5 | protocol BlobExports: JSExport { 6 | func text() -> JSValue? 7 | func arrayBuffer() -> JSValue 8 | } 9 | 10 | /// This implmenets the `Blob` browser API. 11 | /// 12 | /// Reference: [Blob Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Blob) 13 | final class Blob: NSObject, BlobExports { 14 | var content: String 15 | 16 | weak var context: JSContext? 17 | 18 | init(content: String) { 19 | self.content = content 20 | } 21 | 22 | func text() -> JSValue? { 23 | guard let context = context else { 24 | fatalError("JSContext is nil") 25 | } 26 | 27 | return JSValue(newPromiseIn: context) { resolve, _ in 28 | let blobObject = JSValue(object: self.content, in: context)! 29 | resolve?.call(withArguments: [blobObject]) 30 | } 31 | } 32 | 33 | func arrayBuffer() -> JSValue { 34 | return JSValue(newPromiseIn: context) { [weak self] resolve, reject in 35 | guard let data = self?.content.data(using: .utf8) else { 36 | let errorDescription = "Failed to convert blob content to ArrayBuffer" 37 | reject?.call(withArguments: [errorDescription]) 38 | return 39 | } 40 | 41 | // Convert Data to [UInt8] 42 | let byteArray = [UInt8](data) 43 | 44 | // Convert [UInt8] to JavaScript ArrayBuffer 45 | let jsArrayBufferConstructor = self?.context?.evaluateScript("ArrayBuffer") 46 | let jsUint8ArrayConstructor = self?.context?.evaluateScript("Uint8Array") 47 | guard let arrayBuffer = jsArrayBufferConstructor?.construct(withArguments: [byteArray.count]), 48 | let uint8Array = jsUint8ArrayConstructor?.construct(withArguments: [arrayBuffer]) 49 | else { 50 | let errorDescription = "Failed to create ArrayBuffer" 51 | reject?.call(withArguments: [errorDescription]) 52 | return 53 | } 54 | 55 | // Set bytes to ArrayBuffer 56 | for (index, byte) in byteArray.enumerated() { 57 | uint8Array.setValue(byte, at: index) 58 | } 59 | 60 | resolve?.call(withArguments: [arrayBuffer]) 61 | } 62 | } 63 | } 64 | 65 | /// Helper to register the ``Blob`` API with a context. 66 | struct BlobAPI { 67 | func registerAPIInto(context: JSContext) { 68 | let blobClass: @convention(block) (String) -> Blob = { text in 69 | let blob = Blob(content: text) 70 | blob.context = context 71 | return blob 72 | } 73 | 74 | context.setObject( 75 | unsafeBitCast(blobClass, to: AnyObject.self), 76 | forKeyedSubscript: "Blob" as NSString 77 | ) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/ECMASwift/Helpers/JSValue+callFunction.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | 3 | public extension JSValue { 4 | 5 | /// Calls and invidual async function identified by `key`. 6 | /// 7 | /// - Parameters: 8 | /// - key: The subscript key / identifier of the function 9 | /// - withArguments: Optional arguments 10 | /// - Returns: The return value of the function 11 | func callAsyncFunction(key: String, withArguments: [Any] = []) async throws -> JSValue { 12 | try await withCheckedThrowingContinuation { continuation in 13 | let onFulfilled: @convention(block) (JSValue) -> Void = { 14 | continuation.resume(returning: $0) 15 | } 16 | let onRejected: @convention(block) (JSValue) -> Void = { error in 17 | let nsError = JSContext.getErrorFrom(key: key, error: error) 18 | continuation.resume(throwing: nsError) 19 | } 20 | let promiseArgs = [ 21 | unsafeBitCast(onFulfilled, to: JSValue.self), 22 | unsafeBitCast(onRejected, to: JSValue.self) 23 | ] 24 | 25 | let promise = call(withArguments: withArguments) 26 | promise?.invokeMethod("then", withArguments: promiseArgs) 27 | } 28 | } 29 | 30 | /// Calls an async function `methodKey` on the object identified by `key`. 31 | /// 32 | /// - Parameters: 33 | /// - methodKey: The identifier of the method 34 | /// - withArguments: Optional arguments 35 | /// - Returns: The return value of the function 36 | func invokeAsyncMethod(methodKey: String, withArguments: [Any] = []) async throws -> JSValue { 37 | try await withCheckedThrowingContinuation { continuation in 38 | 39 | let onFulfilled: @convention(block) (JSValue) -> Void = { 40 | continuation.resume(returning: $0) 41 | } 42 | 43 | let onRejected: @convention(block) (JSValue) -> Void = { error in 44 | let nsError = JSContext.getErrorFrom(key: methodKey, error: error) 45 | continuation.resume(throwing: nsError) 46 | } 47 | 48 | let promiseArgs = [ 49 | unsafeBitCast(onFulfilled, to: JSValue.self), 50 | unsafeBitCast(onRejected, to: JSValue.self) 51 | ] 52 | 53 | guard let promise = invokeMethod(methodKey, withArguments: withArguments), 54 | promise.hasValue else { 55 | let error = NSError( 56 | domain: methodKey, 57 | code: 1, 58 | userInfo: [ 59 | NSLocalizedDescriptionKey: "JavaScript execution failed or returned an unexpected value." 60 | ] 61 | ) 62 | continuation.resume(throwing: error) 63 | return 64 | } 65 | 66 | promise.invokeMethod("then", withArguments: promiseArgs) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/RequestTests.swift: -------------------------------------------------------------------------------- 1 | import ECMASwift 2 | import JavaScriptCore 3 | import XCTest 4 | 5 | final class RequestTests: XCTestCase { 6 | let runtime = JSRuntime() 7 | 8 | func testInitialiser() async throws { 9 | _ = runtime.context.evaluateScript(""" 10 | let request = new Request("https://languagetool.org", { method: "get" }) 11 | """)! 12 | 13 | XCTAssertEqual("https://languagetool.org", runtime.context.evaluateScript("request.url").toString()) 14 | XCTAssertEqual("GET", runtime.context.evaluateScript("request.method").toString()) 15 | } 16 | 17 | func testURLSearchParamsBody() async throws { 18 | let result = runtime.context.evaluateScript(""" 19 | let params = new URLSearchParams("lang=swift&version=5.5") 20 | let request = new Request("https://languagetool.org", { 21 | method: "post", 22 | body: params 23 | } 24 | ) 25 | request.text() 26 | """)! 27 | 28 | XCTAssertEqual("lang=swift&version=5.5", result.toString()) 29 | } 30 | 31 | func testRequestMethod() { 32 | let result = runtime.context.evaluateScript(""" 33 | let request = new Request("https://example.com", { method: "POST" }); 34 | request.method; 35 | """) 36 | XCTAssertEqual(result!.toString(), "POST") 37 | } 38 | 39 | func testRequestURL() { 40 | let result = runtime.context.evaluateScript(""" 41 | let request = new Request("https://example.com"); 42 | request.url; 43 | """) 44 | XCTAssertEqual(result!.toString(), "https://example.com") 45 | } 46 | 47 | func testRequestHeaders() { 48 | let result = runtime.context.evaluateScript(""" 49 | let request = new Request('https://example.com', { 50 | headers: { 51 | 'Content-Type': 'application/json' 52 | } 53 | }); 54 | request.headers.get('Content-Type'); 55 | """) 56 | XCTAssertEqual(result!.toString(), "application/json") 57 | } 58 | 59 | func testRequestBodyString() { 60 | let result = runtime.context.evaluateScript(""" 61 | let request = new Request('https://example.com', { 62 | method: 'POST', 63 | body: JSON.stringify({ key: 'value' }) 64 | }); 65 | request.text(); 66 | """) 67 | XCTAssertEqual(result!.toString(), "{\"key\":\"value\"}") 68 | } 69 | 70 | func testBlobText() async { 71 | _ = runtime.context.evaluateScript(""" 72 | async function getBlob() { 73 | let request = new Request('https://example.com', { 74 | method: 'POST', 75 | body: 'Blob Text' 76 | }) 77 | let blob = await request.blob() 78 | return await blob.text() 79 | } 80 | """) 81 | let result = try! await runtime.context.callAsyncFunction(key: "getBlob") 82 | XCTAssertEqual(result.toString(), "Blob Text") 83 | } 84 | 85 | func testClone() { 86 | let result = runtime.context.evaluateScript(""" 87 | let request = new Request("https://example.com", { method: "POST" }); 88 | let cloned = request.clone() 89 | cloned.method 90 | """) 91 | XCTAssertEqual(result!.toString(), "POST") 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/ECMASwift/Helpers/JSContext+callFunction.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | 3 | public extension JSContext { 4 | func callFunction(key: String, withArguments: [Any] = []) throws -> JSValue? { 5 | return objectForKeyedSubscript(key) 6 | .call(withArguments: withArguments) 7 | } 8 | 9 | static func getErrorFrom(key: String, error: JSValue) -> NSError { 10 | var userInfo: [String: Any] = [:] 11 | 12 | if error.isObject { 13 | userInfo = error.toDictionary() as? [String: Any] ?? [:] 14 | } else { 15 | userInfo[NSLocalizedDescriptionKey] = error.toString() ?? "UnknownError" 16 | } 17 | 18 | return NSError(domain: key, code: 0, userInfo: userInfo) 19 | } 20 | 21 | /// Calls and invidual async function identified by `key`. 22 | /// 23 | /// - Parameters: 24 | /// - key: The subscript key / identifier of the function 25 | /// - withArguments: Optional arguments 26 | /// - Returns: The return value of the function 27 | func callAsyncFunction(key: String, withArguments: [Any] = []) async throws -> JSValue { 28 | try await withCheckedThrowingContinuation { continuation in 29 | let onFulfilled: @convention(block) (JSValue) -> Void = { 30 | continuation.resume(returning: $0) 31 | } 32 | let onRejected: @convention(block) (JSValue) -> Void = { error in 33 | let nsError = JSContext.getErrorFrom(key: key, error: error) 34 | continuation.resume(throwing: nsError) 35 | } 36 | let promiseArgs = [ 37 | unsafeBitCast(onFulfilled, to: JSValue.self), 38 | unsafeBitCast(onRejected, to: JSValue.self) 39 | ] 40 | 41 | let promise = self.objectForKeyedSubscript(key) 42 | .call(withArguments: withArguments) 43 | promise?.invokeMethod("then", withArguments: promiseArgs) 44 | } 45 | } 46 | 47 | /// Calls an async function `methodKey` on the object identified by `key`. 48 | /// 49 | /// - Parameters: 50 | /// - methodKey: The identifier of the method 51 | /// - withArguments: Optional arguments 52 | /// - Returns: The return value of the function 53 | func invokeAsyncMethod(key: String, methodKey: String, withArguments: [Any] = []) async throws -> JSValue { 54 | try await withCheckedThrowingContinuation { continuation in 55 | 56 | let onFulfilled: @convention(block) (JSValue) -> Void = { 57 | continuation.resume(returning: $0) 58 | } 59 | 60 | let onRejected: @convention(block) (JSValue) -> Void = { error in 61 | let nsError = JSContext.getErrorFrom(key: key, error: error) 62 | continuation.resume(throwing: nsError) 63 | } 64 | 65 | let promiseArgs = [ 66 | unsafeBitCast(onFulfilled, to: JSValue.self), 67 | unsafeBitCast(onRejected, to: JSValue.self) 68 | ] 69 | 70 | guard let promise = self.objectForKeyedSubscript(key).invokeMethod(methodKey, withArguments: withArguments), 71 | promise.hasValue else { 72 | let error = NSError( 73 | domain: key, 74 | code: 1, 75 | userInfo: [ 76 | NSLocalizedDescriptionKey: "JavaScript execution failed or returned an unexpected value." 77 | ] 78 | ) 79 | continuation.resume(throwing: error) 80 | return 81 | } 82 | 83 | promise.invokeMethod("then", withArguments: promiseArgs) 84 | } 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/URLSearchParams/URLSearchParams.swift: -------------------------------------------------------------------------------- 1 | import JavaScriptCore 2 | 3 | @objc 4 | protocol URLSearchParamsExports: JSExport { 5 | func append(_ key: String, _ value: String) 6 | func getAll(_ key: String) -> [String] 7 | func get(_ key: String) -> String? 8 | func set(_ key: String, _ value: String) 9 | func delete(_ key: String) 10 | func toString() -> String 11 | } 12 | 13 | /// This implmenets the `URLSearchParams` browser API. 14 | /// 15 | /// Reference: [URLSearchParams Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) 16 | @objc 17 | final class URLSearchParams: NSObject, URLSearchParamsExports { 18 | private var urlComponents: URLComponents 19 | 20 | override init() { 21 | self.urlComponents = URLComponents() 22 | super.init() 23 | } 24 | 25 | init(_ query: String) { 26 | self.urlComponents = URLComponents() 27 | super.init() 28 | self.urlComponents.query = query 29 | } 30 | 31 | // Append a new query item 32 | func append(_ name: String, _ value: String) { 33 | let newItem = URLQueryItem(name: name, value: value) 34 | if urlComponents.queryItems != nil { 35 | urlComponents.queryItems?.append(newItem) 36 | } else { 37 | urlComponents.queryItems = [newItem] 38 | } 39 | } 40 | 41 | // Get all values for a specific query name 42 | func getAll(_ name: String) -> [String] { 43 | return urlComponents.queryItems?.filter { $0.name == name }.compactMap { $0.value } ?? [] 44 | } 45 | 46 | // Get the first value for a specific query name 47 | func get(_ name: String) -> String? { 48 | return urlComponents.queryItems?.first(where: { $0.name == name })?.value 49 | } 50 | 51 | // Set a value for a specific query name, replacing existing values 52 | func set(_ name: String, _ value: String) { 53 | // Remove existing items 54 | urlComponents.queryItems = urlComponents.queryItems?.filter { $0.name != name } 55 | // Append the new item 56 | append(name, value) 57 | } 58 | 59 | // Delete all values for a specific query name 60 | func delete(_ name: String) { 61 | urlComponents.queryItems = urlComponents.queryItems?.filter { $0.name != name } 62 | } 63 | 64 | func toEncodedString() -> String { 65 | return queryString(using: { param in 66 | param.addingPercentEncoding(withAllowedCharacters: .alphanumerics)? 67 | .replacingOccurrences(of: "%20", with: "+") 68 | }) 69 | } 70 | 71 | func toString() -> String { 72 | return queryString() 73 | } 74 | 75 | // Representation of query items as a percent-encoded string, similar to JavaScript's URLSearchParams.toString() 76 | private func queryString(using encoder: ((String) -> String?)? = nil) -> String { 77 | guard let queryItems = urlComponents.queryItems else { 78 | return "" 79 | } 80 | 81 | return queryItems.compactMap { item in 82 | guard let value = item.value else { return nil } 83 | if let encoder = encoder, let encodedValue = encoder(value) { 84 | return "\(item.name)=\(encodedValue)" 85 | } else { 86 | return "\(item.name)=\(value)" 87 | } 88 | }.joined(separator: "&") 89 | } 90 | } 91 | 92 | /// Helper to register the ``URLSearchParams`` API with a context. 93 | public struct URLSearchParamsAPI { 94 | public func registerAPIInto(context: JSContext) { 95 | let searchParamsClass: @convention(block) (JSValue?) -> URLSearchParams = { query in 96 | if let query, !query.isUndefined, !query.isNull { 97 | return URLSearchParams(query.toString()) 98 | } 99 | return URLSearchParams() 100 | } 101 | context.setObject( 102 | unsafeBitCast(searchParamsClass, to: AnyObject.self), 103 | forKeyedSubscript: "URLSearchParams" as NSString 104 | ) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/URLTests.swift: -------------------------------------------------------------------------------- 1 | import ECMASwift 2 | import JavaScriptCore 3 | import XCTest 4 | 5 | final class URLTests: XCTestCase { 6 | let runtime = JSRuntime() 7 | 8 | func testPathname() { 9 | let result = runtime.context.evaluateScript(""" 10 | let url = new URL("https://foobar.com/baz?1=2&3=4") 11 | url.pathname 12 | """) 13 | XCTAssertEqual(result!.toString(), "/baz") 14 | } 15 | 16 | func testGetProtocol() { 17 | let result = runtime.context.evaluateScript(""" 18 | let url = new URL("https://foobar.com/baz?1=2&3=4") 19 | url.protocol 20 | """) 21 | XCTAssertEqual(result!.toString(), "https") 22 | } 23 | 24 | func testSetProtocol() { 25 | let result = runtime.context.evaluateScript(""" 26 | let url = new URL("http://foobar.com/baz?1=2&3=4") 27 | url.protocol = "https" 28 | url.protocol 29 | """) 30 | XCTAssertEqual(result!.toString(), "https") 31 | } 32 | 33 | func testSearchParams() { 34 | let result = runtime.context.evaluateScript(""" 35 | let url = new URL("https://foobar.com") 36 | url.searchParams.set("foo", "bar") 37 | url.searchParams.get("foo") 38 | """) 39 | XCTAssertEqual(result!.toString(), "bar") 40 | } 41 | 42 | func testToString() { 43 | let result = runtime.context.evaluateScript(""" 44 | let url = new URL("https://foobar.com") 45 | url.searchParams.set("foo", "bar") 46 | url.toString() 47 | """) 48 | XCTAssertEqual(result!.toString(), "https://foobar.com?foo=bar") 49 | } 50 | 51 | func testOrigin() { 52 | let result = runtime.context.evaluateScript(""" 53 | let url = new URL("https://foobar.com/baz?1=2&3=4") 54 | url.origin 55 | """) 56 | XCTAssertEqual(result!.toString(), "https://foobar.com") 57 | } 58 | 59 | func testHostname() { 60 | let result = runtime.context.evaluateScript(""" 61 | let url = new URL("https://foobar.com/baz?1=2&3=4") 62 | url.hostname 63 | """) 64 | XCTAssertEqual(result!.toString(), "foobar.com") 65 | } 66 | 67 | func testPort() { 68 | let result = runtime.context.evaluateScript(""" 69 | let url = new URL("https://foobar.com:8080/baz?1=2&3=4") 70 | url.port 71 | """) 72 | XCTAssertEqual(result!.toString(), "8080") 73 | } 74 | 75 | func testSetPort() { 76 | let result = runtime.context.evaluateScript(""" 77 | let url = new URL("https://foobar.com/baz?1=2&3=4") 78 | url.port = "8080" 79 | url.port 80 | """) 81 | XCTAssertEqual(result!.toString(), "8080") 82 | } 83 | 84 | func testSearch() { 85 | let result = runtime.context.evaluateScript(""" 86 | let url = new URL("https://foobar.com/baz?1=2&3=4") 87 | url.search 88 | """) 89 | XCTAssertEqual(result!.toString(), "?1=2&3=4") 90 | } 91 | 92 | func testSetSearch() { 93 | let result = runtime.context.evaluateScript(""" 94 | let url = new URL("https://foobar.com/baz") 95 | url.search = "1=2&3=4" 96 | url.search 97 | """) 98 | XCTAssertEqual(result!.toString(), "?1=2&3=4") 99 | } 100 | 101 | func testSetHash() { 102 | let result = runtime.context.evaluateScript(""" 103 | let url = new URL("https://foobar.com/baz?1=2&3=4") 104 | url.hash = "#section1" 105 | url.hash 106 | """) 107 | XCTAssertEqual(result!.toString(), "#section1") 108 | } 109 | 110 | func testURLWithoutProtocol() { 111 | let result = runtime.context.evaluateScript(""" 112 | let url = new URL("foobar.com", "https://default.com") 113 | url.toString() 114 | """) 115 | XCTAssertEqual(result!.toString(), "https://default.com/foobar.com") 116 | } 117 | 118 | func testURLWithBaseURL() { 119 | let result = runtime.context.evaluateScript(""" 120 | let base = new URL("https://base.com/directory/file") 121 | let url = new URL("another/file", base) 122 | url.toString() 123 | """) 124 | XCTAssertEqual(result!.toString(), "https://base.com/directory/another/file") 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Tests/ECMASwiftTests/FetchTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ECMASwift 2 | import JavaScriptCore 3 | import XCTest 4 | 5 | final class FetchTests: XCTestCase { 6 | func testCreateURLRequest() async throws { 7 | let runtime = JSRuntime() 8 | 9 | let options: [String: Any] = [ 10 | "method": ["POST", "GET", "PUT", "OPTIONS"].randomElement()!, 11 | "headers": [ 12 | "Content-Type": "application/json" 13 | ], 14 | "body": "{\"foo\":\"bar\"}" 15 | ] 16 | 17 | let value = JSValue(object: options, in: runtime.context) 18 | let request = Request(url: "https://foobar.com", options: value).request 19 | 20 | XCTAssertEqual(request.url, URL(string: "https://foobar.com")!) 21 | XCTAssertEqual(request.httpMethod, options["method"] as? String) 22 | XCTAssertEqual(request.allHTTPHeaderFields, options["headers"] as? [String: String]) 23 | XCTAssertEqual(String(data: request.httpBody!, encoding: .utf8), options["body"] as? String) 24 | } 25 | 26 | func testGetRequest() async { 27 | let client = MockClient( 28 | url: URL(string: "https://foobar.com")!, 29 | json: "{\"foo\": \"bar\"}", 30 | statusCode: 200 31 | ) 32 | let runtime = JSRuntime(client: client) 33 | 34 | _ = runtime.context.evaluateScript(""" 35 | async function getJSON() { 36 | let res = await fetch("https://foobar.com") 37 | let json = await res.text() 38 | return json 39 | } 40 | """) 41 | let result = try! await runtime.context.callAsyncFunction(key: "getJSON") 42 | 43 | XCTAssertEqual("{\"foo\": \"bar\"}", result.toString()!) 44 | } 45 | 46 | func testPostRequest() async { 47 | let client = MockClient( 48 | url: URL(string: "https://api.example.com/post")!, 49 | json: "{\"success\": true}", 50 | statusCode: 201 51 | ) 52 | let runtime = JSRuntime(client: client) 53 | 54 | _ = runtime.context.evaluateScript(""" 55 | async function postData() { 56 | let res = await fetch("https://api.example.com/post", { 57 | method: 'POST', 58 | headers: { 59 | 'Content-Type': 'application/json' 60 | }, 61 | body: JSON.stringify({data: 'test'}) 62 | }) 63 | let json = await res.json() 64 | return json 65 | } 66 | """) 67 | let result = try! await runtime.context.callAsyncFunction(key: "postData") 68 | 69 | XCTAssertEqual(true, result.objectForKeyedSubscript("success").toBool()) 70 | } 71 | 72 | func testFetchWithHeaders() async { 73 | let client = MockClient( 74 | url: URL(string: "https://api.example.com/headers")!, 75 | json: "{\"receivedHeader\": \"CustomValue\"}", 76 | statusCode: 200 77 | ) 78 | let runtime = JSRuntime(client: client) 79 | 80 | _ = runtime.context.evaluateScript(""" 81 | async function fetchWithHeaders() { 82 | let res = await fetch("https://api.example.com/headers", { 83 | headers: { 84 | 'X-Custom-Header': 'CustomValue' 85 | } 86 | }) 87 | let json = await res.json() 88 | return json 89 | } 90 | """) 91 | let result = try! await runtime.context.callAsyncFunction(key: "fetchWithHeaders") 92 | 93 | XCTAssertEqual("CustomValue", result.objectForKeyedSubscript("receivedHeader").toString()) 94 | } 95 | 96 | func testFetchError() async { 97 | let client = MockClient( 98 | url: URL(string: "https://api.example.com/error")!, 99 | json: "{\"error\": \"Not Found\"}", 100 | statusCode: 404 101 | ) 102 | let runtime = JSRuntime(client: client) 103 | 104 | _ = runtime.context.evaluateScript(""" 105 | async function fetchWithError() { 106 | try { 107 | let res = await fetch("https://api.example.com/error") 108 | if (!res.ok) { 109 | throw new Error(`HTTP error! status: ${res.status}`) 110 | } 111 | return await res.json() 112 | } catch (e) { 113 | return e.message 114 | } 115 | } 116 | """) 117 | let result = try! await runtime.context.callAsyncFunction(key: "fetchWithError") 118 | 119 | XCTAssertEqual("HTTP error! status: 404", result.toString()) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Request/Request.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JavaScriptCore 3 | 4 | @objc 5 | protocol RequestExports: JSExport { 6 | var url: String { get set } 7 | var method: String { get } 8 | var headers: Headers { get } 9 | var signal: AbortSignal? { get set } 10 | var bodyUsed: Bool { get } 11 | 12 | func text() -> String? 13 | func blob() -> JSValue? 14 | func clone() -> Request 15 | func formData() -> FormData? 16 | } 17 | 18 | /// This implmenets the `Request` browser API. 19 | /// 20 | /// Reference: [Request Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Request) 21 | final class Request: NSObject, RequestExports { 22 | var request: URLRequest 23 | var body: RequestBody? 24 | var bodyUsed: Bool = false 25 | var cache: String? 26 | var credentials: String? 27 | var destination: String? 28 | var integrity: String? 29 | var mode: String = "cors" 30 | var redirect: String? 31 | var referrer: String? 32 | var referrerPolicy: String = "" 33 | var signal: AbortSignal? 34 | 35 | weak var context: JSContext? 36 | 37 | init(url: String, options: JSValue? = nil) { 38 | let url = Foundation.URL(string: url)! 39 | self.request = URLRequest(url: url) 40 | self.request.httpMethod = "GET" 41 | 42 | if let options, options.hasValue { 43 | if options.hasProperty("method"), options.forProperty("method").hasValue { 44 | request.httpMethod = options.forProperty("method").toString() 45 | } 46 | if let body = options.forProperty("body") { 47 | self.body = RequestBody.create(from: body) 48 | request.httpBody = self.body?.data() 49 | } 50 | if let signal = options.forProperty("signal").toType(AbortSignal.self) { 51 | self.signal = signal 52 | } 53 | if let headers = options.forProperty("headers") { 54 | if let headersInstance = headers.toType(Headers.self) { 55 | request.allHTTPHeaderFields = headersInstance.getAll() 56 | } else if let headersObject = headers.toDictionary() as? [String: String] { 57 | request.allHTTPHeaderFields = Headers(withHeaders: headersObject).getAll() 58 | } 59 | } 60 | } 61 | } 62 | 63 | var url: String { 64 | get { request.url!.absoluteString } 65 | set(newValue) { request.url = Foundation.URL(string: newValue) } 66 | } 67 | 68 | var method: String { 69 | get { request.httpMethod! } 70 | } 71 | 72 | var headers: Headers { 73 | get { 74 | if let headers = request.allHTTPHeaderFields { 75 | return Headers(withHeaders: headers) 76 | } 77 | return Headers() 78 | } 79 | set(newValue) { 80 | self.request.allHTTPHeaderFields = newValue.getAll() 81 | } 82 | } 83 | 84 | // MARK: - Body Methods 85 | 86 | func arrayBuffer() -> Data? { 87 | bodyUsed = true 88 | return body?.data() 89 | } 90 | 91 | func blob() -> JSValue? { 92 | bodyUsed = true 93 | guard let context = context, let body = self.body else { return nil } 94 | 95 | return JSValue(newPromiseIn: context) { resolve, _ in 96 | let blob = Blob(content: body.string()!) 97 | blob.context = context 98 | resolve?.call(withArguments: [blob]) 99 | } 100 | } 101 | 102 | func formData() -> FormData? { 103 | bodyUsed = true 104 | return nil 105 | } 106 | 107 | func json() -> Any? { 108 | bodyUsed = true 109 | guard let data = body?.data() else { return nil } 110 | return try? JSONSerialization.jsonObject(with: data, options: []) 111 | } 112 | 113 | func text() -> String? { 114 | bodyUsed = true 115 | return body?.string() 116 | } 117 | 118 | func clone() -> Request { 119 | let request = Request(url: url) 120 | request.request = self.request 121 | return request 122 | } 123 | } 124 | 125 | /// Helper to register the ``Request`` API with a context. 126 | struct RequestAPI { 127 | func registerAPIInto(context: JSContext) { 128 | let requestClass: @convention(block) (String, JSValue?) -> Request = { url, options in 129 | let request: Request 130 | if let options = options, options.hasValue { 131 | request = Request(url: url, options: options) 132 | } else { 133 | request = Request(url: url) 134 | } 135 | request.context = context 136 | return request 137 | } 138 | 139 | context.setObject( 140 | requestClass, 141 | forKeyedSubscript: "Request" as NSString 142 | ) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/Fetch/Fetch.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency import JavaScriptCore 2 | 3 | /// This implmenets the `Fetch` browser API. 4 | /// 5 | /// Reference: [Fetch API Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) 6 | public final class FetchAPI { 7 | let client: HTTPClient 8 | 9 | public init(client: HTTPClient) { 10 | self.client = client 11 | } 12 | 13 | static func text(data: Data, context: JSContext) -> Any? { 14 | return JSValue(newPromiseIn: context) { resolve, _ in 15 | resolve?.call(withArguments: [String(data: data, encoding: .utf8) ?? ""]) 16 | } 17 | } 18 | 19 | static func json(data: Data) -> Any? { 20 | do { 21 | return try JSONSerialization.jsonObject( 22 | with: data, options: [] 23 | ) 24 | } catch { 25 | return nil 26 | } 27 | } 28 | 29 | static func createResponse( 30 | response: HTTPURLResponse, 31 | data: Data, 32 | context: JSContext 33 | ) -> [String: Any] { 34 | let jsonjs: @convention(block) () -> Any? = { 35 | FetchAPI.json(data: data) 36 | } 37 | let textjs: @convention(block) () -> Any? = { 38 | FetchAPI.text(data: data, context: context) 39 | } 40 | return [ 41 | "url": response.url?.absoluteString as Any, 42 | "ok": response.statusCode >= 200 && response.statusCode < 400, 43 | "status": response.statusCode, 44 | "json": JSValue(object: jsonjs, in: context) as Any, 45 | "text": JSValue(object: textjs, in: context) as Any 46 | ] as [String: Any] 47 | } 48 | 49 | public func registerAPIInto(context: JSContext) { 50 | let fetch: @convention(block) (JSValue, JSValue?) -> JSManagedValue? = { url, options in 51 | var fetchTask: Task? 52 | let promise = JSValue(newPromiseIn: context) { [weak self] resolve, reject in 53 | guard let resolve, let reject else { return } 54 | guard var request = url.isInstance(of: Request.self) 55 | ? (url.toObjectOf(Request.self) as? Request)?.request 56 | : Request(url: url.toString(), options: options).request else { 57 | reject.call(withArguments: [ 58 | [ 59 | "name": "FetchError", 60 | "response": "Could not decode URL / Request." 61 | ] 62 | ]) 63 | return 64 | } 65 | // options can include body. 66 | if let options, options.hasValue, options.hasProperty("body") { 67 | request.httpBody = options.forProperty("body").toString().data(using: .utf8) 68 | } 69 | guard let client = self?.client else { return } 70 | fetchTask = Task { 71 | do { 72 | let (data, response) = try await client.data(for: request) 73 | guard let response = (response as? HTTPURLResponse) else { 74 | reject.call(withArguments: ["URL is empty"]) 75 | return 76 | } 77 | resolve.call(withArguments: [ 78 | FetchAPI.createResponse( 79 | response: response, 80 | data: data, 81 | context: context 82 | ) 83 | ]) 84 | } catch let error { 85 | reject.call(withArguments: [ 86 | [ 87 | "name": "FetchError", 88 | "response": "\(error.localizedDescription)" 89 | ] 90 | ]) 91 | return 92 | } 93 | } 94 | if let options = options, options.hasValue { 95 | if let signal = options.forProperty("signal").toType(AbortSignal.self) { 96 | signal.onAbort = { 97 | if signal.aborted { 98 | fetchTask?.cancel() 99 | reject.call(withArguments: [["name": "AbortError"]]) 100 | } 101 | } 102 | } 103 | } 104 | } 105 | 106 | return JSManagedValue(value: promise) 107 | } 108 | 109 | context.setObject( 110 | fetch, 111 | forKeyedSubscript: "fetch" as NSCopying & NSObjectProtocol 112 | ) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/ECMASwift/API/URL/URL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JavaScriptCore 3 | 4 | @objc 5 | protocol URLExports: JSExport { 6 | var `protocol`: String { get set } 7 | var hostname: String { get set } 8 | var host: String { get set } 9 | var href: String { get set } 10 | var pathname: String { get set } 11 | var port: String { get set } 12 | var origin: String { get set } 13 | var searchParams: URLSearchParams { get } 14 | var search: String { get set } 15 | var fragment: String { get set } /// This is `hash` in the actual API but it collides here, needs a different solution. 16 | func toString() -> String 17 | } 18 | 19 | /// This implmenets the `URL` browser API. 20 | /// 21 | /// Reference: [URL Reference on MDN](https://developer.mozilla.org/en-US/docs/Web/API/URL) 22 | @objc 23 | final class URL: NSObject, URLExports { 24 | var url: Foundation.URL? 25 | 26 | init(string: String) { 27 | super.init() 28 | url = Foundation.URL(string: string) 29 | } 30 | 31 | init(string: String, base: String?) { 32 | super.init() 33 | if let base = base, let baseURL = Foundation.URL(string: base) { 34 | url = Foundation.URL(string: string, relativeTo: baseURL) 35 | } else { 36 | url = Foundation.URL(string: string) 37 | } 38 | } 39 | 40 | init(url: Foundation.URL) { 41 | super.init() 42 | self.url = url 43 | } 44 | 45 | func toString() -> String { 46 | if let url = url, !searchParams.toEncodedString().isEmpty { 47 | return url.absoluteString + "?" + searchParams.toEncodedString() 48 | } 49 | return url?.absoluteString ?? "" 50 | } 51 | 52 | func setURLComponent(_ key: WritableKeyPath, value: T) { 53 | guard let url = url else { return } 54 | var components = URLComponents(url: url, resolvingAgainstBaseURL: false) 55 | components?[keyPath: key] = value 56 | self.url = components?.url 57 | } 58 | 59 | var `protocol`: String { 60 | get { return url?.scheme ?? "" } 61 | set(newValue) { setURLComponent(\.scheme, value: newValue) } 62 | } 63 | 64 | var hostname: String { 65 | get { return url?.host ?? "" } 66 | set(newValue) { setURLComponent(\.host, value: newValue) } 67 | } 68 | 69 | var host: String { 70 | get { return url?.host ?? "" } 71 | set(newValue) { setURLComponent(\.host, value: newValue) } 72 | } 73 | 74 | var href: String { 75 | get { return toString() } 76 | set(newValue) { 77 | self.url = Foundation.URL(string: newValue) 78 | } 79 | } 80 | 81 | var pathname: String { 82 | get { return url?.path ?? "" } 83 | set(newValue) { setURLComponent(\.path, value: newValue) } 84 | } 85 | 86 | var origin: String { 87 | get { 88 | guard let scheme = url?.scheme, let host = url?.host else { 89 | return "" 90 | } 91 | 92 | var origin = "\(scheme)://\(host)" 93 | 94 | if let port = url?.port { 95 | origin.append(":\(port)") 96 | } 97 | 98 | return origin 99 | } 100 | set(newValue) { 101 | setURLComponent(\.host, value: newValue) 102 | } 103 | } 104 | 105 | var port: String { 106 | get { 107 | guard let port = url?.port else { return "" } 108 | return String(port) 109 | } 110 | set(newValue) { setURLComponent(\.port, value: Int(newValue)) } 111 | } 112 | 113 | var searchParams: URLSearchParams = URLSearchParams("") 114 | 115 | var search: String { 116 | get { 117 | guard let query = url?.query else { return "" } 118 | return "?" + query 119 | } 120 | set(newValue) { 121 | setURLComponent(\.query, value: newValue) 122 | } 123 | } 124 | 125 | /// The actual property should be exposed as `hash` but this colides with `NSObject`, this isn't used anyway. 126 | /// Objective-C offers the macro `ExportAs` however there isn't a Swift equivalent. 127 | var fragment: String { 128 | get { 129 | guard let fragment = url?.fragment else { return "" } 130 | return "#" + fragment 131 | } 132 | set(newValue) { 133 | setURLComponent(\.fragment, value: newValue) 134 | } 135 | } 136 | } 137 | 138 | /// Helper to register the ``URL`` API with a context. 139 | public struct URLAPI { 140 | public func registerAPIInto(context: JSContext) { 141 | let urlClass: @convention(block) (String, JSValue?) -> URL = { string, baseValue in 142 | if let baseValue = baseValue, !baseValue.isUndefined, !baseValue.isNull { 143 | return URL(string: string, base: baseValue.toString()) 144 | } else { 145 | return URL(string: string) 146 | } 147 | } 148 | 149 | context.setObject( 150 | urlClass, 151 | forKeyedSubscript: "URL" as NSString 152 | ) 153 | } 154 | } 155 | --------------------------------------------------------------------------------