@aipoch/open-science 0.0.0-stage → 1.0.2

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.
@@ -0,0 +1,389 @@
1
+ /* eslint-disable @typescript-eslint/explicit-function-return-type -- Browser ESM; public types are in browser-import.d.ts. */
2
+ // Browser-only entry point: deliberately has no Node imports, daemon token, or remote downloader.
3
+ const MAX_BYTES = 2 * 1024 ** 3
4
+ const CHUNK_BYTES = 4 * 1024 ** 2
5
+ const hex = (bytes) =>
6
+ Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, '0')).join('')
7
+ const randomHex = () => hex(crypto.getRandomValues(new Uint8Array(32)))
8
+ const wait = (ms, signal) =>
9
+ new Promise((resolve, reject) => {
10
+ signal?.throwIfAborted()
11
+ const abort = () => {
12
+ clearTimeout(timer)
13
+ reject(signal.reason)
14
+ }
15
+ const timer = setTimeout(() => {
16
+ signal?.removeEventListener('abort', abort)
17
+ resolve()
18
+ }, ms)
19
+ signal?.addEventListener('abort', abort, { once: true })
20
+ })
21
+
22
+ /** Stable codes let websites show recovery guidance without parsing English messages. */
23
+ export class BrowserImportError extends Error {
24
+ constructor(code, message) {
25
+ super(message)
26
+ this.name = 'BrowserImportError'
27
+ this.code = code
28
+ }
29
+ }
30
+
31
+ /** Request app launch synchronously from a user click; return does not prove OS delivery. */
32
+ export function launchOpenScience() {
33
+ if (typeof globalThis.location?.assign !== 'function')
34
+ throw new BrowserImportError(
35
+ 'unsupported-browser',
36
+ 'This environment cannot open Open-Science.'
37
+ )
38
+ try {
39
+ globalThis.location.assign('open-science://open')
40
+ } catch {
41
+ throw new BrowserImportError('launch-failed', 'Could not request opening Open-Science.')
42
+ }
43
+ }
44
+
45
+ const supported = () =>
46
+ typeof Blob === 'function' &&
47
+ typeof fetch === 'function' &&
48
+ !!globalThis.crypto?.subtle &&
49
+ typeof globalThis.AbortSignal?.any === 'function'
50
+ const validOrigin = (origin) => {
51
+ const site = new URL(origin)
52
+ if (
53
+ site.origin !== origin ||
54
+ (site.protocol !== 'https:' &&
55
+ !(site.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(site.hostname)))
56
+ )
57
+ throw new Error('Browser import requires an HTTPS website or loopback development origin.')
58
+ if (globalThis.location && globalThis.location.origin !== origin)
59
+ throw new Error('The import origin must match this page.')
60
+ return origin
61
+ }
62
+ // Explicitly dispose timers after every request/deadline; response body reads share the budget.
63
+ const bounded = async (signal, timeoutMs, work) => {
64
+ const controller = new AbortController()
65
+ const timer = setTimeout(
66
+ () =>
67
+ controller.abort(
68
+ new BrowserImportError('timeout', 'Open-Science did not become ready in time.')
69
+ ),
70
+ timeoutMs
71
+ )
72
+ const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
73
+ try {
74
+ combined.throwIfAborted()
75
+ return await work(combined)
76
+ } finally {
77
+ clearTimeout(timer)
78
+ }
79
+ }
80
+ const discoveryRequest = async (options) => {
81
+ validOrigin(options.origin ?? globalThis.location?.origin)
82
+ const port = options.port ?? 49151
83
+ const timeoutMs = options.timeoutMs ?? 2000
84
+ if (
85
+ !Number.isInteger(port) ||
86
+ port < 1024 ||
87
+ port > 65535 ||
88
+ !Number.isFinite(timeoutMs) ||
89
+ timeoutMs <= 0 ||
90
+ timeoutMs > 300_000
91
+ )
92
+ throw new Error('Invalid discovery options.')
93
+ return bounded(options.signal, timeoutMs, async (signal) => {
94
+ const response = await fetch(`http://127.0.0.1:${port}/v1/browser-import/capabilities`, {
95
+ method: 'GET',
96
+ signal,
97
+ mode: 'cors',
98
+ credentials: 'omit',
99
+ cache: 'no-store',
100
+ redirect: 'error',
101
+ targetAddressSpace: 'loopback'
102
+ })
103
+ const value = await response.json()
104
+ if (
105
+ !response.ok ||
106
+ !value ||
107
+ value.service !== 'open-science-browser-import' ||
108
+ value.version !== 1
109
+ )
110
+ return { status: 'incompatible' }
111
+ return { status: 'available' }
112
+ })
113
+ }
114
+ const discover = async (options) => {
115
+ if (!supported()) return { status: 'unsupported-browser' }
116
+ try {
117
+ return await discoveryRequest(options)
118
+ } catch (error) {
119
+ if (options.signal?.aborted) return { status: 'aborted' }
120
+ if (error.code === 'timeout' || error.name === 'TimeoutError') return { status: 'timeout' }
121
+ if (error.name === 'NotAllowedError') return { status: 'unavailable' }
122
+ if (error instanceof SyntaxError) return { status: 'incompatible' }
123
+ // Browsers deliberately conflate connection refusal, CORS and local-network denial.
124
+ if (error instanceof TypeError) return { status: 'unavailable' }
125
+ throw error
126
+ }
127
+ }
128
+ /** Advisory discovery only; the secret-bound handshake still authenticates every transfer. */
129
+ export const checkBrowserImportAvailability = (options = {}) => discover(options)
130
+ /** Prepare a transfer from bytes already downloaded by the website. Open url from a user click,
131
+ * then call send(). Its result acknowledges local receipt, not the user's eventual import decision.
132
+ */
133
+ export function prepareBrowserImport(file, options = {}) {
134
+ const origin = options.origin ?? globalThis.location?.origin
135
+ if (!supported())
136
+ throw new BrowserImportError(
137
+ 'unsupported-browser',
138
+ 'This browser cannot transfer packages to Open-Science.'
139
+ )
140
+ validOrigin(origin)
141
+ const filename = options.filename ?? file?.name
142
+ const port = options.port ?? 49152 + (crypto.getRandomValues(new Uint16Array(1))[0] % 16384)
143
+ if (!(file instanceof Blob) || file.size <= 0 || file.size > MAX_BYTES)
144
+ throw new Error('Choose a nonempty .science package no larger than 2 GiB.')
145
+ if (
146
+ typeof filename !== 'string' ||
147
+ filename.length > 160 ||
148
+ new TextEncoder().encode(filename).byteLength > 255 ||
149
+ /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(filename) ||
150
+ !/\.science$/i.test(filename) ||
151
+ // eslint-disable-next-line no-control-regex -- Reject control characters in external filenames.
152
+ /[\x00-\x1f\x7f/\\:*?"<>|\u202a-\u202e\u2066-\u2069]/u.test(filename) ||
153
+ filename.startsWith('.') ||
154
+ /[. ]$/.test(filename)
155
+ )
156
+ throw new Error('Invalid .science filename.')
157
+ if (!Number.isInteger(port) || port < 49152 || port > 65535)
158
+ throw new Error('Invalid loopback port.')
159
+ const secret = randomHex()
160
+ const url = `open-science://import?${new URLSearchParams({ version: '1', origin, secret, port: String(port), name: filename, size: String(file.size) })}`
161
+ const base = `http://127.0.0.1:${port}/v1/import`
162
+ const keyBytes = new Uint8Array(secret.match(/../g).map((part) => parseInt(part, 16)))
163
+ const authKey = crypto.subtle.importKey(
164
+ 'raw',
165
+ keyBytes,
166
+ { name: 'HMAC', hash: 'SHA-256' },
167
+ false,
168
+ ['sign']
169
+ )
170
+ const sign = async (message) =>
171
+ hex(await crypto.subtle.sign('HMAC', await authKey, new TextEncoder().encode(message)))
172
+ const authorize = async (method, path, digest = '') => ({
173
+ Authorization: `HMAC ${await sign(`${method}\n/v1/import${path}\n${digest}`)}`
174
+ })
175
+ const cipherKey = crypto.subtle
176
+ .digest('SHA-256', new TextEncoder().encode(`open-science-encryption-v1:${secret}`))
177
+ .then((bytes) => crypto.subtle.importKey('raw', bytes, 'AES-GCM', false, ['encrypt']))
178
+ let sending = false
179
+ let received = false
180
+ let cancelled
181
+
182
+ return Object.freeze({
183
+ url,
184
+ async send({
185
+ signal,
186
+ onProgress,
187
+ onPhase,
188
+ readinessMode = 'attempts',
189
+ readyTimeoutMs = 120_000,
190
+ readinessAttempts = 5,
191
+ attemptTimeoutMs = 2000
192
+ } = {}) {
193
+ if (!['attempts', 'wait-for-launch'].includes(readinessMode))
194
+ throw new Error('Invalid readiness mode.')
195
+ const waitingForLaunch = readinessMode === 'wait-for-launch'
196
+ if (!Number.isFinite(readyTimeoutMs) || readyTimeoutMs <= 0)
197
+ throw new Error('Invalid receiver timeout.')
198
+ if (
199
+ (!waitingForLaunch &&
200
+ (!Number.isInteger(readinessAttempts) ||
201
+ readinessAttempts < 1 ||
202
+ readinessAttempts > 5)) ||
203
+ !Number.isFinite(attemptTimeoutMs) ||
204
+ attemptTimeoutMs <= 0 ||
205
+ attemptTimeoutMs > 30_000
206
+ )
207
+ throw new Error('Invalid readiness retry options.')
208
+ if (sending) throw new Error('This browser transfer is already running.')
209
+ if (cancelled) throw cancelled
210
+ if (received) return { received: true }
211
+ signal?.throwIfAborted()
212
+ sending = true
213
+ let verified = false
214
+ const request = async (path, init = {}) => {
215
+ const response = await fetch(base + path, {
216
+ mode: 'cors',
217
+ credentials: 'omit',
218
+ cache: 'no-store',
219
+ redirect: 'error',
220
+ targetAddressSpace: 'loopback',
221
+ ...init,
222
+ signal: AbortSignal.any(
223
+ [signal, init.signal ?? AbortSignal.timeout(30_000)].filter(Boolean)
224
+ )
225
+ })
226
+ if (response.status === 410) {
227
+ const value = await response.json().catch(() => {
228
+ throw new BrowserImportError(
229
+ 'incompatible',
230
+ 'Could not verify the local receiver identity.'
231
+ )
232
+ })
233
+ if (value?.code !== 'cancelled' || value.proof !== (await sign('cancelled')))
234
+ throw new BrowserImportError(
235
+ 'incompatible',
236
+ 'Could not verify the local receiver identity.'
237
+ )
238
+ cancelled = new BrowserImportError(
239
+ 'cancelled',
240
+ 'The transfer was cancelled in Open-Science. Prepare a new transfer to try again.'
241
+ )
242
+ throw cancelled
243
+ }
244
+ return response
245
+ }
246
+ try {
247
+ onPhase?.('waiting-for-receiver')
248
+ // Browser protocol prompts have no observable completion event. Only this secret's proof
249
+ // ends launch waiting; the bounded deadline covers requests, body reads and retry gaps.
250
+ const budget = Math.min(readyTimeoutMs, waitingForLaunch ? 120_000 : 300_000)
251
+ const attemptBudget = waitingForLaunch ? Math.min(attemptTimeoutMs, 2000) : attemptTimeoutMs
252
+ const deadline = Date.now() + budget
253
+ const ready = await bounded(signal, budget, async (readySignal) => {
254
+ for (let attempt = 0; waitingForLaunch || attempt < readinessAttempts; attempt++) {
255
+ readySignal.throwIfAborted()
256
+ try {
257
+ return await bounded(readySignal, attemptBudget, async (attemptSignal) => {
258
+ const challenge = randomHex()
259
+ const response = await request(`/ready?challenge=${challenge}`, {
260
+ signal: attemptSignal
261
+ })
262
+ const value = await response.json()
263
+ if (
264
+ !value ||
265
+ typeof value !== 'object' ||
266
+ typeof value.accepted !== 'boolean' ||
267
+ !Number.isSafeInteger(value.offset) ||
268
+ value.offset < 0 ||
269
+ value.offset > file.size ||
270
+ value.chunkBytes !== CHUNK_BYTES
271
+ )
272
+ throw new BrowserImportError(
273
+ 'incompatible',
274
+ 'Could not verify the local receiver identity.'
275
+ )
276
+ const expected = await sign(
277
+ `open-science-import-v1:${challenge}:${value.offset}:${value.accepted ? 1 : 0}`
278
+ )
279
+ attemptSignal.throwIfAborted()
280
+ if (Date.now() >= deadline)
281
+ throw new BrowserImportError(
282
+ 'timeout',
283
+ 'Open-Science did not become ready in time.'
284
+ )
285
+ if (!response.ok || value.proof !== expected)
286
+ throw new BrowserImportError(
287
+ 'incompatible',
288
+ 'Could not verify the local receiver identity. Prepare a new transfer and reopen Open-Science.'
289
+ )
290
+ return value
291
+ })
292
+ } catch (error) {
293
+ readySignal.throwIfAborted()
294
+ if (error.code === 'incompatible' || error.code === 'cancelled') throw error
295
+ if (error instanceof SyntaxError)
296
+ throw new BrowserImportError(
297
+ 'incompatible',
298
+ 'Could not verify the local receiver identity.'
299
+ )
300
+ if (waitingForLaunch || attempt + 1 < readinessAttempts) await wait(1000, readySignal)
301
+ }
302
+ }
303
+ throw new BrowserImportError(
304
+ 'readiness-exhausted',
305
+ 'Open-Science did not become ready. Open the app, allow local network access, and retry.'
306
+ )
307
+ })
308
+ verified = true
309
+ if (
310
+ !Number.isSafeInteger(ready.offset) ||
311
+ ready.offset < 0 ||
312
+ ready.offset > file.size ||
313
+ ready.chunkBytes !== CHUNK_BYTES
314
+ )
315
+ throw new Error('Invalid local transfer response.')
316
+ if (!ready.accepted) {
317
+ onPhase?.('transferring')
318
+ for (let offset = ready.offset; offset < file.size;) {
319
+ signal?.throwIfAborted()
320
+ const bytes = await file.slice(offset, offset + CHUNK_BYTES).arrayBuffer()
321
+ const digest = hex(await crypto.subtle.digest('SHA-256', bytes))
322
+ const iv = crypto.getRandomValues(new Uint8Array(12))
323
+ const encrypted = await crypto.subtle.encrypt(
324
+ { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(`offset:${offset}`) },
325
+ await cipherKey,
326
+ bytes
327
+ )
328
+ const body = new Uint8Array(12 + encrypted.byteLength)
329
+ body.set(iv)
330
+ body.set(new Uint8Array(encrypted), 12)
331
+ const chunk = await request(`/chunk?offset=${offset}`, {
332
+ method: 'PUT',
333
+ body,
334
+ headers: {
335
+ ...(await authorize('PUT', `/chunk?offset=${offset}`, digest)),
336
+ 'Content-Type': 'application/octet-stream',
337
+ 'X-Chunk-SHA256': digest
338
+ }
339
+ })
340
+ if (!chunk.ok) throw new Error(`Local package transfer failed (${chunk.status}).`)
341
+ const result = await chunk.json()
342
+ if (
343
+ result.offset !== offset + bytes.byteLength ||
344
+ result.proof !== (await sign(`offset:${result.offset}`))
345
+ )
346
+ throw new Error('Invalid local transfer offset.')
347
+ offset = result.offset
348
+ onProgress?.({ sentBytes: offset, totalBytes: file.size })
349
+ }
350
+ const complete = await request('/complete', {
351
+ method: 'POST',
352
+ headers: await authorize('POST', '/complete')
353
+ })
354
+ const receipt = await complete.json()
355
+ if (
356
+ complete.status !== 202 ||
357
+ receipt.received !== true ||
358
+ receipt.proof !== (await sign(`received:${file.size}`))
359
+ )
360
+ throw new Error('Open-Science did not acknowledge the complete package.')
361
+ }
362
+ received = true
363
+ return { received: true }
364
+ } catch (error) {
365
+ if (verified && signal?.aborted) {
366
+ await fetch(base, {
367
+ method: 'DELETE',
368
+ mode: 'cors',
369
+ credentials: 'omit',
370
+ redirect: 'error',
371
+ headers: await authorize('DELETE', ''),
372
+ signal: AbortSignal.timeout(2000),
373
+ targetAddressSpace: 'loopback'
374
+ }).catch(() => undefined)
375
+ }
376
+ signal?.throwIfAborted()
377
+ if (error.code === 'cancelled' || error.code === 'incompatible') throw error
378
+ if (verified)
379
+ throw new BrowserImportError(
380
+ 'transfer-failed',
381
+ 'The local package transfer failed. Retry while the receiver is available.'
382
+ )
383
+ throw error
384
+ } finally {
385
+ sending = false
386
+ }
387
+ }
388
+ })
389
+ }