Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'

This commit is contained in:
-Puter
2026-07-19 03:25:10 +05:30
parent dd1071cc10
commit 2daf979036
2214 changed files with 673090 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"
import * as NodePathLayer from "@effect/platform-node/NodePath"
import { assert, describe, it } from "@effect/vitest"
import * as Layer from "effect/Layer"
import { HttpRouter, HttpStaticServer } from "effect/unstable/http"
import { copyFile, cp, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import * as NodePath from "node:path"
import { fileURLToPath } from "node:url"
const fixturesRoot = fileURLToPath(new URL("./fixtures/http-static-server", import.meta.url))
const fixturesOutsideFile = fileURLToPath(new URL("./fixtures/http-static-server-outside.txt", import.meta.url))
const staticFilesLayer = Layer.mergeAll(
NodePathLayer.layer,
NodeFileSystem.layer,
NodeHttpPlatform.layer
)
const makeHandler = (
root: string,
options: Omit<Parameters<typeof HttpStaticServer.make>[0], "root"> = {}
) =>
HttpRouter.toWebHandler(
HttpStaticServer.layer({
root,
...options
}).pipe(Layer.provideMerge(staticFilesLayer)),
{ disableLogger: true }
)
const withStaticFiles = async (
run: (context: {
readonly handler: (request: Request) => Promise<Response>
readonly root: string
readonly outsideFile: string
}) => Promise<void>,
options: Omit<Parameters<typeof HttpStaticServer.make>[0], "root"> = {}
) => {
const temporaryRoot = await mkdtemp(NodePath.join(tmpdir(), "effect-http-static-server-"))
const root = NodePath.join(temporaryRoot, "root")
const outsideFile = NodePath.join(temporaryRoot, NodePath.basename(fixturesOutsideFile))
await Promise.all([
cp(fixturesRoot, root, { recursive: true }),
copyFile(fixturesOutsideFile, outsideFile)
])
const { handler, dispose } = makeHandler(root, options)
try {
await run({ handler, root, outsideFile })
} finally {
await dispose()
await rm(temporaryRoot, { recursive: true, force: true })
}
}
describe("HttpStaticServer", () => {
it("serves files with expected content type and body", async () => {
await withStaticFiles(async ({ handler }) => {
const response = await handler(new Request("http://localhost/hello.txt"))
assert.strictEqual(response.status, 200)
assert.strictEqual(response.headers.get("content-type"), "text/plain; charset=utf-8")
assert.strictEqual((await response.text()).trimEnd(), "hello static file")
})
})
it("serves default index file for directory paths", async () => {
await withStaticFiles(async ({ handler }) => {
const response = await handler(new Request("http://localhost/guide"))
assert.strictEqual(response.status, 200)
assert.strictEqual(response.headers.get("content-type"), "text/html; charset=utf-8")
assert.strictEqual((await response.text()).trimEnd(), "<html><body>guide index</body></html>")
})
})
it("supports custom index file and disabled index fallback", async () => {
await withStaticFiles(async ({ handler }) => {
const response = await handler(new Request("http://localhost/custom"))
assert.strictEqual(response.status, 200)
assert.strictEqual((await response.text()).trimEnd(), "<html><body>custom home</body></html>")
}, { index: "home.html" })
await withStaticFiles(async ({ handler }) => {
const response = await handler(new Request("http://localhost/custom"))
assert.strictEqual(response.status, 404)
}, { index: undefined })
})
it("returns 304 for If-None-Match exact, weak, and wildcard", async () => {
await withStaticFiles(async ({ handler }) => {
const warmup = await handler(new Request("http://localhost/conditional.txt"))
const etag = warmup.headers.get("etag")
assert.notStrictEqual(etag, null)
if (etag === null) {
throw new Error("missing etag")
}
const weakComparable = etag.startsWith("W/") ? etag.slice(2) : `W/${etag}`
const exact = await handler(
new Request("http://localhost/conditional.txt", { headers: { "If-None-Match": etag } })
)
const weak = await handler(
new Request("http://localhost/conditional.txt", { headers: { "If-None-Match": weakComparable } })
)
const any = await handler(new Request("http://localhost/conditional.txt", { headers: { "If-None-Match": "*" } }))
assert.deepStrictEqual([exact.status, weak.status, any.status], [304, 304, 304])
assert.strictEqual(exact.headers.get("etag"), etag)
assert.strictEqual(exact.headers.get("cache-control"), "public, max-age=60")
assert.strictEqual(exact.headers.get("content-type"), null)
assert.strictEqual(exact.headers.get("content-length"), null)
}, { cacheControl: "public, max-age=60" })
})
it("returns 304 for If-Modified-Since and 200 when file changed", async () => {
await withStaticFiles(async ({ handler, root }) => {
const first = await handler(new Request("http://localhost/conditional.txt"))
const lastModified = first.headers.get("last-modified")
assert.notStrictEqual(lastModified, null)
if (lastModified === null) {
throw new Error("missing last-modified")
}
const notModified = await handler(
new Request("http://localhost/conditional.txt", {
headers: {
"If-Modified-Since": lastModified
}
})
)
assert.strictEqual(notModified.status, 304)
const conditionalFile = NodePath.join(root, "conditional.txt")
await writeFile(conditionalFile, "updated conditional body")
const updatedTime = new Date(Date.now() + 10_000)
await utimes(conditionalFile, updatedTime, updatedTime)
const modified = await handler(
new Request("http://localhost/conditional.txt", {
headers: {
"If-Modified-Since": lastModified
}
})
)
assert.strictEqual(modified.status, 200)
assert.strictEqual(await modified.text(), "updated conditional body")
})
})
it("handles range requests for valid, invalid, and malformed headers", async () => {
await withStaticFiles(async ({ handler }) => {
const fullBody = await handler(new Request("http://localhost/range.txt")).then((response) => response.text())
const fileSize = fullBody.length
const first = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=0-10" } }))
assert.strictEqual(first.status, 206)
assert.strictEqual(first.headers.get("content-range"), `bytes 0-10/${fileSize}`)
assert.strictEqual(await first.text(), "0123456789a")
const openEnded = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=5-" } }))
assert.strictEqual(openEnded.status, 206)
assert.strictEqual(openEnded.headers.get("content-range"), `bytes 5-${fileSize - 1}/${fileSize}`)
assert.strictEqual(await openEnded.text(), fullBody.slice(5))
const suffix = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=-10" } }))
assert.strictEqual(suffix.status, 206)
assert.strictEqual(suffix.headers.get("content-range"), `bytes ${fileSize - 10}-${fileSize - 1}/${fileSize}`)
assert.strictEqual(await suffix.text(), fullBody.slice(-10))
const invalid = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=100-200" } }))
assert.strictEqual(invalid.status, 416)
assert.strictEqual(invalid.headers.get("content-range"), `bytes */${fileSize}`)
const malformed = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=abc" } }))
assert.strictEqual(malformed.status, 200)
assert.strictEqual(await malformed.text(), fullBody)
})
})
it("handles SPA fallback for html accept and missing routes", async () => {
await withStaticFiles(async ({ handler }) => {
const htmlFallback = await handler(new Request("http://localhost/missing", { headers: { accept: "text/html" } }))
assert.strictEqual(htmlFallback.status, 200)
assert.strictEqual((await htmlFallback.text()).trimEnd(), "<html><body>root index</body></html>")
const withExtension = await handler(
new Request("http://localhost/missing.js", { headers: { accept: "text/html" } })
)
assert.strictEqual(withExtension.status, 404)
const withoutHtmlAccept = await handler(
new Request("http://localhost/missing", { headers: { accept: "application/json" } })
)
assert.strictEqual(withoutHtmlAccept.status, 404)
}, { spa: true, index: "index.html" })
})
it("rejects directory traversal attempts", async () => {
await withStaticFiles(async ({ handler, outsideFile }) => {
const plainTraversal = await handler(new Request("http://localhost/../../../etc/passwd"))
const encodedTraversal = await handler(new Request("http://localhost/..%2F..%2Fetc%2Fpasswd"))
const encodedExistingParentFileTraversal = await handler(
new Request(`http://localhost/..%2F${encodeURIComponent(NodePath.basename(outsideFile))}`)
)
assert.deepStrictEqual([
plainTraversal.status,
encodedTraversal.status,
encodedExistingParentFileTraversal.status
], [
404,
404,
404
])
})
})
it("rejects null bytes and malformed uri encoding", async () => {
await withStaticFiles(async ({ handler }) => {
const nullByte = await handler(new Request("http://localhost/null%00byte.txt"))
const malformed = await handler(new Request("http://localhost/%E0%A4%A"))
assert.deepStrictEqual([nullByte.status, malformed.status], [404, 404])
})
})
it("applies custom mime types and cache-control", async () => {
await withStaticFiles(async ({ handler }) => {
const response = await handler(new Request("http://localhost/hello.txt"))
assert.strictEqual(response.status, 200)
assert.strictEqual(response.headers.get("content-type"), "application/x-custom-text")
assert.strictEqual(response.headers.get("cache-control"), "public, max-age=120")
}, {
cacheControl: "public, max-age=120",
mimeTypes: {
txt: "application/x-custom-text"
}
})
})
it("uses application/octet-stream for unknown extension and 404 for missing file", async () => {
await withStaticFiles(async ({ handler }) => {
const unknown = await handler(new Request("http://localhost/file.binx"))
assert.strictEqual(unknown.status, 200)
assert.strictEqual(unknown.headers.get("content-type"), "application/octet-stream")
const missing = await handler(new Request("http://localhost/does-not-exist.txt"))
assert.strictEqual(missing.status, 404)
})
})
})

View File

@@ -0,0 +1,296 @@
import { assert, describe, it } from "@effect/vitest"
import * as Effect from "effect/Effect"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import * as Path from "effect/Path"
import * as PlatformError from "effect/PlatformError"
import {
HttpEffect,
HttpPlatform,
HttpRouter,
HttpServerError,
HttpServerRequest,
HttpServerResponse,
HttpStaticServer
} from "effect/unstable/http"
const root = "/root"
const filePath = `${root}/file.txt`
const fileBody = "hello static"
const lastModified = "Wed, 01 Jan 2025 00:00:00 GMT"
const notFoundError = (path: string) =>
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "stat",
description: "No such file or directory",
pathOrDescriptor: path
})
const permissionDeniedError = (path: string) =>
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "stat",
description: "Operation not permitted",
pathOrDescriptor: path
})
const fileInfo: FileSystem.File.Info = {
type: "File",
mtime: Option.some(new Date(lastModified)),
atime: Option.none(),
birthtime: Option.none(),
dev: 0,
ino: Option.none(),
mode: 0,
nlink: Option.none(),
uid: Option.none(),
gid: Option.none(),
rdev: Option.none(),
size: FileSystem.Size(fileBody.length),
blksize: Option.none(),
blocks: Option.none()
}
const makeHandler = async () => {
const fileSystem = FileSystem.makeNoop({
stat: (path) => path === filePath ? Effect.succeed(fileInfo) : Effect.fail(notFoundError(path))
})
const httpPlatform = HttpPlatform.HttpPlatform.of({
fileResponse: (_path, options) =>
Effect.succeed(HttpServerResponse.text(fileBody, {
status: options?.status,
headers: {
ETag: "\"etag-value\"",
"Last-Modified": lastModified
}
})),
fileWebResponse: () => Effect.die("not implemented")
})
const app = await Effect.runPromise(
HttpStaticServer.make({
root,
cacheControl: "public, max-age=60"
}).pipe(
Effect.provide(Path.layer),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(HttpPlatform.HttpPlatform, httpPlatform)
)
)
return HttpEffect.toWebHandler(app)
}
const makeFailingApp = async (options: {
readonly statError?: PlatformError.PlatformError
readonly fileResponseError?: PlatformError.PlatformError
}) => {
const fileSystem = FileSystem.makeNoop({
stat: (path) => {
if (options.statError !== undefined) {
return Effect.fail(options.statError)
}
return path === filePath ? Effect.succeed(fileInfo) : Effect.fail(notFoundError(path))
}
})
const httpPlatform = HttpPlatform.HttpPlatform.of({
fileResponse: (_path, fileOptions) => {
if (options.fileResponseError !== undefined) {
return Effect.fail(options.fileResponseError)
}
return Effect.succeed(HttpServerResponse.text(fileBody, {
status: fileOptions?.status,
headers: {
ETag: "\"etag-value\"",
"Last-Modified": lastModified
}
}))
},
fileWebResponse: () => Effect.die("not implemented")
})
return Effect.runPromise(
HttpStaticServer.make({ root }).pipe(
Effect.provide(Path.layer),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(HttpPlatform.HttpPlatform, httpPlatform)
)
)
}
const makeLayerHandler = (options: {
readonly statError?: PlatformError.PlatformError
readonly fileResponseError?: PlatformError.PlatformError
}) => {
const fileSystem = FileSystem.makeNoop({
stat: (path) => {
if (options.statError !== undefined) {
return Effect.fail(options.statError)
}
return path === filePath ? Effect.succeed(fileInfo) : Effect.fail(notFoundError(path))
}
})
const httpPlatform = HttpPlatform.HttpPlatform.of({
fileResponse: () =>
options.fileResponseError !== undefined
? Effect.fail(options.fileResponseError)
: Effect.succeed(HttpServerResponse.text(fileBody)),
fileWebResponse: () => Effect.die("not implemented")
})
const dependencies = Layer.mergeAll(
Path.layer,
Layer.succeed(FileSystem.FileSystem, fileSystem),
Layer.succeed(HttpPlatform.HttpPlatform, httpPlatform)
)
return HttpRouter.toWebHandler(
HttpStaticServer.layer({ root }).pipe(Layer.provideMerge(dependencies)),
{ disableLogger: true }
)
}
describe("HttpStaticServer", () => {
it("304 with If-None-Match exact, weak, and wildcard", async () => {
const handler = await makeHandler()
const warmupResponse = await handler(new Request("http://localhost/file.txt"))
const etag = warmupResponse.headers.get("etag")
assert.strictEqual(etag, "\"etag-value\"")
if (etag === null) {
throw new Error("expected ETag header")
}
const exact = await handler(new Request("http://localhost/file.txt", { headers: { "If-None-Match": etag } }))
const weak = await handler(new Request("http://localhost/file.txt", { headers: { "If-None-Match": `W/${etag}` } }))
const list = await handler(
new Request("http://localhost/file.txt", { headers: { "If-None-Match": `"other", W/${etag}` } })
)
const any = await handler(new Request("http://localhost/file.txt", { headers: { "If-None-Match": "*" } }))
assert.deepStrictEqual([exact.status, weak.status, list.status, any.status], [304, 304, 304, 304])
assert.strictEqual(exact.headers.get("etag"), "\"etag-value\"")
assert.strictEqual(exact.headers.get("cache-control"), "public, max-age=60")
assert.strictEqual(exact.headers.get("last-modified"), lastModified)
assert.strictEqual(exact.headers.get("content-type"), null)
assert.strictEqual(exact.headers.get("content-length"), null)
})
it("304 with If-Modified-Since and no If-None-Match", async () => {
const handler = await makeHandler()
const notModified = await handler(
new Request("http://localhost/file.txt", {
headers: {
"If-Modified-Since": lastModified
}
})
)
const modified = await handler(
new Request("http://localhost/file.txt", {
headers: {
"If-Modified-Since": "Tue, 31 Dec 2024 23:59:59 GMT"
}
})
)
const invalidDate = await handler(
new Request("http://localhost/file.txt", {
headers: {
"If-Modified-Since": "not-a-date"
}
})
)
assert.strictEqual(notModified.status, 304)
assert.strictEqual(modified.status, 200)
assert.strictEqual(invalidDate.status, 200)
})
it("If-None-Match takes precedence over If-Modified-Since", async () => {
const handler = await makeHandler()
const response = await handler(
new Request("http://localhost/file.txt", {
headers: {
"If-None-Match": "\"different\"",
"If-Modified-Since": "Thu, 02 Jan 2025 00:00:00 GMT"
}
})
)
assert.strictEqual(response.status, 200)
})
it("matched If-None-Match takes precedence over Range", async () => {
const handler = await makeHandler()
const response = await handler(
new Request("http://localhost/file.txt", {
headers: {
"If-None-Match": "\"etag-value\"",
Range: "bytes=1000-1001"
}
})
)
assert.strictEqual(response.status, 304)
assert.strictEqual(response.headers.get("etag"), "\"etag-value\"")
assert.strictEqual(response.headers.get("cache-control"), "public, max-age=60")
assert.strictEqual(response.headers.get("last-modified"), lastModified)
assert.strictEqual(response.headers.get("content-range"), null)
assert.strictEqual(response.headers.get("accept-ranges"), null)
assert.strictEqual(response.headers.get("content-type"), null)
assert.strictEqual(response.headers.get("content-length"), null)
})
it("wraps missing routes as HttpServerError RouteNotFound", async () => {
const app = await makeFailingApp({})
const error = await Effect.runPromise(
app.pipe(
Effect.provideService(
HttpServerRequest.HttpServerRequest,
HttpServerRequest.fromWeb(new Request("http://localhost/missing.txt"))
),
Effect.flip
)
)
assert.instanceOf(error, HttpServerError.HttpServerError)
assert.strictEqual(error.reason._tag, "RouteNotFound")
})
it("maps non-NotFound platform failures to HttpServerError InternalError", async () => {
const statError = permissionDeniedError(filePath)
const app = await makeFailingApp({ statError })
const error = await Effect.runPromise(
app.pipe(
Effect.provideService(
HttpServerRequest.HttpServerRequest,
HttpServerRequest.fromWeb(new Request("http://localhost/file.txt"))
),
Effect.flip
)
)
assert.instanceOf(error, HttpServerError.HttpServerError)
assert.strictEqual(error.reason._tag, "InternalError")
assert.strictEqual(error.reason.cause, statError)
})
it("layer renders InternalError responses as 500", async () => {
const { handler, dispose } = makeLayerHandler({
fileResponseError: permissionDeniedError(filePath)
})
try {
const response = await handler(new Request("http://localhost/file.txt"))
assert.strictEqual(response.status, 500)
} finally {
await dispose()
}
})
})

View File

@@ -0,0 +1,59 @@
import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
import { assert, describe, it } from "@effect/vitest"
import * as Crypto from "effect/Crypto"
import * as Effect from "effect/Effect"
import * as TestClock from "effect/testing/TestClock"
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const uuidV7Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const hex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")
describe("NodeCrypto", () => {
it.effect("generates empty random bytes", () =>
Effect.gen(function*() {
const crypto = yield* Crypto.Crypto
const bytes = yield* crypto.randomBytes(0)
assert.deepStrictEqual(bytes, new Uint8Array(0))
}).pipe(Effect.provide(NodeCrypto.layer)))
it.effect("generates random bytes with the requested size", () =>
Effect.gen(function*() {
const crypto = yield* Crypto.Crypto
const bytes = yield* crypto.randomBytes(32)
assert.strictEqual(bytes.length, 32)
}).pipe(Effect.provide(NodeCrypto.layer)))
it.effect("fails invalid random byte sizes", () =>
Effect.gen(function*() {
const crypto = yield* Crypto.Crypto
const error = yield* Effect.flip(crypto.randomBytes(-1))
assert.strictEqual(error._tag, "PlatformError")
}).pipe(Effect.provide(NodeCrypto.layer)))
it.effect("generates UUIDv4 values", () =>
Effect.gen(function*() {
const crypto = yield* Crypto.Crypto
const uuid1 = yield* crypto.randomUUIDv4
const uuid2 = yield* crypto.randomUUIDv4
assert.match(uuid1, uuidV4Regex)
assert.match(uuid2, uuidV4Regex)
assert.notStrictEqual(uuid1, uuid2)
}).pipe(Effect.provide(NodeCrypto.layer)))
it.effect("generates UUIDv7 values", () =>
Effect.gen(function*() {
yield* TestClock.setTime(0x0123456789ab)
const crypto = yield* Crypto.Crypto
const uuid = yield* crypto.randomUUIDv7
assert.match(uuid, uuidV7Regex)
assert.strictEqual(uuid.slice(0, 13), "01234567-89ab")
}).pipe(Effect.provide(NodeCrypto.layer)))
it.effect("computes SHA-256 digests", () =>
Effect.gen(function*() {
const crypto = yield* Crypto.Crypto
const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode("hello"))
assert.strictEqual(hex(digest), "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
}).pipe(Effect.provide(NodeCrypto.layer)))
})

View File

@@ -0,0 +1,157 @@
import * as NodeClient from "@effect/platform-node/NodeHttpClient"
import { describe, expect, it } from "@effect/vitest"
import { Struct } from "effect"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Schema from "effect/Schema"
import * as Stream from "effect/Stream"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
const Todo = Schema.Struct({
userId: Schema.Number,
id: Schema.Number,
title: Schema.String,
completed: Schema.Boolean
})
const TodoWithoutId = Schema.Struct({
...Struct.omit(Todo.fields, ["id"])
})
const makeJsonPlaceholder = Effect.gen(function*() {
const defaultClient = yield* HttpClient.HttpClient
const client = defaultClient.pipe(
HttpClient.mapRequest(HttpClientRequest.prependUrl("https://jsonplaceholder.typicode.com"))
)
const createTodo = (todo: typeof TodoWithoutId.Type) =>
HttpClientRequest.post("/todos").pipe(
HttpClientRequest.schemaBodyJson(TodoWithoutId)(todo),
Effect.flatMap(client.execute),
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
)
return {
client,
createTodo
} as const
})
interface JsonPlaceholder extends Effect.Success<typeof makeJsonPlaceholder> {}
const JsonPlaceholder = Context.Service<JsonPlaceholder>("test/JsonPlaceholder")
const JsonPlaceholderLive = Layer.effect(JsonPlaceholder)(makeJsonPlaceholder)
;[
{
name: "fetch",
layer: NodeClient.layerFetch
},
{
name: "node:http",
layer: NodeClient.layerNodeHttp
},
{
name: "undici",
layer: NodeClient.layerUndici
}
].forEach(({ layer, name }) => {
describe(`NodeHttpClient - ${name}`, () => {
it.effect("google", () =>
Effect.gen(function*() {
const response = yield* HttpClient.get("https://www.google.com/").pipe(
Effect.flatMap((_) => _.text)
)
expect(response).toContain("Google")
}).pipe(Effect.provide(layer), flaky))
it.effect("google followRedirects", () =>
flaky(
Effect.gen(function*() {
const client = (yield* HttpClient.HttpClient).pipe(
HttpClient.followRedirects()
)
const response = yield* client.get("http://google.com/").pipe(
Effect.flatMap((_) => _.text)
)
expect(response).toContain("Google")
}).pipe(Effect.provide(layer))
))
it.effect("google stream", () =>
flaky(
Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
const response = yield* client.get("https://www.google.com/").pipe(
Effect.map((_) => _.stream),
Stream.unwrap,
Stream.decodeText(),
Stream.mkString
)
expect(response).toContain("Google")
}).pipe(Effect.provide(layer))
))
it.effect("jsonplaceholder", () =>
Effect.gen(function*() {
const jp = yield* JsonPlaceholder
const response = yield* jp.client.get("/todos/1").pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
)
expect(response.id).toBe(1)
}).pipe(
Effect.provide(JsonPlaceholderLive.pipe(
Layer.provide(layer)
)),
flaky
))
it.effect("jsonplaceholder schemaBodyJson", () =>
Effect.gen(function*() {
const jp = yield* JsonPlaceholder
const response = yield* jp.createTodo({
userId: 1,
title: "test",
completed: false
})
expect(response.title).toBe("test")
}).pipe(
Effect.provide(JsonPlaceholderLive.pipe(
Layer.provide(layer)
)),
flaky
))
it.effect("head request with schemaJson", () =>
Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
const response = yield* client.head("https://jsonplaceholder.typicode.com/todos").pipe(
Effect.flatMap(
HttpClientResponse.schemaJson(Schema.Struct({ status: Schema.Literal(200) }))
)
)
expect(response).toEqual({ status: 200 })
}).pipe(Effect.provide(layer), flaky))
it.live("interrupt", () =>
Effect.gen(function*() {
const client = yield* HttpClient.HttpClient
const response = yield* client.get("https://www.google.com/").pipe(
Effect.flatMap((_) => _.text),
Effect.timeout(1),
Effect.asSome,
Effect.catchTag("TimeoutError", () => Effect.succeedNone)
)
expect(response._tag).toEqual("None")
}).pipe(Effect.provide(layer), flaky))
it.effect("close early", () =>
Effect.gen(function*() {
const response = yield* HttpClient.get("https://www.google.com/")
expect(response.status).toBe(200)
}).pipe(Effect.provide(layer), flaky))
})
})
const flaky = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.timeoutOrElse({
duration: "10 seconds",
orElse: () => Effect.void
})
)

View File

@@ -0,0 +1,51 @@
import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"
import { assert, describe, it } from "@effect/vitest"
import * as Effect from "effect/Effect"
import type * as HttpBody from "effect/unstable/http/HttpBody"
import * as HttpPlatform from "effect/unstable/http/HttpPlatform"
import { Readable } from "node:stream"
const readStream = (stream: Readable) =>
Effect.promise(async () => {
let text = ""
for await (const chunk of stream) {
text += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")
}
return text
})
describe("NodeHttpPlatform", () => {
it.effect("fileResponse reads exact bytesToRead", () =>
Effect.gen(function*() {
const platform = yield* HttpPlatform.HttpPlatform
const response = yield* platform.fileResponse(`${__dirname}/fixtures/text.txt`, {
offset: 6,
bytesToRead: 5
})
assert.strictEqual(response.headers["content-length"], "5")
assert.strictEqual(response.body._tag, "Raw")
const body = (response.body as HttpBody.Raw).body
assert(body instanceof Readable)
const text = yield* readStream(body)
assert.strictEqual(text, "ipsum")
}).pipe(Effect.provide(NodeHttpPlatform.layer)))
it.effect("fileResponse supports zero bytesToRead", () =>
Effect.gen(function*() {
const platform = yield* HttpPlatform.HttpPlatform
const response = yield* platform.fileResponse(`${__dirname}/fixtures/text.txt`, {
offset: 6,
bytesToRead: 0
})
assert.strictEqual(response.headers["content-length"], "0")
assert.strictEqual(response.body._tag, "Raw")
const body = (response.body as HttpBody.Raw).body
assert(body instanceof Readable)
const text = yield* readStream(body)
assert.strictEqual(text, "")
}).pipe(Effect.provide(NodeHttpPlatform.layer)))
})

View File

@@ -0,0 +1,598 @@
/** @effect-diagnostics preferSchemaOverJson:skip-file */
import { NodeHttpServer } from "@effect/platform-node"
import { assert, describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"
import * as Duration from "effect/Duration"
import * as Fiber from "effect/Fiber"
import * as Layer from "effect/Layer"
import * as Schema from "effect/Schema"
import * as Stream from "effect/Stream"
import * as Tracer from "effect/Tracer"
import {
Cookies,
HttpBody,
HttpClient,
HttpClientRequest,
HttpClientResponse,
HttpPlatform,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerRespondable,
HttpServerResponse,
Multipart,
UrlParams
} from "effect/unstable/http"
import * as HttpApiError from "effect/unstable/httpapi/HttpApiError"
import * as Buffer from "node:buffer"
const Todo = Schema.Struct({
id: Schema.Number,
title: Schema.String
})
const IdParams = Schema.Struct({
id: Schema.FiniteFromString
})
const todoResponse = HttpServerResponse.schemaJson(Todo)
describe("HttpServer", () => {
it.effect("schema", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"GET",
"/todos/:id",
Effect.flatMap(
HttpRouter.schemaParams(IdParams),
({ id }) => todoResponse({ id, title: "test" })
)
).pipe(
HttpRouter.serve,
Layer.build
)
const todo = yield* HttpClient.get("/todos/1").pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
)
expect(todo).toEqual({ id: 1, title: "test" })
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("formData", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"POST",
"/upload",
Effect.gen(function*() {
const request = yield* HttpServerRequest.HttpServerRequest
const formData = yield* request.multipart
const part = formData.file
assert(typeof part !== "string")
const file = part[0]
assert(typeof file !== "string")
expect(file.path.endsWith("/test.txt")).toEqual(true)
expect(file.contentType).toEqual("text/plain")
return yield* HttpServerResponse.json({ ok: "file" in formData })
})
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const formData = new FormData()
formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt")
const result = yield* client.post("/upload", { body: HttpBody.formData(formData) }).pipe(
Effect.flatMap((r) => r.json)
)
expect(result).toEqual({ ok: true })
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("schemaBodyForm", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"POST",
"/upload",
Effect.gen(function*() {
const files = yield* HttpServerRequest.schemaBodyForm(Schema.Struct({
file: Multipart.FilesSchema,
test: Schema.String
}))
expect(files).toHaveProperty("file")
expect(files).toHaveProperty("test")
return HttpServerResponse.empty()
})
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const formData = new FormData()
formData.append("file", new Blob(["test"], { type: "text/plain" }), "test.txt")
formData.append("test", "test")
const response = yield* client.post("/upload", { body: HttpBody.formData(formData) })
expect(response.status).toEqual(204)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("formData withMaxFileSize", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"POST",
"/upload",
Effect.gen(function*() {
const request = yield* HttpServerRequest.HttpServerRequest
yield* request.multipart
return HttpServerResponse.empty()
}).pipe(
Effect.catchTag("MultipartError", (error) =>
error.reason._tag === "FileTooLarge" ?
Effect.succeed(HttpServerResponse.empty({ status: 413 })) :
Effect.fail(error))
)
).pipe(
HttpRouter.serve,
Layer.build,
Effect.provideService(Multipart.MaxFileSize, 100)
)
const client = yield* HttpClient.HttpClient
const formData = new FormData()
const data = new Uint8Array(1000)
formData.append("file", new Blob([data], { type: "text/plain" }), "test.txt")
const response = yield* client.post("/upload", { body: HttpBody.formData(formData) })
expect(response.status).toEqual(413)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("formData withMaxFieldSize", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"POST",
"/upload",
Effect.gen(function*() {
const request = yield* HttpServerRequest.HttpServerRequest
yield* request.multipart
return HttpServerResponse.empty()
}).pipe(
Effect.catchTag("MultipartError", (error) =>
error.reason._tag === "FieldTooLarge" ?
Effect.succeed(HttpServerResponse.empty({ status: 413 })) :
Effect.fail(error))
)
).pipe(
HttpRouter.serve,
Layer.build,
Effect.provideService(Multipart.MaxFieldSize, 100)
)
const client = yield* HttpClient.HttpClient
const formData = new FormData()
const data = new Uint8Array(1000).fill(1)
formData.append("file", new TextDecoder().decode(data))
const response = yield* client.post("/upload", { body: HttpBody.formData(formData) })
expect(response.status).toEqual(413)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("mountApp", () =>
Effect.gen(function*() {
const child = Effect.map(HttpServerRequest.HttpServerRequest, (_) => HttpServerResponse.text(_.url))
yield* HttpRouter.use((router) => router.prefixed("/child").add("*", "*", child)).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const todo = yield* client.get("/child/1").pipe(Effect.flatMap((_) => _.text))
expect(todo).toEqual("/1")
const root = yield* client.get("/child").pipe(Effect.flatMap((_) => _.text))
expect(root).toEqual("/")
const rootSearch = yield* client.get("/child?foo=bar").pipe(Effect.flatMap((_) => _.text))
expect(rootSearch).toEqual("?foo=bar")
const rootSlash = yield* client.get("/child/").pipe(Effect.flatMap((_) => _.text))
expect(rootSlash).toEqual("/")
const invalid = yield* client.get("/child1/", {
urlParams: { foo: "bar" }
}).pipe(Effect.map((_) => _.status))
expect(invalid).toEqual(404)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("file", () =>
Effect.gen(function*() {
yield* (yield* HttpServerResponse.file(`${__dirname}/fixtures/text.txt`).pipe(
Effect.updateService(
HttpPlatform.HttpPlatform,
(_) => ({
..._,
fileResponse: (path, options) =>
Effect.map(
_.fileResponse(path, options),
(res) => {
;(res as any).headers.etag = "\"etag\""
return res
}
)
})
)
)).pipe(
Effect.succeed,
HttpServer.serveEffect()
)
const client = yield* HttpClient.HttpClient
const res = yield* client.get("/")
expect(res.status).toEqual(200)
expect(res.headers["content-type"]).toEqual("text/plain")
expect(res.headers["content-length"]).toEqual("27")
expect(res.headers.etag).toEqual("\"etag\"")
const text = yield* res.text
expect(text.trim()).toEqual("lorem ipsum dolar sit amet")
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("fileWeb", () =>
Effect.gen(function*() {
const now = new Date()
const file = new Buffer.File([new TextEncoder().encode("test")], "test.txt", {
type: "text/plain",
lastModified: now.getTime()
})
yield* HttpServerResponse.fileWeb(file).pipe(
Effect.updateService(
HttpPlatform.HttpPlatform,
(_) => ({
..._,
fileWebResponse: (path, options) =>
Effect.map(
_.fileWebResponse(path, options),
(res) => ({ ...res, headers: { ...res.headers, etag: "W/\"etag\"" } })
)
})
),
HttpServer.serveEffect()
)
const client = yield* HttpClient.HttpClient
const res = yield* client.get("/")
expect(res.status).toEqual(200)
expect(res.headers["content-type"]).toEqual("text/plain")
expect(res.headers["content-length"]).toEqual("4")
expect(res.headers["last-modified"]).toEqual(now.toUTCString())
expect(res.headers.etag).toEqual("W/\"etag\"")
const text = yield* res.text
expect(text.trim()).toEqual("test")
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("schemaBodyUrlParams", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"POST",
"/todos",
Effect.flatMap(
HttpServerRequest.schemaBodyUrlParams(Schema.Struct({
id: Schema.FiniteFromString,
title: Schema.String
})),
({ id, title }) => todoResponse({ id, title })
)
).pipe(
HttpRouter.serve,
Layer.build
)
const todo = yield* HttpClientRequest.post("/todos").pipe(
HttpClientRequest.bodyUrlParams({ id: "1", title: "test" }),
HttpClient.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
)
expect(todo).toEqual({ id: 1, title: "test" })
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("schemaBodyUrlParams error", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"GET",
"/todos",
Effect.flatMap(
HttpServerRequest.schemaBodyUrlParams(Schema.Struct({
id: Schema.FiniteFromString,
title: Schema.String
})),
({ id, title }) => todoResponse({ id, title })
).pipe(
Effect.catchTag("SchemaError", (error) =>
Effect.succeed(HttpServerResponse.jsonUnsafe({ error }, { status: 400 })))
)
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const response = yield* client.get("/todos")
expect(response.status).toEqual(400)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("schemaBodyFormJson", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"POST",
"/upload",
Effect.gen(function*() {
const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({
test: Schema.String
}))("json")
expect(result.test).toEqual("content")
return HttpServerResponse.empty()
})
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const formData = new FormData()
formData.append("json", JSON.stringify({ test: "content" }))
const response = yield* client.post("/upload", { body: HttpBody.formData(formData) })
expect(response.status).toEqual(204)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("schemaBodyFormJson file", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"POST",
"/upload",
Effect.gen(function*() {
const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({
test: Schema.String
}))("json")
expect(result.test).toEqual("content")
return HttpServerResponse.empty()
})
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const formData = new FormData()
formData.append(
"json",
new Blob([JSON.stringify({ test: "content" })], { type: "application/json" }),
"test.json"
)
const response = yield* client.post("/upload", { body: HttpBody.formData(formData) })
expect(response.status).toEqual(204)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("schemaBodyFormJson url encoded", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"POST",
"/upload",
Effect.gen(function*() {
const result = yield* HttpServerRequest.schemaBodyFormJson(Schema.Struct({
test: Schema.String
}))("json")
expect(result.test).toEqual("content")
return HttpServerResponse.empty()
})
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const response = yield* client.post("/upload", {
body: HttpBody.urlParams(UrlParams.fromInput({
json: JSON.stringify({ test: "content" })
}))
})
expect(response.status).toEqual(204)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("tracing", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"GET",
"/",
Effect.flatMap(
Effect.currentSpan,
(_) => HttpServerResponse.json({ spanId: _.spanId, parent: _.parent })
)
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const requestSpan = yield* Effect.makeSpan("client request")
const body = yield* client.get("/").pipe(
Effect.flatMap((r) => r.json),
Effect.provideService(
Tracer.Tracer,
Tracer.make({
span(options) {
assert.strictEqual(options.name, "http.client GET")
assert.strictEqual(options.kind, "client")
assert(options.parent._tag === "Some")
if (options.parent.value._tag !== "Span") {
throw new Error("Expected span parent")
}
assert.strictEqual(options.parent.value.name, "request parent")
return requestSpan
}
})
),
Effect.withSpan("request parent"),
Effect.repeat({ times: 2 })
)
expect((body as any).parent._tag).toEqual("Some")
expect((body as any).parent.value.spanId).toEqual(requestSpan.spanId)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("html", () =>
Effect.gen(function*() {
yield* HttpRouter.addAll([
HttpRouter.route("GET", "/home", HttpServerResponse.html("<html />")),
HttpRouter.route(
"GET",
"/about",
HttpServerResponse.html`<html>${Effect.succeed("<body />")}</html>`
),
HttpRouter.route(
"GET",
"/stream",
HttpServerResponse.htmlStream`<html>${Stream.make("<body />", 123, "hello")}</html>`
)
]).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const home = yield* client.get("/home").pipe(Effect.flatMap((r) => r.text))
expect(home).toEqual("<html />")
const about = yield* client.get("/about").pipe(Effect.flatMap((r) => r.text))
expect(about).toEqual("<html><body /></html>")
const stream = yield* client.get("/stream").pipe(Effect.flatMap((r) => r.text))
expect(stream).toEqual("<html><body />123hello</html>")
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("setCookie", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"GET",
"/home",
HttpServerResponse.empty().pipe(
HttpServerResponse.setCookieUnsafe("test", "value"),
HttpServerResponse.setCookieUnsafe("test2", "value2", {
httpOnly: true,
secure: true,
sameSite: "lax",
partitioned: true,
path: "/",
domain: "example.com",
expires: new Date(2022, 1, 1, 0, 0, 0, 0),
maxAge: "5 minutes"
})
)
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const res = yield* client.get("/home")
assert.deepStrictEqual(
res.cookies.toJSON(),
Cookies.fromReadonlyRecord({
test: Cookies.makeCookieUnsafe("test", "value"),
test2: Cookies.makeCookieUnsafe("test2", "value2", {
httpOnly: true,
secure: true,
sameSite: "lax",
partitioned: true,
path: "/",
domain: "example.com",
expires: new Date(2022, 1, 1, 0, 0, 0, 0),
maxAge: Duration.minutes(5)
})
}).toJSON()
)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.live("uninterruptible routes", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"GET",
"/home",
Effect.gen(function*() {
const fiber = Fiber.getCurrent()!
setTimeout(() => fiber.interruptUnsafe(fiber.id), 10)
yield* Effect.sleep(50)
return HttpServerResponse.empty()
}),
{ uninterruptible: true }
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const res = yield* client.get("/home")
assert.strictEqual(res.status, 204)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
describe("HttpServerRespondable", () => {
it.effect("error/schema", () =>
Effect.gen(function*() {
class CustomError extends Schema.ErrorClass<CustomError>("CustomError")({
_tag: Schema.tag("CustomError"),
name: Schema.String
}) {
[HttpServerRespondable.symbol]() {
return HttpServerResponse.schemaJson(CustomError)(this, { status: 599 })
}
}
yield* HttpRouter.add(
"GET",
"/home",
new CustomError({ name: "test" })
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const res = yield* client.get("/home")
assert.strictEqual(res.status, 599)
const err = yield* HttpClientResponse.schemaBodyJson(CustomError)(res)
assert.deepStrictEqual(err, new CustomError({ name: "test" }))
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
it.effect("httpapi error", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"GET",
"/home",
new HttpApiError.BadRequest({})
).pipe(
HttpRouter.serve,
Layer.build
)
const client = yield* HttpClient.HttpClient
const res = yield* client.get("/home")
assert.strictEqual(res.status, 400)
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
})
it.effect("RouterConfig", () =>
Effect.gen(function*() {
yield* HttpRouter.add(
"GET",
"/:param",
Effect.succeed(HttpServerResponse.empty())
).pipe(
HttpRouter.serve,
Layer.build
)
let res = yield* HttpClient.get("/123456")
assert.strictEqual(res.status, 404)
res = yield* HttpClient.get("/12345")
assert.strictEqual(res.status, 204)
}).pipe(
Effect.provide([
NodeHttpServer.layerTest,
Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 5 })
])
))
it.effect("HttpRouter prefixed", () =>
Effect.gen(function*() {
const handler = HttpRouter.serve(HttpRouter.use(Effect.fnUntraced(function*(router_) {
const router = router_.prefixed("/todos")
yield* router.add(
"GET",
"/:id",
Effect.flatMap(
HttpRouter.schemaParams(IdParams),
({ id }) => todoResponse({ id, title: "test" })
)
)
yield* router.addAll([
HttpRouter.route("GET", "/", Effect.succeed(HttpServerResponse.text("root")))
])
})))
yield* Layer.build(handler)
const todo = yield* HttpClient.get("/todos/1").pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo))
)
expect(todo).toEqual({ id: 1, title: "test" })
const root = yield* HttpClient.get("/todos").pipe(
Effect.flatMap((r) => r.text)
)
expect(root).toEqual("root")
}).pipe(Effect.provide(NodeHttpServer.layerTest)))
})

View File

@@ -0,0 +1,84 @@
import { NodeRedis } from "@effect/platform-node"
import { assert, it } from "@effect/vitest"
import { RedisContainer } from "@testcontainers/redis"
import { Effect, Layer, Schema } from "effect"
import * as PersistedCacheTest from "effect-test/unstable/persistence/PersistedCacheTest"
import * as PersistedQueueTest from "effect-test/unstable/persistence/PersistedQueueTest"
import { PersistedQueue, Persistence } from "effect/unstable/persistence"
const RedisLayer = Layer.unwrap(
Effect.gen(function*() {
const container = yield* Effect.acquireRelease(
Effect.promise(() => new RedisContainer("redis:alpine").start()),
(container) => Effect.promise(() => container.stop())
)
return NodeRedis.layer({
host: container.getHost(),
port: container.getMappedPort(6379)
})
}).pipe(
Effect.catchCause(() => Effect.fail(new PersistedCacheTest.TransientError()))
)
)
PersistedCacheTest.suite(
"NodeRedis",
Persistence.layerRedis.pipe(Layer.provide(RedisLayer))
)
PersistedQueueTest.suite(
"NodeRedis",
// short intervals so the periodic reset runs while the suite's takes are
// in flight
PersistedQueue.layerStoreRedis({
pollInterval: "50 millis",
lockRefreshInterval: "100 millis"
}).pipe(Layer.provide(RedisLayer))
)
const PersistedQueueRedisLayer = Layer.mergeAll(
RedisLayer,
PersistedQueue.layer.pipe(
Layer.provideMerge(
PersistedQueue.layerStoreRedis().pipe(Layer.provide(RedisLayer))
)
)
)
it.layer(PersistedQueueRedisLayer, { timeout: "30 seconds" })(
"PersistedQueue (NodeRedis)",
(it) => {
// The shared PersistedQueue suite can only assert that exhausted elements
// are no longer delivered, which is also true if they are silently
// dropped. There is no public API for reading failed elements, so
// verifying they are preserved in the dead-letter list requires
// inspecting Redis directly.
it.effect("moves exhausted elements to the failed list", () =>
Effect.gen(function*() {
const redis = yield* NodeRedis.NodeRedis
const queueName = "test-redis-failed"
const queue = yield* PersistedQueue.make({
name: queueName,
schema: RedisItem
})
const id = yield* queue.offer({ n: 42 })
const error = yield* queue.take(() => Effect.fail("boom"), { maxAttempts: 1 }).pipe(Effect.flip)
assert.strictEqual(error, "boom")
const failed = yield* redis.use((client) => client.lrange(`effectq:${queueName}:failed`, 0, -1))
assert.strictEqual(failed.length, 1)
const failedItem = JSON.parse(failed[0])
assert.strictEqual(failedItem.id, id)
assert.deepStrictEqual(failedItem.element, { n: 42 })
assert.strictEqual(failedItem.attempts, 1)
const pending = yield* redis.use((client) => client.hlen(`effectq:${queueName}:pending`))
assert.strictEqual(pending, 0)
}))
}
)
const RedisItem = Schema.Struct({
n: Schema.Number
})

View File

@@ -0,0 +1,153 @@
import { NodeSocket, NodeSocketServer } from "@effect/platform-node"
import { assert, describe, expect, it } from "@effect/vitest"
import { Effect, Queue } from "effect"
import * as Fiber from "effect/Fiber"
import * as Stream from "effect/Stream"
import { Socket, type SocketServer } from "effect/unstable/socket"
import { WS } from "vitest-websocket-mock"
const makeServer = Effect.gen(function*() {
const server = yield* NodeSocketServer.make({ port: 0 })
yield* server.run(Effect.fnUntraced(function*(socket) {
const write = yield* socket.writer
yield* socket.run(write)
}, Effect.scoped)).pipe(Effect.forkScoped)
return server
})
describe("Socket", () => {
it.effect("open", () =>
Effect.gen(function*() {
const server = yield* makeServer
const channel = NodeSocket.makeNetChannel({ port: (server.address as SocketServer.TcpAddress).port })
const outputEffect = Stream.make("Hello", "World").pipe(
Stream.encodeText,
Stream.pipeThroughChannel(channel),
Stream.decodeText(),
Stream.mkString
)
const output = yield* outputEffect
assert.strictEqual(output, "HelloWorld")
}))
describe("WebSocket", () => {
const url = `ws://localhost:1234`
const makeServer = Effect.acquireRelease(
Effect.sync(() => new WS(url)),
(ws) =>
Effect.sync(() => {
ws.close()
WS.clean()
})
)
it.effect("messages", () =>
Effect.gen(function*() {
const server = yield* makeServer
const socket = yield* Socket.makeWebSocket(Effect.succeed(url), {
closeCodeIsError: () => false
})
const messages = yield* Queue.unbounded<Uint8Array>()
const fiber = yield* Effect.forkChild(socket.run((_) => Queue.offer(messages, _)))
yield* Effect.gen(function*() {
const write = yield* socket.writer
yield* write(new TextEncoder().encode("Hello"))
yield* write(new TextEncoder().encode("World"))
}).pipe(Effect.scoped)
yield* Effect.promise(async () => {
await expect(server).toReceiveMessage(new TextEncoder().encode("Hello"))
await expect(server).toReceiveMessage(new TextEncoder().encode("World"))
})
server.send("Right back at you!")
let message = yield* Queue.take(messages)
assert.deepStrictEqual(message, new TextEncoder().encode("Right back at you!"))
server.send(new Blob(["A Blob message"]))
message = yield* Queue.take(messages)
assert.deepStrictEqual(message, new TextEncoder().encode("A Blob message"))
server.close()
const exit = yield* Fiber.await(fiber)
assert.strictEqual(exit._tag, "Success")
}).pipe(
Effect.provideService(Socket.WebSocketConstructor, (url) => new globalThis.WebSocket(url))
))
it.effect("close codes are errors by default", () =>
Effect.gen(function*() {
const server = yield* makeServer
const socket = yield* Socket.makeWebSocket(Effect.succeed(url))
const fiber = yield* Effect.forkChild(socket.run(() => {}))
yield* Effect.promise(() => server.connected)
server.close({ code: 1000, reason: "done", wasClean: true })
const exit = yield* Effect.exit(Fiber.join(fiber))
assert.isTrue(exit._tag === "Failure")
if (exit._tag === "Failure") {
const failure = exit.cause.reasons[0]
if (failure._tag === "Fail") {
assert.isTrue(failure.error instanceof Socket.SocketError)
assert.strictEqual(failure.error.reason._tag, "SocketCloseError")
if (failure.error.reason._tag === "SocketCloseError") {
assert.strictEqual(failure.error.reason.code, 1000)
assert.strictEqual(failure.error.reason.closeReason, "done")
}
}
}
}).pipe(
Effect.provideService(Socket.WebSocketConstructor, (url) => new globalThis.WebSocket(url))
))
})
describe("TransformStream", () => {
it.effect("works", () =>
Effect.gen(function*() {
const readable = Stream.make("A", "B", "C").pipe(
Stream.tap(() => Effect.sleep(50)),
Stream.toReadableStream()
)
const decoder = new TextDecoder()
const chunks: Array<string> = []
const writable = new WritableStream<Uint8Array>({
write(chunk) {
chunks.push(decoder.decode(chunk))
}
})
const socket = yield* Socket.fromTransformStream(
Effect.succeed({
readable,
writable
}),
{
closeCodeIsError: () => false
}
)
yield* socket.writer.pipe(
Effect.tap((write) =>
write("Hello").pipe(
Effect.andThen(write("World"))
)
),
Effect.scoped,
Effect.forkChild
)
const received: Array<string> = []
yield* socket.run((chunk) =>
Effect.sync(() => {
received.push(decoder.decode(chunk))
})
).pipe(Effect.scoped)
assert.deepStrictEqual(chunks, ["Hello", "World"])
assert.deepStrictEqual(received, ["A", "B", "C"])
}))
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,292 @@
import { NodeHttpServer, NodeSocket, NodeSocketServer } from "@effect/platform-node"
import { assert, describe, it } from "@effect/vitest"
import { Cause, Deferred, Effect, Fiber, Layer, Ref, Schedule, Schema, Stream } from "effect"
import { Entity, EntityProxy, EntityProxyServer, Sharding } from "effect/unstable/cluster"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import { Rpc, RpcClient, RpcGroup, RpcSerialization, RpcServer, RpcTest } from "effect/unstable/rpc"
import { SocketServer } from "effect/unstable/socket"
import { e2eSuite, UsersClient } from "./fixtures/rpc-e2e.ts"
import { RpcLive, User } from "./fixtures/rpc-schemas.ts"
describe("RpcServer", () => {
// http ndjson
const HttpProtocol = RpcServer.layerProtocolHttp({ path: "/rpc" }).pipe(
Layer.provide(HttpRouter.layer)
)
const HttpNdjsonServer = RpcLive.pipe(
Layer.provideMerge(HttpProtocol),
Layer.provide(HttpRouter.serve(HttpProtocol, { disableListenLog: true, disableLogger: true }))
)
const HttpNdjsonClient = UsersClient.layer.pipe(
Layer.provide(
RpcClient.layerProtocolHttp({
url: "",
transformClient: HttpClient.mapRequest(HttpClientRequest.appendUrl("/rpc"))
})
)
)
const CustomDefectLayer = HttpNdjsonClient.pipe(
Layer.provideMerge(HttpNdjsonServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerNdjson])
)
e2eSuite(
"e2e http ndjson",
HttpNdjsonClient.pipe(
Layer.provideMerge(HttpNdjsonServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerNdjson])
)
)
e2eSuite(
"e2e http msgpack",
HttpNdjsonClient.pipe(
Layer.provideMerge(HttpNdjsonServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerMsgPack])
)
)
e2eSuite(
"e2e http jsonrpc",
HttpNdjsonClient.pipe(
Layer.provideMerge(HttpNdjsonServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerNdJsonRpc()])
)
)
// websocket
const WsProtocol = RpcServer.layerProtocolWebsocket({ path: "/rpc" }).pipe(
Layer.provide(HttpRouter.layer)
)
const HttpWsServer = RpcLive.pipe(
Layer.provideMerge(WsProtocol),
Layer.provide(HttpRouter.serve(WsProtocol, { disableListenLog: true, disableLogger: true }))
)
const HttpWsClient = UsersClient.layer.pipe(
Layer.provide(RpcClient.layerProtocolSocket()),
Layer.provide(
Effect.gen(function*() {
const server = yield* HttpServer.HttpServer
const address = server.address as HttpServer.TcpAddress
return NodeSocket.layerWebSocket(`http://127.0.0.1:${address.port}/rpc`)
}).pipe(Layer.unwrap)
)
)
e2eSuite(
"e2e ws ndjson",
HttpWsClient.pipe(
Layer.provideMerge(HttpWsServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerNdjson])
)
)
e2eSuite(
"e2e ws json",
HttpWsClient.pipe(
Layer.provideMerge(HttpWsServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerJson])
)
)
e2eSuite(
"e2e ws msgpack",
HttpWsClient.pipe(
Layer.provideMerge(HttpWsServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerMsgPack])
)
)
e2eSuite(
"e2e ws jsonrpc",
HttpWsClient.pipe(
Layer.provideMerge(HttpWsServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerJsonRpc()])
)
)
// tcp
const TcpServer = RpcLive.pipe(
Layer.provideMerge(RpcServer.layerProtocolSocketServer),
Layer.provideMerge(NodeSocketServer.layer({ port: 0 }))
)
const TcpClient = UsersClient.layer.pipe(
Layer.provide(RpcClient.layerProtocolSocket()),
Layer.provide(
Effect.gen(function*() {
const server = yield* SocketServer.SocketServer
const address = server.address as SocketServer.TcpAddress
return NodeSocket.layerNet({ port: address.port })
}).pipe(Layer.unwrap)
)
)
e2eSuite(
"e2e tcp ndjson",
TcpClient.pipe(
Layer.provideMerge(TcpServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerNdjson])
)
)
e2eSuite(
"e2e tcp msgpack",
TcpClient.pipe(
Layer.provideMerge(TcpServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerMsgPack])
)
)
e2eSuite(
"e2e tcp jsonrpc",
TcpClient.pipe(
Layer.provideMerge(TcpServer),
Layer.provide([NodeHttpServer.layerTest, RpcSerialization.layerNdJsonRpc()])
)
)
// worker
// const WorkerClient = UsersClient.layer.pipe(
// Layer.provide(RpcClient.layerProtocolWorker({ size: 1 })),
// Layer.provide(
// NodeWorker.layerPlatform(() =>
// CP.fork(new URL("./fixtures/rpc-worker.ts", import.meta.url), {
// execPath: "node"
// })
// )
// ),
// Layer.merge(Layer.succeed(RpcServer.Protocol, {
// supportsAck: true
// } as any))
// )
// e2eSuite("e2e worker", WorkerClient)
describe("RpcTest", () => {
it.effect("works", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const user = yield* client.GetUser({ id: "1" })
assert.deepStrictEqual(user, new User({ id: "1", name: "Logged in user" }))
}).pipe(Effect.provide(UsersClient.layerTest)))
})
describe("custom defect schema", () => {
it.effect("preserves full defect with custom schema", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const cause = yield* client.ProduceDefectCustom().pipe(
Effect.sandbox,
Effect.flip
)
const defect = Cause.squash(cause)
assert.instanceOf(defect, Error)
assert.strictEqual(defect.name, "CustomDefect")
assert.strictEqual(defect.message, "detailed error")
assert.strictEqual(defect.stack, "Error: detailed error\n at handler.ts:1")
}).pipe(Effect.provide(CustomDefectLayer)))
})
describe("entity proxy", () => {
it.effect("provides handler context for generated rpc handlers", () =>
Effect.gen(function*() {
const TestEntity = Entity.make("TestEntity", [Rpc.make("NoPayload")])
const TestEntityRpcs = EntityProxy.toRpcGroup(TestEntity)
const called = yield* Deferred.make<void>()
const testClient = (entityId: string) => ({
NoPayload: (payload: void, options?: { readonly discard?: boolean }) =>
Effect.gen(function*() {
assert.strictEqual(entityId, "id")
assert.strictEqual(payload, undefined)
assert.strictEqual(options?.discard, true)
yield* Deferred.succeed(called, undefined)
})
})
const sharding = Sharding.Sharding.of({
...({} as Sharding.Sharding["Service"]),
isShutdown: Effect.succeed(false),
makeClient: () => Effect.succeed(testClient) as never,
pollStorage: Effect.void
})
const client = yield* RpcTest.makeClient(TestEntityRpcs).pipe(
Effect.provide(EntityProxyServer.layerRpcHandlers(TestEntity)),
Effect.provideService(Sharding.Sharding, sharding)
)
yield* client["TestEntity.NoPayloadDiscard"]({
entityId: "id",
payload: undefined
})
yield* Deferred.await(called)
}))
})
describe("unknown-tag isolation", () => {
const Ticker = Rpc.make("Ticker", {
success: Schema.Number,
stream: true
})
const Ghost = Rpc.make("Ghost", {
payload: { value: Schema.String },
success: Schema.String
})
const serverGroup = RpcGroup.make(Ticker)
const clientGroup = RpcGroup.make(Ticker, Ghost)
const TickerHandlers = serverGroup.toLayer({
Ticker: () => Stream.fromSchedule(Schedule.spaced("60 millis"))
})
const IsolationServer = RpcServer.layer(serverGroup).pipe(
Layer.provide(TickerHandlers),
Layer.provideMerge(RpcServer.layerProtocolSocketServer),
Layer.provideMerge(NodeSocketServer.layer({ port: 0 })),
Layer.provide(RpcSerialization.layerNdjson)
)
const IsolationClient = RpcClient.layerProtocolSocket().pipe(
Layer.provide(
Effect.gen(function*() {
const server = yield* SocketServer.SocketServer
const address = server.address as SocketServer.TcpAddress
return NodeSocket.layerNet({ port: address.port })
}).pipe(Layer.unwrap)
),
Layer.provide(RpcSerialization.layerNdjson)
)
it.live(
"an unknown request tag fails only its own request, not other in-flight streams on the same connection",
() =>
Effect.gen(function*() {
const client = yield* RpcClient.make(clientGroup)
const received = yield* Ref.make<Array<number>>([])
const tickerFiber = yield* client.Ticker().pipe(
Stream.runForEach((value) => Ref.update(received, (xs) => [...xs, value])),
Effect.forkChild
)
yield* Effect.retry(
Effect.flatMap(
Ref.get(received),
(xs) => xs.length >= 2 ? Effect.void : Effect.fail("not enough ticks yet")
),
{ schedule: Schedule.spaced("50 millis"), times: 200 }
)
const ticksBeforeGhost = (yield* Ref.get(received)).length
assert.isAtLeast(ticksBeforeGhost, 2)
const ghostExit = yield* client.Ghost({ value: "boo" }).pipe(Effect.exit)
assert.isTrue(ghostExit._tag === "Failure", "Ghost call should fail with the routing miss")
yield* Effect.sleep("300 millis")
const ticksAfterGhost = (yield* Ref.get(received)).length
const tickerStatus = tickerFiber.pollUnsafe()
yield* Fiber.interrupt(tickerFiber)
assert.isUndefined(tickerStatus, "Ticker stream must still be running after the unknown-tag failure")
assert.isAbove(
ticksAfterGhost,
ticksBeforeGhost,
"Ticker stream must keep emitting after the unknown-tag failure"
)
}).pipe(Effect.provide(IsolationClient.pipe(Layer.provideMerge(IsolationServer)))),
{ timeout: 30_000 }
)
})
})

View File

@@ -0,0 +1,715 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`HttpApi > original tests > OpenAPI spec > fixture 1`] = `
{
"components": {
"schemas": {
"ComponentsSchema": {
"additionalProperties": false,
"properties": {
"contentType": {
"type": "string",
},
"length": {
"type": "integer",
},
},
"required": [
"contentType",
"length",
],
"type": "object",
},
"Group": {
"additionalProperties": false,
"properties": {
"id": {
"type": "integer",
},
"name": {
"type": "string",
},
},
"required": [
"id",
"name",
],
"type": "object",
},
"NoStatusError": {
"additionalProperties": false,
"properties": {
"_tag": {
"enum": [
"NoStatusError",
],
"type": "string",
},
},
"required": [
"_tag",
],
"type": "object",
},
"User": {
"additionalProperties": false,
"properties": {
"createdAt": {
"type": "string",
},
"id": {
"type": "integer",
},
"name": {
"type": "string",
},
"uuid": {
"anyOf": [
{
"type": "string",
},
{
"type": "null",
},
],
},
},
"required": [
"id",
"name",
"createdAt",
],
"type": "object",
},
"UserError": {
"additionalProperties": false,
"properties": {
"_tag": {
"enum": [
"UserError",
],
"type": "string",
},
},
"required": [
"_tag",
],
"type": "object",
},
},
"securitySchemes": {
"cookie": {
"in": "cookie",
"name": "token",
"type": "apiKey",
},
},
},
"info": {
"summary": "test api summary",
"title": "API",
"version": "0.0.1",
},
"openapi": "3.1.0",
"paths": {
"/groups": {
"post": {
"operationId": "groups.create",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
},
},
"required": [
"name",
],
"type": "object",
},
},
"application/x-www-form-urlencoded": {
"schema": {
"additionalProperties": false,
"properties": {
"foo": {
"type": "string",
},
},
"required": [
"foo",
],
"type": "object",
},
},
"multipart/form-data": {
"schema": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
},
},
"required": [
"name",
],
"type": "object",
},
},
},
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Group",
},
},
},
"description": "Group",
},
},
"security": [],
"tags": [
"groups",
],
},
},
"/groups/handle/{id}": {
"post": {
"operationId": "groups.handle",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
},
},
"required": [
"name",
],
"type": "object",
},
},
},
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number",
},
"name": {
"type": "string",
},
},
"required": [
"id",
"name",
],
"type": "object",
},
},
},
"description": "Success",
},
},
"security": [],
"tags": [
"groups",
],
},
},
"/groups/handleraw/{id}": {
"post": {
"operationId": "groups.handleRaw",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
},
},
"required": [
"name",
],
"type": "object",
},
},
},
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"id": {
"type": "number",
},
"name": {
"type": "string",
},
},
"required": [
"id",
"name",
],
"type": "object",
},
},
},
"description": "Success",
},
},
"security": [],
"tags": [
"groups",
],
},
},
"/groups/{id}": {
"get": {
"operationId": "groups.findById",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Group",
},
},
},
"description": "Group",
},
"418": {
"description": "GroupError",
},
},
"security": [],
"tags": [
"groups",
],
},
},
"/healthz": {
"get": {
"operationId": "healthz",
"parameters": [],
"responses": {
"204": {
"description": "Empty",
},
},
"security": [],
"tags": [
"root",
],
},
},
"/users": {
"get": {
"deprecated": true,
"operationId": "listUsers",
"parameters": [
{
"in": "header",
"name": "page",
"required": false,
"schema": {
"allOf": [
{
"pattern": "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$",
},
],
"type": "string",
},
},
{
"in": "query",
"name": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
},
{
"type": "null",
},
],
"description": "search query",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/User",
},
"type": "array",
},
},
},
"description": "Success",
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NoStatusError",
},
},
},
"description": "NoStatusError",
},
},
"security": [
{
"cookie": [],
},
],
"summary": "test summary",
"tags": [
"Users API",
],
},
"post": {
"operationId": "users.create",
"parameters": [
{
"in": "query",
"name": "id",
"required": true,
"schema": {
"allOf": [
{
"pattern": "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$",
},
],
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
},
"uuid": {
"anyOf": [
{
"type": "string",
},
{
"type": "null",
},
],
},
},
"required": [
"name",
],
"type": "object",
},
},
},
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User",
},
},
},
"description": "Some description for User",
},
"400": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/UserError",
},
{
"$ref": "#/components/schemas/UserError",
},
],
},
},
},
"description": "UserError",
},
},
"security": [
{
"cookie": [],
},
],
"tags": [
"Users API",
],
},
},
"/users/upload/{0}": {
"post": {
"operationId": "users.upload",
"parameters": [
{
"in": "path",
"name": "0",
"required": true,
"schema": {
"anyOf": [
{
"type": "string",
},
{
"type": "null",
},
],
},
},
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"additionalProperties": false,
"properties": {
"file": {
"format": "binary",
"type": "string",
},
},
"required": [
"file",
],
"type": "object",
},
},
},
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"contentType": {
"type": "string",
},
"length": {
"type": "integer",
},
},
"required": [
"contentType",
"length",
],
"type": "object",
},
},
},
"description": "Success",
},
},
"security": [
{
"cookie": [],
},
],
"tags": [
"Users API",
],
},
},
"/users/uploadstream": {
"post": {
"operationId": "users.uploadStream",
"parameters": [],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"additionalProperties": false,
"properties": {
"file": {
"format": "binary",
"type": "string",
},
},
"required": [
"file",
],
"type": "object",
},
},
},
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"additionalProperties": false,
"properties": {
"contentType": {
"type": "string",
},
"length": {
"type": "integer",
},
},
"required": [
"contentType",
"length",
],
"type": "object",
},
},
},
"description": "Success",
},
},
"security": [
{
"cookie": [],
},
],
"tags": [
"Users API",
],
},
},
"/users/{id}": {
"get": {
"operationId": "users.findById",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"allOf": [
{
"pattern": "^[+-]?\\d*\\.?\\d+(?:[Ee][+-]?\\d+)?$",
},
],
"type": "string",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User",
},
},
},
"description": "Some description for User",
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserError",
},
},
},
"description": "UserError",
},
},
"security": [
{
"cookie": [],
},
],
"tags": [
"Users API",
],
},
},
},
"security": [],
"tags": [
{
"name": "groups",
},
{
"name": "Users API",
},
{
"name": "root",
},
{
"name": "Tag from OpenApi.Transform annotation",
},
],
}
`;

View File

@@ -0,0 +1,196 @@
import { describe, expect, it } from "@effect/vitest"
import { Context, Effect, Exit, Fiber, Latch, Layer, Option, Schema } from "effect"
import { TestClock } from "effect/testing"
import {
EntityAddress,
EntityId,
EntityType,
Envelope,
Message,
MessageStorage,
Reply,
ShardId,
ShardingConfig,
Snowflake
} from "effect/unstable/cluster"
import { Headers } from "effect/unstable/http"
import { Rpc, RpcSchema } from "effect/unstable/rpc"
const MemoryLive = MessageStorage.layerMemory.pipe(
Layer.provideMerge(Snowflake.layerGenerator),
Layer.provide(ShardingConfig.layerDefaults)
)
describe("MessageStorage", () => {
describe("memory", () => {
it.effect("saves a request", () =>
Effect.gen(function*() {
const storage = yield* MessageStorage.MessageStorage
const request = yield* makeRequest()
const result = yield* storage.saveRequest(request)
expect(result._tag).toEqual("Success")
const messages = yield* storage.unprocessedMessages([request.envelope.address.shardId])
expect(messages).toHaveLength(1)
}).pipe(Effect.provide(MemoryLive)))
it.effect("detects duplicates", () =>
Effect.gen(function*() {
const storage = yield* MessageStorage.MessageStorage
yield* storage.saveRequest(
yield* makeRequest({
rpc: PrimaryKeyTest,
payload: PrimaryKeyTest.payloadSchema.make({ id: 123 })
})
)
const result = yield* storage.saveRequest(
yield* makeRequest({
rpc: PrimaryKeyTest,
payload: PrimaryKeyTest.payloadSchema.make({ id: 123 })
})
)
expect(result._tag).toEqual("Duplicate")
}).pipe(Effect.provide(MemoryLive)))
it.effect("unprocessedMessages excludes complete requests", () =>
Effect.gen(function*() {
const storage = yield* MessageStorage.MessageStorage
const request = yield* makeRequest()
yield* storage.saveRequest(request)
yield* storage.saveReply(yield* makeReply(request))
const messages = yield* storage.unprocessedMessages([request.envelope.address.shardId])
expect(messages).toHaveLength(0)
}).pipe(Effect.provide(MemoryLive)))
it.effect("repliesFor", () =>
Effect.gen(function*() {
const storage = yield* MessageStorage.MessageStorage
const request = yield* makeRequest()
yield* storage.saveRequest(request)
let replies = yield* storage.repliesFor([request])
expect(replies).toHaveLength(0)
yield* storage.saveReply(yield* makeReply(request))
replies = yield* storage.repliesFor([request])
expect(replies).toHaveLength(1)
expect(replies[0].requestId).toEqual(request.envelope.requestId)
}).pipe(Effect.provide(MemoryLive)))
it.effect("registerReplyHandler", () =>
Effect.gen(function*() {
const storage = yield* MessageStorage.MessageStorage
const latch = yield* Latch.make()
const request = yield* makeRequest()
yield* storage.saveRequest(request)
const fiber = yield* storage.registerReplyHandler(
new Message.OutgoingRequest({
...request,
respond: () => latch.open
})
).pipe(Effect.forkChild)
yield* TestClock.adjust(1)
yield* storage.saveReply(yield* makeReply(request))
yield* latch.await
yield* Fiber.await(fiber)
}).pipe(Effect.provide(MemoryLive)))
})
})
export const GetUserRpc = Rpc.make("GetUser", {
payload: { id: Schema.Number }
})
export const makeRequest = Effect.fnUntraced(function*(options?: {
readonly rpc?: Rpc.AnyWithProps
readonly payload?: any
}) {
const snowflake = yield* Snowflake.Generator
const rpc = options?.rpc ?? GetUserRpc
return new Message.OutgoingRequest({
envelope: Envelope.makeRequest<any>({
requestId: snowflake.nextUnsafe(),
address: EntityAddress.make({
shardId: ShardId.make("default", 1),
entityType: EntityType.make("test"),
entityId: EntityId.make("1")
}),
tag: rpc._tag,
payload: options?.payload ?? { id: 123 },
traceId: "noop",
spanId: "noop",
sampled: false,
headers: Headers.empty
}),
annotations: rpc.annotations,
context: Context.empty() as any,
rpc,
lastReceivedReply: Option.none(),
respond() {
return Effect.void
}
})
})
export class PrimaryKeyTest extends Rpc.make("PrimaryKeyTest", {
payload: {
id: Schema.Number
},
primaryKey: (value) => value.id.toString()
}) {}
export class StreamRpc extends Rpc.make("StreamTest", {
success: RpcSchema.Stream(Schema.Void, Schema.Never),
payload: {
id: Schema.Number
},
primaryKey: (value) => value.id.toString()
}) {}
export const makeReply = Effect.fnUntraced(function*(request: Message.OutgoingRequest<any>) {
const snowflake = yield* Snowflake.Generator
return new Reply.ReplyWithContext({
reply: new Reply.WithExit({
id: snowflake.nextUnsafe(),
requestId: request.envelope.requestId,
exit: Exit.void as any
}),
context: request.context,
rpc: request.rpc
})
})
export const makeAckChunk = Effect.fnUntraced(function*(
request: Message.OutgoingRequest<any>,
chunk: Reply.ReplyWithContext<any>
) {
const snowflake = yield* Snowflake.Generator
return new Message.OutgoingEnvelope({
envelope: new Envelope.AckChunk({
id: snowflake.nextUnsafe(),
address: request.envelope.address,
requestId: chunk.reply.requestId,
replyId: chunk.reply.id
}),
rpc: request.rpc
})
})
export const makeChunkReply = Effect.fnUntraced(function*(request: Message.OutgoingRequest<any>, sequence = 0) {
const snowflake = yield* Snowflake.Generator
return new Reply.ReplyWithContext({
reply: new Reply.Chunk({
id: snowflake.nextUnsafe(),
requestId: request.envelope.requestId,
sequence,
values: [undefined]
}),
context: request.context,
rpc: request.rpc
})
})
export const makeEmptyReply = (request: Message.OutgoingRequest<any>) => {
return new Reply.ReplyWithContext({
reply: Reply.Chunk.emptyFrom(request.envelope.requestId),
context: request.context,
rpc: request.rpc
})
}

View File

@@ -0,0 +1,120 @@
import { NodeClusterSocket } from "@effect/platform-node"
import { describe, it } from "@effect/vitest"
import { BigDecimal, Effect, Layer, Option, PrimaryKey, Schema } from "effect"
import {
ClusterSchema,
Entity,
MessageStorage,
RunnerAddress,
RunnerHealth,
RunnerStorage,
ShardingConfig,
SocketRunner
} from "effect/unstable/cluster"
import { Rpc, RpcSerialization } from "effect/unstable/rpc"
class TestPayload extends Schema.Class<TestPayload>("TestPayload")({
id: Schema.String,
amount: Schema.BigDecimal
}) {
[PrimaryKey.symbol]() {
return this.id
}
}
const TestEntity = Entity
.make("TestEntity", [
Rpc.make("Process", {
payload: TestPayload,
success: Schema.Void
})
])
.annotateRpcs(ClusterSchema.Persisted, true)
.annotateRpcs(ClusterSchema.Uninterruptible, true)
const TestEntityLayer = TestEntity.toLayer(
Effect.succeed({
Process: () => Effect.void
})
)
const RUNNER_PORT = 50_123
// Build shared storage instances once, so runner and client see the same state.
// MessageStorage.layerMemory requires ShardingConfig, so we provide a minimal one.
const SharedStorage = Layer.mergeAll(
RunnerStorage.layerMemory,
MessageStorage.layerMemory
).pipe(
Layer.provide(ShardingConfig.layerDefaults)
)
const makeRunnerLayer = (port: number) =>
TestEntityLayer.pipe(
Layer.provideMerge(SocketRunner.layer),
Layer.provide(RunnerHealth.layerNoop),
Layer.provide(NodeClusterSocket.layerSocketServer),
Layer.provide(NodeClusterSocket.layerClientProtocol),
Layer.provide(ShardingConfig.layer({
runnerAddress: Option.some(RunnerAddress.make("localhost", port)),
entityTerminationTimeout: 0,
entityMessagePollInterval: 5000,
sendRetryInterval: 100
})),
Layer.provide(RpcSerialization.layerMsgPack)
)
const makeClientLayer = (port: number) =>
SocketRunner.layerClientOnly.pipe(
Layer.provide(NodeClusterSocket.layerClientProtocol),
Layer.provide(ShardingConfig.layer({
runnerAddress: Option.some(RunnerAddress.make("localhost", port)),
runnerListenAddress: Option.some(RunnerAddress.make("localhost", port)),
entityTerminationTimeout: 0,
entityMessagePollInterval: 5000,
sendRetryInterval: 100
})),
Layer.provide(RpcSerialization.layerMsgPack)
)
// BigDecimal.normalize creates a circular `normalized` self-reference.
// When a persisted message is sent with discard: true, the notify path in Runners.makeRpc
// passes the raw envelope (with circular BigDecimal payload) to the runner via msgpack,
// causing RangeError: Maximum call stack size exceeded.
describe("SocketRunner", () => {
it.live(
"entity call with BigDecimal and discard should not stack overflow",
() =>
Effect.gen(function*() {
// Start the runner (with socket server and entity handler)
yield* Layer.launch(makeRunnerLayer(RUNNER_PORT)).pipe(Effect.forkScoped)
// Give the runner time to start and acquire shards
yield* Effect.sleep("2 seconds")
yield* Effect.log("Before starting the client")
// Send a message from the client with discard: true.
// The BigDecimal is normalized to trigger the circular `normalized` self-reference.
yield* Effect.gen(function*() {
yield* Effect.log("Starting the client")
yield* Effect.sleep("2 seconds")
const makeClient = yield* TestEntity.client
// Give the client time to discover the runner
yield* Effect.sleep("3 seconds")
const client = makeClient("entity-1")
const amount = BigDecimal.fromStringUnsafe("123.45")
yield* client.Process(
TestPayload.make({ id: "req-1", amount }),
{ discard: true }
)
}).pipe(
Effect.provide(makeClientLayer(RUNNER_PORT)),
Effect.scoped
)
}).pipe(Effect.provide(
SharedStorage
)),
30_000
)
})

View File

@@ -0,0 +1,222 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SqliteClient } from "@effect/sql-sqlite-node"
import { assert, describe, expect, it } from "@effect/vitest"
import { Effect, Fiber, FileSystem, Latch, Layer, Option } from "effect"
import { TestClock } from "effect/testing"
import { Message, MessageStorage, ShardingConfig, Snowflake, SqlMessageStorage } from "effect/unstable/cluster"
import { SqlClient } from "effect/unstable/sql"
import { MysqlContainer } from "../fixtures/mysql2-utils.ts"
import { PgContainer } from "../fixtures/pg-utils.ts"
import {
makeAckChunk,
makeChunkReply,
makeReply,
makeRequest,
PrimaryKeyTest,
StreamRpc
} from "./MessageStorageTest.ts"
const StorageLive = SqlMessageStorage.layer.pipe(
Layer.provideMerge(Snowflake.layerGenerator),
Layer.provide(ShardingConfig.layerDefaults)
)
const truncate = Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
yield* sql`DELETE FROM cluster_replies`
yield* sql`DELETE FROM cluster_messages`
})
describe("SqlMessageStorage", () => {
;([
["pg", Layer.orDie(PgContainer.layerClient)],
["mysql", Layer.orDie(MysqlContainer.layerClient)],
["sqlite", Layer.orDie(SqliteLayer)]
] as const).forEach(([label, layer]) => {
it.layer(StorageLive.pipe(Layer.provideMerge(layer)), {
timeout: 120000
})(label, (it) => {
it.effect("saveRequest", () =>
Effect.gen(function*() {
const storage = yield* MessageStorage.MessageStorage
const request = yield* makeRequest({ payload: { id: 1 } })
const result = yield* storage.saveRequest(request)
expect(result._tag).toEqual("Success")
for (let i = 2; i <= 5; i++) {
yield* storage.saveRequest(yield* makeRequest({ payload: { id: i } }))
}
yield* storage.saveReply(yield* makeReply(request))
let messages = yield* storage.unprocessedMessages([request.envelope.address.shardId])
expect(messages).toHaveLength(4)
expect(messages.map((m: any) => m.envelope.payload.id)).toEqual([2, 3, 4, 5])
for (let i = 6; i <= 10; i++) {
yield* storage.saveRequest(yield* makeRequest({ payload: { id: i } }))
}
messages = yield* storage.unprocessedMessages([request.envelope.address.shardId])
expect(messages).toHaveLength(5)
expect(messages.map((m: any) => m.envelope.payload.id)).toEqual([6, 7, 8, 9, 10])
}))
it.effect("saveReply + saveRequest duplicate", () =>
Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
const storage = yield* MessageStorage.MessageStorage
const request = yield* makeRequest({
rpc: StreamRpc,
payload: StreamRpc.payloadSchema.make({ id: 123 })
})
let result = yield* storage.saveRequest(request)
expect(result._tag).toEqual("Success")
let chunk = yield* makeChunkReply(request, 0)
yield* storage.saveReply(chunk)
const ackChunk = yield* makeAckChunk(request, chunk)
yield* storage.saveEnvelope(ackChunk)
chunk = yield* makeChunkReply(request, 1)
yield* storage.saveReply(chunk)
result = yield* storage.saveRequest(
yield* makeRequest({
rpc: StreamRpc,
payload: StreamRpc.payloadSchema.make({ id: 123 })
})
)
assert(result._tag === "Duplicate" && Option.isSome(result.lastReceivedReply))
expect(result.lastReceivedReply.value._tag).toEqual("Chunk")
// get the un-acked chunk
const replies = yield* storage.repliesFor([request])
expect(replies).toHaveLength(1)
yield* storage.saveReply(yield* makeReply(request))
result = yield* storage.saveRequest(
yield* makeRequest({
rpc: StreamRpc,
payload: StreamRpc.payloadSchema.make({ id: 123 })
})
)
assert(result._tag === "Duplicate" && Option.isSome(result.lastReceivedReply))
expect(result.lastReceivedReply.value._tag).toEqual("WithExit")
// duplicate WithExit
const fiber = yield* storage.saveReply(yield* makeReply(request)).pipe(Effect.forkChild)
yield* TestClock.adjust(1)
while (!fiber.pollUnsafe()) {
yield* sql`SELECT 1`
yield* TestClock.adjust(1000)
}
const error = yield* Effect.flip(Fiber.join(fiber))
expect(error._tag).toEqual("PersistenceError")
}))
it.effect("detects duplicates", () =>
Effect.gen(function*() {
yield* truncate
const storage = yield* MessageStorage.MessageStorage
yield* storage.saveRequest(
yield* makeRequest({
rpc: PrimaryKeyTest,
payload: PrimaryKeyTest.payloadSchema.make({ id: 123 })
})
)
const result = yield* storage.saveRequest(
yield* makeRequest({
rpc: PrimaryKeyTest,
payload: PrimaryKeyTest.payloadSchema.make({ id: 123 })
})
)
expect(result._tag).toEqual("Duplicate")
}))
it.effect("unprocessedMessages", () =>
Effect.gen(function*() {
yield* truncate
const storage = yield* MessageStorage.MessageStorage
const request = yield* makeRequest()
yield* storage.saveRequest(request)
let messages = yield* storage.unprocessedMessages([request.envelope.address.shardId])
expect(messages).toHaveLength(1)
messages = yield* storage.unprocessedMessages([request.envelope.address.shardId])
expect(messages).toHaveLength(0)
yield* storage.saveRequest(yield* makeRequest())
messages = yield* storage.unprocessedMessages([request.envelope.address.shardId])
expect(messages).toHaveLength(1)
}))
it.effect("unprocessedMessages excludes complete requests", () =>
Effect.gen(function*() {
yield* truncate
const storage = yield* MessageStorage.MessageStorage
const request = yield* makeRequest()
yield* storage.saveRequest(request)
yield* storage.saveReply(yield* makeReply(request))
const messages = yield* storage.unprocessedMessages([request.envelope.address.shardId])
expect(messages).toHaveLength(0)
}))
it.effect("repliesFor", () =>
Effect.gen(function*() {
yield* truncate
const storage = yield* MessageStorage.MessageStorage
const request = yield* makeRequest()
yield* storage.saveRequest(request)
let replies = yield* storage.repliesFor([request])
expect(replies).toHaveLength(0)
yield* storage.saveReply(yield* makeReply(request))
replies = yield* storage.repliesFor([request])
expect(replies).toHaveLength(1)
expect(replies[0].requestId).toEqual(request.envelope.requestId)
}))
it.effect("registerReplyHandler", () =>
Effect.gen(function*() {
const storage = yield* MessageStorage.MessageStorage
const latch = yield* Latch.make()
const request = yield* makeRequest()
yield* storage.saveRequest(request)
const fiber = yield* storage.registerReplyHandler(
new Message.OutgoingRequest({
...request,
respond: () => latch.open
})
).pipe(Effect.forkChild)
yield* TestClock.adjust(1)
yield* storage.saveReply(yield* makeReply(request))
yield* latch.await
yield* Fiber.await(fiber)
}))
it.effect("unprocessedMessagesById", () =>
Effect.gen(function*() {
yield* truncate
const storage = yield* MessageStorage.MessageStorage
const request = yield* makeRequest()
yield* storage.saveRequest(request)
let messages = yield* storage.unprocessedMessagesById([request.envelope.requestId])
expect(messages).toHaveLength(1)
yield* storage.saveReply(yield* makeReply(request))
messages = yield* storage.unprocessedMessagesById([request.envelope.requestId])
expect(messages).toHaveLength(0)
}))
})
})
})
const SqliteLayer = Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const dir = yield* fs.makeTempDirectoryScoped()
return SqliteClient.layer({
filename: dir + "/test.db"
})
}).pipe(Layer.unwrap, Layer.provide(NodeFileSystem.layer))

View File

@@ -0,0 +1,101 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SqliteClient } from "@effect/sql-sqlite-node"
import { describe, expect, it } from "@effect/vitest"
import { Effect, FileSystem, Layer } from "effect"
import {
Runner,
RunnerAddress,
RunnerStorage,
ShardId,
ShardingConfig,
SqlRunnerStorage
} from "effect/unstable/cluster"
import { MysqlContainer } from "../fixtures/mysql2-utils.ts"
import { PgContainer } from "../fixtures/pg-utils.ts"
const StorageLive = SqlRunnerStorage.layer
describe("SqlRunnerStorage", () => {
;([
["pg", Layer.orDie(PgContainer.layerClient)],
["mysql", Layer.orDie(MysqlContainer.layerClient)],
["vitess", Layer.orDie(MysqlContainer.layerClientVitess)],
["sqlite", Layer.orDie(SqliteLayer)]
] as const).flatMap(([label, layer]) =>
[
[label, StorageLive.pipe(Layer.provideMerge(layer), Layer.provide(ShardingConfig.layer()))],
[
label + " (no advisory)",
StorageLive.pipe(
Layer.provideMerge(layer),
Layer.provide(ShardingConfig.layer({
shardLockDisableAdvisory: true
}))
)
]
] as const
).forEach(([label, layer]) => {
it.layer(layer, {
timeout: 60000
})(label, (it) => {
it.effect("getRunners", () =>
Effect.gen(function*() {
const storage = yield* RunnerStorage.RunnerStorage
const runner = Runner.make({
address: runnerAddress1,
groups: ["default"],
weight: 1
})
const machineId = yield* storage.register(runner, true)
yield* storage.register(runner, true)
expect(machineId).toEqual(1)
expect(yield* storage.getRunners).toEqual([[runner, true]])
yield* storage.setRunnerHealth(runnerAddress1, false)
expect(yield* storage.getRunners).toEqual([[runner, false]])
yield* storage.unregister(runnerAddress1)
expect(yield* storage.getRunners).toEqual([])
}), 30_000)
it.effect("acquireShards", () =>
Effect.gen(function*() {
const storage = yield* RunnerStorage.RunnerStorage
let acquired = yield* storage.acquire(runnerAddress1, [
ShardId.make("default", 1),
ShardId.make("default", 2),
ShardId.make("default", 3)
])
expect(acquired.map((_) => _.id)).toEqual([1, 2, 3])
acquired = yield* storage.acquire(runnerAddress1, [
ShardId.make("default", 1),
ShardId.make("default", 2),
ShardId.make("default", 3)
])
expect(acquired.map((_) => _.id)).toEqual([1, 2, 3])
const refreshed = yield* storage.refresh(runnerAddress1, [
ShardId.make("default", 1),
ShardId.make("default", 2),
ShardId.make("default", 3)
])
expect(refreshed.map((_) => _.id)).toEqual([1, 2, 3])
// smoke test release
yield* storage.release(runnerAddress1, ShardId.make("default", 2))
}))
})
})
})
const runnerAddress1 = RunnerAddress.make("localhost", 1234)
const SqliteLayer = Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const dir = yield* fs.makeTempDirectoryScoped()
return SqliteClient.layer({
filename: dir + "/test.db"
})
}).pipe(Layer.unwrap, Layer.provide(NodeFileSystem.layer))

View File

@@ -0,0 +1 @@
outside root

View File

@@ -0,0 +1 @@
initial conditional body

View File

@@ -0,0 +1 @@
<html><body>custom home</body></html>

View File

@@ -0,0 +1 @@
binary-ish

View File

@@ -0,0 +1 @@
<html><body>guide index</body></html>

View File

@@ -0,0 +1 @@
hello static file

View File

@@ -0,0 +1 @@
<html><body>root index</body></html>

View File

@@ -0,0 +1 @@
0123456789abcdefghijklmnopqrstuvwxyz

View File

@@ -0,0 +1,64 @@
import { MysqlClient } from "@effect/sql-mysql2"
import type { StartedMySqlContainer } from "@testcontainers/mysql"
import { MySqlContainer } from "@testcontainers/mysql"
import { Context, Data, Effect, Layer, Redacted, String } from "effect"
export class ContainerError extends Data.TaggedError("ContainerError")<{
cause: unknown
}> {}
export class MysqlContainer extends Context.Service<
MysqlContainer,
StartedMySqlContainer
>()("test/MysqlContainer") {
static readonly layer = Layer.effect(this)(
Effect.acquireRelease(
Effect.tryPromise({
try: () => new MySqlContainer("mysql:lts").start(),
catch: (cause) => new ContainerError({ cause })
}),
(container) => Effect.promise(() => container.stop())
)
)
static client = Layer.unwrap(
Effect.gen(function*() {
const container = yield* MysqlContainer
return MysqlClient.layer({
url: Redacted.make(container.getConnectionUri())
})
})
)
static layerClient = this.client.pipe(Layer.provide(this.layer))
static layerClientWithTransforms = Layer.unwrap(
Effect.gen(function*() {
const container = yield* MysqlContainer
return MysqlClient.layer({
url: Redacted.make(container.getConnectionUri()),
transformQueryNames: String.camelToSnake,
transformResultNames: String.snakeToCamel
})
})
).pipe(Layer.provide(this.layer))
static layerVitest = Layer.effect(
this,
Effect.acquireRelease(
Effect.tryPromise({
try: () =>
new MySqlContainer("vitess/vttestserver:mysql80").withEnvironment({
KEYSPACES: "test,unsharded",
NUM_SHARDS: "1,1",
MYSQL_BIND_HOST: "0.0.0.0",
PORT: "3303"
}).start(),
catch: (cause) => new ContainerError({ cause })
}),
(container) => Effect.promise(() => container.stop())
)
)
static layerClientVitess = this.client.pipe(Layer.provide(this.layerVitest))
}

View File

@@ -0,0 +1,39 @@
import { PgClient } from "@effect/sql-pg"
import { PostgreSqlContainer } from "@testcontainers/postgresql"
import { Context, Data, Effect, Layer, Redacted, String } from "effect"
export class ContainerError extends Data.TaggedError("ContainerError")<{
cause: unknown
}> {}
export class PgContainer extends Context.Service<PgContainer>()("test/PgContainer", {
make: Effect.acquireRelease(
Effect.tryPromise({
try: () => new PostgreSqlContainer("postgres:alpine").start(),
catch: (cause) => new ContainerError({ cause })
}),
(container) => Effect.promise(() => container.stop())
)
}) {
static readonly layer = Layer.effect(this)(this.make)
static layerClient = Layer.unwrap(
Effect.gen(function*() {
const container = yield* PgContainer
return PgClient.layer({
url: Redacted.make(container.getConnectionUri())
})
})
).pipe(Layer.provide(this.layer))
static layerClientWithTransforms = Layer.unwrap(
Effect.gen(function*() {
const container = yield* PgContainer
return PgClient.layer({
url: Redacted.make(container.getConnectionUri()),
transformResultNames: String.snakeToCamel,
transformQueryNames: String.camelToSnake
})
})
).pipe(Layer.provide(this.layer))
}

View File

@@ -0,0 +1,149 @@
import { assert, describe, it } from "@effect/vitest"
import { Cause, Context, Effect, Fiber, Option, Stream } from "effect"
// @ts-ignore
// oxlint-disable-next-line @typescript-eslint/no-unused-vars
import { NodeInspectSymbol } from "effect/Inspectable"
import * as Layer from "effect/Layer"
import * as RpcClient from "effect/unstable/rpc/RpcClient"
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"
import type * as RpcGroup from "effect/unstable/rpc/RpcGroup"
import * as RpcServer from "effect/unstable/rpc/RpcServer"
import * as RpcTest from "effect/unstable/rpc/RpcTest"
import { AuthClient, AuthLive, TimingLive, User, UserRpcs, UsersLive } from "./rpc-schemas.ts"
export class UsersClient extends Context.Service<
UsersClient,
RpcClient.RpcClient<RpcGroup.Rpcs<typeof UserRpcs>, RpcClientError>
>()("UsersClient") {
static readonly layer = Layer.effect(UsersClient)(RpcClient.make(UserRpcs)).pipe(
Layer.provide(AuthClient)
)
static layerTest = Layer.effect(UsersClient)(RpcTest.makeClient(UserRpcs)).pipe(
Layer.provide([UsersLive, AuthLive, TimingLive, AuthClient])
)
}
export const e2eSuite = <E>(
name: string,
layer: Layer.Layer<UsersClient | RpcServer.Protocol, E>,
concurrent = true
) => {
describe(name, { concurrent, timeout: 30_000 }, () => {
it.effect("should get user", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const user = yield* client.GetUser({ id: "1" })
assert.instanceOf(user, User)
assert.deepStrictEqual(user, new User({ id: "1", name: "Logged in user" }))
}).pipe(Effect.provide(layer)))
it.effect("should get deferred user", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const user = yield* client.GetUserDeferred({ id: "1" })
assert.instanceOf(user, User)
assert.deepStrictEqual(user, new User({ id: "1", name: "John" }))
}).pipe(Effect.provide(layer)))
it.effect("nested method", () =>
Effect.gen(function*() {
const client = yield* UsersClient
yield* client["nested.test"]()
}).pipe(Effect.provide(layer)))
it.effect("should not flatten Option", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const user = yield* client.GetUserOption({ id: "1" })
assert.deepStrictEqual(user, Option.some(new User({ id: "1", name: "John" })))
}).pipe(Effect.provide(layer)))
it.effect("headers", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const user = yield* client.GetUser({ id: "1" })
assert.instanceOf(user, User)
assert.deepStrictEqual(user, new User({ id: "123", name: "Logged in user" }))
}).pipe(
RpcClient.withHeaders({ userId: "123" }),
Effect.provide(layer)
))
it.live("Stream", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const users: Array<User> = []
yield* client.StreamUsers({ id: "1" }).pipe(
Stream.take(5),
Stream.runForEach((user) =>
Effect.sync(() => {
users.push(user)
})
),
Effect.forkChild
)
yield* Effect.sleep(2000)
assert.lengthOf(users, 5)
// test interrupts
const interrupts = yield* client.GetInterrupts()
assert.equal(interrupts, 1)
const { supportsAck } = yield* RpcServer.Protocol
// test backpressure
if (supportsAck) {
const emits = yield* client.GetEmits()
assert.equal(emits, 5)
}
}).pipe(Effect.provide(layer)), { timeout: 20000 })
it.effect("defect", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const cause = yield* client.ProduceDefect().pipe(
Effect.sandbox,
Effect.flip
)
assert.deepStrictEqual(cause, Cause.die("boom"))
}).pipe(
RpcClient.withHeaders({ userId: "123" }),
Effect.provide(layer)
))
it.live("never", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const fiber = yield* client.Never().pipe(
Effect.forkChild
)
yield* Effect.sleep(500)
assert.isUndefined(fiber.pollUnsafe())
yield* Fiber.interrupt(fiber)
yield* Effect.sleep(100)
const { supportsAck } = yield* RpcServer.Protocol
if (supportsAck) {
const interrupts = yield* client.GetInterrupts()
assert.equal(interrupts, 1)
}
}).pipe(
RpcClient.withHeaders({ userId: "123" }),
Effect.provide(layer)
))
it.effect("timing middleware", () =>
Effect.gen(function*() {
const client = yield* UsersClient
const result = yield* client.TimedMethod({ shouldFail: false })
assert.equal(result, 1)
yield* client.TimedMethod({ shouldFail: true }).pipe(Effect.exit)
const { count, defect, success } = yield* client.GetTimingMiddlewareMetrics()
assert.notEqual(count, 0)
assert.notEqual(defect, 0)
assert.notEqual(success, 0)
}).pipe(Effect.provide(layer)))
})
}

View File

@@ -0,0 +1,175 @@
import { Context, Deferred, Effect, Layer, Metric, Option, Queue, Schema } from "effect"
import { Headers } from "effect/unstable/http"
import * as Rpc from "effect/unstable/rpc/Rpc"
import * as RpcGroup from "effect/unstable/rpc/RpcGroup"
import * as RpcMiddleware from "effect/unstable/rpc/RpcMiddleware"
import * as RpcServer from "effect/unstable/rpc/RpcServer"
export class User extends Schema.Class<User>("User")({
id: Schema.String,
name: Schema.String
}) {}
class StreamUsers extends Rpc.make("StreamUsers", {
success: User,
payload: {
id: Schema.String
},
stream: true
}) {}
class CurrentUser extends Context.Service<CurrentUser, User>()("CurrentUser") {}
class Unauthorized extends Schema.ErrorClass<Unauthorized>("Unauthorized")({
_tag: Schema.tag("Unauthorized")
}) {}
class AuthMiddleware extends RpcMiddleware.Service<AuthMiddleware, {
provides: CurrentUser
}>()("AuthMiddleware", {
error: Unauthorized,
requiredForClient: true
}) {}
class TimingMiddleware extends RpcMiddleware.Service<TimingMiddleware>()("TimingMiddleware") {}
class GetUser extends Rpc.make("GetUser", {
success: User,
payload: { id: Schema.String }
}) {}
export const UserRpcs = RpcGroup.make(
GetUser,
Rpc.make("GetUserDeferred", {
success: User,
payload: { id: Schema.String }
}),
Rpc.make("GetUserOption", {
success: Schema.Option(User),
payload: { id: Schema.String }
}),
StreamUsers,
Rpc.make("GetInterrupts", {
success: Schema.Number
}),
Rpc.make("GetEmits", {
success: Schema.Number
}),
Rpc.make("ProduceDefect"),
Rpc.make("ProduceDefectCustom", {
defect: Schema.Defect({ includeStack: true })
}),
Rpc.make("Never"),
Rpc.make("nested.test"),
Rpc.make("TimedMethod", {
payload: {
shouldFail: Schema.Boolean
},
success: Schema.Number
}).middleware(TimingMiddleware),
Rpc.make("GetTimingMiddlewareMetrics", {
success: Schema.Struct({
success: Schema.Number,
defect: Schema.Number,
count: Schema.Number
})
})
).middleware(AuthMiddleware)
export const AuthLive = Layer.succeed(AuthMiddleware)(
AuthMiddleware.of((effect, options) =>
Effect.provideService(
effect,
CurrentUser,
new User({ id: options.headers.userid ?? "1", name: options.headers.name ?? "Fallback name" })
)
)
)
const rpcSuccesses = Metric.counter("rpc_middleware_success")
const rpcDefects = Metric.counter("rpc_middleware_defects")
const rpcCount = Metric.counter("rpc_middleware_count")
export const TimingLive = Layer.succeed(TimingMiddleware)(
TimingMiddleware.of((effect) =>
effect.pipe(
Effect.tap(Metric.update(rpcSuccesses, 1)),
Effect.tapDefect(() => Metric.update(rpcDefects, 1)),
Effect.ensuring(Metric.update(rpcCount, 1))
)
)
)
export const UsersLive = UserRpcs.toLayer(Effect.gen(function*() {
let interrupts = 0
let emits = 0
return UserRpcs.of({
GetUser: (_) =>
CurrentUser.pipe(
Rpc.fork
),
GetUserDeferred(_) {
const deferred = Deferred.makeUnsafe<User>()
Deferred.doneUnsafe(deferred, Effect.succeed(new User({ id: "1", name: "John" })))
return Effect.succeed(deferred)
},
GetUserOption: Effect.fnUntraced(function*(req) {
return Option.some(new User({ id: req.id, name: "John" }))
}),
StreamUsers: Effect.fnUntraced(function*(req, _) {
const mailbox = yield* Queue.bounded<User>(0)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
interrupts++
})
)
yield* Queue.offer(mailbox, new User({ id: req.id, name: "John" })).pipe(
Effect.tap(() =>
Effect.sync(() => {
emits++
})
),
Effect.delay(100),
Effect.forever,
Effect.forkScoped
)
return mailbox
}),
GetInterrupts: () => Effect.sync(() => interrupts),
GetEmits: () => Effect.sync(() => emits),
ProduceDefect: () => Effect.die("boom"),
ProduceDefectCustom: () =>
Effect.die({
message: "detailed error",
stack: "Error: detailed error\n at handler.ts:1",
name: "CustomDefect"
}),
Never: () => Effect.never.pipe(Effect.onInterrupt(() => Effect.sync(() => interrupts++))),
"nested.test": () => Effect.void,
TimedMethod: (_) => _.shouldFail ? Effect.die("boom") : Effect.succeed(1),
GetTimingMiddlewareMetrics: () =>
Effect.all({
defect: Metric.value(rpcDefects).pipe(Effect.map((_) => _.count)),
success: Metric.value(rpcSuccesses).pipe(Effect.map((_) => _.count)),
count: Metric.value(rpcCount).pipe(Effect.map((_) => _.count))
})
})
}))
export const RpcLive = RpcServer.layer(UserRpcs, {
disableFatalDefects: true
}).pipe(
Layer.provide([
UsersLive,
AuthLive,
TimingLive
])
)
export const AuthClient = RpcMiddleware.layerClient(AuthMiddleware, ({ next, request }) =>
next({
...request,
headers: Headers.set(request.headers, "name", "Logged in user")
}))

View File

@@ -0,0 +1 @@
lorem ipsum dolar sit amet