Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'
This commit is contained in:
97
repos/effect/packages/platform-browser/src/BrowserCrypto.ts
Normal file
97
repos/effect/packages/platform-browser/src/BrowserCrypto.ts
Normal 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
|
||||
})
|
||||
})
|
||||
)
|
||||
430
repos/effect/packages/platform-browser/src/BrowserHttpClient.ts
Normal file
430
repos/effect/packages/platform-browser/src/BrowserHttpClient.ts
Normal 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)
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
334
repos/effect/packages/platform-browser/src/BrowserPersistence.ts
Normal file
334
repos/effect/packages/platform-browser/src/BrowserPersistence.ts
Normal 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)
|
||||
})
|
||||
53
repos/effect/packages/platform-browser/src/BrowserRuntime.ts
Normal file
53
repos/effect/packages/platform-browser/src/BrowserRuntime.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
52
repos/effect/packages/platform-browser/src/BrowserSocket.ts
Normal file
52
repos/effect/packages/platform-browser/src/BrowserSocket.ts
Normal 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
|
||||
56
repos/effect/packages/platform-browser/src/BrowserStream.ts
Normal file
56
repos/effect/packages/platform-browser/src/BrowserStream.ts
Normal 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)
|
||||
98
repos/effect/packages/platform-browser/src/BrowserWorker.ts
Normal file
98
repos/effect/packages/platform-browser/src/BrowserWorker.ts
Normal 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)
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -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))
|
||||
143
repos/effect/packages/platform-browser/src/Clipboard.ts
Normal file
143
repos/effect/packages/platform-browser/src/Clipboard.ts
Normal 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"
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
233
repos/effect/packages/platform-browser/src/Geolocation.ts
Normal file
233
repos/effect/packages/platform-browser/src/Geolocation.ts
Normal 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)
|
||||
))
|
||||
113
repos/effect/packages/platform-browser/src/IndexedDb.ts
Normal file
113
repos/effect/packages/platform-browser/src/IndexedDb.ts
Normal 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"))
|
||||
}
|
||||
})
|
||||
)
|
||||
648
repos/effect/packages/platform-browser/src/IndexedDbDatabase.ts
Normal file
648
repos/effect/packages/platform-browser/src/IndexedDbDatabase.ts
Normal 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
|
||||
}
|
||||
2032
repos/effect/packages/platform-browser/src/IndexedDbQueryBuilder.ts
Normal file
2032
repos/effect/packages/platform-browser/src/IndexedDbQueryBuilder.ts
Normal file
File diff suppressed because it is too large
Load Diff
260
repos/effect/packages/platform-browser/src/IndexedDbTable.ts
Normal file
260
repos/effect/packages/platform-browser/src/IndexedDbTable.ts
Normal 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
|
||||
138
repos/effect/packages/platform-browser/src/IndexedDbVersion.ts
Normal file
138
repos/effect/packages/platform-browser/src/IndexedDbVersion.ts
Normal 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]))
|
||||
})
|
||||
146
repos/effect/packages/platform-browser/src/Permissions.ts
Normal file
146
repos/effect/packages/platform-browser/src/Permissions.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
90
repos/effect/packages/platform-browser/src/index.ts
Normal file
90
repos/effect/packages/platform-browser/src/index.ts
Normal 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"
|
||||
Reference in New Issue
Block a user