@tranquilload/core 0.1.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.
- package/.turbo/turbo-build.log +88 -0
- package/dist/compression-service-Bm1VBnhT.mjs +18 -0
- package/dist/compression-service-Bm1VBnhT.mjs.map +1 -0
- package/dist/compression-service-Bn86iTJe.cjs +35 -0
- package/dist/compression-service-Bn86iTJe.cjs.map +1 -0
- package/dist/compression-service-CiF7Px08.d.cts +15 -0
- package/dist/compression-service-CiF7Px08.d.cts.map +1 -0
- package/dist/compression-service-DI7ZXVxH.d.mts +15 -0
- package/dist/compression-service-DI7ZXVxH.d.mts.map +1 -0
- package/dist/errors.cjs +9 -0
- package/dist/errors.d.cts +2 -0
- package/dist/errors.d.mts +2 -0
- package/dist/errors.mjs +2 -0
- package/dist/index-Ch8xM6Xt.d.cts +60 -0
- package/dist/index-Ch8xM6Xt.d.cts.map +1 -0
- package/dist/index-DBGtgXEd.d.mts +60 -0
- package/dist/index-DBGtgXEd.d.mts.map +1 -0
- package/dist/logger-service-1J5r_akj.mjs +8 -0
- package/dist/logger-service-1J5r_akj.mjs.map +1 -0
- package/dist/logger-service-BF2pZOHN.d.mts +12 -0
- package/dist/logger-service-BF2pZOHN.d.mts.map +1 -0
- package/dist/logger-service-CbN12RhO.d.cts +12 -0
- package/dist/logger-service-CbN12RhO.d.cts.map +1 -0
- package/dist/logger-service-cx8vzkXs.cjs +19 -0
- package/dist/logger-service-cx8vzkXs.cjs.map +1 -0
- package/dist/middleware-CAI0cnW2.d.mts +10 -0
- package/dist/middleware-CAI0cnW2.d.mts.map +1 -0
- package/dist/middleware-CYcctmlY.d.cts +10 -0
- package/dist/middleware-CYcctmlY.d.cts.map +1 -0
- package/dist/multipart.cjs +244 -0
- package/dist/multipart.cjs.map +1 -0
- package/dist/multipart.d.cts +2 -0
- package/dist/multipart.d.mts +2 -0
- package/dist/multipart.mjs +243 -0
- package/dist/multipart.mjs.map +1 -0
- package/dist/normalize-callback-BNBZZ1jT.cjs +44 -0
- package/dist/normalize-callback-BNBZZ1jT.cjs.map +1 -0
- package/dist/normalize-callback-DQ6C4gaV.mjs +33 -0
- package/dist/normalize-callback-DQ6C4gaV.mjs.map +1 -0
- package/dist/oneshot.cjs +64 -0
- package/dist/oneshot.cjs.map +1 -0
- package/dist/oneshot.d.cts +28 -0
- package/dist/oneshot.d.cts.map +1 -0
- package/dist/oneshot.d.mts +28 -0
- package/dist/oneshot.d.mts.map +1 -0
- package/dist/oneshot.mjs +63 -0
- package/dist/oneshot.mjs.map +1 -0
- package/dist/pipeline.cjs +16 -0
- package/dist/pipeline.cjs.map +1 -0
- package/dist/pipeline.d.cts +9 -0
- package/dist/pipeline.d.cts.map +1 -0
- package/dist/pipeline.d.mts +9 -0
- package/dist/pipeline.d.mts.map +1 -0
- package/dist/pipeline.mjs +14 -0
- package/dist/pipeline.mjs.map +1 -0
- package/dist/progress.cjs +0 -0
- package/dist/progress.d.cts +3 -0
- package/dist/progress.d.mts +3 -0
- package/dist/progress.mjs +1 -0
- package/dist/services.cjs +8 -0
- package/dist/services.d.cts +3 -0
- package/dist/services.d.mts +3 -0
- package/dist/services.mjs +3 -0
- package/dist/upload-error-B2ISUc_k.d.cts +48 -0
- package/dist/upload-error-B2ISUc_k.d.cts.map +1 -0
- package/dist/upload-error-BUexBh08.cjs +119 -0
- package/dist/upload-error-BUexBh08.cjs.map +1 -0
- package/dist/upload-error-jol-eoDW.d.mts +48 -0
- package/dist/upload-error-jol-eoDW.d.mts.map +1 -0
- package/dist/upload-error-zDvpxT9X.mjs +72 -0
- package/dist/upload-error-zDvpxT9X.mjs.map +1 -0
- package/dist/upload-event-C9TOVp5l.d.mts +36 -0
- package/dist/upload-event-C9TOVp5l.d.mts.map +1 -0
- package/dist/upload-event-D77olieX.d.cts +36 -0
- package/dist/upload-event-D77olieX.d.cts.map +1 -0
- package/package.json +70 -0
- package/src/errors/index.ts +10 -0
- package/src/errors/upload-error.test.ts +218 -0
- package/src/errors/upload-error.ts +89 -0
- package/src/multipart/chunk-stream.test.ts +79 -0
- package/src/multipart/chunk-stream.ts +37 -0
- package/src/multipart/circuit-breaker.test.ts +95 -0
- package/src/multipart/circuit-breaker.ts +68 -0
- package/src/multipart/index.test.ts +283 -0
- package/src/multipart/index.ts +119 -0
- package/src/multipart/upload-stream.test.ts +336 -0
- package/src/multipart/upload-stream.ts +246 -0
- package/src/oneshot/index.test.ts +153 -0
- package/src/oneshot/index.ts +76 -0
- package/src/oneshot/upload.test.ts +130 -0
- package/src/oneshot/upload.ts +52 -0
- package/src/pipeline/compress.test.ts +69 -0
- package/src/pipeline/compress.ts +8 -0
- package/src/pipeline/index.ts +3 -0
- package/src/pipeline/middleware.test.ts +102 -0
- package/src/pipeline/middleware.ts +30 -0
- package/src/progress/getprogress.test.ts +102 -0
- package/src/progress/index.ts +10 -0
- package/src/progress/upload-event.test.ts +102 -0
- package/src/progress/upload-event.ts +37 -0
- package/src/scaffold.test.ts +5 -0
- package/src/services/compression-service.test.ts +68 -0
- package/src/services/compression-service.ts +31 -0
- package/src/services/index.ts +11 -0
- package/src/services/logger-service-integration.test.ts +98 -0
- package/src/services/logger-service.test.ts +40 -0
- package/src/services/logger-service.ts +17 -0
- package/src/utils/abort-interop.test.ts +65 -0
- package/src/utils/abort-interop.ts +14 -0
- package/src/utils/normalize-callback.test.ts +46 -0
- package/src/utils/normalize-callback.ts +18 -0
- package/tsconfig.json +8 -0
- package/tsdown.config.ts +16 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
+
import { Cause, Effect, Exit } from "effect"
|
|
3
|
+
import { AbortError } from "../errors/upload-error.js"
|
|
4
|
+
import { compress } from "../pipeline/compress.js"
|
|
5
|
+
import type { Transform } from "../pipeline/middleware.js"
|
|
6
|
+
import { uploadOnce } from "./index.js"
|
|
7
|
+
import type { UploadCompleted } from "../progress/upload-event.js"
|
|
8
|
+
|
|
9
|
+
// Helper: read all events from the ReadableStream
|
|
10
|
+
const readAllEvents = async <T>(rs: ReadableStream<T>): Promise<T[]> => {
|
|
11
|
+
const reader = rs.getReader()
|
|
12
|
+
const events: T[] = []
|
|
13
|
+
while (true) {
|
|
14
|
+
const { done, value } = await reader.read()
|
|
15
|
+
if (done) break
|
|
16
|
+
events.push(value)
|
|
17
|
+
}
|
|
18
|
+
return events
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("uploadOnce — Dual API entry point", () => {
|
|
22
|
+
it.effect("success (plain void callback): events emits UploadCompleted, result resolves with UploadCompleted", () =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const { events, result } = uploadOnce({
|
|
25
|
+
stream: new ReadableStream(),
|
|
26
|
+
upload: () => Promise.resolve(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const [evts, res] = yield* Effect.all([
|
|
30
|
+
Effect.promise(() => readAllEvents(events)),
|
|
31
|
+
Effect.promise(() => result),
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
expect(evts).toHaveLength(1)
|
|
35
|
+
expect(evts[0]!._tag).toBe("UploadCompleted")
|
|
36
|
+
expect((evts[0]! as UploadCompleted).totalParts).toBe(1)
|
|
37
|
+
expect(res._tag).toBe("UploadCompleted")
|
|
38
|
+
expect(res).toBe(evts[0]) // same object reference
|
|
39
|
+
})
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
it.effect("success (Promise callback): result resolves with UploadCompleted", () =>
|
|
43
|
+
Effect.gen(function* () {
|
|
44
|
+
let callCount = 0
|
|
45
|
+
const { result } = uploadOnce({
|
|
46
|
+
stream: new ReadableStream(),
|
|
47
|
+
upload: (_stream) => {
|
|
48
|
+
callCount++
|
|
49
|
+
return Promise.resolve()
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const res = yield* Effect.promise(() => result)
|
|
54
|
+
expect(res._tag).toBe("UploadCompleted")
|
|
55
|
+
// Callback invoked exactly once (single-run guarantee)
|
|
56
|
+
expect(callCount).toBe(1)
|
|
57
|
+
})
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
it.effect("abort: result rejects with AbortError, events closes cleanly", () =>
|
|
61
|
+
Effect.gen(function* () {
|
|
62
|
+
const controller = new AbortController()
|
|
63
|
+
controller.abort()
|
|
64
|
+
|
|
65
|
+
const { events, result } = uploadOnce({
|
|
66
|
+
stream: new ReadableStream(),
|
|
67
|
+
upload: () => new Promise<void>(() => {}), // never resolves
|
|
68
|
+
signal: controller.signal,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// events closes cleanly — no error thrown to stream consumer
|
|
72
|
+
const evts = yield* Effect.promise(() => readAllEvents(events))
|
|
73
|
+
expect(evts).toHaveLength(0)
|
|
74
|
+
|
|
75
|
+
// result rejects with AbortError — use tryPromise to capture rejection as typed failure
|
|
76
|
+
const resultExit = yield* Effect.exit(
|
|
77
|
+
Effect.tryPromise({
|
|
78
|
+
try: () => result,
|
|
79
|
+
catch: (e) => e,
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
expect(Exit.isFailure(resultExit)).toBe(true)
|
|
83
|
+
if (Exit.isFailure(resultExit)) {
|
|
84
|
+
const errOption = Cause.failureOption(resultExit.cause)
|
|
85
|
+
expect(errOption._tag).toBe("Some")
|
|
86
|
+
const err = (errOption as { _tag: "Some"; value: unknown }).value
|
|
87
|
+
expect(err).toBeInstanceOf(AbortError)
|
|
88
|
+
expect((err as AbortError)._tag).toBe("AbortError")
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
it.effect("uploadOnce.effect returns a Stream (effect escape hatch)", () =>
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
// Calling .effect should not throw — returns a Stream (lazy, not executed)
|
|
96
|
+
const stream = uploadOnce.effect({
|
|
97
|
+
stream: new ReadableStream(),
|
|
98
|
+
upload: () => Promise.resolve(),
|
|
99
|
+
})
|
|
100
|
+
// Stream has a pipe method (duck-type check — we don't run it)
|
|
101
|
+
expect(typeof stream.pipe).toBe("function")
|
|
102
|
+
})
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
it.effect("applies plain Transform pipeline — upload callback receives transformed stream", () =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
let receivedStream: ReadableStream<Uint8Array> | undefined
|
|
108
|
+
|
|
109
|
+
// Pipeline that returns a known marker stream
|
|
110
|
+
const markerStream = new ReadableStream<Uint8Array>({ start(c) { c.close() } })
|
|
111
|
+
const pipeline: Transform = () => markerStream
|
|
112
|
+
|
|
113
|
+
const { result } = uploadOnce({
|
|
114
|
+
stream: new ReadableStream({ start(c) { c.close() } }),
|
|
115
|
+
pipeline,
|
|
116
|
+
upload: (s) => {
|
|
117
|
+
receivedStream = s
|
|
118
|
+
return Promise.resolve()
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
yield* Effect.promise(() => result)
|
|
123
|
+
// upload callback received the transformed stream (identity check)
|
|
124
|
+
expect(receivedStream).toBe(markerStream)
|
|
125
|
+
})
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
it.effect("applies Effect pipeline (compress) — upload callback receives compressed bytes", () =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
let receivedByteCount = 0
|
|
131
|
+
const original = new Uint8Array([1, 2, 3, 4, 5])
|
|
132
|
+
|
|
133
|
+
const { result } = uploadOnce({
|
|
134
|
+
stream: new ReadableStream({
|
|
135
|
+
start(c) { c.enqueue(original); c.close() },
|
|
136
|
+
}),
|
|
137
|
+
pipeline: compress("deflate-raw"),
|
|
138
|
+
upload: async (stream) => {
|
|
139
|
+
const reader = stream.getReader()
|
|
140
|
+
while (true) {
|
|
141
|
+
const { done, value } = await reader.read()
|
|
142
|
+
if (done) break
|
|
143
|
+
receivedByteCount += value.length
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
yield* Effect.promise(() => result)
|
|
149
|
+
// Compressed output is non-empty
|
|
150
|
+
expect(receivedByteCount).toBeGreaterThan(0)
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Cause, Effect, Exit, Stream } from "effect"
|
|
2
|
+
import type { UploadCompleted, UploadEvent } from "../progress/upload-event.js"
|
|
3
|
+
import type { Transform } from "../pipeline/middleware.js"
|
|
4
|
+
import { CompressionServiceLive } from "../services/compression-service.js"
|
|
5
|
+
import { LoggerServiceLive } from "../services/logger-service.js"
|
|
6
|
+
import { uploadOnceEffect, type UploadOnceOptions } from "./upload.js"
|
|
7
|
+
|
|
8
|
+
export type UploadResult = UploadCompleted
|
|
9
|
+
export type { UploadOnceOptions }
|
|
10
|
+
|
|
11
|
+
export interface OneShotPublicOptions extends UploadOnceOptions {
|
|
12
|
+
readonly pipeline?: Transform | Effect.Effect<Transform, unknown, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const uploadOnce = (
|
|
16
|
+
options: OneShotPublicOptions
|
|
17
|
+
): {
|
|
18
|
+
events: ReadableStream<UploadEvent>
|
|
19
|
+
result: Promise<UploadResult>
|
|
20
|
+
} => {
|
|
21
|
+
const collected: Promise<ReadonlyArray<UploadEvent>> = (async () => {
|
|
22
|
+
let processedStream = options.stream
|
|
23
|
+
if (options.pipeline !== undefined) {
|
|
24
|
+
if (typeof options.pipeline === "function") {
|
|
25
|
+
processedStream = options.pipeline(options.stream)
|
|
26
|
+
} else {
|
|
27
|
+
const transform = await Effect.runPromise(
|
|
28
|
+
Effect.provide(
|
|
29
|
+
options.pipeline as Effect.Effect<Transform, unknown, never>,
|
|
30
|
+
CompressionServiceLive
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
processedStream = transform(options.stream)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const program = uploadOnceEffect({ ...options, stream: processedStream }).pipe(
|
|
38
|
+
Stream.provideLayer(LoggerServiceLive)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const exit = await Stream.runCollect(program).pipe(
|
|
42
|
+
Effect.map((chunk) => Array.from(chunk)),
|
|
43
|
+
Effect.runPromiseExit
|
|
44
|
+
)
|
|
45
|
+
if (Exit.isSuccess(exit)) return exit.value
|
|
46
|
+
return Promise.reject(Cause.squash(exit.cause))
|
|
47
|
+
})()
|
|
48
|
+
|
|
49
|
+
// events: ReadableStream built from collected array; closes cleanly on error
|
|
50
|
+
const events = new ReadableStream<UploadEvent>({
|
|
51
|
+
async start(controller) {
|
|
52
|
+
try {
|
|
53
|
+
const evts = await collected
|
|
54
|
+
for (const event of evts) controller.enqueue(event)
|
|
55
|
+
controller.close()
|
|
56
|
+
} catch (_) {
|
|
57
|
+
// Close cleanly — abort/upload errors surface via `result` only
|
|
58
|
+
controller.close()
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// result: resolves with UploadCompleted, rejects with UploadError on failure
|
|
64
|
+
const result: Promise<UploadResult> = collected.then((evts) => {
|
|
65
|
+
const last = evts[evts.length - 1]
|
|
66
|
+
if (last === undefined) {
|
|
67
|
+
return Promise.reject(new Error("uploadOnce: stream ended without emitting an event"))
|
|
68
|
+
}
|
|
69
|
+
return last as UploadResult
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return { events, result }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Effect escape hatch — LoggerService layer left open for user composition
|
|
76
|
+
uploadOnce.effect = uploadOnceEffect
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Cause, Effect, Exit, Fiber, Stream } from "effect"
|
|
2
|
+
import { it, describe, expect } from "@effect/vitest"
|
|
3
|
+
import { uploadOnceEffect } from "./upload.js"
|
|
4
|
+
import { AbortError, CompleteUploadError } from "../errors/upload-error.js"
|
|
5
|
+
import { LoggerServiceLive } from "../services/logger-service.js"
|
|
6
|
+
import type { UploadCompleted } from "../progress/upload-event.js"
|
|
7
|
+
|
|
8
|
+
const runStream = (opts: Parameters<typeof uploadOnceEffect>[0]) =>
|
|
9
|
+
Stream.runCollect(uploadOnceEffect(opts)).pipe(
|
|
10
|
+
Effect.provide(LoggerServiceLive),
|
|
11
|
+
Effect.exit
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
describe("uploadOnceEffect", () => {
|
|
15
|
+
it.effect("emits UploadCompleted on success (plain value callback)", () =>
|
|
16
|
+
Effect.gen(function* () {
|
|
17
|
+
const mockStream = new ReadableStream()
|
|
18
|
+
const exit = yield* runStream({
|
|
19
|
+
stream: mockStream,
|
|
20
|
+
upload: () => undefined,
|
|
21
|
+
})
|
|
22
|
+
expect(Exit.isSuccess(exit)).toBe(true)
|
|
23
|
+
if (Exit.isSuccess(exit)) {
|
|
24
|
+
const events = Array.from(exit.value)
|
|
25
|
+
expect(events).toHaveLength(1)
|
|
26
|
+
expect(events[0]!._tag).toBe("UploadCompleted")
|
|
27
|
+
expect((events[0]! as UploadCompleted).totalParts).toBe(1)
|
|
28
|
+
expect(typeof events[0]!.timestamp).toBe("number")
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
it.effect("emits UploadCompleted on success (Promise callback)", () =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const mockStream = new ReadableStream()
|
|
36
|
+
const exit = yield* runStream({
|
|
37
|
+
stream: mockStream,
|
|
38
|
+
upload: () => Promise.resolve(),
|
|
39
|
+
})
|
|
40
|
+
expect(Exit.isSuccess(exit)).toBe(true)
|
|
41
|
+
if (Exit.isSuccess(exit)) {
|
|
42
|
+
const events = Array.from(exit.value)
|
|
43
|
+
expect(events).toHaveLength(1)
|
|
44
|
+
expect(events[0]!._tag).toBe("UploadCompleted")
|
|
45
|
+
expect((events[0]! as UploadCompleted).totalParts).toBe(1)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
it.effect("sync throw from callback → CompleteUploadError with correct cause", () =>
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const originalError = new Error("network failure")
|
|
53
|
+
const exit = yield* runStream({
|
|
54
|
+
stream: new ReadableStream(),
|
|
55
|
+
upload: () => {
|
|
56
|
+
throw originalError
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
60
|
+
if (Exit.isFailure(exit)) {
|
|
61
|
+
const failure = Cause.failureOption(exit.cause)
|
|
62
|
+
expect(failure._tag).toBe("Some")
|
|
63
|
+
const err = (failure as { _tag: "Some"; value: unknown }).value
|
|
64
|
+
expect(err).toBeInstanceOf(CompleteUploadError)
|
|
65
|
+
expect((err as CompleteUploadError)._tag).toBe("CompleteUploadError")
|
|
66
|
+
expect((err as CompleteUploadError).cause).toBe(originalError)
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
it.effect("Promise rejection → CompleteUploadError with correct cause", () =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const originalError = new Error("async failure")
|
|
74
|
+
const exit = yield* runStream({
|
|
75
|
+
stream: new ReadableStream(),
|
|
76
|
+
upload: () => Promise.reject(originalError),
|
|
77
|
+
})
|
|
78
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
79
|
+
if (Exit.isFailure(exit)) {
|
|
80
|
+
const failure = Cause.failureOption(exit.cause)
|
|
81
|
+
expect(failure._tag).toBe("Some")
|
|
82
|
+
const err = (failure as { _tag: "Some"; value: unknown }).value
|
|
83
|
+
expect(err).toBeInstanceOf(CompleteUploadError)
|
|
84
|
+
expect((err as CompleteUploadError)._tag).toBe("CompleteUploadError")
|
|
85
|
+
expect((err as CompleteUploadError).cause).toBe(originalError)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
it.effect("abort mid-upload → AbortError with correct tag and message", () =>
|
|
91
|
+
Effect.gen(function* () {
|
|
92
|
+
const controller = new AbortController()
|
|
93
|
+
const fiber = yield* Effect.fork(
|
|
94
|
+
Stream.runCollect(
|
|
95
|
+
uploadOnceEffect({
|
|
96
|
+
stream: new ReadableStream(),
|
|
97
|
+
upload: () => new Promise<void>(() => {}), // never resolves
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
})
|
|
100
|
+
).pipe(Effect.provide(LoggerServiceLive))
|
|
101
|
+
)
|
|
102
|
+
yield* Effect.sync(() => controller.abort())
|
|
103
|
+
const exit = yield* Fiber.await(fiber)
|
|
104
|
+
|
|
105
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
106
|
+
if (Exit.isFailure(exit)) {
|
|
107
|
+
const failure = Cause.failureOption(exit.cause)
|
|
108
|
+
expect(failure._tag).toBe("Some")
|
|
109
|
+
const err = (failure as { _tag: "Some"; value: unknown }).value
|
|
110
|
+
expect(err).toBeInstanceOf(AbortError)
|
|
111
|
+
expect((err as AbortError)._tag).toBe("AbortError")
|
|
112
|
+
expect((err as AbortError).message).toBe("Upload aborted")
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
it.effect("abort fires after upload completes → success (no spurious abort)", () =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
const controller = new AbortController()
|
|
120
|
+
const exit = yield* runStream({
|
|
121
|
+
stream: new ReadableStream(),
|
|
122
|
+
upload: () => Promise.resolve(),
|
|
123
|
+
signal: controller.signal,
|
|
124
|
+
})
|
|
125
|
+
// Abort after upload completes - should not affect the result
|
|
126
|
+
yield* Effect.sync(() => controller.abort())
|
|
127
|
+
expect(Exit.isSuccess(exit)).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Effect, Stream } from "effect"
|
|
2
|
+
import type { UploadError } from "../errors/upload-error.js"
|
|
3
|
+
import { AbortError, CompleteUploadError } from "../errors/upload-error.js"
|
|
4
|
+
import { LoggerService } from "../services/logger-service.js"
|
|
5
|
+
import { fromAbortSignal } from "../utils/abort-interop.js"
|
|
6
|
+
import { normalizeCallback } from "../utils/normalize-callback.js"
|
|
7
|
+
import type { UploadEvent } from "../progress/upload-event.js"
|
|
8
|
+
|
|
9
|
+
export interface UploadOnceOptions {
|
|
10
|
+
readonly stream: ReadableStream<Uint8Array>
|
|
11
|
+
readonly upload: (
|
|
12
|
+
stream: ReadableStream<Uint8Array>
|
|
13
|
+
) => void | Promise<void> | Effect.Effect<void, UploadError>
|
|
14
|
+
readonly signal?: AbortSignal
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const uploadOnceEffect = (
|
|
18
|
+
options: UploadOnceOptions
|
|
19
|
+
): Stream.Stream<UploadEvent, UploadError, LoggerService> => {
|
|
20
|
+
const { stream, upload, signal } = options
|
|
21
|
+
|
|
22
|
+
const program: Effect.Effect<UploadEvent, UploadError, LoggerService> = Effect.gen(
|
|
23
|
+
function* () {
|
|
24
|
+
const logger = yield* LoggerService
|
|
25
|
+
yield* Effect.sync(() => logger.log("info", "One-shot upload starting"))
|
|
26
|
+
|
|
27
|
+
const uploadEffect: Effect.Effect<void, UploadError> = normalizeCallback(
|
|
28
|
+
() => upload(stream)
|
|
29
|
+
).pipe(
|
|
30
|
+
Effect.mapError((cause): UploadError => {
|
|
31
|
+
if (cause instanceof AbortError) return cause
|
|
32
|
+
return new CompleteUploadError(cause)
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
yield* signal
|
|
37
|
+
? Effect.raceFirst(uploadEffect, fromAbortSignal(signal))
|
|
38
|
+
: uploadEffect
|
|
39
|
+
|
|
40
|
+
yield* Effect.sync(() => logger.log("info", "One-shot upload completed"))
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
_tag: "UploadCompleted" as const,
|
|
44
|
+
uploadId: "",
|
|
45
|
+
totalParts: 1,
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
} satisfies UploadEvent
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return Stream.fromEffect(program)
|
|
52
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { it, describe, expect } from "@effect/vitest"
|
|
2
|
+
import { Cause, Effect, Layer } from "effect"
|
|
3
|
+
import { compress } from "./compress.js"
|
|
4
|
+
import {
|
|
5
|
+
CompressionService,
|
|
6
|
+
CompressionServiceLive,
|
|
7
|
+
CompressionUnavailableError,
|
|
8
|
+
} from "../services/compression-service.js"
|
|
9
|
+
|
|
10
|
+
describe("compress", () => {
|
|
11
|
+
it.effect("uses custom CompressionService when provided", () =>
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const marker = new ReadableStream<Uint8Array>()
|
|
14
|
+
let receivedAlgorithm = ""
|
|
15
|
+
const TestLayer = Layer.succeed(CompressionService, {
|
|
16
|
+
compress: (stream, algorithm) => {
|
|
17
|
+
receivedAlgorithm = algorithm
|
|
18
|
+
return marker
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
const transform = yield* Effect.provide(compress("deflate-raw"), TestLayer)
|
|
22
|
+
const input = new ReadableStream<Uint8Array>()
|
|
23
|
+
const result = transform(input)
|
|
24
|
+
expect(result).toBe(marker)
|
|
25
|
+
expect(receivedAlgorithm).toBe("deflate-raw")
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
it.effect("CompressionServiceLive produces compressed bytes (deflate-raw)", () =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
const transform = yield* Effect.provide(compress("deflate-raw"), CompressionServiceLive)
|
|
32
|
+
const input = new ReadableStream<Uint8Array>({
|
|
33
|
+
start(ctrl) {
|
|
34
|
+
ctrl.enqueue(new Uint8Array([1, 2, 3, 4]))
|
|
35
|
+
ctrl.close()
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
const compressed = transform(input)
|
|
39
|
+
const reader = compressed.getReader()
|
|
40
|
+
const chunks: Uint8Array[] = []
|
|
41
|
+
while (true) {
|
|
42
|
+
const { done, value } = yield* Effect.promise(() => reader.read())
|
|
43
|
+
if (done) break
|
|
44
|
+
chunks.push(value)
|
|
45
|
+
}
|
|
46
|
+
const totalBytes = chunks.reduce((acc, c) => acc + c.length, 0)
|
|
47
|
+
expect(totalBytes).toBeGreaterThan(0)
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
it.effect("fails with typed CompressionUnavailableError when CompressionStream is absent", () =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const AbsentLayer: Layer.Layer<CompressionService, CompressionUnavailableError> =
|
|
54
|
+
Layer.effect(CompressionService, Effect.fail(new CompressionUnavailableError()))
|
|
55
|
+
const result = yield* Effect.exit(
|
|
56
|
+
Effect.provide(compress("deflate-raw"), AbsentLayer)
|
|
57
|
+
)
|
|
58
|
+
expect(result._tag).toBe("Failure")
|
|
59
|
+
if (result._tag === "Failure") {
|
|
60
|
+
const failure = Cause.failureOption(result.cause)
|
|
61
|
+
expect(failure._tag).toBe("Some")
|
|
62
|
+
if (failure._tag === "Some") {
|
|
63
|
+
expect(failure.value).toBeInstanceOf(CompressionUnavailableError)
|
|
64
|
+
expect(failure.value._tag).toBe("CompressionUnavailableError")
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { CompressionService } from "../services/compression-service.js"
|
|
3
|
+
import type { Transform } from "./middleware.js"
|
|
4
|
+
|
|
5
|
+
export const compress = (
|
|
6
|
+
algorithm: CompressionFormat = "deflate-raw"
|
|
7
|
+
): Effect.Effect<Transform, never, CompressionService> =>
|
|
8
|
+
Effect.map(CompressionService, (svc) => (stream) => svc.compress(stream, algorithm))
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { it as effectIt } from "@effect/vitest"
|
|
3
|
+
import { Effect } from "effect"
|
|
4
|
+
import { compose, type Transform } from "./middleware.js"
|
|
5
|
+
import { compress } from "./compress.js"
|
|
6
|
+
import { CompressionServiceLive } from "../services/compression-service.js"
|
|
7
|
+
|
|
8
|
+
// Helper: creates a ReadableStream emitting a single Uint8Array chunk
|
|
9
|
+
function makeStream(data: Uint8Array): ReadableStream<Uint8Array> {
|
|
10
|
+
return new ReadableStream({
|
|
11
|
+
start(controller) {
|
|
12
|
+
controller.enqueue(data)
|
|
13
|
+
controller.close()
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Helper: collects all chunks from a ReadableStream
|
|
19
|
+
async function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array[]> {
|
|
20
|
+
const reader = stream.getReader()
|
|
21
|
+
const chunks: Uint8Array[] = []
|
|
22
|
+
while (true) {
|
|
23
|
+
const { done, value } = await reader.read()
|
|
24
|
+
if (done) break
|
|
25
|
+
chunks.push(value)
|
|
26
|
+
}
|
|
27
|
+
return chunks
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("compose", () => {
|
|
31
|
+
it("zero transforms: stream passes through unchanged", async () => {
|
|
32
|
+
const data = new Uint8Array([1, 2, 3])
|
|
33
|
+
const pipeline = compose()
|
|
34
|
+
const result = await collect(pipeline(makeStream(data)))
|
|
35
|
+
expect(result).toEqual([data])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("single transform: applied to stream", async () => {
|
|
39
|
+
const data = new Uint8Array([1, 2, 3])
|
|
40
|
+
const double = (stream: ReadableStream<Uint8Array>) =>
|
|
41
|
+
new ReadableStream<Uint8Array>({
|
|
42
|
+
async start(controller) {
|
|
43
|
+
const chunks = await collect(stream)
|
|
44
|
+
for (const chunk of chunks) controller.enqueue(chunk.map(b => b * 2))
|
|
45
|
+
controller.close()
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
const result = await collect(compose(double)(makeStream(data)))
|
|
49
|
+
expect(result).toEqual([new Uint8Array([2, 4, 6])])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("two transforms: applied left-to-right", async () => {
|
|
53
|
+
const order: string[] = []
|
|
54
|
+
const t1 = (stream: ReadableStream<Uint8Array>) => { order.push("t1"); return stream }
|
|
55
|
+
const t2 = (stream: ReadableStream<Uint8Array>) => { order.push("t2"); return stream }
|
|
56
|
+
compose(t1, t2)(makeStream(new Uint8Array([1])))
|
|
57
|
+
expect(order).toEqual(["t1", "t2"])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("three transforms: applied left-to-right", async () => {
|
|
61
|
+
const order: string[] = []
|
|
62
|
+
const t1 = (stream: ReadableStream<Uint8Array>) => { order.push("t1"); return stream }
|
|
63
|
+
const t2 = (stream: ReadableStream<Uint8Array>) => { order.push("t2"); return stream }
|
|
64
|
+
const t3 = (stream: ReadableStream<Uint8Array>) => { order.push("t3"); return stream }
|
|
65
|
+
compose(t1, t2, t3)(makeStream(new Uint8Array([1])))
|
|
66
|
+
expect(order).toEqual(["t1", "t2", "t3"])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("compose with plain transforms is still a plain Transform function", () => {
|
|
70
|
+
const t1: Transform = (s) => s
|
|
71
|
+
const t2: Transform = (s) => s
|
|
72
|
+
const composed = compose(t1, t2)
|
|
73
|
+
expect(typeof composed).toBe("function")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
effectIt.effect("compose(compress()) returns an Effect that resolves to a working Transform", () =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const transformEffect = compose(compress("deflate-raw"))
|
|
79
|
+
// Confirm it's an Effect (has .pipe method, not a function)
|
|
80
|
+
expect(typeof transformEffect).not.toBe("function")
|
|
81
|
+
|
|
82
|
+
// Resolve it with CompressionServiceLive
|
|
83
|
+
const transform = yield* Effect.provide(transformEffect, CompressionServiceLive)
|
|
84
|
+
expect(typeof transform).toBe("function")
|
|
85
|
+
|
|
86
|
+
// Apply the transform to a stream
|
|
87
|
+
const input = new ReadableStream<Uint8Array>({
|
|
88
|
+
start(c) { c.enqueue(new Uint8Array([1, 2, 3])); c.close() }
|
|
89
|
+
})
|
|
90
|
+
const output = transform(input)
|
|
91
|
+
const reader = output.getReader()
|
|
92
|
+
const chunks: Uint8Array[] = []
|
|
93
|
+
while (true) {
|
|
94
|
+
const { done, value } = yield* Effect.promise(() => reader.read())
|
|
95
|
+
if (done) break
|
|
96
|
+
chunks.push(value)
|
|
97
|
+
}
|
|
98
|
+
const totalBytes = chunks.reduce((acc, c) => acc + c.length, 0)
|
|
99
|
+
expect(totalBytes).toBeGreaterThan(0)
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
|
|
3
|
+
export type Transform = (stream: ReadableStream<Uint8Array>) => ReadableStream<Uint8Array>
|
|
4
|
+
|
|
5
|
+
export function compose(): Transform
|
|
6
|
+
export function compose(...transforms: Transform[]): Transform
|
|
7
|
+
export function compose<E, R>(
|
|
8
|
+
...transforms: Array<Transform | Effect.Effect<Transform, E, R>>
|
|
9
|
+
): Effect.Effect<Transform, E, R>
|
|
10
|
+
export function compose(
|
|
11
|
+
...transforms: Array<Transform | Effect.Effect<Transform, any, any>>
|
|
12
|
+
): Transform | Effect.Effect<Transform, any, any> {
|
|
13
|
+
const hasEffect = transforms.some((t) => typeof t !== "function")
|
|
14
|
+
if (!hasEffect || transforms.length === 0) {
|
|
15
|
+
return (stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> =>
|
|
16
|
+
(transforms as Transform[]).reduce((s, t) => t(s), stream)
|
|
17
|
+
}
|
|
18
|
+
return Effect.map(
|
|
19
|
+
Effect.all(
|
|
20
|
+
transforms.map((t) =>
|
|
21
|
+
typeof t === "function"
|
|
22
|
+
? Effect.succeed(t as Transform)
|
|
23
|
+
: (t as Effect.Effect<Transform, any, any>)
|
|
24
|
+
)
|
|
25
|
+
),
|
|
26
|
+
(resolved) =>
|
|
27
|
+
(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> =>
|
|
28
|
+
resolved.reduce((s, t) => t(s), stream)
|
|
29
|
+
)
|
|
30
|
+
}
|