Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'

This commit is contained in:
-Puter
2026-07-19 03:25:10 +05:30
parent dd1071cc10
commit 2daf979036
2214 changed files with 673090 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
/**
* Browser-backed implementation of Effect's Crypto service.
*
* This module provides a `Crypto.Crypto` layer backed by the Web Crypto API.
* The {@link WebCrypto} context reference defaults to `globalThis.crypto`, so
* browser programs can use the standard implementation while tests or embedded
* runtimes can provide their own `Crypto` object.
*
* @since 1.0.0
*/
import * as Context from "effect/Context"
import * as EffectCrypto from "effect/Crypto"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as PlatformError from "effect/PlatformError"
/**
* Provides Browser Web Crypto APIs used by the Crypto service implementation.
*
* **When to use**
*
* Use to override the browser `Crypto` object used by the platform crypto
* layer.
*
* @category references
* @since 1.0.0
*/
export const WebCrypto = Context.Reference<Crypto>("@effect/platform-browser/Crypto/WebCrypto", {
defaultValue: () => globalThis.crypto
})
/**
* Layer that directly interfaces with the Web Crypto API.
*
* **When to use**
*
* Use to provide cryptographic randomness, UUID generation, and digest
* operations in browser runtimes backed by `globalThis.crypto`.
*
* **Details**
*
* Random bytes are produced with `crypto.getRandomValues`. Digests are computed
* with `crypto.subtle.digest` and returned as `Uint8Array` values.
*
* **Gotchas**
*
* The layer dies if the Web Crypto object is unavailable. Digest operations
* fail with `PlatformError` when `crypto.subtle.digest` is unavailable or the
* browser rejects the digest request.
*
* @category layers
* @since 1.0.0
*/
export const layer: Layer.Layer<EffectCrypto.Crypto> = Layer.effect(
EffectCrypto.Crypto,
Effect.gen(function*() {
const crypto = yield* WebCrypto
if (!crypto) {
return yield* Effect.die(new Error("Web Crypto API is not available"))
}
const randomBytes = (size: number): Uint8Array => {
const bytes = new Uint8Array(size)
crypto.getRandomValues(bytes)
return bytes
}
const digest: EffectCrypto.Crypto["digest"] = (algorithm, data) => {
if (typeof crypto.subtle.digest !== "function") {
return Effect.fail(PlatformError.systemError({
module: "Crypto",
method: "digest",
_tag: "Unknown",
description: "crypto.subtle.digest is not available"
}))
}
return Effect.map(
Effect.tryPromise({
try: () => crypto.subtle.digest(algorithm, new Uint8Array(data)),
catch: (cause) =>
PlatformError.systemError({
module: "Crypto",
method: "digest",
_tag: "Unknown",
description: "Could not compute digest",
cause
})
}),
(buffer) => new Uint8Array(buffer)
)
}
return EffectCrypto.make({
randomBytes,
digest
})
})
)

View File

@@ -0,0 +1,430 @@
/**
* Browser implementations of the Effect `HttpClient`.
*
* This module exposes HTTP client layers for code that runs in a browser. It
* re-exports the fetch-based `Fetch`, `RequestInit`, and `layerFetch` APIs for
* the common case where requests use the platform `fetch` implementation. It
* also provides an `XMLHttpRequest`-backed client, including controls for the
* XHR response type, an overridable `XMLHttpRequest` constructor service, and
* the `layerXMLHttpRequest` layer.
*
* @since 4.0.0
*/
import * as Cause from "effect/Cause"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import type { LazyArg } from "effect/Function"
import * as Inspectable from "effect/Inspectable"
import type * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import { type Pipeable, pipeArguments } from "effect/Pipeable"
import * as Queue from "effect/Queue"
import type * as Schema from "effect/Schema"
import * as Stream from "effect/Stream"
import * as Cookies from "effect/unstable/http/Cookies"
import * as Headers from "effect/unstable/http/Headers"
import * as HttpClient from "effect/unstable/http/HttpClient"
import * as HttpClientError from "effect/unstable/http/HttpClientError"
import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
import * as HttpIncomingMessage from "effect/unstable/http/HttpIncomingMessage"
import * as UrlParams from "effect/unstable/http/UrlParams"
import * as HeaderParser from "multipasta/HeadersParser"
// =============================================================================
// Fetch
// =============================================================================
export {
/**
* Context reference for the `fetch` implementation used by the fetch-based HTTP client.
*
* @category fetch
* @since 4.0.0
*/
Fetch,
/**
* Layer that provides an `HttpClient` implementation backed by the configured `Fetch` function.
*
* @category fetch
* @since 4.0.0
*/
layer as layerFetch,
/**
* Service that contains default fetch options for the browser fetch client.
*
* **When to use**
*
* Use to provide default credentials, cache, redirect, integrity, or other
* fetch options for browser HTTP requests.
*
* @category fetch
* @since 4.0.0
*/
RequestInit
} from "effect/unstable/http/FetchHttpClient"
// =============================================================================
// XML Http Request
// =============================================================================
/**
* Allowed response body modes for the browser XHR HTTP client.
*
* @category models
* @since 4.0.0
*/
export type XHRResponseType = "arraybuffer" | "text"
/**
* Context reference for the `XMLHttpRequest.responseType` used by the browser XHR HTTP client, defaulting to `"text"`.
*
* **When to use**
*
* Use when you need XHR-backed HTTP requests to receive response bodies as text
* or raw `ArrayBuffer` values.
*
* @see {@link XHRResponseType} for the allowed response body modes
* @see {@link withXHRArrayBuffer} for scoping XHR response handling to `ArrayBuffer`
*
* @category references
* @since 4.0.0
*/
export const CurrentXHRResponseType: Context.Reference<XHRResponseType> = Context.Reference(
"@effect/platform-browser/BrowserHttpClient/CurrentXHRResponseType",
{ defaultValue: (): XHRResponseType => "text" }
)
/**
* Runs an effect with `CurrentXHRResponseType` set to `"arraybuffer"` so the XHR HTTP client receives response bodies as `ArrayBuffer` values.
*
* @category references
* @since 4.0.0
*/
export const withXHRArrayBuffer = <A, E, R>(
self: Effect.Effect<A, E, R>
): Effect.Effect<A, E, R> =>
Effect.provideService(
self,
CurrentXHRResponseType,
"arraybuffer"
)
/**
* Service tag for the `XMLHttpRequest` constructor used by the browser XHR HTTP client.
*
* @category services
* @since 4.0.0
*/
export class XMLHttpRequest extends Context.Service<
XMLHttpRequest,
LazyArg<globalThis.XMLHttpRequest>
>()("@effect/platform-browser/BrowserHttpClient/XMLHttpRequest") {}
const makeXhrRequest = () => new globalThis.XMLHttpRequest()
const makeXmlHttpRequest = HttpClient.make(
(request, url, signal, fiber) =>
Effect.suspend(() => {
const xhr = Context.getOrElse(
fiber.context,
XMLHttpRequest,
() => makeXhrRequest
)()
signal.addEventListener("abort", () => {
xhr.abort()
xhr.onreadystatechange = null
}, { once: true })
xhr.open(request.method, url.toString(), true)
xhr.responseType = fiber.getRef(CurrentXHRResponseType)
Object.entries(request.headers).forEach(([k, v]) => {
xhr.setRequestHeader(k, v)
})
return Effect.andThen(
sendBody(xhr, request),
Effect.callback<ClientResponseImpl, HttpClientError.HttpClientError>((resume) => {
let sent = false
const onChange = () => {
if (!sent && xhr.readyState >= 2) {
sent = true
resume(Effect.succeed(new ClientResponseImpl(request, xhr)))
}
}
xhr.onreadystatechange = onChange
xhr.onerror = (_event) => {
resume(Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request,
cause: xhr.statusText
})
})
))
}
onChange()
return Effect.void
})
)
})
)
const sendBody = (
xhr: globalThis.XMLHttpRequest,
request: HttpClientRequest.HttpClientRequest
): Effect.Effect<void, HttpClientError.HttpClientError> => {
const body = request.body
switch (body._tag) {
case "Empty":
return Effect.sync(() => xhr.send())
case "Raw":
return Effect.sync(() => xhr.send(body.body as any))
case "Uint8Array":
return Effect.sync(() => xhr.send(body.body as any))
case "FormData":
return Effect.sync(() => xhr.send(body.formData))
case "Stream":
return Effect.matchEffect(
Stream.runFold(body.stream, () => new Uint8Array(0), (acc, chunk) => {
const next = new Uint8Array(acc.length + chunk.length)
next.set(acc, 0)
next.set(chunk, acc.length)
return next
}),
{
onFailure: (cause) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.EncodeError({
request,
cause
})
})
),
onSuccess: (body) => Effect.sync(() => xhr.send(body))
}
)
}
}
const encoder = new TextEncoder()
abstract class IncomingMessageImpl<E> extends Inspectable.Class implements HttpIncomingMessage.HttpIncomingMessage<E> {
readonly [HttpIncomingMessage.TypeId]: typeof HttpIncomingMessage.TypeId
readonly source: globalThis.XMLHttpRequest
readonly onError: (error: unknown) => E
constructor(source: globalThis.XMLHttpRequest, onError: (error: unknown) => E) {
super()
this[HttpIncomingMessage.TypeId] = HttpIncomingMessage.TypeId
this.source = source
this.onError = onError
this._rawHeaderString = source.getAllResponseHeaders()
}
private _rawHeaderString: string
private _rawHeaders: Record<string, string | Array<string>> | undefined
private _headers: Headers.Headers | undefined
get headers() {
if (this._headers) {
return this._headers
}
if (this._rawHeaderString === "") {
return this._headers = Headers.empty
}
const parser = HeaderParser.make()
const result = parser(encoder.encode(this._rawHeaderString + "\r\n"), 0)
this._rawHeaders = result._tag === "Headers" ? result.headers : undefined
const parsed = result._tag === "Headers" ? Headers.fromInput(result.headers) : Headers.empty
return this._headers = parsed
}
cachedCookies: Cookies.Cookies | undefined
get cookies() {
if (this.cachedCookies) {
return this.cachedCookies
}
if (this._rawHeaders === undefined) {
return Cookies.empty
} else if (this._rawHeaders["set-cookie"] === undefined) {
return this.cachedCookies = Cookies.empty
}
return this.cachedCookies = Cookies.fromSetCookie(this._rawHeaders["set-cookie"])
}
get remoteAddress() {
return Option.none()
}
_textEffect: Effect.Effect<string, E> | undefined
get text(): Effect.Effect<string, E> {
if (this._textEffect) {
return this._textEffect
}
return this._textEffect = Effect.callback<string, E>((resume) => {
if (this.source.readyState === 4) {
resume(Effect.succeed(this.source.responseText))
return
}
const onReadyStateChange = () => {
if (this.source.readyState === 4) {
resume(Effect.succeed(this.source.responseText))
}
}
const onError = () => {
resume(Effect.fail(this.onError(this.source.statusText)))
}
this.source.addEventListener("readystatechange", onReadyStateChange)
this.source.addEventListener("error", onError)
return Effect.sync(() => {
this.source.removeEventListener("readystatechange", onReadyStateChange)
this.source.removeEventListener("error", onError)
})
}).pipe(
Effect.cached,
Effect.runSync
)
}
get json(): Effect.Effect<Schema.Json, E> {
return Effect.flatMap(this.text, (text) =>
Effect.try({
try: () => text === "" ? null : JSON.parse(text),
catch: this.onError
}))
}
get urlParamsBody(): Effect.Effect<UrlParams.UrlParams, E> {
return Effect.flatMap(this.text, (text) =>
Effect.try({
try: () => UrlParams.fromInput(new URLSearchParams(text)),
catch: this.onError
}))
}
get stream(): Stream.Stream<Uint8Array, E> {
return Stream.callback<Uint8Array, E>((queue) => {
let offset = 0
const onReadyStateChange = () => {
if (this.source.readyState === 3) {
const encoded = encoder.encode(this.source.responseText.slice(offset))
Queue.offerUnsafe(queue, encoded)
offset = this.source.responseText.length
} else if (this.source.readyState === 4) {
const encoded = encoder.encode(this.source.responseText.slice(offset))
if (offset < this.source.responseText.length) {
Queue.offerUnsafe(queue, encoded)
}
Queue.endUnsafe(queue)
}
}
const onError = () => {
Queue.failCauseUnsafe(queue, Cause.fail(this.onError(this.source.statusText)))
}
this.source.addEventListener("readystatechange", onReadyStateChange)
this.source.addEventListener("error", onError)
onReadyStateChange()
return Effect.sync(() => {
this.source.removeEventListener("readystatechange", onReadyStateChange)
this.source.removeEventListener("error", onError)
})
})
}
_arrayBufferEffect: Effect.Effect<ArrayBuffer, E> | undefined
get arrayBuffer(): Effect.Effect<ArrayBuffer, E> {
if (this._arrayBufferEffect) {
return this._arrayBufferEffect
}
return this._arrayBufferEffect = Effect.callback<ArrayBuffer, E>((resume) => {
if (this.source.readyState === 4) {
resume(Effect.succeed(this.source.response))
return
}
const onReadyStateChange = () => {
if (this.source.readyState === 4) {
resume(Effect.succeed(this.source.response))
}
}
const onError = () => {
resume(Effect.fail(this.onError(this.source.statusText)))
}
this.source.addEventListener("readystatechange", onReadyStateChange)
this.source.addEventListener("error", onError)
return Effect.sync(() => {
this.source.removeEventListener("readystatechange", onReadyStateChange)
this.source.removeEventListener("error", onError)
})
}).pipe(
Effect.map((response) => {
if (typeof response === "string") {
const arr = encoder.encode(response)
return arr.byteLength !== arr.buffer.byteLength
? arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength)
: arr.buffer
}
return response
}),
Effect.cached,
Effect.runSync
)
}
}
class ClientResponseImpl extends IncomingMessageImpl<HttpClientError.HttpClientError>
implements HttpClientResponse.HttpClientResponse, Pipeable
{
readonly [HttpClientResponse.TypeId]: typeof HttpClientResponse.TypeId
readonly request: HttpClientRequest.HttpClientRequest
constructor(
request: HttpClientRequest.HttpClientRequest,
source: globalThis.XMLHttpRequest
) {
super(source, (cause) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.DecodeError({
request,
response: this,
cause
})
}))
this.request = request
this[HttpClientResponse.TypeId] = HttpClientResponse.TypeId
}
get status() {
return this.source.status
}
get formData(): Effect.Effect<FormData, HttpClientError.HttpClientError> {
return Effect.die("Not implemented")
}
override toString(): string {
return `ClientResponse(${this.status})`
}
toJSON(): unknown {
return HttpIncomingMessage.inspect(this, {
_id: "@effect/platform/HttpClientResponse",
request: this.request.toJSON(),
status: this.status
})
}
pipe() {
return pipeArguments(this, arguments)
}
}
/**
* Layer that provides an `HttpClient` implementation backed by the browser `XMLHttpRequest` API.
*
* @category layers
* @since 4.0.0
*/
export const layerXMLHttpRequest: Layer.Layer<HttpClient.HttpClient> = HttpClient.layerMergedContext(
Effect.succeed(makeXmlHttpRequest)
)

View File

@@ -0,0 +1,177 @@
/**
* Browser-backed `KeyValueStore` layers for client-side Effect programs.
*
* This module provides browser implementations of the unstable persistence
* `KeyValueStore` service. Use {@link layerLocalStorage} for small
* origin-scoped values that should survive reloads and browser restarts, use
* {@link layerSessionStorage} for tab / page-session state, and use
* {@link layerIndexedDb} when the store should be asynchronous and backed by
* IndexedDB. The IndexedDB layer requires the browser `IndexedDb` service and
* accepts an optional database name.
*
* @since 4.0.0
*/
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
import { IndexedDb } from "./IndexedDb.ts"
/**
* Creates a `KeyValueStore` layer that uses the browser's `localStorage` API and stores values between browser sessions.
*
* @category layers
* @since 4.0.0
*/
export const layerLocalStorage: Layer.Layer<KeyValueStore.KeyValueStore> = KeyValueStore.layerStorage(() =>
globalThis.localStorage
)
/**
* Creates a `KeyValueStore` layer that uses the browser's `sessionStorage` API and stores values only for the current session.
*
* @category layers
* @since 4.0.0
*/
export const layerSessionStorage: Layer.Layer<KeyValueStore.KeyValueStore> = KeyValueStore.layerStorage(() =>
globalThis.sessionStorage
)
/**
* Creates a `KeyValueStore` layer backed by IndexedDB.
*
* **When to use**
*
* Use when you need persistent asynchronous IndexedDB storage for a browser
* `KeyValueStore` instead of the synchronous Web Storage APIs.
*
* **Details**
*
* The database name defaults to `"effect_key_value_store"`. The layer requires
* the `IndexedDb` service and stores string and `Uint8Array` values in the same
* backing object store.
*
* **Gotchas**
*
* IndexedDB may be unavailable or blocked by browser settings, private browsing,
* quota limits, or restricted contexts. The string and `Uint8Array` accessors do
* not coerce values stored with the other representation.
*
* @see {@link layerLocalStorage} for synchronous persistent Web Storage
* @see {@link layerSessionStorage} for synchronous tab-session Web Storage
*
* @category layers
* @since 4.0.0
*/
export const layerIndexedDb = (options?: {
readonly database?: string | undefined
}): Layer.Layer<KeyValueStore.KeyValueStore, never, IndexedDb> =>
Layer.effect(KeyValueStore.KeyValueStore)(
Effect.gen(function*() {
const db = yield* Effect.acquireRelease(
openDatabase(options?.database ?? "effect_key_value_store"),
(db) => Effect.sync(() => db.close())
).pipe(Effect.orDie)
return KeyValueStore.make({
clear: Effect.suspend(() => {
const store = getKvsEntriesStore(db, "readwrite")
return idbRequest({ method: "clear", message: "Failed to clear backing store" }, () => store.clear())
}),
get: (key: string) =>
Effect.map(
Effect.suspend(() => {
const store = getKvsEntriesStore(db, "readonly")
return idbRequest<{ key: string; value: string } | undefined>({
method: "get",
message: "Failed to get value from backing store",
key
}, () => store.get(key))
}),
(found) => typeof found?.value === "string" ? found.value : undefined
),
getUint8Array: (key: string) =>
Effect.map(
Effect.suspend(() => {
const store = getKvsEntriesStore(db, "readonly")
return idbRequest<{ key: string; value: Uint8Array } | undefined>({
method: "getUint8Array",
message: "Failed to get value from backing store",
key
}, () => store.get(key))
}),
(found) => found?.value && found.value instanceof Uint8Array ? found.value : undefined
),
set: (key: string, value: string | Uint8Array) =>
Effect.asVoid(Effect.suspend(() => {
const store = getKvsEntriesStore(db, "readwrite")
return idbRequest(
{ method: "set", message: "Failed to set value in backing store", key },
() => store.put({ key, value })
)
})),
size: Effect.suspend(() => {
const store = getKvsEntriesStore(db, "readonly")
return idbRequest<number>(
{ method: "size", message: "Failed to get backing store size" },
() => store.count()
)
}),
remove: (key: string) =>
Effect.asVoid(Effect.suspend(() => {
const store = getKvsEntriesStore(db, "readwrite")
return idbRequest(
{ method: "remove", message: "Failed to remove value from backing store", key },
() => store.delete(key)
)
}))
})
})
)
const databaseVersion = 1
const entriesStoreName = "entries"
const openDatabase = Effect.fnUntraced(function*(database: string) {
const idb = (yield* IndexedDb).indexedDB
const openRequest = yield* Effect.try({
try: () => idb.open(database, databaseVersion),
catch: (cause) =>
new KeyValueStore.KeyValueStoreError({
method: "open",
message: "Failed to open backing store database",
cause
})
})
openRequest.onupgradeneeded = () => {
const db = openRequest.result
if (!db.objectStoreNames.contains(entriesStoreName)) {
db.createObjectStore(entriesStoreName, { keyPath: "key" })
}
}
return yield* idbRequest({ method: "open", message: "Failed to open backing store database" }, () => openRequest)
})
const idbRequest = <A>(
failArgs: { method: string; message: string; key?: string },
evaluate: () => IDBRequest<A>
): Effect.Effect<A, KeyValueStore.KeyValueStoreError> =>
Effect.callback<A, KeyValueStore.KeyValueStoreError>((resume) => {
const request = evaluate()
if (request.readyState === "done") {
return resume(Effect.succeed(request.result))
}
request.onsuccess = () => {
resume(Effect.succeed(request.result))
}
request.onerror = () =>
resume(Effect.fail(
new KeyValueStore.KeyValueStoreError({
...failArgs,
cause: request.error
})
))
})
const getKvsEntriesStore = (db: IDBDatabase, mode: IDBTransactionMode) => {
const transaction = db.transaction(entriesStoreName, mode)
return transaction.objectStore(entriesStoreName)
}

View File

@@ -0,0 +1,334 @@
/**
* IndexedDB-backed persistence layers for browser Effect programs.
*
* This module provides a low-level `BackingPersistence` layer and a higher-level
* `Persistence` layer that store object values in IndexedDB. Values are stored
* by persistence store id and key, and reads check TTL expiration before
* returning stored data. The database name can be customized and defaults to
* `"effect_persistence"`.
*
* @since 4.0.0
*/
import type * as Arr from "effect/Array"
import * as Clock from "effect/Clock"
import type * as Duration from "effect/Duration"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Persistence from "effect/unstable/persistence/Persistence"
/**
* Creates a `BackingPersistence` layer backed by IndexedDB, optionally using the provided database name.
*
* **When to use**
*
* Use when composing persistence manually and the lower-level
* `BackingPersistence` service should be backed by browser IndexedDB.
*
* **Details**
*
* The database name defaults to `"effect_persistence"`. Entries are stored by
* persistence store id and key in a shared object store, and TTL expiration is
* checked when values are read.
*
* **Gotchas**
*
* Opening the database is defected during layer acquisition if IndexedDB is
* unavailable or cannot be opened. Store operations report `PersistenceError`
* for IndexedDB request, transaction, quota, and structured-clone failures.
*
* @see {@link layerIndexedDb} for providing the higher-level `Persistence` service
*
* @category layers
* @since 4.0.0
*/
export const layerBackingIndexedDb = (options?: {
readonly database?: string | undefined
}): Layer.Layer<Persistence.BackingPersistence> =>
Layer.effect(Persistence.BackingPersistence)(Effect.gen(function*() {
const db = yield* Effect.acquireRelease(
openDatabase(options?.database ?? defaultDatabase),
(db) => Effect.sync(() => db.close())
).pipe(Effect.orDie)
return Persistence.BackingPersistence.of({
make: Effect.fnUntraced(function*(storeId) {
const clock = yield* Clock.Clock
return {
get: (key) => get(db, clock, storeId, key),
getMany: (keys) => getMany(db, clock, storeId, keys),
set: (key, value, ttl) => set(db, clock, storeId, key, value, ttl),
setMany: (entries) => setMany(db, clock, storeId, entries),
remove: (key) => remove(db, storeId, key),
clear: clear(db, storeId)
}
})
})
}))
const defaultDatabase = "effect_persistence"
const databaseVersion = 1
const entriesStoreName = "entries"
const storeIdIndexName = "storeId"
/**
* Creates a `Persistence` layer backed by IndexedDB, optionally using the provided database name.
*
* @category layers
* @since 4.0.0
*/
export const layerIndexedDb = (options?: {
readonly database?: string | undefined
}): Layer.Layer<Persistence.Persistence> =>
Persistence.layer.pipe(
Layer.provide(layerBackingIndexedDb(options))
)
const openDatabase = (database: string): Effect.Effect<IDBDatabase, Persistence.PersistenceError> =>
Effect.gen(function*() {
const openRequest = yield* Effect.try({
try: () => globalThis.indexedDB.open(database, databaseVersion),
catch: (cause) =>
new Persistence.PersistenceError({
message: "Failed to open backing store database",
cause
})
})
openRequest.onupgradeneeded = () => {
const db = openRequest.result
const entries = db.objectStoreNames.contains(entriesStoreName)
? openRequest.transaction?.objectStore(entriesStoreName)
: db.createObjectStore(entriesStoreName, { keyPath: ["storeId", "id"] })
if (entries && !entries.indexNames.contains(storeIdIndexName)) {
entries.createIndex(storeIdIndexName, storeIdIndexName, { unique: false })
}
}
return yield* idbRequest("Failed to open backing store database", () => openRequest)
})
interface EntryRow {
readonly storeId: string
readonly id: string
readonly value: object
readonly expires: number | null
}
const isExpired = (row: EntryRow, now: number): boolean => row.expires !== null && row.expires <= now
const get = (
db: IDBDatabase,
clock: Clock.Clock,
storeId: string,
key: string
): Effect.Effect<object | undefined, Persistence.PersistenceError> =>
withEntriesTransaction<object | undefined>(
db,
"readwrite",
`Failed to get key ${key} from backing store`,
(
entries,
setResult,
fail
) => {
const now = clock.currentTimeMillisUnsafe()
const id: [string, string] = [storeId, key]
const request = entries.get(id)
request.onerror = () => fail(request.error)
request.onsuccess = () => {
const row = request.result as EntryRow | undefined
if (!row || !isExpired(row, now)) {
setResult(row?.value)
return
}
const deleteRequest = entries.delete(id)
deleteRequest.onerror = () => fail(deleteRequest.error)
deleteRequest.onsuccess = () => setResult(undefined)
}
}
)
const getMany = (
db: IDBDatabase,
clock: Clock.Clock,
storeId: string,
keys: Arr.NonEmptyArray<string>
): Effect.Effect<Arr.NonEmptyArray<object | undefined>, Persistence.PersistenceError> =>
withEntriesTransaction(
db,
"readwrite",
"Failed to getMany from backing store",
(entries, setResult, fail) => {
const now = clock.currentTimeMillisUnsafe()
const results = new Array<object | undefined>(keys.length)
setResult(results as any)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const keyPath = [storeId, key]
const request = entries.get(keyPath)
request.onerror = () => fail(request.error)
request.onsuccess = () => {
const row = request.result as EntryRow | undefined
if (!row) return
else if (!isExpired(row, now)) {
results[i] = row.value
return
}
const deleteRequest = entries.delete(keyPath)
deleteRequest.onerror = () => fail(deleteRequest.error)
}
}
}
)
const set = (
db: IDBDatabase,
clock: Clock.Clock,
storeId: string,
key: string,
value: object,
ttl: Duration.Duration | undefined
): Effect.Effect<void, Persistence.PersistenceError> =>
withEntriesTransaction(
db,
"readwrite",
`Failed to set key ${key} in backing store`,
(entries, setResult, fail) => {
const request = entries.put(
{
storeId,
id: key,
value,
expires: Persistence.unsafeTtlToExpires(clock, ttl)
} satisfies EntryRow
)
request.onerror = () => fail(request.error)
request.onsuccess = () => setResult(undefined)
}
)
const setMany = (
db: IDBDatabase,
clock: Clock.Clock,
storeId: string,
entries: Arr.NonEmptyArray<readonly [key: string, value: object, ttl: Duration.Duration | undefined]>
): Effect.Effect<void, Persistence.PersistenceError> =>
withEntriesTransaction(
db,
"readwrite",
"Failed to setMany in backing store",
(store, setResult, fail) => {
for (const [key, value, ttl] of entries) {
const request = store.put(
{
storeId,
id: key,
value,
expires: Persistence.unsafeTtlToExpires(clock, ttl)
} satisfies EntryRow
)
request.onerror = () => fail(request.error)
request.onsuccess = () => setResult(undefined)
}
}
)
const remove = (
db: IDBDatabase,
storeId: string,
key: string
): Effect.Effect<void, Persistence.PersistenceError> =>
withEntriesTransaction(
db,
"readwrite",
`Failed to remove key ${key} from backing store`,
(entries, setResult, fail) => {
const request = entries.delete([storeId, key])
request.onerror = () => fail(request.error)
request.onsuccess = () => setResult(undefined)
}
)
const clear = (db: IDBDatabase, storeId: string): Effect.Effect<void, Persistence.PersistenceError> =>
withEntriesTransaction(db, "readwrite", "Failed to clear backing store", (entries, setResult, fail) => {
const index = entries.index(storeIdIndexName)
const cursorRequest = index.openCursor(storeId)
cursorRequest.onerror = () => fail(cursorRequest.error)
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result
if (!cursor) {
setResult(undefined)
return
}
const deleteRequest = cursor.delete()
deleteRequest.onerror = () => fail(deleteRequest.error)
deleteRequest.onsuccess = () => cursor.continue()
}
})
const withEntriesTransaction = <A>(
db: IDBDatabase,
mode: IDBTransactionMode,
message: string,
run: (
entries: IDBObjectStore,
onResult: (result: A) => void,
fail: (cause: unknown) => void
) => void
): Effect.Effect<A, Persistence.PersistenceError> =>
Effect.callback<A, Persistence.PersistenceError>((resume) => {
const tx = db.transaction(entriesStoreName, mode)
const entries = tx.objectStore(entriesStoreName)
let result: A | undefined
let setResult = false
let done = false
const fail = (cause: unknown) => {
done = true
resume(Effect.fail(new Persistence.PersistenceError({ message, cause })))
}
tx.oncomplete = () => {
done = true
if (setResult) resume(Effect.succeed(result!))
}
tx.onerror = () => {
done = true
fail(tx.error)
}
tx.onabort = () => {
done = true
fail(tx.error)
}
run(entries, (next) => {
if (done) return resume(Effect.succeed(next))
setResult = true
result = next
}, fail)
return Effect.sync(() => {
tx.abort()
})
})
const idbRequest = <A>(
message: string,
evaluate: () => IDBRequest<A>
): Effect.Effect<A, Persistence.PersistenceError> =>
Effect.callback<A, Persistence.PersistenceError>((resume) => {
const request = evaluate()
const fail = (cause: unknown) => {
resume(Effect.fail(new Persistence.PersistenceError({ message, cause })))
}
if (request.readyState === "done") {
resume(Effect.succeed(request.result))
}
request.onsuccess = () => {
resume(Effect.succeed(request.result))
}
request.onerror = () => fail(request.error)
})

View File

@@ -0,0 +1,53 @@
/**
* Browser helper for running a root Effect program.
*
* This module exports `runMain`, a browser version of the main Effect runner.
* It forwards runner options to the core runtime and adds a `beforeunload`
* listener that interrupts the main fiber when the page is unloading.
*
* @since 4.0.0
*/
import type * as Effect from "effect/Effect"
import { makeRunMain, type Teardown } from "effect/Runtime"
/**
* Runs an effect as the browser main program and interrupts its fiber when the page receives a `beforeunload` event.
*
* **When to use**
*
* Use to launch a browser page, single-page application, demo, or browser test
* harness as a root Effect program.
*
* **Details**
*
* Supports both direct and curried call forms. Options are forwarded to
* `makeRunMain`, including `disableErrorReporting` and custom `teardown`
* behavior.
*
* **Gotchas**
*
* The `beforeunload` interruption is best-effort. Browser teardown may prevent
* asynchronous finalizers, network work, timers, or prompts from completing.
*
* @category Runtime
* @since 4.0.0
*/
export const runMain: {
(
options?: {
readonly disableErrorReporting?: boolean | undefined
readonly teardown?: Teardown | undefined
}
): <E, A>(effect: Effect.Effect<A, E>) => void
<E, A>(
effect: Effect.Effect<A, E>,
options?: {
readonly disableErrorReporting?: boolean | undefined
readonly teardown?: Teardown | undefined
}
): void
} = makeRunMain(({ fiber }) => {
globalThis.addEventListener("beforeunload", () => {
fiber.interruptUnsafe(fiber.id)
})
})

View File

@@ -0,0 +1,52 @@
/**
* Browser WebSocket layers for Effect sockets.
*
* `layerWebSocket` creates a `Socket.Socket` connected to a WebSocket URL using
* the browser `WebSocket` constructor. `layerWebSocketConstructor` provides
* only the browser-backed constructor service for lower-level socket code.
*
* @since 4.0.0
*/
import * as Layer from "effect/Layer"
import * as Socket from "effect/unstable/socket/Socket"
/**
* Creates a `Socket` layer connected to the given URL using the browser `WebSocket` constructor.
*
* **When to use**
*
* Use when you need browser code to satisfy the platform socket service from a
* URL without wiring the browser constructor service separately.
*
* **Details**
*
* Delegates socket construction to `Socket.makeWebSocket` and provides the
* browser-backed `WebSocketConstructor` service.
*
* **Gotchas**
*
* Browser WebSocket rules still control URL schemes, mixed-content blocking,
* cookies, authentication, origin checks, subprotocols, and extensions. Close
* events are errors unless `closeCodeIsError` classifies the close code as
* clean.
*
* @see {@link layerWebSocketConstructor} for providing only the browser constructor service
*
* @category layers
* @since 4.0.0
*/
export const layerWebSocket = (url: string, options?: {
readonly closeCodeIsError?: (code: number) => boolean
}): Layer.Layer<Socket.Socket> =>
Layer.effect(Socket.Socket, Socket.makeWebSocket(url, options)).pipe(
Layer.provide(layerWebSocketConstructor)
)
/**
* Layer that provides a `WebSocketConstructor` service backed by `globalThis.WebSocket`.
*
* @category layers
* @since 4.0.0
*/
export const layerWebSocketConstructor: Layer.Layer<Socket.WebSocketConstructor> =
Socket.layerWebSocketConstructorGlobal

View File

@@ -0,0 +1,56 @@
/**
* Browser DOM event streams.
*
* This module provides typed constructors that turn `window.addEventListener`
* and `document.addEventListener` events into Effect `Stream` values. Both
* helpers accept the usual listener options and an optional stream buffer size.
*
* @since 4.0.0
*/
import * as Stream from "effect/Stream"
/**
* Creates a `Stream` from `window.addEventListener`.
*
* **Details**
*
* By default, the underlying buffer is unbounded in size. You can customize the
* buffer size by passing an object as the second argument with the `bufferSize`
* field.
*
* @category streams
* @since 4.0.0
*/
export const fromEventListenerWindow = <K extends keyof WindowEventMap>(
type: K,
options?: boolean | {
readonly capture?: boolean
readonly passive?: boolean
readonly once?: boolean
readonly bufferSize?: number | undefined
} | undefined
): Stream.Stream<WindowEventMap[K], never, never> => Stream.fromEventListener<WindowEventMap[K]>(window, type, options)
/**
* Creates a `Stream` from `document.addEventListener`.
*
* **Details**
*
* By default, the underlying buffer is unbounded in size. You can customize the
* buffer size by passing an object as the second argument with the `bufferSize`
* field.
*
* @category streams
* @since 4.0.0
*/
export const fromEventListenerDocument = <K extends keyof DocumentEventMap>(
type: K,
options?: boolean | {
readonly capture?: boolean
readonly passive?: boolean
readonly once?: boolean
readonly bufferSize?: number | undefined
} | undefined
): Stream.Stream<DocumentEventMap[K], never, never> =>
Stream.fromEventListener<DocumentEventMap[K]>(document, type, options)

View File

@@ -0,0 +1,98 @@
/**
* Parent-side browser platform for Effect workers.
*
* `layerPlatform` provides the `WorkerPlatform` used to communicate with a
* browser `Worker`, `SharedWorker`, or `MessagePort` through Effect's worker
* protocol. `layer` combines that platform with a `Spawner` built from a
* callback that creates or returns the worker endpoint for each worker id.
*
* @since 4.0.0
*/
import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Scope from "effect/Scope"
import * as Worker from "effect/unstable/workers/Worker"
import { WorkerError, WorkerReceiveError } from "effect/unstable/workers/WorkerError"
/**
* Creates browser worker layers by combining the default `WorkerPlatform` with a spawner for `Worker`, `SharedWorker`, or `MessagePort` instances.
*
* **When to use**
*
* Use when you need both the browser `WorkerPlatform` and a `Spawner` from one
* layer.
*
* **Details**
*
* The `spawn` callback receives the numeric worker id and may return a
* `Worker`, `SharedWorker`, or `MessagePort`.
*
* **Gotchas**
*
* Scope finalization sends the worker close protocol over the port. Dedicated
* workers created by `spawn` are not terminated by this layer.
*
* @see {@link layerPlatform} for providing only the browser worker platform
*
* @category layers
* @since 4.0.0
*/
export const layer = (
spawn: (id: number) => Worker | SharedWorker | MessagePort
): Layer.Layer<Worker.WorkerPlatform | Worker.Spawner> =>
Layer.merge(
layerPlatform,
Worker.layerSpawner(spawn)
)
/**
* Layer that provides the browser `WorkerPlatform` for `Worker`, `SharedWorker`, and `MessagePort` communication.
*
* @category layers
* @since 4.0.0
*/
export const layerPlatform: Layer.Layer<Worker.WorkerPlatform> = Layer.succeed(Worker.WorkerPlatform)(
Worker.makePlatform<globalThis.SharedWorker | globalThis.Worker | MessagePort>()({
setup({ scope, worker }) {
const port = "port" in worker ? worker.port : worker
return Effect.as(
Scope.addFinalizer(
scope,
Effect.sync(() => {
port.postMessage([1])
})
),
port
)
},
listen({ deferred, emit, port, scope }) {
function onMessage(event: MessageEvent) {
emit(event.data)
}
function onError(event: ErrorEvent) {
Deferred.doneUnsafe(
deferred,
new WorkerError({
reason: new WorkerReceiveError({
message: "An error event was emitter",
cause: event.error ?? event.message
})
})
)
}
port.addEventListener("message", onMessage as any)
port.addEventListener("error", onError as any)
if ("start" in port) {
port.start()
}
return Scope.addFinalizer(
scope,
Effect.sync(() => {
port.removeEventListener("message", onMessage as any)
port.removeEventListener("error", onError as any)
})
)
}
})
)

View File

@@ -0,0 +1,199 @@
/**
* Runner-side browser platform for Effect worker handlers.
*
* `make` builds a `WorkerRunnerPlatform` over a `MessagePort` or `Window`.
* `layer` provides the platform from the global worker `self`, and
* `layerMessagePort` provides it from an explicit endpoint. The platform
* receives parent or client messages, runs Effect handlers, and posts responses
* back through the browser messaging channel.
*
* @since 4.0.0
*/
import * as Cause from "effect/Cause"
import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as Fiber from "effect/Fiber"
import { identity } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Queue from "effect/Queue"
import * as Scope from "effect/Scope"
import { WorkerError, WorkerReceiveError } from "effect/unstable/workers/WorkerError"
import * as WorkerRunner from "effect/unstable/workers/WorkerRunner"
const cachedPorts = new Set<MessagePort>()
function globalHandleConnect(event: MessageEvent) {
cachedPorts.add((event as MessageEvent).ports[0])
}
if (typeof self !== "undefined" && "onconnect" in self) {
self.onconnect = globalHandleConnect
}
/**
* Creates a `WorkerRunnerPlatform` service that runs worker handlers over a `MessagePort` or `Window`.
*
* @category constructors
* @since 4.0.0
*/
export const make = (self: MessagePort | Window): WorkerRunner.WorkerRunnerPlatform["Service"] => ({
start: Effect.fnUntraced(function*<O = unknown, I = unknown>() {
const disconnects = yield* Queue.make<number>()
let currentPortId = 0
const ports = new Map<number, readonly [MessagePort, Scope.Closeable]>()
const sendUnsafe = (portId: number, message: O, transfer?: ReadonlyArray<unknown>) =>
(ports.get(portId)?.[0] ?? self).postMessage([1, message], {
transfer: transfer as any
})
const send = (portId: number, message: O, transfer?: ReadonlyArray<unknown>) =>
Effect.sync(() => sendUnsafe(portId, message, transfer))
const run = <A, E, R>(
handler: (portId: number, message: I) => Effect.Effect<A, E, R> | void
) =>
Effect.scopedWith(Effect.fnUntraced(function*(scope) {
const closeLatch = Deferred.makeUnsafe<void, WorkerError>()
const trackFiber = Fiber.runIn(scope)
const services = yield* Effect.context<R>()
const runFork = Effect.runForkWith(services)
const onExit = (exit: Exit.Exit<any, E>) => {
if (exit._tag === "Failure" && !Cause.hasInterruptsOnly(exit.cause)) {
runFork(Effect.logError("unhandled error in worker", exit.cause))
}
}
function onMessage(portId: number) {
return function(event: MessageEvent) {
const message = event.data as WorkerRunner.PlatformMessage<I>
if (message[0] === 0) {
const result = handler(portId, message[1])
if (Effect.isEffect(result)) {
const fiber = runFork(result)
fiber.addObserver(onExit)
trackFiber(fiber)
}
} else {
const port = ports.get(portId)
if (!port) {
return
} else if (ports.size === 1) {
// let the last port close with the outer scope
return Deferred.doneUnsafe(closeLatch, Exit.void)
}
ports.delete(portId)
Effect.runFork(Scope.close(port[1], Exit.void))
}
}
}
function onMessageError(error: MessageEvent) {
Deferred.doneUnsafe(
closeLatch,
new WorkerError({
reason: new WorkerReceiveError({
message: "An messageerror event was emitted",
cause: error.data
})
})
)
}
function onError(error: any) {
Deferred.doneUnsafe(
closeLatch,
new WorkerError({
reason: new WorkerReceiveError({
message: "An error event was emitted",
cause: error.data
})
})
)
}
function handlePort(port: MessagePort) {
const portScope = Scope.forkUnsafe(scope)
const portId = currentPortId++
ports.set(portId, [port, portScope])
const onMsg = onMessage(portId)
port.addEventListener("message", onMsg)
port.addEventListener("messageerror", onMessageError)
if ("start" in port) {
port.start()
}
port.postMessage([0])
Effect.runSync(Scope.addFinalizer(
portScope,
Effect.sync(() => {
port.removeEventListener("message", onMsg)
port.removeEventListener("messageerror", onError)
port.close()
})
))
}
self.addEventListener("error", onError)
let prevOnConnect: unknown | undefined
if ("onconnect" in self) {
prevOnConnect = self.onconnect
self.onconnect = function(event: MessageEvent) {
const port = (event as MessageEvent).ports[0]
handlePort(port)
}
for (const port of cachedPorts) {
handlePort(port)
}
cachedPorts.clear()
} else {
handlePort(self as any)
}
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
self.removeEventListener("error", onError)
if ("onconnect" in self) {
self.close()
self.onconnect = prevOnConnect
}
})
)
yield* Deferred.await(closeLatch)
}))
return identity<WorkerRunner.WorkerRunner<O, I>>({ run, send, sendUnsafe, disconnects })
}) as any
})
/**
* Layer that provides a browser `WorkerRunnerPlatform` using the global `self` worker context.
*
* **When to use**
*
* Use when you need a browser worker entry point to use the ambient `self`
* object as the worker transport.
*
* **Details**
*
* Delegates to `make(self)` and provides the runner-side platform used by
* protocols such as `RpcServer.layerProtocolWorkerRunner`.
*
* **Gotchas**
*
* This layer depends on the browser worker global `self`. Use
* `layerMessagePort` when the transport is an explicit `MessagePort` or
* `Window`.
*
* @see {@link make} for constructing a runner platform from an explicit endpoint
* @see {@link layerMessagePort} for providing a platform from an explicit endpoint
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<WorkerRunner.WorkerRunnerPlatform> = Layer.sync(WorkerRunner.WorkerRunnerPlatform)(() =>
make(self)
)
/**
* Layer that provides a `WorkerRunnerPlatform` using the supplied `MessagePort` or `Window`.
*
* @category layers
* @since 4.0.0
*/
export const layerMessagePort = (port: MessagePort | Window): Layer.Layer<WorkerRunner.WorkerRunnerPlatform> =>
Layer.succeed(WorkerRunner.WorkerRunnerPlatform)(make(port))

View File

@@ -0,0 +1,143 @@
/**
* Browser clipboard integration for Effect programs.
*
* This module defines the `Clipboard` service, the `ClipboardError` raised by
* failed browser operations, a `make` constructor for custom implementations,
* and a browser-backed `layer` that uses `navigator.clipboard`. The service
* supports reading and writing text, reading and writing `ClipboardItem`
* payloads, writing one `Blob`, and clearing the clipboard.
*
* @since 4.0.0
*/
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
const TypeId = "~@effect/platform-browser/Clipboard"
const ErrorTypeId = "~@effect/platform-browser/Clipboard/ClipboardError"
/**
* Defines the service interface for reading from, writing to, and clearing the browser clipboard.
*
* **When to use**
*
* Use when an application needs clipboard operations through an Effect service
* so browser failures stay in the error channel.
*
* **Details**
*
* `read` and `write` work with `ClipboardItem` arrays. `readString` and
* `writeString` use text, `writeBlob` writes one `Blob`, and `clear` writes an
* empty string.
*
* **Gotchas**
*
* Clipboard access generally requires a secure context and may require user
* activation, permissions, or a focused document. `ClipboardItem` and non-text
* MIME type support varies by browser. Failed browser operations are surfaced
* as `ClipboardError`.
*
* @category models
* @since 4.0.0
*/
export interface Clipboard {
readonly [TypeId]: typeof TypeId
readonly read: Effect.Effect<ClipboardItems, ClipboardError>
readonly readString: Effect.Effect<string, ClipboardError>
readonly write: (items: ClipboardItems) => Effect.Effect<void, ClipboardError>
readonly writeString: (text: string) => Effect.Effect<void, ClipboardError>
readonly writeBlob: (blob: Blob) => Effect.Effect<void, ClipboardError>
readonly clear: Effect.Effect<void, ClipboardError>
}
/**
* Tagged error raised when a browser clipboard operation fails.
*
* @category errors
* @since 4.0.0
*/
export class ClipboardError extends Data.TaggedError("ClipboardError")<{
readonly message: string
readonly cause: unknown
}> {
readonly [ErrorTypeId] = ErrorTypeId
}
/**
* Service tag for browser clipboard capabilities.
*
* **When to use**
*
* Use when you need to require or provide clipboard capabilities through
* Effect's context.
*
* @see {@link make} for building a custom clipboard service
* @see {@link layer} for providing the browser-backed clipboard service
*
* @category services
* @since 4.0.0
*/
export const Clipboard: Context.Service<Clipboard, Clipboard> = Context.Service<Clipboard>(TypeId)
/**
* Builds a `Clipboard` service from primitive read and write operations, deriving `clear` and `writeBlob` helpers.
*
* @category constructors
* @since 4.0.0
*/
export const make = (
impl: Omit<Clipboard, "clear" | "writeBlob" | typeof TypeId>
): Clipboard =>
Clipboard.of({
...impl,
[TypeId]: TypeId,
clear: impl.writeString(""),
writeBlob: (blob: Blob) => impl.write([new ClipboardItem({ [blob.type]: blob })])
})
/**
* Layer that directly interfaces with the browser Clipboard API.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<Clipboard> = Layer.succeed(
Clipboard,
make({
read: Effect.tryPromise({
try: () => navigator.clipboard.read(),
catch: (cause) =>
new ClipboardError({
cause,
"message": "Unable to read from clipboard"
})
}),
write: (s: Array<ClipboardItem>) =>
Effect.tryPromise({
try: () => navigator.clipboard.write(s),
catch: (cause) =>
new ClipboardError({
cause,
"message": "Unable to write to clipboard"
})
}),
readString: Effect.tryPromise({
try: () => navigator.clipboard.readText(),
catch: (cause) =>
new ClipboardError({
cause,
"message": "Unable to read a string from clipboard"
})
}),
writeString: (text: string) =>
Effect.tryPromise({
try: () => navigator.clipboard.writeText(text),
catch: (cause) =>
new ClipboardError({
cause,
"message": "Unable to write a string to clipboard"
})
})
})
)

View File

@@ -0,0 +1,233 @@
/**
* Browser geolocation integration for Effect programs.
*
* This module defines a `Geolocation` service backed by
* `navigator.geolocation`. The service can read one current position or stream
* watched position updates with a sliding buffer. Browser callback failures are
* represented as `GeolocationError` values with `PositionUnavailable`,
* `PermissionDenied`, or `Timeout` reasons. The module also provides the
* browser-backed layer and a `watchPosition` accessor.
*
* @since 4.0.0
*/
import * as Cause from "effect/Cause"
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Queue from "effect/Queue"
import * as Stream from "effect/Stream"
const TypeId = "~@effect/platform-browser/Geolocation"
const ErrorTypeId = "~@effect/platform-browser/Geolocation/GeolocationError"
/**
* Defines the service interface for browser geolocation, providing effects for the current position and streams of watched positions.
*
* **When to use**
*
* Use when browser code needs a typed Effect service for one-shot location
* reads or streamed location updates.
*
* **Details**
*
* `getCurrentPosition` returns one position effect. `watchPosition` returns a
* stream and accepts the browser `PositionOptions` plus an optional sliding
* `bufferSize`.
*
* **Gotchas**
*
* Browser permission prompts, denied permissions, timeouts, unavailable
* position data, secure-context restrictions, and policy restrictions are
* surfaced as `GeolocationError`.
*
* @see {@link GeolocationError} for represented browser geolocation failures
* @see {@link layer} for the browser-backed service implementation
*
* @category models
* @since 4.0.0
*/
export interface Geolocation {
readonly [TypeId]: typeof TypeId
readonly getCurrentPosition: (
options?: PositionOptions | undefined
) => Effect.Effect<GeolocationPosition, GeolocationError>
readonly watchPosition: (
options?:
| PositionOptions & {
readonly bufferSize?: number | undefined
}
| undefined
) => Stream.Stream<GeolocationPosition, GeolocationError>
}
/**
* Service tag for browser geolocation capabilities.
*
* **When to use**
*
* Use when you need to access or provide geolocation capabilities through
* Effect's context.
*
* @see {@link layer} for providing the browser-backed geolocation service
*
* @category services
* @since 4.0.0
*/
export const Geolocation: Context.Service<Geolocation, Geolocation> = Context.Service<Geolocation>(TypeId)
/**
* Tagged error wrapping a browser geolocation failure reason.
*
* @category errors
* @since 4.0.0
*/
export class GeolocationError extends Data.TaggedError("GeolocationError")<{
readonly reason: GeolocationErrorReason
}> {
constructor(props: {
readonly reason: GeolocationErrorReason
}) {
super({
...props,
cause: props.reason.cause
} as any)
}
readonly [ErrorTypeId] = ErrorTypeId
override get message(): string {
return this.reason.message
}
}
/**
* Error reason for the browser geolocation `POSITION_UNAVAILABLE` failure.
*
* @category errors
* @since 4.0.0
*/
export class PositionUnavailable extends Data.TaggedError("PositionUnavailable")<{
readonly cause: unknown
}> {
override get message(): string {
return this._tag
}
}
/**
* Error reason for the browser geolocation `PERMISSION_DENIED` failure.
*
* @category errors
* @since 4.0.0
*/
export class PermissionDenied extends Data.TaggedError("PermissionDenied")<{
readonly cause: unknown
}> {
override get message(): string {
return this._tag
}
}
/**
* Error reason for the browser geolocation `TIMEOUT` failure.
*
* @category errors
* @since 4.0.0
*/
export class Timeout extends Data.TaggedError("Timeout")<{
readonly cause: unknown
}> {
override get message(): string {
return this._tag
}
}
/**
* Union of browser geolocation error reasons represented by the service.
*
* @category errors
* @since 4.0.0
*/
export type GeolocationErrorReason = PositionUnavailable | PermissionDenied | Timeout
const makeQueue = (
options:
| PositionOptions & {
readonly bufferSize?: number | undefined
}
| undefined
) =>
Queue.sliding<GeolocationPosition, GeolocationError>(options?.bufferSize ?? 16).pipe(
Effect.tap((queue) =>
Effect.acquireRelease(
Effect.sync(() =>
navigator.geolocation.watchPosition(
(position) => Queue.offerUnsafe(queue, position),
(cause) => {
if (cause.code === cause.PERMISSION_DENIED) {
const error = new GeolocationError({
reason: new PermissionDenied({ cause })
})
Queue.failCauseUnsafe(queue, Cause.fail(error))
} else if (cause.code === cause.TIMEOUT) {
const error = new GeolocationError({
reason: new Timeout({ cause })
})
Queue.failCauseUnsafe(queue, Cause.fail(error))
} else if (cause.code === cause.POSITION_UNAVAILABLE) {
const error = new GeolocationError({
reason: new PositionUnavailable({ cause })
})
Queue.failCauseUnsafe(queue, Cause.fail(error))
}
},
options
)
),
(handleId) => Effect.sync(() => navigator.geolocation.clearWatch(handleId))
)
)
)
/**
* Layer that provides `Geolocation` using `navigator.geolocation`, with watched positions buffered in a sliding queue.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<Geolocation> = Layer.succeed(
Geolocation,
Geolocation.of({
[TypeId]: TypeId,
getCurrentPosition: (options) =>
makeQueue(options).pipe(
Effect.flatMap(Queue.take),
Effect.scoped
),
watchPosition: (options) =>
makeQueue(options).pipe(
Effect.map(Stream.fromQueue),
Stream.unwrap
)
})
)
/**
* Reads geolocation positions from the `Geolocation` service as a stream, with
* an optional sliding buffer size.
*
* @category accessors
* @since 4.0.0
*/
export const watchPosition = (
options?:
| PositionOptions & {
readonly bufferSize?: number | undefined
}
| undefined
): Stream.Stream<GeolocationPosition, GeolocationError, Geolocation> =>
Stream.unwrap(Effect.map(
Effect.service(Geolocation),
(geolocation) => geolocation.watchPosition(options)
))

View File

@@ -0,0 +1,113 @@
/**
* Browser IndexedDB primitives and key schemas for Effect applications.
*
* This module is the low-level bridge used by the platform-browser IndexedDB
* integration. It provides an `IndexedDb` service around the browser
* `indexedDB` factory and `IDBKeyRange` constructor, a `layerWindow` layer for
* wiring those primitives from `window`, and schemas for the key shapes accepted
* by IndexedDB object stores and indexes.
*
* @since 4.0.0
*/
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Schema from "effect/Schema"
const TypeId = "~@effect/platform-browser/IndexedDb"
/**
* Service interface that provides the browser `indexedDB` factory and `IDBKeyRange` constructor.
*
* @category models
* @since 4.0.0
*/
export interface IndexedDb {
readonly [TypeId]: typeof TypeId
readonly indexedDB: globalThis.IDBFactory
readonly IDBKeyRange: typeof globalThis.IDBKeyRange
}
/**
* Service tag for browser IndexedDB primitives.
*
* @category services
* @since 4.0.0
*/
export const IndexedDb: Context.Service<IndexedDb, IndexedDb> = Context.Service<IndexedDb, IndexedDb>(TypeId)
/** @internal */
const IDBFlatKey = Schema.Union([
Schema.String,
Schema.Number.check(Schema.makeFilter((input) => !Number.isNaN(input))),
Schema.DateValid,
Schema.declare(
(input): input is BufferSource =>
input instanceof ArrayBuffer ||
(ArrayBuffer.isView(input) && input.buffer instanceof ArrayBuffer)
)
])
/**
* Schema for IndexedDB keys: strings, non-NaN numbers, valid dates, buffer sources, or arrays of those flat key values.
*
* @category schemas
* @since 4.0.0
*/
export const IDBValidKey = Schema.Union([IDBFlatKey, Schema.Array(IDBFlatKey)])
/**
* Schema for auto-incremented IndexedDB keys, accepting integers from 1 through `2 ** 53`.
*
* **When to use**
*
* Use when you need to define numeric key-path fields for `IndexedDbTable`
* definitions that use IndexedDB auto-increment keys.
*
* **Details**
*
* The schema accepts integer values from `1` through `2 ** 53`, matching the
* range used for generated IndexedDB auto-increment keys.
*
* @see {@link IDBValidKey} for the broader IndexedDB key schema
*
* @category schemas
* @since 4.0.0
*/
export const AutoIncrement = Schema.Int.check(
Schema.isBetween({ minimum: 1, maximum: 2 ** 53 })
).annotate({
identifier: "AutoIncrement",
title: "autoIncrement",
description: "Defines a valid autoIncrement key path for the IndexedDb table"
})
/**
* Creates an `IndexedDb` service from an `IDBFactory` and `IDBKeyRange` constructor.
*
* @category constructors
* @since 4.0.0
*/
export const make = (impl: Omit<IndexedDb, typeof TypeId>): IndexedDb => IndexedDb.of({ [TypeId]: TypeId, ...impl })
/**
* Layer that provides `IndexedDb` from `window.indexedDB` and `window.IDBKeyRange`, failing with a config error when they are unavailable.
*
* @category constructors
* @since 4.0.0
*/
export const layerWindow: Layer.Layer<IndexedDb> = Layer.effect(
IndexedDb,
Effect.suspend(() => {
if (window.indexedDB && window.IDBKeyRange) {
return Effect.succeed(
make({
indexedDB: window.indexedDB,
IDBKeyRange: window.IDBKeyRange
})
)
} else {
return Effect.die(new Error("window.indexedDB is not available"))
}
})
)

View File

@@ -0,0 +1,648 @@
/**
* Builds and opens typed IndexedDB databases from versioned schema migrations.
*
* This module turns an `IndexedDbVersion` migration chain into an
* `IndexedDbDatabase` layer. The layer opens the browser database, runs any
* pending upgrade migrations, provides a query builder for the current schema,
* and exposes a `rebuild` effect that deletes and reopens the database.
* Migration transactions can create or delete object stores and indexes, and
* database failures are represented as `IndexedDbDatabaseError` values.
*
* @since 4.0.0
*/
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as Effectable from "effect/Effectable"
import * as Fiber from "effect/Fiber"
import * as Layer from "effect/Layer"
import * as MutableRef from "effect/MutableRef"
import * as Semaphore from "effect/Semaphore"
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as IndexedDb from "./IndexedDb.ts"
import * as IndexedDbQueryBuilder from "./IndexedDbQueryBuilder.ts"
import type * as IndexedDbTable from "./IndexedDbTable.ts"
import type * as IndexedDbVersion from "./IndexedDbVersion.ts"
const TypeId = "~@effect/platform-browser/IndexedDbDatabase"
const ErrorTypeId = "~@effect/platform-browser/IndexedDbDatabase/IndexedDbDatabaseError"
const SchemaProto = {
[TypeId]: {
_A: (_: never) => _
},
...Effectable.Prototype<IndexedDbSchema<any, any, any>>({
label: "IndexedDbSchema",
evaluate() {
return this.getQueryBuilder
}
}),
get getQueryBuilder() {
const self = this as unknown as IndexedDbSchema<any, any, any>
return IndexedDbDatabase.useSync(({ database, IDBKeyRange, reactivity }) =>
IndexedDbQueryBuilder.make({
database,
IDBKeyRange,
tables: self.version.tables,
reactivity
})
)
},
add<Version extends IndexedDbVersion.AnyWithProps>(
this: IndexedDbSchema<any, any, any>,
version: Version,
migrate: (
fromQuery: Transaction<any>,
toQuery: Transaction<Version>
) => Effect.Effect<void, Error>
) {
return makeMigration({
fromVersion: this.version,
version,
migrate,
previous: this
})
},
layer(this: IndexedDbSchema<any, any, any>, databaseName: string) {
return layer(databaseName, this)
}
}
/**
* String union describing the failure categories for IndexedDB database opening, migration, and schema operations.
*
* @category errors
* @since 4.0.0
*/
export type ErrorReason =
| "TransactionError"
| "MissingTable"
| "OpenError"
| "UpgradeError"
| "Aborted"
| "Blocked"
| "MissingIndex"
/**
* Tagged error for IndexedDB database operations, carrying a database error reason and the original cause.
*
* @category errors
* @since 4.0.0
*/
export class IndexedDbDatabaseError extends Data.TaggedError(
"IndexedDbDatabaseError"
)<{
reason: ErrorReason
cause: unknown
}> {
/**
* Marks this value as an IndexedDB database error for runtime guards.
*
* @since 4.0.0
*/
readonly [ErrorTypeId]: typeof ErrorTypeId = ErrorTypeId
override readonly message = this.reason
}
/**
* Service tag for an open IndexedDB database, its `IDBKeyRange` constructor, reactivity service, and rebuild effect.
*
* **When to use**
*
* Use when you need access to the live database service after an
* `IndexedDbSchema` layer has been provided, especially for `rebuild` or
* lower-level database primitives.
*
* **Details**
*
* `database` is a mutable reference to the current `IDBDatabase`. `IDBKeyRange`
* and `reactivity` are shared with query builders created from the schema.
*
* **Gotchas**
*
* `rebuild` closes and deletes the browser database, then reopens it and reruns
* migrations. Records not recreated by migrations are removed.
*
* @see {@link IndexedDb.IndexedDb} for the lower-level browser IndexedDB primitives
* @see {@link make} for creating a schema that provides this service as a layer
*
* @category models
* @since 4.0.0
*/
export class IndexedDbDatabase extends Context.Service<
IndexedDbDatabase,
{
readonly database: MutableRef.MutableRef<globalThis.IDBDatabase>
readonly IDBKeyRange: typeof globalThis.IDBKeyRange
readonly reactivity: Reactivity.Reactivity["Service"]
readonly rebuild: Effect.Effect<void, IndexedDbDatabaseError>
}
>()(TypeId) {}
/**
* Describes an IndexedDB schema version and its migrations, and acts as an effect that yields a query builder for the target version.
*
* @category models
* @since 4.0.0
*/
export interface IndexedDbSchema<
in out FromVersion extends IndexedDbVersion.AnyWithProps,
in out ToVersion extends IndexedDbVersion.AnyWithProps,
out Error = never
> extends
Effect.Effect<
IndexedDbQueryBuilder.IndexedDbQueryBuilder<ToVersion>,
never,
IndexedDbDatabase
>
{
new(_: never): {}
readonly previous: [FromVersion] extends [never] ? undefined
: IndexedDbSchema<never, FromVersion, Error>
readonly fromVersion: FromVersion
readonly version: ToVersion
readonly migrate: [FromVersion] extends [never] ? (query: Transaction<ToVersion>) => Effect.Effect<void, Error>
: (
fromQuery: Transaction<FromVersion>,
toQuery: Transaction<ToVersion>
) => Effect.Effect<void, Error>
readonly add: <Version extends IndexedDbVersion.AnyWithProps, MigrationError>(
version: Version,
migrate: (
fromQuery: Transaction<ToVersion>,
toQuery: Transaction<Version>
) => Effect.Effect<void, MigrationError>
) => IndexedDbSchema<ToVersion, Version, MigrationError | Error>
readonly getQueryBuilder: Effect.Effect<
IndexedDbQueryBuilder.IndexedDbQueryBuilder<ToVersion>,
never,
IndexedDbDatabase
>
readonly layer: (
databaseName: string
) => Layer.Layer<
IndexedDbDatabase,
IndexedDbDatabaseError,
IndexedDb.IndexedDb
>
}
/**
* Query builder available during a database migration, extended with object-store and index management helpers for the active `IDBTransaction`.
*
* @category models
* @since 4.0.0
*/
export interface Transaction<
Source extends IndexedDbVersion.AnyWithProps = never
> extends Omit<IndexedDbQueryBuilder.IndexedDbQueryBuilder<Source>, "transaction"> {
readonly transaction: globalThis.IDBTransaction
readonly createObjectStore: <
A extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
>(
table: A
) => Effect.Effect<globalThis.IDBObjectStore, IndexedDbDatabaseError>
readonly deleteObjectStore: <
A extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
>(
table: A
) => Effect.Effect<void, IndexedDbDatabaseError>
readonly createIndex: <
Name extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
>(
table: Name,
indexName: IndexFromTableName<Source, Name>,
options?: IDBIndexParameters
) => Effect.Effect<globalThis.IDBIndex, IndexedDbDatabaseError>
readonly deleteIndex: <
Name extends IndexedDbTable.TableName<IndexedDbVersion.Tables<Source>>
>(
table: Name,
indexName: IndexFromTableName<Source, Name>
) => Effect.Effect<void, IndexedDbDatabaseError>
}
/**
* Extracts the string-literal index names defined by an `IndexedDbTable`.
*
* @category models
* @since 4.0.0
*/
export type IndexFromTable<Table extends IndexedDbTable.AnyWithProps> = IsStringLiteral<
Extract<keyof IndexedDbTable.Indexes<Table>, string>
> extends true ? Extract<keyof IndexedDbTable.Indexes<Table>, string>
: never
/**
* Extracts the valid index names for a table name within an IndexedDB version.
*
* @category models
* @since 4.0.0
*/
export type IndexFromTableName<
Version extends IndexedDbVersion.AnyWithProps,
Table extends string
> = IndexFromTable<
IndexedDbTable.WithName<IndexedDbVersion.Tables<Version>, Table>
>
/**
* Type-erased IndexedDB schema shape used when traversing schema migration chains.
*
* @category models
* @since 4.0.0
*/
export interface Any {
readonly previous?: Any | undefined
readonly layer: (
databaseName: string
) => Layer.Layer<
IndexedDbDatabase,
IndexedDbDatabaseError,
IndexedDb.IndexedDb
>
}
/**
* Type-erased `IndexedDbSchema` covering any source version, target version, and migration error type.
*
* @category models
* @since 4.0.0
*/
export type AnySchema = IndexedDbSchema<
IndexedDbVersion.AnyWithProps,
IndexedDbVersion.AnyWithProps,
any
>
/**
* Creates the initial `IndexedDbSchema` from a version and an initialization migration run during database upgrade.
*
* @category constructors
* @since 4.0.0
*/
export const make = <
InitialVersion extends IndexedDbVersion.AnyWithProps,
Error
>(
initialVersion: InitialVersion,
init: (toQuery: Transaction<InitialVersion>) => Effect.Effect<void, Error>
): IndexedDbSchema<never, InitialVersion, Error> => {
// oxlint-disable-next-line typescript/no-extraneous-class
function Initial() {}
Object.setPrototypeOf(Initial, SchemaProto)
;(Initial as any).version = initialVersion
;(Initial as any).migrate = init
return Initial as any
}
const makeMigration = <
FromVersion extends IndexedDbVersion.AnyWithProps,
ToVersion extends IndexedDbVersion.AnyWithProps,
Error
>(options: {
readonly previous:
| IndexedDbSchema<FromVersion, ToVersion, Error>
| IndexedDbSchema<never, FromVersion, Error>
readonly fromVersion: FromVersion
readonly version: ToVersion
readonly migrate: (
fromQuery: Transaction<FromVersion>,
toQuery: Transaction<ToVersion>
) => Effect.Effect<void, Error>
}): IndexedDbSchema<FromVersion, ToVersion, Error> => {
// oxlint-disable-next-line typescript/no-extraneous-class
function Migration() {}
Object.setPrototypeOf(Migration, SchemaProto)
;(Migration as any).previous = options.previous
;(Migration as any).fromVersion = options.fromVersion
;(Migration as any).version = options.version
;(Migration as any).migrate = options.migrate
return Migration as any
}
const layer = <DatabaseName extends string>(
databaseName: DatabaseName,
migration: Any
) =>
Layer.effect(
IndexedDbDatabase,
Effect.gen(function*() {
const { IDBKeyRange, indexedDB } = yield* IndexedDb.IndexedDb
const reactivity = yield* Reactivity.Reactivity
const context = yield* Effect.context()
const runForkWith = Effect.runForkWith(context)
let oldVersion = 0
const migrations: Array<Any> = []
let current = migration
while (current) {
migrations.unshift(current)
current = (current as unknown as AnySchema).previous as any
}
const version = migrations.length
const database = MutableRef.make<globalThis.IDBDatabase>(null as any)
const open = Effect.callback<
void,
IndexedDbDatabaseError
>((resume) => {
const request = indexedDB.open(databaseName, version)
request.onblocked = (event) => {
resume(
Effect.fail(
new IndexedDbDatabaseError({
reason: "Blocked",
cause: event
})
)
)
}
request.onerror = (event) => {
const idbRequest = event.target as IDBRequest<IDBDatabase>
resume(
Effect.fail(
new IndexedDbDatabaseError({
reason: "OpenError",
cause: idbRequest.error
})
)
)
}
let fiber: Fiber.Fiber<void, IndexedDbDatabaseError> | undefined
request.onupgradeneeded = (event) => {
const idbRequest = event.target as IDBRequest<IDBDatabase>
const db = idbRequest.result
const transaction = idbRequest.transaction
oldVersion = event.oldVersion
MutableRef.set(database, db)
if (transaction === null) {
return resume(
Effect.fail(
new IndexedDbDatabaseError({
reason: "TransactionError",
cause: null
})
)
)
}
transaction.onabort = (event) => {
resume(
Effect.fail(
new IndexedDbDatabaseError({
reason: "Aborted",
cause: event
})
)
)
}
transaction.onerror = (event) => {
resume(
Effect.fail(
new IndexedDbDatabaseError({
reason: "TransactionError",
cause: event
})
)
)
}
const effect = Effect.forEach(
migrations.slice(oldVersion),
(untypedMigration) => {
if (untypedMigration.previous === undefined) {
const migration = untypedMigration as any as AnySchema
const api = makeTransactionProto({
database,
IDBKeyRange,
tables: migration.version.tables,
transaction,
reactivity
})
return (migration as any).migrate(api) as Effect.Effect<
void,
IndexedDbDatabaseError
>
} else if (untypedMigration.previous) {
const migration = untypedMigration as any as AnySchema
const fromApi = makeTransactionProto({
database,
IDBKeyRange,
tables: migration.fromVersion.tables,
transaction,
reactivity
})
const toApi = makeTransactionProto({
database,
IDBKeyRange,
tables: migration.version.tables,
transaction,
reactivity
})
return migration.migrate(fromApi, toApi) as Effect.Effect<
void,
IndexedDbDatabaseError
>
}
return Effect.die(new Error("Invalid migration"))
},
{ discard: true }
).pipe(
Effect.mapError(
(cause) =>
new IndexedDbDatabaseError({
reason: "UpgradeError",
cause
})
),
Effect.provideService(IndexedDbQueryBuilder.IndexedDbTransaction, transaction)
)
fiber = runForkWith(effect)
fiber.currentDispatcher.flush()
}
request.onsuccess = (event) => {
const idbRequest = event.target as IDBRequest<IDBDatabase>
const db = idbRequest.result
MutableRef.set(database, db)
if (fiber) {
// ensure migration errors are propagated
resume(Effect.asVoid(Fiber.join(fiber)))
} else {
resume(Effect.void)
}
}
})
yield* Effect.addFinalizer(() => {
database.current?.close()
return Effect.void
})
yield* open
const rebuildLock = Semaphore.makeUnsafe(1).withPermit
const rebuild = Effect.callback<void, IndexedDbDatabaseError>((resume) => {
database.current?.close()
const request = indexedDB.deleteDatabase(databaseName)
request.onerror = (_) => {
resume(
Effect.fail(
new IndexedDbDatabaseError({
reason: "OpenError",
cause: request.error
})
)
)
}
request.onsuccess = () => {
resume(Effect.void)
}
}).pipe(
Effect.flatMap(() => open),
rebuildLock
)
return IndexedDbDatabase.of({ database, IDBKeyRange, rebuild, reactivity })
})
).pipe(
Layer.provide(Reactivity.layer)
)
// -----------------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------------
type IsStringLiteral<T> = T extends string ? string extends T ? false
: true
: false
const makeTransactionProto = <Source extends IndexedDbVersion.AnyWithProps>({
IDBKeyRange,
database,
tables,
transaction,
reactivity
}: {
readonly database: MutableRef.MutableRef<globalThis.IDBDatabase>
readonly IDBKeyRange: typeof globalThis.IDBKeyRange
readonly tables: ReadonlyMap<string, IndexedDbVersion.Tables<Source>>
readonly transaction: globalThis.IDBTransaction
readonly reactivity: Reactivity.Reactivity["Service"]
}): Transaction<Source> => {
const migration = IndexedDbQueryBuilder.make({
database,
IDBKeyRange,
tables,
reactivity
}) as any
migration.transaction = transaction
migration.createObjectStore = Effect.fnUntraced(function*(table: string) {
const createTable = yield* Effect.fromNullishOr(tables.get(table)).pipe(
Effect.mapError(
(cause) =>
new IndexedDbDatabaseError({
reason: "MissingTable",
cause
})
)
)
return yield* Effect.try({
try: () =>
database.current.createObjectStore(createTable.tableName, {
keyPath: createTable.keyPath,
autoIncrement: createTable.autoIncrement
}),
catch: (cause) =>
new IndexedDbDatabaseError({
reason: "TransactionError",
cause
})
})
})
migration.deleteObjectStore = Effect.fnUntraced(function*(table: string) {
const createTable = yield* Effect.fromNullishOr(tables.get(table)).pipe(
Effect.mapError(
(cause) =>
new IndexedDbDatabaseError({
reason: "MissingTable",
cause
})
)
)
return yield* Effect.try({
try: () => database.current.deleteObjectStore(createTable.tableName),
catch: (cause) =>
new IndexedDbDatabaseError({
reason: "TransactionError",
cause
})
})
})
migration.createIndex = Effect.fnUntraced(function*(
table: string,
indexName: string,
options?: IDBIndexParameters
) {
const store = transaction.objectStore(table)
const sourceTable = tables.get(table)!
const keyPath = yield* Effect.fromNullishOr(
sourceTable.indexes[indexName]
).pipe(
Effect.mapError(
(cause) =>
new IndexedDbDatabaseError({
reason: "MissingIndex",
cause
})
)
)
return yield* Effect.try({
try: () => store.createIndex(indexName, keyPath, options),
catch: (cause) =>
new IndexedDbDatabaseError({
reason: "TransactionError",
cause
})
})
})
migration.deleteIndex = (table: string, indexName: string) =>
Effect.try({
try: () => transaction.objectStore(table).deleteIndex(indexName),
catch: (cause) =>
new IndexedDbDatabaseError({
reason: "TransactionError",
cause
})
})
return migration
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
/**
* Typed object-store descriptors for the browser IndexedDB integration.
*
* An {@link IndexedDbTable} is the schema-backed description of one IndexedDB
* object store. It carries the store name, row schema, key path, index key
* paths, auto-increment mode, and transaction durability used by database
* versions, migrations, and typed queries. The `make` constructor also derives
* the read, array, and auto-increment write schemas used by the query builder.
*
* @see {@link make} for constructing table descriptors.
*
* @since 4.0.0
*/
import { type Pipeable, pipeArguments } from "effect/Pipeable"
import * as Schema from "effect/Schema"
import * as Struct from "effect/Struct"
import type { NoInfer } from "effect/Types"
import * as IndexedDb from "./IndexedDb.ts"
import type * as IndexedDbQueryBuilder from "./IndexedDbQueryBuilder.ts"
const TypeId = "~@effect/platform-browser/IndexedDbTable"
/**
* Typed IndexedDB table definition containing its name, schema, key path, indexes, auto-increment setting, and transaction durability.
*
* @category interface
* @since 4.0.0
*/
export interface IndexedDbTable<
out Name extends string,
out TableSchema extends AnySchemaStruct,
out Indexes extends Record<
string,
IndexedDbQueryBuilder.KeyPath<TableSchema>
>,
out KeyPath extends Readonly<IDBValidKey | undefined>,
out AutoIncrement extends boolean
> extends Pipeable {
new(_: never): {}
readonly [TypeId]: typeof TypeId
readonly tableName: Name
readonly tableSchema: TableSchema
readonly readSchema: Schema.Top
readonly autoincrementSchema: Schema.Top
readonly arraySchema: Schema.Top
readonly keyPath: KeyPath
readonly indexes: Indexes
readonly autoIncrement: AutoIncrement
readonly durability: IDBTransactionDurability
}
/**
* Schema constraint for table schemas that expose struct fields.
*
* @category models
* @since 4.0.0
*/
export type AnySchemaStruct = Schema.Top & {
readonly fields: Schema.Struct.Fields
}
/**
* Type-erased shape of an `IndexedDbTable` used when table type parameters are not needed.
*
* @category models
* @since 4.0.0
*/
export interface Any {
readonly [TypeId]: typeof TypeId
readonly keyPath: any
readonly tableName: string
readonly tableSchema: Schema.Top
readonly readSchema: Schema.Top
readonly autoincrementSchema: Schema.Top
readonly arraySchema: Schema.Top
readonly autoIncrement: boolean
readonly indexes: any
}
/**
* Type-erased `IndexedDbTable` retaining the table interface properties with broad type parameters.
*
* @category models
* @since 4.0.0
*/
export type AnyWithProps = IndexedDbTable<
string,
AnySchemaStruct,
any,
any,
boolean
>
/**
* Extracts the table name type from an `IndexedDbTable`.
*
* @category models
* @since 4.0.0
*/
export type TableName<Table extends Any> = Table["tableName"]
/**
* Extracts the key-path type from an `IndexedDbTable`.
*
* @category models
* @since 4.0.0
*/
export type KeyPath<Table extends Any> = Table["keyPath"]
/**
* Extracts the auto-increment flag type from an `IndexedDbTable`.
*
* @category models
* @since 4.0.0
*/
export type AutoIncrement<Table extends Any> = Table["autoIncrement"]
/**
* Extracts the schema type from an `IndexedDbTable`.
*
* @category models
* @since 4.0.0
*/
export type TableSchema<Table extends Any> = Table["tableSchema"]
/**
* Extracts the decoding or encoding service requirements needed by an `IndexedDbTable` schema.
*
* @category models
* @since 4.0.0
*/
export type Context<Table extends Any> =
| Table["tableSchema"]["DecodingServices"]
| Table["tableSchema"]["EncodingServices"]
/**
* Extracts the encoded row type from an `IndexedDbTable` schema.
*
* @category models
* @since 4.0.0
*/
export type Encoded<Table extends Any> = Table["tableSchema"]["Encoded"]
/**
* Extracts the index definition map from an `IndexedDbTable`.
*
* @category models
* @since 4.0.0
*/
export type Indexes<Table extends Any> = Table["indexes"]
/**
* Selects the table with the given name from a union of `IndexedDbTable` types.
*
* @category models
* @since 4.0.0
*/
export type WithName<Table extends Any, TableName extends string> = Extract<
Table,
{ readonly tableName: TableName }
>
const Proto = {
[TypeId]: TypeId,
pipe() {
return pipeArguments(this, arguments)
}
}
/**
* Creates a typed IndexedDB table definition from its name, schema, optional key path, indexes, auto-increment flag, and durability.
*
* **When to use**
*
* Use to define a typed object-store descriptor for inclusion in an
* `IndexedDbVersion` and for migration or query APIs.
*
* **Details**
*
* `autoIncrement` defaults to `false` and `durability` defaults to `"relaxed"`.
* Tables without a key path get a read schema that includes an out-of-line
* `key`, while auto-increment tables use a write schema where the generated key
* may be omitted.
*
* **Gotchas**
*
* Tables without a key path cannot define a `key` field in their row schema.
* Key paths and index paths must point to encoded fields whose values are valid
* IndexedDB keys, and declared indexes still need to be created during
* database migrations.
*
* @see `IndexedDbVersion.make` for grouping table definitions into a schema version
*
* @category constructors
* @since 4.0.0
*/
export const make = <
const Name extends string,
TableSchema extends AnySchemaStruct,
const Indexes extends Record<
string,
IndexedDbQueryBuilder.KeyPath<TableSchema>
>,
const KeyPath extends
| (AutoIncrement extends true ? IndexedDbQueryBuilder.KeyPathNumber<NoInfer<TableSchema>>
: IndexedDbQueryBuilder.KeyPath<NoInfer<TableSchema>>)
| undefined = undefined,
const AutoIncrement extends boolean = false
>(options: {
readonly name: Name
readonly schema: [KeyPath] extends [undefined]
? "key" extends keyof TableSchema["fields"] ? "Cannot have a 'key' field when keyPath is undefined"
: TableSchema
: TableSchema
readonly keyPath?: KeyPath
readonly indexes?: Indexes | undefined
readonly autoIncrement?: IsValidAutoIncrementKeyPath<
TableSchema,
KeyPath
> extends true ? AutoIncrement | undefined
: never
readonly durability?: IDBTransactionDurability | undefined
}): IndexedDbTable<
Name,
TableSchema,
Indexes,
Extract<KeyPath, Readonly<IDBValidKey | undefined>>,
AutoIncrement
> => {
// oxlint-disable-next-line typescript/no-extraneous-class
class Table {}
Object.assign(Table, Proto)
const readSchema = options.keyPath === undefined
? Schema.Struct({
...(options.schema as Schema.Struct<{}>).fields,
key: IndexedDb.IDBValidKey
})
: options.schema
;(Table as any).tableName = options.name
;(Table as any).tableSchema = options.schema
;(Table as any).readSchema = readSchema
;(Table as any).arraySchema = Schema.Array(readSchema as any)
;(Table as any).autoincrementSchema = options.autoIncrement
? Schema.Struct(Struct.omit((options.schema as Schema.Struct<{}>).fields, [options.keyPath!] as any))
: options.schema
;(Table as any).keyPath = options.keyPath
;(Table as any).indexes = options.indexes
;(Table as any).autoIncrement = options.autoIncrement === true
;(Table as any).durability = options.durability ?? "relaxed"
return Table as any
}
// -----------------------------------------------------------------------------
// internal
// -----------------------------------------------------------------------------
type IsValidAutoIncrementKeyPath<
TableSchema extends AnySchemaStruct,
KeyPath
> = KeyPath extends keyof TableSchema["Encoded"] ? TableSchema["Encoded"][KeyPath] extends number ? true
: false
: false

View File

@@ -0,0 +1,138 @@
/**
* Typed IndexedDB schema version definitions.
*
* This module represents one logical IndexedDB database version as a non-empty set of `IndexedDbTable` definitions.
* Versions are consumed by `IndexedDbDatabase.make` and `.add` to type query builders and migration transactions, so
* applications can describe the tables available after initialization or after each schema upgrade.
*
* Use an `IndexedDbVersion` when defining the initial stores for a browser database, adding or removing object stores,
* changing indexes, or moving data between differently shaped table schemas. The version value is a typed description of
* the target schema; creating and deleting object stores or indexes still happens explicitly inside the corresponding
* `IndexedDbDatabase` migration callback.
*
* IndexedDB versioning is ordered by the migration chain rather than by a number stored here. Each `.add` step becomes
* the next browser database version, and only migrations after the browser's current version are run. Include every table
* that should be queryable in each target version, avoid duplicate table names, and remember that key-path or
* auto-increment changes usually require creating a new object store and copying data during the upgrade transaction.
*
* @since 4.0.0
*/
import type { NonEmptyReadonlyArray } from "effect/Array"
import type { Pipeable } from "effect/Pipeable"
import { pipeArguments } from "effect/Pipeable"
import type * as IndexedDbTable from "./IndexedDbTable.ts"
const TypeId = "~@effect/platform-browser/IndexedDbVersion"
/**
* Typed IndexedDB version definition containing the tables available in that schema version.
*
* @category interface
* @since 4.0.0
*/
export interface IndexedDbVersion<
out Tables extends IndexedDbTable.AnyWithProps
> extends Pipeable {
new(_: never): {}
readonly [TypeId]: typeof TypeId
readonly tables: ReadonlyMap<string, Tables>
}
/**
* Type-erased shape of an `IndexedDbVersion`.
*
* @category models
* @since 4.0.0
*/
export interface Any {
readonly [TypeId]: typeof TypeId
}
/**
* Type-erased `IndexedDbVersion` retaining version properties with broad table types.
*
* @category models
* @since 4.0.0
*/
export type AnyWithProps = IndexedDbVersion<IndexedDbTable.AnyWithProps>
/**
* Extracts the table union from an `IndexedDbVersion`.
*
* @category models
* @since 4.0.0
*/
export type Tables<Db extends Any> = Db extends IndexedDbVersion<infer _Tables> ? _Tables : never
/**
* Selects a table by name from an `IndexedDbVersion`.
*
* @category models
* @since 4.0.0
*/
export type TableWithName<
Db extends Any,
TableName extends string
> = IndexedDbTable.WithName<Tables<Db>, TableName>
/**
* Extracts the schema for a named table within an `IndexedDbVersion`.
*
* @category models
* @since 4.0.0
*/
export type SchemaWithName<
Db extends Any,
TableName extends string
> = IndexedDbTable.TableSchema<IndexedDbTable.WithName<Tables<Db>, TableName>>
const Proto = {
[TypeId]: TypeId,
pipe() {
return pipeArguments(this, arguments)
}
}
const makeProto = <Tables extends IndexedDbTable.AnyWithProps>(options: {
readonly tables: ReadonlyMap<string, Tables>
}): IndexedDbVersion<Tables> => {
// oxlint-disable-next-line typescript/no-extraneous-class
class Version {}
Object.assign(Version, Proto)
;(Version as any).tables = options.tables
return Version as any
}
/**
* Creates an `IndexedDbVersion` from one or more table definitions.
*
* **When to use**
*
* Use when you need a typed description of the target IndexedDB schema that a
* database migration will materialize.
*
* **Details**
*
* The returned version exposes a `tables` map keyed by each table's
* `tableName`, and its type is the union of the supplied table definitions.
*
* **Gotchas**
*
* This constructor only describes the target schema; object stores and indexes
* still need to be created in the corresponding `IndexedDbDatabase` migration.
* Duplicate table names are not rejected, and the runtime map keeps the later
* table for a repeated key.
*
* @see {@link IndexedDbTable.make} for creating table definitions consumed by this constructor
*
* @category constructors
* @since 4.0.0
*/
export const make = <
const Tables extends NonEmptyReadonlyArray<IndexedDbTable.AnyWithProps>
>(
...tables: Tables
): IndexedDbVersion<Tables[number]> =>
makeProto({
tables: new Map(tables.map((table) => [table.tableName, table]))
})

View File

@@ -0,0 +1,146 @@
/**
* Effect service for the browser Permissions API.
*
* This module defines a `Permissions` service backed by
* `navigator.permissions`. The service exposes `query`, which returns the
* browser `PermissionStatus` for a permission name. Failed browser operations
* are represented as `PermissionsError` values with `InvalidStateError` or
* `TypeError` reasons, and `layer` provides the browser-backed service.
*
* @since 4.0.0
*/
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
const TypeId = "~@effect/platform-browser/Permissions"
const ErrorTypeId = "~@effect/platform-browser/Permissions/PermissionsError"
/**
* Wrapper on the Permission API (`navigator.permissions`) with methods for
* querying status of permissions.
*
* @category models
* @since 4.0.0
*/
export interface Permissions {
readonly [TypeId]: typeof TypeId
/**
* Returns the state of a user permission on the global scope.
*/
readonly query: <Name extends PermissionName>(
name: Name
) => Effect.Effect<
// `name` is identical to the name passed to Permissions.query
// https://developer.mozilla.org/en-US/docs/Web/API/PermissionStatus
Omit<PermissionStatus, "name"> & { name: Name },
PermissionsError
>
}
/**
* Error reason for an `InvalidStateError` raised by the browser Permissions API.
*
* @category errors
* @since 4.0.0
*/
export class PermissionsInvalidStateError extends Data.TaggedError("InvalidStateError")<{
readonly cause: unknown
}> {
override get message(): string {
return this._tag
}
}
/**
* Error reason for a `TypeError` raised by the browser Permissions API.
*
* @category errors
* @since 4.0.0
*/
export class PermissionsTypeError extends Data.TaggedError("TypeError")<{
readonly cause: unknown
}> {
override get message(): string {
return this._tag
}
}
/**
* Union of browser Permissions API error reasons represented by the service.
*
* @category errors
* @since 4.0.0
*/
export type PermissionsErrorReason = PermissionsInvalidStateError | PermissionsTypeError
/**
* Tagged error wrapping a browser Permissions API failure reason.
*
* @category errors
* @since 4.0.0
*/
export class PermissionsError extends Data.TaggedError("PermissionsError")<{
readonly reason: PermissionsErrorReason
}> {
constructor(props: { readonly reason: PermissionsErrorReason }) {
super({
...props,
cause: props.reason.cause
} as any)
}
readonly [ErrorTypeId] = ErrorTypeId
override get message(): string {
return this.reason.message
}
}
/**
* Service tag for browser permission querying.
*
* **When to use**
*
* Use when you need to require or provide browser permission querying through
* Effect's context.
*
* @category services
* @since 4.0.0
*/
export const Permissions: Context.Service<Permissions, Permissions> = Context.Service<Permissions>(TypeId)
/**
* Provides the `Permissions` service using the browser `navigator.permissions` API.
*
* **When to use**
*
* Use when you need a live browser `Permissions` service backed by the ambient
* `navigator.permissions` implementation.
*
* **Details**
*
* `query` delegates to `navigator.permissions.query({ name })` and wraps
* rejected browser operations in `PermissionsError`.
*
* @category layers
* @since 4.0.0
*/
export const layer: Layer.Layer<Permissions> = Layer.succeed(
Permissions,
Permissions.of({
[TypeId]: TypeId,
query: (name) =>
Effect.tryPromise({
try: () => navigator.permissions.query({ name }) as Promise<any>,
catch: (cause) =>
new PermissionsError({
reason: cause instanceof DOMException
? new PermissionsInvalidStateError({ cause })
: new PermissionsTypeError({ cause })
})
})
})
)

View File

@@ -0,0 +1,90 @@
/**
* @since 4.0.0
*/
// @barrel: Auto-generated exports. Do not edit manually.
/**
* @since 1.0.0
*/
export * as BrowserCrypto from "./BrowserCrypto.ts"
/**
* @since 4.0.0
*/
export * as BrowserHttpClient from "./BrowserHttpClient.ts"
/**
* @since 4.0.0
*/
export * as BrowserKeyValueStore from "./BrowserKeyValueStore.ts"
/**
* @since 4.0.0
*/
export * as BrowserPersistence from "./BrowserPersistence.ts"
/**
* @since 4.0.0
*/
export * as BrowserRuntime from "./BrowserRuntime.ts"
/**
* @since 4.0.0
*/
export * as BrowserSocket from "./BrowserSocket.ts"
/**
* @since 4.0.0
*/
export * as BrowserStream from "./BrowserStream.ts"
/**
* @since 4.0.0
*/
export * as BrowserWorker from "./BrowserWorker.ts"
/**
* @since 4.0.0
*/
export * as BrowserWorkerRunner from "./BrowserWorkerRunner.ts"
/**
* @since 4.0.0
*/
export * as Clipboard from "./Clipboard.ts"
/**
* @since 4.0.0
*/
export * as Geolocation from "./Geolocation.ts"
/**
* @since 4.0.0
*/
export * as IndexedDb from "./IndexedDb.ts"
/**
* @since 4.0.0
*/
export * as IndexedDbDatabase from "./IndexedDbDatabase.ts"
/**
* @since 4.0.0
*/
export * as IndexedDbQueryBuilder from "./IndexedDbQueryBuilder.ts"
/**
* @since 4.0.0
*/
export * as IndexedDbTable from "./IndexedDbTable.ts"
/**
* @since 4.0.0
*/
export * as IndexedDbVersion from "./IndexedDbVersion.ts"
/**
* @since 4.0.0
*/
export * as Permissions from "./Permissions.ts"