@luxexchange/sessions 1.0.0

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 (70) hide show
  1. package/.depcheckrc +20 -0
  2. package/.eslintrc.js +21 -0
  3. package/README.md +1 -0
  4. package/env.d.ts +12 -0
  5. package/package.json +50 -0
  6. package/project.json +42 -0
  7. package/src/challenge-solvers/createChallengeSolverService.ts +64 -0
  8. package/src/challenge-solvers/createHashcashMockSolver.ts +39 -0
  9. package/src/challenge-solvers/createHashcashSolver.test.ts +385 -0
  10. package/src/challenge-solvers/createHashcashSolver.ts +255 -0
  11. package/src/challenge-solvers/createNoneMockSolver.ts +11 -0
  12. package/src/challenge-solvers/createTurnstileMockSolver.ts +30 -0
  13. package/src/challenge-solvers/createTurnstileSolver.ts +353 -0
  14. package/src/challenge-solvers/hashcash/core.native.ts +34 -0
  15. package/src/challenge-solvers/hashcash/core.test.ts +314 -0
  16. package/src/challenge-solvers/hashcash/core.ts +35 -0
  17. package/src/challenge-solvers/hashcash/core.web.ts +123 -0
  18. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.test.ts +195 -0
  19. package/src/challenge-solvers/hashcash/createWorkerHashcashSolver.ts +120 -0
  20. package/src/challenge-solvers/hashcash/shared.ts +70 -0
  21. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.native.ts +22 -0
  22. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.ts +22 -0
  23. package/src/challenge-solvers/hashcash/worker/createHashcashMultiWorkerChannel.web.ts +212 -0
  24. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.native.ts +16 -0
  25. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.ts +16 -0
  26. package/src/challenge-solvers/hashcash/worker/createHashcashWorkerChannel.web.ts +97 -0
  27. package/src/challenge-solvers/hashcash/worker/hashcash.worker.ts +91 -0
  28. package/src/challenge-solvers/hashcash/worker/types.ts +69 -0
  29. package/src/challenge-solvers/turnstileErrors.ts +49 -0
  30. package/src/challenge-solvers/turnstileScriptLoader.ts +325 -0
  31. package/src/challenge-solvers/turnstileSolver.integration.test.ts +331 -0
  32. package/src/challenge-solvers/types.ts +55 -0
  33. package/src/challengeFlow.integration.test.ts +627 -0
  34. package/src/device-id/createDeviceIdService.ts +19 -0
  35. package/src/device-id/types.ts +11 -0
  36. package/src/index.ts +137 -0
  37. package/src/lux-identifier/createLuxIdentifierService.ts +19 -0
  38. package/src/lux-identifier/luxIdentifierQuery.ts +20 -0
  39. package/src/lux-identifier/types.ts +11 -0
  40. package/src/oauth-service/createOAuthService.ts +125 -0
  41. package/src/oauth-service/types.ts +104 -0
  42. package/src/performance/createNoopPerformanceTracker.ts +12 -0
  43. package/src/performance/createPerformanceTracker.ts +43 -0
  44. package/src/performance/index.ts +7 -0
  45. package/src/performance/types.ts +11 -0
  46. package/src/session-initialization/createSessionInitializationService.test.ts +557 -0
  47. package/src/session-initialization/createSessionInitializationService.ts +193 -0
  48. package/src/session-initialization/sessionErrors.ts +32 -0
  49. package/src/session-repository/createSessionClient.ts +10 -0
  50. package/src/session-repository/createSessionRepository.test.ts +313 -0
  51. package/src/session-repository/createSessionRepository.ts +242 -0
  52. package/src/session-repository/errors.ts +22 -0
  53. package/src/session-repository/types.ts +289 -0
  54. package/src/session-service/createNoopSessionService.ts +24 -0
  55. package/src/session-service/createSessionService.test.ts +388 -0
  56. package/src/session-service/createSessionService.ts +61 -0
  57. package/src/session-service/types.ts +59 -0
  58. package/src/session-storage/createSessionStorage.ts +28 -0
  59. package/src/session-storage/types.ts +15 -0
  60. package/src/session.integration.test.ts +480 -0
  61. package/src/sessionLifecycle.integration.test.ts +264 -0
  62. package/src/test-utils/createLocalCookieTransport.ts +52 -0
  63. package/src/test-utils/createLocalHeaderTransport.ts +45 -0
  64. package/src/test-utils/mocks.ts +122 -0
  65. package/src/test-utils.ts +200 -0
  66. package/tsconfig.json +19 -0
  67. package/tsconfig.lint.json +8 -0
  68. package/tsconfig.spec.json +8 -0
  69. package/vitest.config.ts +20 -0
  70. package/vitest.integration.config.ts +14 -0
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Worker-backed hashcash solver.
3
+ *
4
+ * Offloads proof-of-work computation to a Web Worker to avoid
5
+ * blocking the main thread.
6
+ */
7
+ import { parseHashcashChallenge } from '@luxexchange/sessions/src/challenge-solvers/createHashcashSolver'
8
+ import type { HashcashChallenge } from '@luxexchange/sessions/src/challenge-solvers/hashcash/core'
9
+ import type { HashcashWorkerChannelFactory } from '@luxexchange/sessions/src/challenge-solvers/hashcash/worker/types'
10
+ import type { ChallengeData, ChallengeSolver } from '@luxexchange/sessions/src/challenge-solvers/types'
11
+
12
+ interface CreateWorkerHashcashSolverContext {
13
+ /**
14
+ * Factory function to create a worker channel.
15
+ * Platform-specific (web, extension, native) implementations
16
+ * are injected here.
17
+ */
18
+ createChannel: HashcashWorkerChannelFactory
19
+
20
+ /**
21
+ * Optional AbortSignal for external cancellation.
22
+ * When aborted, cancels the in-progress proof search.
23
+ */
24
+ signal?: AbortSignal
25
+ }
26
+
27
+ /**
28
+ * Creates a hashcash solver that runs proof-of-work in a worker.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * import { createHashcashWorkerChannel } from './worker/createHashcashWorkerChannel.web'
33
+ *
34
+ * const getWorker = () => new Worker(new URL('./hashcash.worker.ts', import.meta.url), { type: 'module' })
35
+ *
36
+ * const solver = createWorkerHashcashSolver({
37
+ * createChannel: () => createHashcashWorkerChannel({ getWorker })
38
+ * })
39
+ *
40
+ * const solution = await solver.solve(challengeData)
41
+ * ```
42
+ */
43
+ function createWorkerHashcashSolver(ctx: CreateWorkerHashcashSolverContext): ChallengeSolver {
44
+ async function solve(challengeData: ChallengeData): Promise<string> {
45
+ let challenge: HashcashChallenge
46
+
47
+ // Prefer typed challengeData over legacy extra field
48
+ if (challengeData.challengeData?.case === 'hashcash') {
49
+ const typed = challengeData.challengeData.value
50
+ challenge = {
51
+ difficulty: typed.difficulty,
52
+ subject: typed.subject,
53
+ algorithm: typed.algorithm as 'sha256',
54
+ nonce: typed.nonce,
55
+ max_proof_length: typed.maxProofLength,
56
+ verifier: typed.verifier,
57
+ }
58
+ } else {
59
+ const challengeDataStr = challengeData.extra?.['challengeData']
60
+ if (!challengeDataStr) {
61
+ throw new Error('Missing challengeData in challenge extra field')
62
+ }
63
+ challenge = parseHashcashChallenge(challengeDataStr)
64
+ }
65
+
66
+ // Create worker channel
67
+ const channel = ctx.createChannel()
68
+
69
+ // Handle external cancellation
70
+ const abortHandler = (): void => {
71
+ channel.api.cancel().catch(() => {
72
+ // Ignore cancellation errors
73
+ })
74
+ }
75
+
76
+ if (ctx.signal) {
77
+ ctx.signal.addEventListener('abort', abortHandler)
78
+ }
79
+
80
+ try {
81
+ // Check if already aborted
82
+ if (ctx.signal?.aborted) {
83
+ throw new Error('Challenge solving was cancelled')
84
+ }
85
+
86
+ // Find proof-of-work solution in worker
87
+ const proof = await channel.api.findProof({
88
+ challenge,
89
+ rangeStart: 0,
90
+ rangeSize: challenge.max_proof_length,
91
+ })
92
+
93
+ if (!proof) {
94
+ // Could be cancelled or no solution found
95
+ if (ctx.signal?.aborted) {
96
+ throw new Error('Challenge solving was cancelled')
97
+ }
98
+
99
+ throw new Error(
100
+ `Failed to find valid proof within allowed range (0-${challenge.max_proof_length}). ` +
101
+ 'Challenge may have expired or difficulty may be too high.',
102
+ )
103
+ }
104
+
105
+ // Return the solution in the format expected by backend: "${subject}:${nonce}:${counter}"
106
+ return `${challenge.subject}:${challenge.nonce}:${proof.counter}`
107
+ } finally {
108
+ // Clean up
109
+ if (ctx.signal) {
110
+ ctx.signal.removeEventListener('abort', abortHandler)
111
+ }
112
+ channel.terminate()
113
+ }
114
+ }
115
+
116
+ return { solve }
117
+ }
118
+
119
+ export { createWorkerHashcashSolver }
120
+ export type { CreateWorkerHashcashSolverContext }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Shared types and platform-agnostic utilities for hashcash.
3
+ *
4
+ * This file contains code that has zero platform dependencies and is
5
+ * imported by all platform variants (core.ts, core.web.ts, core.native.ts).
6
+ *
7
+ * NOTE: This file intentionally does NOT follow the platform-split naming
8
+ * convention (no .web.ts/.native.ts variants) so it can be safely imported
9
+ * from platform-specific files without circular resolution.
10
+ */
11
+
12
+ import { base64 } from '@scure/base'
13
+
14
+ export interface HashcashChallenge {
15
+ difficulty: number
16
+ subject: string
17
+ algorithm: 'sha256'
18
+ nonce: string
19
+ max_proof_length: number
20
+ verifier?: string
21
+ }
22
+
23
+ export interface ProofResult {
24
+ counter: string
25
+ hash: Uint8Array
26
+ attempts: number
27
+ timeMs: number
28
+ }
29
+
30
+ /**
31
+ * Check if a hash meets the required difficulty.
32
+ * Difficulty is the number of leading zero bytes required.
33
+ */
34
+ export function checkDifficulty(hash: Uint8Array, difficulty: number): boolean {
35
+ // Backend uses difficulty as number of zero BYTES, not bits
36
+ // difficulty=1 means first byte must be 0
37
+ // difficulty=2 means first two bytes must be 0, etc.
38
+
39
+ // Check if hash has enough bytes
40
+ if (hash.length < difficulty) {
41
+ return false
42
+ }
43
+
44
+ // Check that the first 'difficulty' bytes are all zero
45
+ for (let i = 0; i < difficulty; i++) {
46
+ if (hash[i] !== 0) {
47
+ return false
48
+ }
49
+ }
50
+
51
+ return true
52
+ }
53
+
54
+ /**
55
+ * Format a proof into hashcash string format for submission.
56
+ * Format: version:bits:date:resource:extension:counter:hash
57
+ */
58
+ export function formatHashcashString(challenge: HashcashChallenge, proof: ProofResult): string {
59
+ const version = '1'
60
+ const bits = challenge.difficulty.toString()
61
+ const date = new Date().toISOString().slice(2, 10).replace(/-/g, '')
62
+ const resource = challenge.subject.slice(0, 16)
63
+ const extension = ''
64
+ const counter = proof.counter
65
+
66
+ // Encode the proof hash as base64 (truncated)
67
+ const hashB64 = base64.encode(proof.hash).slice(0, 27)
68
+
69
+ return `${version}:${bits}:${date}:${resource}:${extension}:${counter}:${hashB64}`
70
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Native stub for multi-worker hashcash channel factory.
3
+ * Web Workers are not available in React Native.
4
+ */
5
+
6
+ import type { HashcashWorkerChannel } from '@luxexchange/sessions/src/challenge-solvers/hashcash/worker/types'
7
+ import { NotImplementedError } from '@luxfi/utilities/src/errors'
8
+
9
+ /**
10
+ * Configuration for multi-worker hashcash channel.
11
+ */
12
+ interface MultiWorkerConfig {
13
+ workerCount?: number
14
+ getWorker: () => Worker
15
+ }
16
+
17
+ function createHashcashMultiWorkerChannel(_config: MultiWorkerConfig): HashcashWorkerChannel {
18
+ throw new NotImplementedError('createHashcashMultiWorkerChannel')
19
+ }
20
+
21
+ export { createHashcashMultiWorkerChannel }
22
+ export type { MultiWorkerConfig }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Base stub for multi-worker hashcash channel factory.
3
+ * Platform-specific implementations override this file.
4
+ */
5
+
6
+ import type { HashcashWorkerChannel } from '@luxexchange/sessions/src/challenge-solvers/hashcash/worker/types'
7
+ import { PlatformSplitStubError } from '@luxfi/utilities/src/errors'
8
+
9
+ /**
10
+ * Configuration for multi-worker hashcash channel.
11
+ */
12
+ interface MultiWorkerConfig {
13
+ workerCount?: number
14
+ getWorker: () => Worker
15
+ }
16
+
17
+ function createHashcashMultiWorkerChannel(_config: MultiWorkerConfig): HashcashWorkerChannel {
18
+ throw new PlatformSplitStubError('createHashcashMultiWorkerChannel')
19
+ }
20
+
21
+ export { createHashcashMultiWorkerChannel }
22
+ export type { MultiWorkerConfig }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Multi-worker channel factory for parallel hashcash proof-of-work.
3
+ *
4
+ * Spawns multiple Web Workers to search different counter ranges in parallel.
5
+ * Uses Promise.race() - first worker to find a valid proof wins.
6
+ *
7
+ * This provides significant speedup on multi-core systems:
8
+ * - 4 workers ~= 4x speedup
9
+ * - 8 workers ~= 8x speedup (diminishing returns beyond core count)
10
+ */
11
+
12
+ import type { ProofResult } from '@luxexchange/sessions/src/challenge-solvers/hashcash/core'
13
+ import type {
14
+ FindProofParams,
15
+ HashcashWorkerAPI,
16
+ HashcashWorkerChannel,
17
+ } from '@luxexchange/sessions/src/challenge-solvers/hashcash/worker/types'
18
+ import { createChannel } from 'bidc'
19
+
20
+ /**
21
+ * Configuration for multi-worker hashcash channel.
22
+ */
23
+ interface MultiWorkerConfig {
24
+ /**
25
+ * Number of workers to spawn.
26
+ * Defaults to navigator.hardwareConcurrency or 4.
27
+ */
28
+ workerCount?: number
29
+
30
+ /**
31
+ * Factory function to create a Worker instance.
32
+ */
33
+ getWorker: () => Worker
34
+ }
35
+
36
+ /**
37
+ * Internal worker state for tracking individual workers.
38
+ */
39
+ interface WorkerState {
40
+ worker: Worker
41
+ channel: ReturnType<typeof createChannel>
42
+ cancelled: boolean
43
+ }
44
+
45
+ /**
46
+ * Creates a multi-worker channel for parallel hashcash proof-of-work.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * const channel = createHashcashMultiWorkerChannel({
51
+ * workerCount: 4,
52
+ * getWorker: () => new Worker(
53
+ * new URL('./hashcash.worker.ts', import.meta.url),
54
+ * { type: 'module' }
55
+ * ),
56
+ * })
57
+ *
58
+ * const proof = await channel.api.findProof({ challenge })
59
+ * channel.terminate()
60
+ * ```
61
+ */
62
+ function createHashcashMultiWorkerChannel(config: MultiWorkerConfig): HashcashWorkerChannel {
63
+ const workerCount = config.workerCount ?? (typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : 4)
64
+ const workers: WorkerState[] = []
65
+
66
+ // Track if we've been terminated
67
+ let terminated = false
68
+
69
+ // Initialize workers lazily on first findProof call
70
+ const initWorkers = (): void => {
71
+ if (workers.length > 0 || terminated) {
72
+ return
73
+ }
74
+
75
+ for (let i = 0; i < workerCount; i++) {
76
+ const worker = config.getWorker()
77
+ const channel = createChannel(worker)
78
+ workers.push({ worker, channel, cancelled: false })
79
+ }
80
+ }
81
+
82
+ const api: HashcashWorkerAPI = {
83
+ async findProof(params: FindProofParams): Promise<ProofResult | null> {
84
+ if (terminated) {
85
+ throw new Error('Multi-worker channel has been terminated')
86
+ }
87
+
88
+ initWorkers()
89
+
90
+ // Reset cancelled state for all workers at start of new search
91
+ workers.forEach((state) => {
92
+ state.cancelled = false
93
+ })
94
+
95
+ const { challenge, rangeStart = 0, rangeSize = challenge.max_proof_length } = params
96
+ const rangeEnd = rangeStart + rangeSize
97
+
98
+ // Divide range across workers
99
+ const rangePerWorker = Math.ceil(rangeSize / workerCount)
100
+
101
+ // Track completion state
102
+ let foundResult: ProofResult | null = null
103
+ let completedCount = 0
104
+
105
+ // Pre-calculate expected worker count before starting any workers.
106
+ // This avoids a subtle dependency on JS event loop microtask ordering:
107
+ // if we counted inline, a fast-completing worker's .then() could
108
+ // theoretically race with the loop incrementing the started count.
109
+ let expectedCount = 0
110
+ workers.forEach((state, index) => {
111
+ if (state.cancelled || terminated) {
112
+ return
113
+ }
114
+ const workerRangeStart = rangeStart + index * rangePerWorker
115
+ const workerRangeEnd = Math.min(workerRangeStart + rangePerWorker, rangeEnd)
116
+ if (workerRangeEnd - workerRangeStart > 0) {
117
+ expectedCount++
118
+ }
119
+ })
120
+
121
+ if (expectedCount === 0) {
122
+ return null
123
+ }
124
+
125
+ return new Promise((resolve) => {
126
+ // Start all workers in parallel
127
+ workers.forEach((state, index) => {
128
+ if (terminated) {
129
+ return
130
+ }
131
+
132
+ const workerRangeStart = rangeStart + index * rangePerWorker
133
+ const workerRangeEnd = Math.min(workerRangeStart + rangePerWorker, rangeEnd)
134
+ const workerRangeSize = workerRangeEnd - workerRangeStart
135
+
136
+ if (workerRangeSize <= 0) {
137
+ return
138
+ }
139
+
140
+ // Start worker search (don't await - let them race)
141
+ state.channel
142
+ .send({
143
+ type: 'findProof',
144
+ params: {
145
+ challenge,
146
+ rangeStart: workerRangeStart,
147
+ rangeSize: workerRangeSize,
148
+ },
149
+ })
150
+ .then((result: unknown) => {
151
+ // Check if worker returned busy response
152
+ if (result && typeof result === 'object' && 'busy' in result) {
153
+ return null
154
+ }
155
+ return result as ProofResult | null
156
+ })
157
+ .then((result) => {
158
+ completedCount++
159
+
160
+ // First worker to find a valid proof wins
161
+ if (result && !foundResult && !terminated) {
162
+ foundResult = result
163
+ // Cancel all other workers immediately
164
+ api.cancel().catch(() => {})
165
+ resolve(result)
166
+ } else if (completedCount === expectedCount && !foundResult) {
167
+ // All expected workers done, no result found
168
+ resolve(null)
169
+ }
170
+ })
171
+ .catch(() => {
172
+ completedCount++
173
+ if (completedCount === expectedCount && !foundResult) {
174
+ resolve(null)
175
+ }
176
+ })
177
+ })
178
+ })
179
+ },
180
+
181
+ async cancel(): Promise<void> {
182
+ // Cancel all workers - fire and forget for speed
183
+ workers.forEach((state) => {
184
+ if (!state.cancelled) {
185
+ state.cancelled = true
186
+ state.channel.send({ type: 'cancel' }).catch(() => {
187
+ // Ignore cancel errors
188
+ })
189
+ }
190
+ })
191
+ },
192
+ }
193
+
194
+ return {
195
+ api,
196
+ terminate(): void {
197
+ terminated = true
198
+
199
+ // Terminate all workers
200
+ for (const state of workers) {
201
+ state.cancelled = true
202
+ state.channel.cleanup()
203
+ state.worker.terminate()
204
+ }
205
+
206
+ workers.length = 0
207
+ },
208
+ }
209
+ }
210
+
211
+ export { createHashcashMultiWorkerChannel }
212
+ export type { MultiWorkerConfig }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Native implementation of hashcash worker channel factory.
3
+ * Web Workers are not available in React Native.
4
+ */
5
+
6
+ import type {
7
+ CreateHashcashWorkerChannelContext,
8
+ HashcashWorkerChannel,
9
+ } from '@luxexchange/sessions/src/challenge-solvers/hashcash/worker/types'
10
+ import { NotImplementedError } from '@luxfi/utilities/src/errors'
11
+
12
+ function createHashcashWorkerChannel(_ctx: CreateHashcashWorkerChannelContext): HashcashWorkerChannel {
13
+ throw new NotImplementedError('createHashcashWorkerChannel')
14
+ }
15
+
16
+ export { createHashcashWorkerChannel }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Base stub for hashcash worker channel factory.
3
+ * Platform-specific implementations override this file.
4
+ */
5
+
6
+ import type {
7
+ CreateHashcashWorkerChannelContext,
8
+ HashcashWorkerChannel,
9
+ } from '@luxexchange/sessions/src/challenge-solvers/hashcash/worker/types'
10
+ import { PlatformSplitStubError } from '@luxfi/utilities/src/errors'
11
+
12
+ function createHashcashWorkerChannel(_ctx: CreateHashcashWorkerChannelContext): HashcashWorkerChannel {
13
+ throw new PlatformSplitStubError('createHashcashWorkerChannel')
14
+ }
15
+
16
+ export { createHashcashWorkerChannel }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Web Worker channel factory for hashcash proof-of-work.
3
+ *
4
+ * Creates a Web Worker and establishes a BIDC channel for
5
+ * bidirectional async communication.
6
+ *
7
+ * This is the web platform implementation used by both web app and extension.
8
+ */
9
+
10
+ import type { ProofResult } from '@luxexchange/sessions/src/challenge-solvers/hashcash/core'
11
+ import type {
12
+ CreateHashcashWorkerChannelContext,
13
+ FindProofParams,
14
+ HashcashWorkerAPI,
15
+ HashcashWorkerChannel,
16
+ } from '@luxexchange/sessions/src/challenge-solvers/hashcash/worker/types'
17
+ import { createChannel } from 'bidc'
18
+
19
+ // Singleton worker instance for reuse
20
+ let sharedWorker: Worker | null = null
21
+ let sharedChannel: ReturnType<typeof createChannel> | null = null
22
+ let referenceCount = 0
23
+ // Track pending operations to reject on terminate
24
+ const pendingOperations = new Set<(err: Error) => void>()
25
+
26
+ /**
27
+ * Creates (or reuses) a channel to the hashcash worker.
28
+ *
29
+ * Uses a shared worker instance to avoid creation overhead.
30
+ * The worker is only terminated when all channels are closed.
31
+ *
32
+ * @param ctx - Context containing getWorker function to create the Worker instance
33
+ */
34
+ function createHashcashWorkerChannel(ctx: CreateHashcashWorkerChannelContext): HashcashWorkerChannel {
35
+ // Create worker on first use
36
+ if (!sharedWorker) {
37
+ sharedWorker = ctx.getWorker()
38
+ sharedChannel = createChannel(sharedWorker)
39
+ }
40
+
41
+ referenceCount++
42
+
43
+ const channel = sharedChannel
44
+ if (!channel) {
45
+ throw new Error('Worker channel not initialized')
46
+ }
47
+ const { send } = channel
48
+
49
+ const api: HashcashWorkerAPI = {
50
+ findProof(params: FindProofParams): Promise<ProofResult | null> {
51
+ return new Promise((resolve, reject) => {
52
+ pendingOperations.add(reject)
53
+
54
+ send({ type: 'findProof', params })
55
+ .then((result: unknown) => {
56
+ // Check if worker returned busy response
57
+ // Worker returns { busy: true } when another operation is in progress
58
+ if (result && typeof result === 'object' && 'busy' in result) {
59
+ reject(new Error('Worker is busy - another findProof operation is in progress'))
60
+ } else {
61
+ resolve(result as ProofResult | null)
62
+ }
63
+ })
64
+ .catch(reject)
65
+ .finally(() => pendingOperations.delete(reject))
66
+ })
67
+ },
68
+
69
+ async cancel(): Promise<void> {
70
+ await send({ type: 'cancel' })
71
+ },
72
+ }
73
+
74
+ return {
75
+ api,
76
+ terminate(): void {
77
+ referenceCount--
78
+
79
+ // Only terminate when no more references
80
+ if (referenceCount <= 0 && sharedWorker) {
81
+ // Reject any pending operations before terminating
82
+ const error = new Error('Worker terminated while operation in progress')
83
+ for (const reject of pendingOperations) {
84
+ reject(error)
85
+ }
86
+ pendingOperations.clear()
87
+
88
+ sharedWorker.terminate()
89
+ sharedWorker = null
90
+ sharedChannel = null
91
+ referenceCount = 0
92
+ }
93
+ },
94
+ }
95
+ }
96
+
97
+ export { createHashcashWorkerChannel }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Hashcash Web Worker
3
+ *
4
+ * Runs proof-of-work computation off the main thread using Web Crypto.
5
+ * Uses BIDC for bidirectional async communication.
6
+ */
7
+
8
+ import type { ProofResult } from '../core'
9
+ import { findProof } from '../core'
10
+ import type { FindProofParams } from './types'
11
+ import type { SerializableValue } from 'bidc'
12
+ import { createChannel } from 'bidc'
13
+
14
+ /**
15
+ * Message types sent from main thread to worker
16
+ * These are plain objects at runtime, compatible with SerializableValue
17
+ */
18
+ type WorkerMessage = { type: 'findProof'; params: FindProofParams } | { type: 'cancel' }
19
+
20
+ /**
21
+ * Response type returned from worker to main thread.
22
+ * Matches ProofResult structure but explicitly typed for serialization.
23
+ */
24
+ type WorkerResponse = ProofResult | null
25
+
26
+ /**
27
+ * Operation state for single-operation-at-a-time enforcement.
28
+ *
29
+ * This worker is designed for single-operation-at-a-time usage. Concurrent findProof
30
+ * calls will be rejected with an error. This ensures clean cancellation semantics
31
+ * where cancel() only affects the one in-progress operation.
32
+ */
33
+ let operationInProgress = false
34
+ let cancelled = false
35
+
36
+ // Create BIDC channel - in worker context, connects to parent
37
+ const { receive } = createChannel()
38
+
39
+ /**
40
+ * Response type for busy state - signals to caller that worker is occupied.
41
+ * The channel layer converts this to an appropriate error.
42
+ */
43
+ type WorkerBusyResponse = { busy: true }
44
+
45
+ /**
46
+ * Async message handler for incoming requests from main thread.
47
+ * BIDC supports async handlers - it awaits the Promise before sending response.
48
+ */
49
+ const messageHandler = async (data: WorkerMessage): Promise<WorkerResponse | WorkerBusyResponse> => {
50
+ switch (data.type) {
51
+ case 'findProof': {
52
+ // Enforce single-operation-at-a-time
53
+ if (operationInProgress) {
54
+ return { busy: true }
55
+ }
56
+
57
+ operationInProgress = true
58
+ // Reset cancellation flag for new search
59
+ cancelled = false
60
+
61
+ try {
62
+ // findProof is now async (uses Web Crypto)
63
+ const result = await findProof({
64
+ challenge: data.params.challenge,
65
+ rangeStart: data.params.rangeStart,
66
+ rangeSize: data.params.rangeSize,
67
+ // Check cancellation flag during iteration
68
+ shouldStop: () => cancelled,
69
+ })
70
+
71
+ // Return ProofResult directly - BIDC handles serialization of Uint8Array
72
+ return result
73
+ } finally {
74
+ operationInProgress = false
75
+ }
76
+ }
77
+
78
+ case 'cancel': {
79
+ cancelled = true
80
+ return null
81
+ }
82
+
83
+ default:
84
+ return null
85
+ }
86
+ }
87
+
88
+ // Register async message handler with BIDC
89
+ // BIDC supports async handlers - the response Promise is awaited before sending
90
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
91
+ receive(messageHandler as (data: SerializableValue) => Promise<SerializableValue>)