@livestore/utils 0.4.0-dev.18 → 0.4.0-dev.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/.tsbuildinfo.json +1 -1
  2. package/dist/browser/Opfs/Opfs.d.ts +51 -0
  3. package/dist/browser/Opfs/Opfs.d.ts.map +1 -0
  4. package/dist/browser/Opfs/Opfs.js +345 -0
  5. package/dist/browser/Opfs/Opfs.js.map +1 -0
  6. package/dist/browser/Opfs/debug-utils.d.ts +20 -0
  7. package/dist/browser/Opfs/debug-utils.d.ts.map +1 -0
  8. package/dist/browser/Opfs/debug-utils.js +94 -0
  9. package/dist/browser/Opfs/debug-utils.js.map +1 -0
  10. package/dist/browser/Opfs/mod.d.ts +4 -0
  11. package/dist/browser/Opfs/mod.d.ts.map +1 -0
  12. package/dist/browser/Opfs/mod.js +4 -0
  13. package/dist/browser/Opfs/mod.js.map +1 -0
  14. package/dist/browser/Opfs/utils.d.ts +68 -0
  15. package/dist/browser/Opfs/utils.d.ts.map +1 -0
  16. package/dist/browser/Opfs/utils.js +206 -0
  17. package/dist/browser/Opfs/utils.js.map +1 -0
  18. package/dist/browser/QuotaExceededError.d.ts +59 -0
  19. package/dist/browser/QuotaExceededError.d.ts.map +1 -0
  20. package/dist/browser/QuotaExceededError.js +2 -0
  21. package/dist/browser/QuotaExceededError.js.map +1 -0
  22. package/dist/browser/WebChannelBrowser.d.ts +22 -0
  23. package/dist/browser/WebChannelBrowser.d.ts.map +1 -0
  24. package/dist/browser/WebChannelBrowser.js +76 -0
  25. package/dist/browser/WebChannelBrowser.js.map +1 -0
  26. package/dist/browser/WebError.d.ts +425 -0
  27. package/dist/browser/WebError.d.ts.map +1 -0
  28. package/dist/browser/WebError.js +414 -0
  29. package/dist/browser/WebError.js.map +1 -0
  30. package/dist/browser/WebError.test.d.ts +2 -0
  31. package/dist/browser/WebError.test.d.ts.map +1 -0
  32. package/dist/browser/WebError.test.js +46 -0
  33. package/dist/browser/WebError.test.js.map +1 -0
  34. package/dist/browser/WebLock.d.ts.map +1 -0
  35. package/dist/browser/WebLock.js.map +1 -0
  36. package/dist/{browser.d.ts → browser/detect.d.ts} +1 -1
  37. package/dist/browser/detect.d.ts.map +1 -0
  38. package/dist/{browser.js → browser/detect.js} +1 -1
  39. package/dist/browser/detect.js.map +1 -0
  40. package/dist/browser/mod.d.ts +8 -0
  41. package/dist/browser/mod.d.ts.map +1 -0
  42. package/dist/browser/mod.js +8 -0
  43. package/dist/browser/mod.js.map +1 -0
  44. package/dist/effect/WebChannel/WebChannel.d.ts +2 -21
  45. package/dist/effect/WebChannel/WebChannel.d.ts.map +1 -1
  46. package/dist/effect/WebChannel/WebChannel.js +3 -75
  47. package/dist/effect/WebChannel/WebChannel.js.map +1 -1
  48. package/dist/effect/WebChannel/WebChannel.test.js +1 -1
  49. package/dist/effect/WebChannel/WebChannel.test.js.map +1 -1
  50. package/dist/effect/{index.d.ts → mod.d.ts} +2 -4
  51. package/dist/effect/mod.d.ts.map +1 -0
  52. package/dist/effect/{index.js → mod.js} +2 -4
  53. package/dist/effect/mod.js.map +1 -0
  54. package/dist/mod.d.ts +1 -1
  55. package/dist/mod.d.ts.map +1 -1
  56. package/dist/mod.js +1 -1
  57. package/dist/mod.js.map +1 -1
  58. package/dist/node/mod.d.ts +1 -1
  59. package/dist/node/mod.d.ts.map +1 -1
  60. package/dist/node/mod.js +1 -1
  61. package/dist/node/mod.js.map +1 -1
  62. package/package.json +27 -19
  63. package/src/browser/Opfs/Opfs.ts +428 -0
  64. package/src/browser/Opfs/debug-utils.ts +151 -0
  65. package/src/browser/Opfs/mod.ts +3 -0
  66. package/src/browser/Opfs/utils.ts +270 -0
  67. package/src/browser/QuotaExceededError.ts +59 -0
  68. package/src/browser/WebChannelBrowser.ts +131 -0
  69. package/src/browser/WebError.test.ts +66 -0
  70. package/src/browser/WebError.ts +599 -0
  71. package/src/browser/mod.ts +8 -0
  72. package/src/effect/WebChannel/WebChannel.test.ts +1 -1
  73. package/src/effect/WebChannel/WebChannel.ts +11 -127
  74. package/src/effect/{index.ts → mod.ts} +1 -2
  75. package/src/mod.ts +1 -1
  76. package/src/node/mod.ts +1 -1
  77. package/dist/browser.d.ts.map +0 -1
  78. package/dist/browser.js.map +0 -1
  79. package/dist/effect/WebLock.d.ts.map +0 -1
  80. package/dist/effect/WebLock.js.map +0 -1
  81. package/dist/effect/index.d.ts.map +0 -1
  82. package/dist/effect/index.js.map +0 -1
  83. /package/dist/{effect → browser}/WebLock.d.ts +0 -0
  84. /package/dist/{effect → browser}/WebLock.js +0 -0
  85. /package/src/{effect → browser}/WebLock.ts +0 -0
  86. /package/src/{browser.ts → browser/detect.ts} +0 -0
@@ -0,0 +1,270 @@
1
+ import { Effect, Stream } from 'effect'
2
+ import { Opfs, OpfsError } from './Opfs.ts'
3
+
4
+ /**
5
+ * Set of path segments that are forbidden by the File System specification.
6
+ *
7
+ * A valid segment is non-empty, not equal to `.` or `..`, and must not have path separator characters.
8
+ *
9
+ * @see {@link https://fs.spec.whatwg.org/#valid-file-name | File System Spec}
10
+ */
11
+ const DISALLOWED_SEGMENTS = new Set(['.', '..'])
12
+
13
+ /**
14
+ * Parse a slash-separated OPFS path into validated segments.
15
+ *
16
+ * Rejects empty paths and disallows `.` / `..` so callers cannot rely on implicit current/parent
17
+ * directory semantics that the File System Access API does not support.
18
+ *
19
+ * @param path - Slash-delimited path relative to the OPFS root.
20
+ * @returns Effect that yields the sanitized path segments.
21
+ */
22
+ const parsePathSegments = (path: string) =>
23
+ Effect.gen(function* () {
24
+ const segments = path.split('/').filter((segment) => segment.length > 0)
25
+
26
+ if (segments.length === 0) {
27
+ return yield* new OpfsError({
28
+ message: `Invalid OPFS path '${path}': path must contain at least one non-empty segment`,
29
+ })
30
+ }
31
+
32
+ for (const segment of segments) {
33
+ if (DISALLOWED_SEGMENTS.has(segment)) {
34
+ return yield* new OpfsError({
35
+ message: `Invalid OPFS path '${path}': segment '${segment}' is not supported`,
36
+ })
37
+ }
38
+ }
39
+
40
+ return segments
41
+ })
42
+
43
+ /**
44
+ * Determine whether the provided OPFS path refers to the origin root.
45
+ */
46
+ const isRootPath = (path: string) => path === '' || path === '/'
47
+
48
+ /**
49
+ * Split a set of path segments into parent and leaf portions.
50
+ *
51
+ * @param segments - Non-empty sequence of path segments pointing to a concrete entry.
52
+ * @returns Parent directory segments and the final segment representing the target entry.
53
+ */
54
+ const splitPathSegments = (segments: ReadonlyArray<string>) => ({
55
+ parentSegments: segments.slice(0, -1),
56
+ leafSegment: segments[segments.length - 1]!,
57
+ })
58
+
59
+ /**
60
+ * Resolve a directory path from the OPFS root and return the final directory handle.
61
+ *
62
+ * @param segments - Ordered list of directory names to follow.
63
+ * @param options - Options forwarded to each `getDirectoryHandle` call.
64
+ */
65
+ const traverseDirectoryPath = (segments: ReadonlyArray<string>, options?: FileSystemGetDirectoryOptions) =>
66
+ Effect.gen(function* () {
67
+ let currentDirHandle = yield* Opfs.getRootDirectoryHandle
68
+
69
+ for (let index = 0; index < segments.length; index++) {
70
+ const segment = segments[index]!
71
+ currentDirHandle = yield* Opfs.getDirectoryHandle(currentDirHandle, segment, options)
72
+ }
73
+
74
+ return currentDirHandle
75
+ })
76
+
77
+ /**
78
+ * Ensure that a directory path exists, creating intermediate segments when permitted.
79
+ *
80
+ * @param segments - Ordered list of directory names to ensure.
81
+ * @param options.recursive - When `true`, create every missing segment. Otherwise only the leaf is created.
82
+ */
83
+ const ensureDirectoryPath = (segments: ReadonlyArray<string>, options: { readonly recursive: boolean }) =>
84
+ Effect.gen(function* () {
85
+ let currentDirHandle = yield* Opfs.getRootDirectoryHandle
86
+
87
+ for (let index = 0; index < segments.length; index++) {
88
+ const segment = segments[index]!
89
+ const isLast = index === segments.length - 1
90
+ const shouldCreate = options.recursive || isLast
91
+
92
+ currentDirHandle = yield* Opfs.getDirectoryHandle(
93
+ currentDirHandle,
94
+ segment,
95
+ shouldCreate ? { create: true } : undefined,
96
+ )
97
+ }
98
+
99
+ return currentDirHandle
100
+ })
101
+
102
+ /**
103
+ * Resolve a directory handle using a slash-delimited OPFS path.
104
+ *
105
+ * @param path - Directory path relative to the OPFS root.
106
+ * @param options - Options forwarded to `getDirectoryHandle` when traversing segments.
107
+ * @returns Directory handle for the final segment.
108
+ */
109
+ export const getDirectoryHandleByPath = Effect.fn('@livestore/utils:Opfs.getDirectoryHandleByPath')(function* (
110
+ path: string,
111
+ options?: FileSystemGetDirectoryOptions,
112
+ ) {
113
+ if (isRootPath(path)) return yield* Opfs.getRootDirectoryHandle
114
+
115
+ const pathSegments = yield* parsePathSegments(path)
116
+ return yield* traverseDirectoryPath(pathSegments, options)
117
+ })
118
+
119
+ /**
120
+ * Remove a file or directory identified by an OPFS path.
121
+ *
122
+ * @param path - Slash-delimited path to delete.
123
+ * @param options.recursive - When `true`, recursively delete directory contents; defaults to `false`.
124
+ */
125
+ export const remove = Effect.fn('@livestore/utils:Opfs.remove')(function* (
126
+ path: string,
127
+ options?: { readonly recursive?: boolean },
128
+ ) {
129
+ const recursive = options?.recursive ?? false
130
+
131
+ if (isRootPath(path)) {
132
+ const rootHandle = yield* Opfs.getRootDirectoryHandle
133
+ const handlesStream = yield* Opfs.values(rootHandle)
134
+ yield* handlesStream.pipe(
135
+ Stream.runForEach((handle) => Opfs.removeEntry(rootHandle, handle.name, { recursive: true })),
136
+ )
137
+ return
138
+ }
139
+
140
+ const pathSegments = yield* parsePathSegments(path)
141
+ const { parentSegments, leafSegment: targetName } = splitPathSegments(pathSegments)
142
+ const parentDirHandle = yield* traverseDirectoryPath(parentSegments)
143
+
144
+ yield* Opfs.removeEntry(parentDirHandle, targetName, { recursive })
145
+ })
146
+
147
+ /**
148
+ * Determine whether a file or directory exists at the given OPFS path.
149
+ *
150
+ * @param path - Slash-delimited path to inspect.
151
+ * @returns `true` if the path resolves to a file or directory, otherwise `false`.
152
+ */
153
+ export const exists = Effect.fn('@livestore/utils:Opfs.exists')(function* (path: string) {
154
+ if (isRootPath(path)) return true
155
+
156
+ const pathSegments = yield* parsePathSegments(path)
157
+ const { parentSegments, leafSegment: targetName } = splitPathSegments(pathSegments)
158
+
159
+ const parentDirHandle = yield* traverseDirectoryPath(parentSegments, { create: false }).pipe(
160
+ Effect.catchTag('@livestore/utils/Web/NotFoundError', () => Effect.succeed(undefined)),
161
+ )
162
+
163
+ if (parentDirHandle === undefined) return false
164
+
165
+ return yield* Opfs.getFileHandle(parentDirHandle, targetName).pipe(
166
+ Effect.orElse(() => Opfs.getDirectoryHandle(parentDirHandle, targetName, { create: false })),
167
+ Effect.as(true),
168
+ Effect.catchTag('@livestore/utils/Web/NotFoundError', () => Effect.succeed(false)),
169
+ )
170
+ })
171
+
172
+ /**
173
+ * Create a directory at the provided path, optionally creating parents recursively.
174
+ *
175
+ * @param path - Slash-delimited directory path.
176
+ * @param options.recursive - When `true`, create all missing parent segments; defaults to `false`.
177
+ */
178
+ export const makeDirectory = Effect.fn('@livestore/utils:Opfs.makeDirectory')(function* (
179
+ path: string,
180
+ options?: { readonly recursive?: boolean },
181
+ ) {
182
+ const recursive = options?.recursive ?? false
183
+
184
+ if (isRootPath(path)) return
185
+
186
+ const pathSegments = yield* parsePathSegments(path)
187
+
188
+ yield* ensureDirectoryPath(pathSegments, { recursive })
189
+ })
190
+
191
+ /**
192
+ * Extract basic metadata for a given file handle.
193
+ *
194
+ * @param handle - File handle whose metadata should be read.
195
+ * @returns Object containing name, size, MIME type, and last modification timestamp.
196
+ */
197
+ export const getMetadata = Effect.fn('@livestore/utils:Opfs.getMetadata')(function* (handle: FileSystemFileHandle) {
198
+ return yield* Opfs.getFile(handle).pipe(
199
+ Effect.map((file) => ({
200
+ name: file.name,
201
+ size: file.size,
202
+ type: file.type,
203
+ lastModified: file.lastModified,
204
+ })),
205
+ )
206
+ })
207
+
208
+ /**
209
+ * Write bytes to an OPFS path, creating or replacing the target file.
210
+ *
211
+ * @param path - Slash-delimited file path.
212
+ * @param data - Bytes to persist.
213
+ */
214
+ export const writeFile = Effect.fn('@livestore/utils:Opfs.writeFile')(function* (path: string, data: Uint8Array) {
215
+ if (isRootPath(path)) {
216
+ return yield* new OpfsError({
217
+ message: `Invalid OPFS path '${path}': cannot write file directly to the OPFS root`,
218
+ })
219
+ }
220
+
221
+ const pathSegments = yield* parsePathSegments(path)
222
+ const { parentSegments, leafSegment: fileName } = splitPathSegments(pathSegments)
223
+
224
+ return yield* Effect.scoped(
225
+ Effect.gen(function* () {
226
+ const parentDirHandle = yield* traverseDirectoryPath(parentSegments)
227
+ const fileHandle = yield* Opfs.getFileHandle(parentDirHandle, fileName, { create: true })
228
+
229
+ yield* Opfs.writeFile(fileHandle, new Uint8Array(data), {
230
+ keepExistingData: false,
231
+ })
232
+ }),
233
+ )
234
+ })
235
+
236
+ /**
237
+ * Synchronously write bytes to the target file handle, truncating any existing content.
238
+ *
239
+ * @param handle - Sync access handle to overwrite.
240
+ * @param buffer - Raw data to persist.
241
+ * @returns Effect that resolves once every byte is flushed to durable storage.
242
+ *
243
+ * @remarks
244
+ * - Only available in Dedicated Web Workers.
245
+ * - Crash safety: not atomic. A crash mid-write can leave the file truncated or partially written.
246
+ * For atomic replacement, prefer `writeFile` or a temp-file pattern with two prepared handles.
247
+ */
248
+ export const syncWriteFile = Effect.fn('@livestore/utils:Opfs.syncWriteFile')(function* (
249
+ handle: FileSystemSyncAccessHandle,
250
+ buffer: AllowSharedBufferSource,
251
+ ) {
252
+ const bytes = ArrayBuffer.isView(buffer)
253
+ ? new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
254
+ : new Uint8Array(buffer as ArrayBufferLike)
255
+
256
+ yield* Opfs.syncTruncate(handle, 0)
257
+
258
+ let offset = 0
259
+ while (offset < bytes.byteLength) {
260
+ const wrote = yield* Opfs.syncWrite(handle, bytes.subarray(offset), { at: offset })
261
+ if (wrote === 0) {
262
+ return yield* new OpfsError({
263
+ message: `Short write: wrote ${offset} of ${bytes.byteLength} bytes.`,
264
+ })
265
+ }
266
+ offset += Number(wrote)
267
+ }
268
+
269
+ yield* Opfs.syncFlush(handle)
270
+ })
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Type augmentation for the `QuotaExceededError` interface.
3
+ *
4
+ * In previous versions of the Web platform standard, quota exceeded errors were to be thrown
5
+ * as regular `DOMException` objects with `name: "QuotaExceededError"`. In the latest versions,
6
+ * the `QuotaExceededError` exists as a dedicated interface extending `DOMException`, providing
7
+ * additional properties like `quota` and `requested`.
8
+ *
9
+ * As of TypeScript 5.9, the standard DOM type definitions (`lib.dom.d.ts`) do **not** include
10
+ * the `QuotaExceededError` interface, even though it is already supported by a few browsers.
11
+ *
12
+ * This file provides the missing type definitions so that code can safely reference
13
+ * `globalThis.QuotaExceededError` with proper type checking, supporting both:
14
+ * - Browsers with the new dedicated interface
15
+ * - Browsers still using a regular `DOMException`
16
+ *
17
+ * @see {@link https://webidl.spec.whatwg.org/#quotaexceedederror | Web IDL Specification}
18
+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/QuotaExceededError | MDN Reference}
19
+ */
20
+ declare global {
21
+ interface QuotaExceededError extends DOMException {
22
+ /**
23
+ * The **`message`** read-only property of a message or description associated with the given error name.
24
+ *
25
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/QuotaExceededError/QuotaExceededError#message)
26
+ */
27
+ readonly message: string
28
+ /**
29
+ * A number representing the quota limit in bytes, or undefined.
30
+ *
31
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/QuotaExceededError/quota)
32
+ */
33
+ readonly quota?: number
34
+ /**
35
+ * A number representing the requested amount of storage in bytes, or undefined.
36
+ *
37
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/QuotaExceededError/requested)
38
+ */
39
+ readonly requested?: number
40
+ }
41
+
42
+ /**
43
+ * The **`QuotaExceededError`** represents an error when a requested operation would exceed a system-imposed storage quota.
44
+ *
45
+ * @remarks
46
+ *
47
+ * In browser versions before this interface was implemented, it was a regular DOMException. The subclassing allows for extra information like quota and requested to be included.
48
+ *
49
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/QuotaExceededError)
50
+ */
51
+ var QuotaExceededError:
52
+ | {
53
+ prototype: QuotaExceededError
54
+ new (message?: string, options?: { quota: number; requested: number }): QuotaExceededError
55
+ }
56
+ | undefined
57
+ }
58
+
59
+ export {}
@@ -0,0 +1,131 @@
1
+ import { Deferred, Exit, Scope } from 'effect'
2
+
3
+ import * as Effect from '../effect/Effect.ts'
4
+ import * as Schema from '../effect/Schema/index.ts'
5
+ import * as Stream from '../effect/Stream.ts'
6
+ import {
7
+ type InputSchema,
8
+ listenToDebugPing,
9
+ mapSchema,
10
+ type WebChannel,
11
+ WebChannelSymbol,
12
+ } from '../effect/WebChannel/common.ts'
13
+
14
+ /** Browser BroadcastChannel-backed WebChannel */
15
+ export const broadcastChannel = <MsgListen, MsgSend, MsgListenEncoded, MsgSendEncoded>({
16
+ channelName,
17
+ schema: inputSchema,
18
+ }: {
19
+ channelName: string
20
+ schema: InputSchema<MsgListen, MsgSend, MsgListenEncoded, MsgSendEncoded>
21
+ }): Effect.Effect<WebChannel<MsgListen, MsgSend>, never, Scope.Scope> =>
22
+ Effect.scopeWithCloseable((scope) =>
23
+ Effect.gen(function* () {
24
+ const schema = mapSchema(inputSchema)
25
+
26
+ const channel = new BroadcastChannel(channelName)
27
+
28
+ yield* Effect.addFinalizer(() => Effect.try(() => channel.close()).pipe(Effect.ignoreLogged))
29
+
30
+ const send = (message: MsgSend) =>
31
+ Effect.gen(function* () {
32
+ const messageEncoded = yield* Schema.encode(schema.send)(message)
33
+ channel.postMessage(messageEncoded)
34
+ })
35
+
36
+ // TODO also listen to `messageerror` in parallel
37
+ const listen = Stream.fromEventListener<MessageEvent>(channel, 'message').pipe(
38
+ Stream.map((_) => Schema.decodeEither(schema.listen)(_.data)),
39
+ listenToDebugPing(channelName),
40
+ )
41
+
42
+ const closedDeferred = yield* Deferred.make<void>().pipe(Effect.acquireRelease(Deferred.done(Exit.void)))
43
+ const supportsTransferables = false
44
+
45
+ return {
46
+ [WebChannelSymbol]: WebChannelSymbol,
47
+ send,
48
+ listen,
49
+ closedDeferred,
50
+ shutdown: Scope.close(scope, Exit.succeed('shutdown')),
51
+ schema,
52
+ supportsTransferables,
53
+ }
54
+ }).pipe(Effect.withSpan(`WebChannel:broadcastChannel(${channelName})`)),
55
+ )
56
+
57
+ /**
58
+ * Window.postMessage-based WebChannel
59
+ */
60
+ export const windowChannel = <MsgListen, MsgSend, MsgListenEncoded, MsgSendEncoded>({
61
+ listenWindow,
62
+ sendWindow,
63
+ targetOrigin = '*',
64
+ ids,
65
+ schema: inputSchema,
66
+ }: {
67
+ listenWindow: Window
68
+ sendWindow: Window
69
+ targetOrigin?: string
70
+ ids: { own: string; other: string }
71
+ schema: InputSchema<MsgListen, MsgSend, MsgListenEncoded, MsgSendEncoded>
72
+ }): Effect.Effect<WebChannel<MsgListen, MsgSend>, never, Scope.Scope> =>
73
+ Effect.scopeWithCloseable((scope) =>
74
+ Effect.gen(function* () {
75
+ const schema = mapSchema(inputSchema)
76
+
77
+ const debugInfo = {
78
+ sendTotal: 0,
79
+ listenTotal: 0,
80
+ targetOrigin,
81
+ ids,
82
+ }
83
+
84
+ const WindowMessageListen = Schema.Struct({
85
+ message: schema.listen,
86
+ from: Schema.Literal(ids.other),
87
+ to: Schema.Literal(ids.own),
88
+ }).annotations({ title: 'webmesh.WindowMessageListen' })
89
+
90
+ const WindowMessageSend = Schema.Struct({
91
+ message: schema.send,
92
+ from: Schema.Literal(ids.own),
93
+ to: Schema.Literal(ids.other),
94
+ }).annotations({ title: 'webmesh.WindowMessageSend' })
95
+
96
+ const send = (message: MsgSend) =>
97
+ Effect.gen(function* () {
98
+ debugInfo.sendTotal++
99
+
100
+ const [messageEncoded, transferables] = yield* Schema.encodeWithTransferables(WindowMessageSend)({
101
+ message,
102
+ from: ids.own,
103
+ to: ids.other,
104
+ })
105
+ sendWindow.postMessage(messageEncoded, targetOrigin, transferables)
106
+ })
107
+
108
+ const listen = Stream.fromEventListener<MessageEvent>(listenWindow, 'message').pipe(
109
+ Stream.filter((_) => Schema.is(Schema.encodedSchema(WindowMessageListen))(_.data)),
110
+ Stream.map((_) => {
111
+ debugInfo.listenTotal++
112
+ return Schema.decodeEither(schema.listen)(_.data.message)
113
+ }),
114
+ listenToDebugPing('window'),
115
+ )
116
+
117
+ const closedDeferred = yield* Deferred.make<void>().pipe(Effect.acquireRelease(Deferred.done(Exit.void)))
118
+ const supportsTransferables = true
119
+
120
+ return {
121
+ [WebChannelSymbol]: WebChannelSymbol,
122
+ send,
123
+ listen,
124
+ closedDeferred,
125
+ shutdown: Scope.close(scope, Exit.succeed('shutdown')),
126
+ schema,
127
+ supportsTransferables,
128
+ debugInfo,
129
+ }
130
+ }).pipe(Effect.withSpan(`WebChannel:windowChannel`)),
131
+ )
@@ -0,0 +1,66 @@
1
+ import * as Vitest from '@effect/vitest'
2
+
3
+ import * as WebError from './WebError.ts'
4
+
5
+ Vitest.describe('parseWebError', () => {
6
+ Vitest.it('returns a WebError instance unchanged when no expectations are provided', () => {
7
+ const domException = new DOMException('missing node', 'NotFoundError')
8
+ const webError = new WebError.NotFoundError({ cause: domException })
9
+
10
+ const result = WebError.parseWebError(webError)
11
+
12
+ Vitest.expect(result).toBeInstanceOf(WebError.NotFoundError)
13
+ Vitest.expect(result.cause).toBe(domException)
14
+ })
15
+
16
+ Vitest.it('maps native errors to the corresponding WebError when expected', () => {
17
+ const nativeError = new globalThis.TypeError('unsupported type')
18
+
19
+ const result = WebError.parseWebError(nativeError, [WebError.TypeError])
20
+
21
+ Vitest.expect(result).toBeInstanceOf(WebError.TypeError)
22
+ Vitest.expect(result.cause).toBe(nativeError)
23
+ })
24
+
25
+ Vitest.it('wraps parsed errors that are not in the expected list in UnknownError', () => {
26
+ const nativeError = new globalThis.RangeError('value out of range')
27
+
28
+ const result = WebError.parseWebError(nativeError, [WebError.TypeError])
29
+
30
+ Vitest.expect(result).toBeInstanceOf(WebError.UnknownError)
31
+ Vitest.expect(result.cause).toBeInstanceOf(WebError.RangeError)
32
+ })
33
+
34
+ Vitest.it('translates DOMException names into the matching WebError variant', () => {
35
+ const domException = new DOMException('permission denied', 'NotAllowedError')
36
+
37
+ const result = WebError.parseWebError(domException, [WebError.NotAllowedError])
38
+
39
+ Vitest.expect(result).toBeInstanceOf(WebError.NotAllowedError)
40
+ Vitest.expect(result.cause).toBe(domException)
41
+ })
42
+
43
+ Vitest.it('produces UnknownError for non-error values with the default message', () => {
44
+ const value = { reason: 'unexpected' }
45
+
46
+ const result = WebError.parseWebError(value)
47
+
48
+ Vitest.expect(result).toBeInstanceOf(WebError.UnknownError)
49
+ Vitest.expect(result.cause).toBeDefined()
50
+ Vitest.expect(result.message).toBe('A web error occurred')
51
+ })
52
+
53
+ Vitest.it('returns UnknownError instances without altering their payload when expectations are provided', () => {
54
+ const existing = new WebError.UnknownError({ description: 'pre-parsed' })
55
+
56
+ const result = WebError.parseWebError(existing, [WebError.TypeError])
57
+
58
+ Vitest.expect(result).toBeInstanceOf(WebError.UnknownError)
59
+
60
+ if (!(result instanceof WebError.UnknownError)) {
61
+ throw new Error('Expected an UnknownError instance')
62
+ }
63
+
64
+ Vitest.expect(result.description).toBe('pre-parsed')
65
+ })
66
+ })