Networking was born out of the necessity of having a networking library that has a straightforward API that supports faking requests and caching images out of the box.
- Friendly API
- Singleton free
- Dependency-free
- Minimal implementation
- Fully unit tested
- Simple request cancellation
- Fake requests easily (mocking/stubbing)
- Flexible caching
- Image downloading
- Choosing a configuration
- Changing request headers
- Authenticating
- Making a request
- Choosing how the body is encoded
- Cancelling a request
- Faking a request
- Downloading and caching an image
- Observing requests
- Updating the Network Activity Indicator
- Installing
- Author
- License
- Attribution
Initializing an instance of Networking means you have to select a URLSessionConfiguration. The available types are .default, .ephemeral and .background, if you don't provide any or don't have special needs then default will be used.
-
.default: The default session configuration uses a persistent disk-based cache (except when the result is downloaded to a file) and stores credentials in the user’s keychain. -
.ephemeral: Keeps everything in RAM — caches, credential stores and session data all live there, and the one thing it writes to disk is a URL you explicitly ask it to save to a file. Privacy is the point: data that stays out of the file system stays out of reach later, which is what makes this the configuration behind private browsing modes. -
.background: This configuration type is suitable for transferring data files while the app runs in the background. A session configured with this object hands control of the transfers over to the system, which handles the transfers in a separate process. In iOS, this configuration makes it possible for transfers to continue even when the app itself is suspended or terminated.
// Default
let networking = Networking(baseURL: "http://example.com")
// Ephemeral
let networking = Networking(baseURL: "http://example.com", configuration: .ephemeral)Networking is an actor, so it's safe to share one instance across tasks. Its members are accessed with await (e.g. await networking.setAuthorizationHeader(...)), and it can't be subclassed.
You can set the headerFields in any networking object.
This sets what URLSession sends on each request: a header it already carries is overwritten, and a new one is added.
await networking.setHeaderFields(["User-Agent": "your new user agent"])To authenticate using basic authentication with a username "aladdin" and password "opensesame" you only need to do this:
let networking = Networking(baseURL: "http://example.com")
await networking.setAuthorizationHeader(username: "aladdin", password: "opensesame")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/basic-auth/aladdin/opensesame")
// Successfully authenticated!To authenticate using a bearer token "AAAFFAAAA3DAAAAAA" you only need to do this:
let networking = Networking(baseURL: "http://example.com")
await networking.setAuthorizationHeader(token: "AAAFFAAAA3DAAAAAA")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
// Successfully authenticated!To authenticate using a custom authentication header, for example "Token token=AAAFFAAAA3DAAAAAA" you would need to set the following header field: Authorization: Token token=AAAFFAAAA3DAAAAAA. Luckily, Networking provides a simple way to do this:
let networking = Networking(baseURL: "http://example.com")
await networking.setAuthorizationHeader(headerValue: "Token token=AAAFFAAAA3DAAAAAA")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
// Successfully authenticated!Providing the following authentication header Anonymous-Token: AAAFFAAAA3DAAAAAA is also possible:
let networking = Networking(baseURL: "http://example.com")
await networking.setAuthorizationHeader(headerKey: "Anonymous-Token", headerValue: "AAAFFAAAA3DAAAAAA")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
// Successfully authenticated!When a token expires, the server answers 401/403. Rather than failing that request, register an AuthRefreshInterceptor: on an unauthorized response it runs your refresh closure, then replays the request once with the new credential. Return nil from refresh to give up and let the original failure through.
let networking = Networking(baseURL: "http://example.com")
await networking.setInterceptors([
AuthRefreshInterceptor {
let token = try await myAuth.refreshAccessToken() // your refresh call
await networking.setAuthorizationHeader(token: token) // keep future requests authed
return "Bearer \(token)" // …and replay this one with it
}
])Concurrent requests that all hit 401 at once share a single refresh — they wait on the one in flight, so a single token refresh serves them all. For pure notification of a 401, where a replay is beside the point, events() already carries it as a .completed(_, .failure) with error.statusCode == 401, which is enough on its own.
AuthRefreshInterceptor is one implementation of the general HTTPInterceptor seam — an async intercept(_:next:) hook that wraps every verb request. Calling next runs the rest of the chain (the innermost being the real network call); calling it again replays.
RetryInterceptor retries a request when it fails transiently — a dropped connection or timeout, or an HTTP status in retryableStatusCodes (408/429/500/502/503/504 by default, the same set behind NetworkingError.isRetryable). It backs off exponentially with full jitter between attempts (capped at maxDelay) and honors a server's Retry-After header (seconds or HTTP-date) when present.
await networking.setInterceptors([
RetryInterceptor(maxAttempts: 3, baseDelay: .milliseconds(500), maxDelay: .seconds(30))
])Only idempotent methods are retried by default (GET/HEAD/PUT/DELETE/OPTIONS/TRACE). Retrying a POST/PATCH after a timeout or 5xx could duplicate a side effect — a second charge, a duplicate mutation — because the server may have already processed the first attempt. If a non-idempotent endpoint is safe to retry (e.g. it takes an idempotency key), opt it in explicitly:
RetryInterceptor(retryableMethods: ["GET", "HEAD", "PUT", "DELETE", "POST"])Interceptors run outermost-first, so order matters: put RetryInterceptor before AuthRefreshInterceptor to retry around a refreshed credential, or after to refresh around each retry.
await networking.setInterceptors([
RetryInterceptor(), // outer: retries the whole thing
AuthRefreshInterceptor { "Bearer \(try await refresh())" }, // inner: refreshes on 401
])Interceptors apply to downloads (downloadImage/downloadData) as well as the verbs.
A 2xx can still be the wrong response — the server might return the wrong content-type or a malformed envelope. ResponseValidatorInterceptor runs your check on each successful response and turns a failure into a typed NetworkingError.validation(reason:_:) (carrying the response metadata). Everything outside 2xx passes through untouched, so a real 500 still surfaces as .http.
await networking.setInterceptors([
ResponseValidatorInterceptor { exchange in
let contentType = exchange.response.value(forHTTPHeaderField: "Content-Type") ?? ""
return contentType.hasPrefix("application/json")
? .valid
: .invalid(reason: "expected JSON, got \(contentType)")
}
])Register it outermost (before RetryInterceptor) so it validates the final, post-retry response. The cache sits beneath the interceptors, so cache hits are validated too — a response cached before the validator existed can't slip through.
Making a request is as simple as just calling get, post, put, or delete.
GET example:
let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
switch result {
case .success(let response):
let body = response.body // [String: AnyCodable]
let statusCode = response.statusCode
case .failure(let error):
// Handle error
}POST example:
struct Credentials: Encodable { let username: String; let password: String }
let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/post", body: Credentials(username: "jameson", password: "secret"))
// On success, response.body holds the echoed JSON below.
/*
{
"json" : {
"username" : "jameson",
"password" : "secret"
},
"url" : "http://example.com/post",
"data" : "{"password" : "secret","username" : "jameson"}",
"headers" : {
"Accept" : "application/json",
"Content-Type" : "application/json",
"Host" : "example.com",
"Content-Length" : "44",
"Accept-Language" : "en-us"
}
}
*/You can get the response headers and status code inside the success.
let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
switch result {
case .success(let response):
let headers = response.headers // [String: AnyCodable]
let statusCode = response.statusCode // Int
case .failure(let error):
// Handle error
}get returns Swift's Result with two cases: .success(let response) and .failure(let error). The success carries the decoded value, the failure a NetworkingError.
get is generic over any Decodable, so you decode straight into your own model and the JSON digging stays here:
struct Recipe: Decodable { let title: String }
let networking = Networking(baseURL: "http://fakerecipes.com")
let result: Result<[Recipe], NetworkingError> = await networking.get("/recipes")
switch result {
case .success(let recipes):
// recipes is [Recipe] — fully typed, no optionals
print(recipes.map(\.title))
case .failure(let error):
// error is a NetworkingError — see "Handling errors" below
print(error.localizedDescription)
}Use JSONResponse as the type when you want the raw statusCode, headers, and body instead of a model.
NetworkingError is categorized by where the request failed, so you can branch on the cause instead of parsing a message. Each case preserves the underlying error or response:
switch result {
case .success(let value):
break
case .failure(let error):
switch error {
case .invalidRequest(let reason):
// Couldn't build/encode the request — a caller-side bug. reason says which.
break
case .transport(let urlError):
// Never reached the server: offline, DNS, TLS, timeout. The underlying URLError is intact.
break
case .http(let httpError):
// A non-2xx response. httpError.statusCode, .isClientError/.isServerError, and .metadata
// (status, headers, and the full response body — decode it into your own error type).
break
case .decoding(let decodingError, let metadata):
// 2xx but the body didn't match your type. The DecodingError and response metadata are preserved.
break
case .validation(let reason, let metadata):
// 2xx but a ResponseValidatorInterceptor rejected it (wrong content-type, bad envelope, …).
break
case .invalidResponse:
break
case .cancelled:
break
}
}Two conveniences cut across the cases:
error.statusCode // Int? — present for .http, .decoding, and .validation
error.responseMetadata // ResponseMetadata? — status, headers, and the full body
error.isRetryable // Bool — conservative: transient transport failures + HTTP 408/429/5xxisRetryable is deliberately conservative: only transport timeouts/connection failures and a small set of status codes (408, 429, 500, 502, 503, 504). It reports a 4xx (other than 408/429), a decoding failure and an invalid request as final.
The core treats the error body as opaque. It hands you the complete body in ResponseMetadata so you can decode your API's own error envelope into a typed value — decode(_:) is a small convenience over body:
struct APIError: Decodable { let errors: [String: [String]] } // e.g. a Rails/ActiveModel envelope
if case .failure(.http(let httpError)) = result,
let apiError = try? httpError.metadata.decode(APIError.self) {
// apiError.errors["start_time"] == ["can't be blank"]
}
// metadata.body is the full bytes; metadata.bodySnippet is a truncated excerpt for logs.Just as the response side is generic over any Decodable, the request side is generic over any Encodable. post, put, and patch take a body: that's JSON-encoded for you and sent with Content-Type: application/json — so the body you send is compile-checked, a typed value rather than a [String: Any] dictionary:
struct Credentials: Encodable {
let username: String
let password: String
}
let networking = Networking(baseURL: "http://example.com")
let body = Credentials(username: "jameson", password: "secret")
// Decode the response straight into your model…
let result: Result<Account, NetworkingError> = await networking.post("/login", body: body)
// …or ignore it with a Void result when you only care about success/failure.
let ack: Result<Void, NetworkingError> = await networking.put("/account", body: body)Dates in the body are encoded as ISO-8601, matching how responses are decoded.
Each encoding is a distinct, typed method, and the method you call picks the Content-Type — one decision, rather than a parameters:/parameterType: pair to keep in step.
The common case: pass any Encodable as body:. It's serialized with JSONEncoder and sent as application/json.
let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/post", body: ["name": "jameson"])
// Successful post using `application/json` as `Content-Type`Pass form: to send application/x-www-form-urlencoded; Networking percent-encodes it for you (Percent-encoding / URL-encoding). form: takes any flat Encodable — a [String: String] or your own model — and stringifies scalars for you, so a Bool becomes "true":
let networking = Networking(baseURL: "http://example.com")
// A dictionary…
let result: Result<JSONResponse, NetworkingError> = await networking.post("/post", form: ["name": "jameson"])
// …or your own model.
struct SignIn: Encodable { let name: String; let remember: Bool }
let result2: Result<JSONResponse, NetworkingError> = await networking.post("/post", form: SignIn(name: "jameson", remember: true))
// Successful post using `application/x-www-form-urlencoded` as `Content-Type`Networking provides a simple model to use multipart/form-data. A multipart request consists of appending one or several FormDataPart items to a request. The simplest multipart request would look like this.
let networking = Networking(baseURL: "https://example.com")
let imageData = imageToUpload.pngData()!
let part = FormDataPart(data: imageData, parameterName: "file", filename: "selfie.png")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/image/upload", parts: [part])
// Successful upload using `multipart/form-data` as `Content-Type`To send several parts, or string form fields alongside the files, pass fields::
let networking = Networking(baseURL: "https://example.com")
let part1 = FormDataPart(data: imageData1, parameterName: "file1", filename: "selfie1.png")
let part2 = FormDataPart(data: imageData2, parameterName: "file2", filename: "selfie2.png")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/image/upload", parts: [part1, part2], fields: ["username": "3lvis"])
// Do somethingFormDataPart Content-Type:
Each part's Content-Type comes from its FormDataPartType, which carries the MIME string. The default is .octetStream (application/octet-stream); .png and .jpeg are provided as conveniences. For anything else, construct one directly — FormDataPart(type: FormDataPartType("application/pdf"), data: …, parameterName: …).
To send bytes verbatim under a Content-Type you choose, pass data:contentType:.
let networking = Networking(baseURL: "http://example.com")
let result: Result<JSONResponse, NetworkingError> = await networking.post("/upload", data: imageData, contentType: "application/octet-stream")
// Successful upload using `application/octet-stream` as `Content-Type`get and delete carry their parameters in the URL query string. Pass typed [URLQueryItem] when you need ordering or repeated keys, or any flat Encodable model:
let networking = Networking(baseURL: "http://example.com")
// Explicit query items…
let result: Result<JSONResponse, NetworkingError> = await networking.get("/search", query: [URLQueryItem(name: "q", value: "swift")])
// …or a model.
struct Search: Encodable { let q: String; let page: Int }
let result2: Result<JSONResponse, NetworkingError> = await networking.get("/search", query: Search(q: "swift", page: 2))
// GET /search?q=swift&page=2Hold the Task running the request and call cancel() on it. A cancelled request fails with NetworkingError.cancelled.
let networking = Networking(baseURL: "http://example.com")
let task = Task {
let result: Result<JSONResponse, NetworkingError> = await networking.get("/get")
// On cancellation this is .failure(.cancelled)
}
// In another place
task.cancel()Faking a request means that after calling this method on a specific path, any call to this resource, will return what you registered as a response. This technique is also known as mocking or stubbing.
Faking with successfull response:
struct Story: Codable { let id: Int; let title: String }
let networking = Networking(baseURL: "https://example.com")
await networking.fakeGET("/stories", response: [Story(id: 47333, title: "Site Design: Aquest")])
let result: Result<[Story], NetworkingError> = await networking.get("/stories")
// .success carrying the storiesFaking with contents of a file:
A file outside the main bundle takes the bundle parameter; everything else uses Bundle.main.
let networking = Networking(baseURL: baseURL)
await networking.fakeGET("/entries", fileName: "entries.json")
let result: Result<JSONResponse, NetworkingError> = await networking.get("/entries")
// Response with the contents of entries.jsonFaking with status code:
A fake request defaults to 200 (SUCCESS). Give it a status code outside 2xx and Networking returns .failure(.http(HTTPError)) carrying that status code, with the fake's body in metadata for you to decode — see Handling errors.
Omit response: for a status-code-only fake:
let networking = Networking(baseURL: "https://example.com")
await networking.fakeGET("/stories", statusCode: 500)
let result: Result<JSONResponse, NetworkingError> = await networking.get("/stories")
// .failure with status code 500Downloading:
let networking = Networking(baseURL: "http://example.com")
let result: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
switch result {
case .success(let image):
// Do something with the downloaded image
case .failure(let error):
// Handle error
}Ask for ImageResponse instead of Image (or DataResponse/Data from downloadData) when you also need the response's statusCode and headers:
let result: Result<ImageResponse, NetworkingError> = await networking.downloadImage("/image/png")
// response.image, response.statusCode, response.headersCancelling:
let networking = Networking(baseURL: baseURL)
let task = Task {
let result: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
// On cancellation this is .failure(.cancelled)
}
// In another place
try await networking.cancelImageDownload("/image/png")Caching:
Networking uses a multi-cache architecture when downloading images, the first time the downloadImage method is called for a specific path, it will store the results on disk (Caches directory) and in memory (NSCache), so in the next call it will return the cached results without hitting the network.
let networking = Networking(baseURL: "http://example.com")
let _: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
// Image from network
let _: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
// Image from cacheClearing and expiring the cache. Call await networking.clearCache() to empty it — both the in-memory layer and the on-disk files. (reset() does the same, plus wiping credentials.) clearCache() takes the whole cache at once: it clears everything, and stale entries expire on their own (below). For a download, downloadImage/downloadData with cachingLevel: .none force a fresh fetch and drop that cached copy; for the verbs, .none bypasses the cache and is the default, so an entry you cached deliberately stays where it is.
On-disk entries expire on their own, so a long-lived cache stays bounded. An entry whose on-disk last use is older than cacheTTL (default 7 days; set it via init(…, cacheTTL:) or setCacheTTL(_:)) is swept. The persisted clock is the cache file's modification date, refreshed by disk reads and writes — caching an entry, or reading one back after it's left the in-memory layer, re-warms it; only genuinely idle entries are removed. The in-memory layer is the warm tier (served fast, left to NSCache's own memory-pressure eviction) and leaves the disk file's date alone on a hit, so an entry kept warm only in memory for longer than cacheTTL may be re-fetched once it's evicted — in normal use it would have hit disk again first and stayed warm.
This is a key→blob store rather than HTTP caching.
cachingLevelcaches the downloaded bytes unconditionally by path (orcacheName:), ignoringCache-Control/ETag/Expires— which is what you usually want for images and other assets whose hosts often omit cache headers entirely. HTTP cache semantics — freshness, conditional304revalidation, eviction — are deliberately out of scope. For those, configure aURLCacheon the session and letURLSessionhandle it per the response headers — it composes with the verb requests:let configuration = URLSessionConfiguration.default configuration.urlCache = URLCache(memoryCapacity: 10_000_000, diskCapacity: 50_000_000) configuration.requestCachePolicy = .useProtocolCachePolicy let networking = Networking(baseURL: "https://example.com", configuration: configuration)
Faking:
let networking = Networking(baseURL: baseURL)
let pigImage = UIImage(named: "pig.png")!
await networking.fakeImageDownload("/image/png", image: pigImage)
let result: Result<Image, NetworkingError> = await networking.downloadImage("/image/png")
// Here you'll get the provided pig.png imageNetworking doesn't print to the console. Two separate things give you visibility: a failure log that's on by default (next section), and an event stream you can hook for the full lifecycle.
events() returns an AsyncStream<NetworkingEvent> — one .started then one .completed for every request (verbs and downloads). Iterate it with for await, accumulating into plain local state:
let stream = await networking.events()
Task {
for await event in stream {
switch event {
case let .started(context):
print("→ [\(context.id)] \(context.method) \(context.url?.absoluteString ?? "")")
case let .completed(context, outcome, duration, metrics):
switch outcome {
case let .success(statusCode, byteCount):
print("← [\(context.id)] \(statusCode) (\(byteCount) bytes) in \(duration)")
case let .failure(error):
print("✗ [\(context.id)] \(error.localizedDescription) — retryable: \(error.isRetryable)")
}
if let metrics { print(" DNS \(metrics.domainLookup ?? 0)s · TLS \(metrics.secureConnection ?? 0)s") }
}
}
}RequestContextcarries a uniqueid(shared by the request's.started/.completed, and stamped into the failure logs), plusmethod,url, and the real requestheaders(events() is your own data — see Privacy below for why these aren't redacted)..completedcarries theOutcome(.success(statusCode:byteCount:)/.failure(NetworkingError)), the measuredduration, and — for real network requests —TransactionMetricsdistilled fromURLSessionTaskMetrics(DNS / connect / TLS / request / response timings, byte counts, redirect count, cache hit).- Each
events()call returns its own stream, so multiple consumers can listen independently.
Which headers count as sensitive. Authorization, the active auth-header key, Cookie, and Set-Cookie are the default redaction set — replaced with <redacted> in the built-in logs when redaction is on (release; see Privacy below). events() is unaffected. Change the set:
await networking.setRedactedHeaderFields(["Authorization", "X-Api-Key"])Straight out of the box the library logs failures (HTTP 4xx/5xx, decoding, transport, invalid-request) to Apple's unified logging (os.Logger, subsystem com.elvisnunez.networking), tagged with the request id. This is the modern replacement for console print: it appears automatically in the Xcode console and Console.app, is filterable, and honors privacy annotations.
setLogLevel chooses which requests are logged — logged requests always get full detail (line, request + response headers, request + response bodies, truncated):
await networking.setLogLevel(.none) // nothing
await networking.setLogLevel(.failures) // default — every failure, full detail
await networking.setLogLevel(.all) // every request too, success or failure — the opt-in firehose.failures is the default: failures are rare, so logging them in full is cheap and it's the case you debug — including the request body, the quickest way to catch a wrong-shaped payload. .all adds successful requests (with their response body, for "succeeded but returned the wrong thing"). The level gates only the built-in logging — events() always delivers full structured events regardless. (Downloads — downloadImage/downloadData — log the line + request headers; their response headers/body are omitted since the payload is binary.)
Privacy — one rule: debug shows, release redacts. Requests carry sensitive data (logins, payments, profiles, Authorization/Cookie headers). redactsLogs governs the built-in logs: in debug builds it shows everything (you're debugging — "is my auth header set?" must be answerable); in release it replaces both the body lines and the setRedactedHeaderFields header values (Authorization/Cookie/Set-Cookie by default) with <redacted>. Override either way with setRedactsLogs(_:). One boundary worth knowing: events() always carries the real headers — it's your own request data, and redaction is a logging concern rather than an observation one. For more control, setLogLevel(.none) or filter events() yourself.
Reading logs from a CLI / test / headless run. os.Logger isn't visible in swift test / swift run stdout. Point the library at a file and it mirrors the same diagnostics there as plain text:
await networking.setLogFileURL(URL(fileURLWithPath: "/tmp/networking.log"))Or set it from the environment with NETWORKING_LOG_FILE, leaving the code untouched — handy for CI or an automated agent:
NETWORKING_LOG_FILE=/tmp/networking.log swift test
cat /tmp/networking.log
# 2026-06-14T20:09:58Z → GET /get [F97CE6EB-…]
# 2026-06-14T20:09:58Z ← 200 (452 bytes) in 0.022s [F97CE6EB-…]
# 2026-06-14T20:09:58Z ✗ GET …/status/404 [C3569F63-…] failed: The server returned status 404 (not found).NETWORKING_LOG_FILE accepts an absolute path or a bare filename — a bare name resolves under the app's Caches directory (sandbox-safe; the same default location CocoaLumberjack uses). That makes it work inside a running app on the simulator, where you inject it at launch and read it back from the app's container:
# simctl forwards SIMCTL_CHILD_-prefixed vars into the app (prefix stripped)
xcrun simctl launch SIMCTL_CHILD_NETWORKING_LOG_FILE=networking.log <device> <bundle-id>
dir=$(xcrun simctl get_app_container <device> <bundle-id> data)
cat "$dir/Library/Caches/networking.log"(A physical device has Console.app / os.Logger in place of simctl.)
Networking is distributed through Swift Package Manager. It requires Swift 6.2+ and iOS 18 / macOS 15 / tvOS 18 / watchOS 11.
In your Package.swift:
.package(url: "https://github.com/3lvis/Networking.git", from: "8.0.0")…and add "Networking" to your target's dependencies. In Xcode, use File ▸ Add Package Dependencies… and enter https://github.com/3lvis/Networking.git.
Upgrading from 7.x? 8.0.0 is a major, breaking release (async/
await+Result, Swift 6, typed request bodies, a categorized error model, an event stream, and request interceptors). See the release notes for the full migration.
The integration tests exercise the real HTTP stack against a local go-httpbin — the same backend CI uses. With Docker installed:
make test # starts go-httpbin, runs the suite, tears it downTo iterate, start it once and run the suite directly:
make httpbin # leaves go-httpbin running on :8080
swift test
make httpbin-stopTests default to http://127.0.0.1:8080; set HTTPBIN_BASE_URL to point elsewhere. The offline (faked) suites run on their own.
This library was made with love by @3lvis.
Networking is available under the MIT license. See the LICENSE file for more info.
The logo typeface comes thanks to Sanid Jusić.
